Skip to content

Index

Top-level package for evotorch.

algorithms special

This namespace contains the implementations of various evolutionary algorithms.

cmaes

This namespace contains the CMAES class

CMAES (SearchAlgorithm, SinglePopulationAlgorithmMixin)

This is a GPU-accelerated and vectorized implementation, based on pycma (version r3.2.2) and the below references.

References:

Nikolaus Hansen, Youhei Akimoto, and Petr Baudis.
CMA-ES/pycma on Github. Zenodo, DOI:10.5281/zenodo.2559634,
February 2019.
<https://github.com/CMA-ES/pycma>

Nikolaus Hansen, Andreas Ostermeier (2001).
Completely Derandomized Self-Adaptation in Evolution Strategies.

Nikolaus Hansen (2016).
The CMA Evolution Strategy: A Tutorial.
Source code in evotorch/algorithms/cmaes.py
class CMAES(SearchAlgorithm, SinglePopulationAlgorithmMixin):
    """
    CMAES: Covariance Matrix Adaptation Evolution Strategy.

    This is a GPU-accelerated and vectorized implementation, based on pycma (version r3.2.2)
    and the below references.

    References:

        Nikolaus Hansen, Youhei Akimoto, and Petr Baudis.
        CMA-ES/pycma on Github. Zenodo, DOI:10.5281/zenodo.2559634,
        February 2019.
        <https://github.com/CMA-ES/pycma>

        Nikolaus Hansen, Andreas Ostermeier (2001).
        Completely Derandomized Self-Adaptation in Evolution Strategies.

        Nikolaus Hansen (2016).
        The CMA Evolution Strategy: A Tutorial.

    """

    def __init__(
        self,
        problem: Problem,
        *,
        stdev_init: Real,
        popsize: Optional[int] = None,
        center_init: Optional[Vector] = None,
        c_m: Real = 1.0,
        c_sigma: Optional[Real] = None,
        c_sigma_ratio: Real = 1.0,
        damp_sigma: Optional[Real] = None,
        damp_sigma_ratio: Real = 1.0,
        c_c: Optional[Real] = None,
        c_c_ratio: Real = 1.0,
        c_1: Optional[Real] = None,
        c_1_ratio: Real = 1.0,
        c_mu: Optional[Real] = None,
        c_mu_ratio: Real = 1.0,
        active: bool = True,
        csa_squared: bool = False,
        stdev_min: Optional[Real] = None,
        stdev_max: Optional[Real] = None,
        separable: bool = False,
        limit_C_decomposition: bool = True,
        obj_index: Optional[int] = None,
    ):
        """
        `__init__(...)`: Initialize the CMAES solver.

        Args:
            problem (Problem): The problem object which is being worked on.
            stdev_init (Real): Initial step-size
            popsize: Population size. Can be specified as an int,
                or can be left as None in which case the CMA-ES rule of thumb is applied:
                popsize = 4 + floor(3 log d) where d is the dimension
            center_init: Initial center point of the search distribution.
                Can be given as a Solution or as a 1-D array.
                If left as None, an initial center point is generated
                with the help of the problem object's `generate_values(...)`
                method.
            c_m (Real): Learning rate for updating the mean
                of the search distribution. By default the value is 1.

            c_sigma (Optional[Real]): Learning rate for updating the step size. If None,
                then the CMA-ES rules of thumb will be applied.
            c_sigma_ratio (Real): Multiplier on the learning rate for the step size.
                if c_sigma has been left as None, can be used to rescale the default c_sigma value.

            damp_sigma (Optional[Real]): Damping factor for updating the step size. If None,
                then the CMA-ES rules of thumb will be applied.
            damp_sigma_ratio (Real): Multiplier on the damping factor for the step size.
                if damp_sigma has been left as None, can be used to rescale the default damp_sigma value.

            c_c (Optional[Real]): Learning rate for updating the rank-1 evolution path.
                If None, then the CMA-ES rules of thumb will be applied.
            c_c_ratio (Real): Multiplier on the learning rate for the rank-1 evolution path.
                if c_c has been left as None, can be used to rescale the default c_c value.

            c_1 (Optional[Real]): Learning rate for the rank-1 update to the covariance matrix.
                If None, then the CMA-ES rules of thumb will be applied.
            c_1_ratio (Real): Multiplier on the learning rate for the rank-1 update to the covariance matrix.
                if c_1 has been left as None, can be used to rescale the default c_1 value.

            c_mu (Optional[Real]): Learning rate for the rank-mu update to the covariance matrix.
                If None, then the CMA-ES rules of thumb will be applied.
            c_mu_ratio (Real): Multiplier on the learning rate for the rank-mu update to the covariance matrix.
                if c_mu has been left as None, can be used to rescale the default c_mu value.

            active (bool): Whether to use Active CMA-ES. Defaults to True, consistent with the tutorial paper and pycma.
            csa_squared (bool): Whether to use the squared rule ("CSA_squared" in pycma) for the step-size adapation.
                This effectively corresponds to taking the natural gradient for the evolution path on the step size,
                rather than the default CMA-ES rule of thumb.

            stdev_min (Optional[Real]): Minimum allowed standard deviation of the search
                distribution. Leaving this as None means that no such
                boundary is to be used.
                Can be given as None or as a scalar.
            stdev_max (Optional[Real]): Maximum allowed standard deviation of the search
                distribution. Leaving this as None means that no such
                boundary is to be used.
                Can be given as None or as a scalar.

            separable (bool): Provide this as True if you would like the problem
                to be treated as a separable one. Treating a problem
                as separable means to adapt only the diagonal parts
                of the covariance matrix and to keep the non-diagonal
                parts 0. High dimensional problems result in large
                covariance matrices on which operating is computationally
                expensive. Therefore, for such high dimensional problems,
                setting `separable` as True might be useful.

            limit_C_decomposition (bool): Whether to limit the frequency of decomposition of the shape matrix C
                Setting this to True (default) means that C will not be decomposed every generation
                This degrades the quality of the sampling and updates, but provides a guarantee of O(d^2) time complexity.
                This option can be used with separable=True (e.g. for experimental reasons) but the performance will only degrade
                without time-complexity benefits.


            obj_index (Optional[int]): Objective index according to which evaluation
                of the solution will be done.
        """

        # Initialize the base class
        SearchAlgorithm.__init__(self, problem, center=self._get_center, stepsize=self._get_sigma)

        # Ensure that the problem is numeric
        problem.ensure_numeric()

        # Store the objective index
        self._obj_index = problem.normalize_obj_index(obj_index)

        # Track d = solution length for reference in initialization of hyperparameters
        d = self._problem.solution_length

        # === Initialize population ===
        if not popsize:
            # Default value used in CMA-ES literature 4 + floor(3 log n)
            popsize = 4 + int(np.floor(3 * np.log(d)))
        self.popsize = int(popsize)
        # Half popsize, referred to as mu in CMA-ES literature
        self.mu = int(np.floor(popsize / 2))
        self._population = problem.generate_batch(popsize=popsize)

        # === Initialize search distribution ===

        self.separable = separable

        # If `center_init` is not given, generate an initial solution
        # with the help of the problem object.
        # If it is given as a Solution, then clone the solution's values
        # as a PyTorch tensor.
        # Otherwise, use the given initial solution as the starting
        # point in the search space.
        if center_init is None:
            center_init = self._problem.generate_values(1)
        elif isinstance(center_init, Solution):
            center_init = center_init.values.clone()

        # Store the center
        self.m = self._problem.make_tensor(center_init)

        # Store the initial step size
        self.sigma = self._problem.make_tensor(stdev_init)

        if separable:
            # Initialize C as the diagonal vector. Note that when separable, the eigendecomposition is not needed
            self.C = self._problem.make_ones(d)
            # In this case A is simply the square root of elements of C
            self.A = self._problem.make_ones(d)
        else:
            # Initialize C = AA^T all diagonal.
            self.C = self._problem.make_I(d)
            self.A = self.C.clone()

        # === Initialize raw weights ===
        # Conditioned on popsize

        # w_i = log((lambda + 1) / 2) - log(i) for i = 1 ... lambda
        raw_weights = self.problem.make_tensor(np.log((popsize + 1) / 2) - torch.log(torch.arange(popsize) + 1))
        # positive valued weights are the first mu
        positive_weights = raw_weights[: self.mu]
        negative_weights = raw_weights[self.mu :]

        # Variance effective selection mass of positive weights
        # Not affected by future updates to raw_weights
        self.mu_eff = torch.sum(positive_weights).pow(2.0) / torch.sum(positive_weights.pow(2.0))

        # === Initialize search parameters ===
        # Conditioned on weights

        # Store fixed information
        self.c_m = c_m
        self.active = active
        self.csa_squared = csa_squared
        self.stdev_min = stdev_min
        self.stdev_max = stdev_max

        # Learning rate for step-size adaption
        if c_sigma is None:
            c_sigma = (self.mu_eff + 2.0) / (d + self.mu_eff + 3)
        self.c_sigma = c_sigma_ratio * c_sigma

        # Damping factor for step-size adapation
        if damp_sigma is None:
            damp_sigma = 1 + 2 * max(0, torch.sqrt((self.mu_eff - 1) / (d + 1)) - 1) + self.c_sigma
        self.damp_sigma = damp_sigma_ratio * damp_sigma

        # Learning rate for evolution path for rank-1 update
        if c_c is None:
            # Branches on separability
            if separable:
                c_c = (1 + (1 / d) + (self.mu_eff / d)) / (d**0.5 + (1 / d) + 2 * (self.mu_eff / d))
            else:
                c_c = (4 + self.mu_eff / d) / (d + (4 + 2 * self.mu_eff / d))
        self.c_c = c_c_ratio * c_c

        # Learning rate for rank-1 update to covariance matrix
        if c_1 is None:
            # Branches on separability
            if separable:
                c_1 = 1.0 / (d + 2.0 * np.sqrt(d) + self.mu_eff / d)
            else:
                c_1 = min(1, popsize / 6) * 2 / ((d + 1.3) ** 2.0 + self.mu_eff)
        self.c_1 = c_1_ratio * c_1

        # Learning rate for rank-mu update to covariance matrix
        if c_mu is None:
            # Branches on separability
            if separable:
                c_mu = (0.25 + self.mu_eff + (1.0 / self.mu_eff) - 2) / (d + 4 * np.sqrt(d) + (self.mu_eff / 2.0))
            else:
                c_mu = min(
                    1 - self.c_1, 2 * ((0.25 + self.mu_eff - 2 + (1 / self.mu_eff)) / ((d + 2) ** 2.0 + self.mu_eff))
                )
        self.c_mu = c_mu_ratio * c_mu

        # The 'variance aware' coefficient used for the additive component of the evolution path for sigma
        self.variance_discount_sigma = torch.sqrt(self.c_sigma * (2 - self.c_sigma) * self.mu_eff)
        # The 'variance aware' coefficient used for the additive component of the evolution path for rank-1 updates
        self.variance_discount_c = torch.sqrt(self.c_c * (2 - self.c_c) * self.mu_eff)

        # === Finalize weights ===
        # Conditioned on search parameters and raw weights

        # Positive weights always sum to 1
        positive_weights = positive_weights / torch.sum(positive_weights)

        if self.active:
            # Active CMA-ES: negative weights sum to alpha

            # Get the variance effective selection mass of negative weights
            mu_eff_neg = torch.sum(negative_weights).pow(2.0) / torch.sum(negative_weights.pow(2.0))

            # Alpha is the minimum of the following 3 terms
            alpha_mu = 1 + self.c_1 / self.c_mu
            alpha_mu_eff = 1 + 2 * mu_eff_neg / (self.mu_eff + 2)
            alpha_pos_def = (1 - self.c_mu - self.c_1) / (d * self.c_mu)
            alpha = min([alpha_mu, alpha_mu_eff, alpha_pos_def])

            # Rescale negative weights
            negative_weights = alpha * negative_weights / torch.sum(torch.abs(negative_weights))
        else:
            # Negative weights are simply zero
            negative_weights = torch.zeros_like(negative_weights)

        # Concatenate final weights
        self.weights = torch.cat([positive_weights, negative_weights], dim=-1)

        # === Some final setup ===

        # Initialize the evolution paths
        self.p_sigma = 0.0
        self.p_c = 0.0

        # Hansen's approximation to the expectation of ||x|| x ~ N(0, I_d).
        # Note that we could use the exact formulation with Gamma functions, but we'll retain this form for consistency
        self.unbiased_expectation = np.sqrt(d) * (1 - (1 / (4 * d)) + 1 / (21 * d**2))

        self.last_ex = None

        # How often to decompose C
        if limit_C_decomposition:
            self.decompose_C_freq = max(1, int(np.floor(_safe_divide(1, 10 * d * (self.c_1.cpu() + self.c_mu.cpu())))))
        else:
            self.decompose_C_freq = 1

        # Use the SinglePopulationAlgorithmMixin to enable additional status reports regarding the population.
        SinglePopulationAlgorithmMixin.__init__(self)

    @property
    def population(self) -> SolutionBatch:
        """Population generated by the CMA-ES algorithm"""
        return self._population

    def _get_center(self) -> torch.Tensor:
        """Get the center of search distribution, m"""
        return self.m

    def _get_sigma(self) -> float:
        """Get the step-size of the search distribution, sigma"""
        return float(self.sigma.cpu())

    @property
    def obj_index(self) -> int:
        """Index of the objective being focused on"""
        return self._obj_index

    def sample_distribution(self, num_samples: Optional[int] = None) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
        """Sample the population. All 3 representations of solutions are returned for easy calculations of updates.
        Note that the computation time of this operation of O(d^2 num_samples) unless separable, in which case O(d num_samples)
        Args:
            num_samples (Optional[int]): The number of samples to draw. If None, then the population size is used
        Returns:
            zs (torch.Tensor): A tensor of shape [num_samples, d] of samples from the local coordinate space e.g. z_i ~ N(0, I_d)
            ys (torch.Tensor): A tensor of shape [num_samples, d] of samples from the shaped coordinate space e.g. y_i ~ N(0, C)
            xs (torch.Tensor): A tensor of shape [num_samples, d] of samples from the search space e.g. x_i ~ N(m, sigma^2 C)
        """
        if num_samples is None:
            num_samples = self.popsize
        # Generate z values
        zs = self._problem.make_gaussian(num_solutions=num_samples)
        # Construct ys = A zs
        if self.separable:
            # In the separable case A is diagonal so is represented as a single vector
            ys = self.A.unsqueeze(0) * zs
        else:
            ys = (self.A @ zs.T).T
        # Construct xs = m + sigma ys
        xs = self.m.unsqueeze(0) + self.sigma * ys
        return zs, ys, xs

    def get_population_weights(self, xs: torch.Tensor) -> torch.Tensor:
        """Get the assigned weights of the population (e.g. evaluate, rank and return)
        Args:
            xs (torch.Tensor): The population samples drawn from N(mu, sigma^2 C)
        Returns:
            assigned_weights (torch.Tensor): A [popsize, ] dimensional tensor of ordered weights
        """
        # Computation is O(popsize * F_time) where F_time is the evalutation time per sample
        # Fill the population
        self._population.set_values(xs)
        # Evaluate the current population
        self.problem.evaluate(self._population)
        # Sort the population
        indices = self._population.argsort(obj_index=self.obj_index)
        # Invert the sorting of the population to obtain the ranks
        # Note that these ranks start at zero, but this is fine as we are just using them for indexing
        ranks = torch.zeros_like(indices)
        ranks[indices] = torch.arange(self.popsize, dtype=indices.dtype, device=indices.device)
        # Get weights corresponding to each rank
        assigned_weights = self.weights[ranks]
        return assigned_weights

    def update_m(self, zs: torch.Tensor, ys: torch.Tensor, assigned_weights: torch.Tensor) -> torch.Tensor:
        """Update the center of the search distribution m
        With zs and ys retained from sampling, this operation is O(popsize d), as it involves summing across popsize d-dimensional vectors.
        Args:
            zs (torch.Tensor): A tensor of shape [popsize, d] of samples from the local coordinate space e.g. z_i ~ N(0, I_d)
            ys (torch.Tensor): A tensor of shape [popsize, d] of samples from the shaped coordinate space e.g. y_i ~ N(0, C)
            assigned_weights (torch.Tensor): A [popsize, ] dimensional tensor of ordered weights
        Returns:
            local_m_displacement (torch.Tensor): A tensor of shape [d], corresponding to the local transformation of m,
                (1/sigma) (C^-1/2) (m' - m) where m' is the updated m
            shaped_m_displacement (torch.Tensor): A tensor of shape [d], corresponding to the shaped transformation of m,
                (1/sigma) (m' - m) where m' is the updated m
        """
        # Get the top-mu weights
        top_mu = torch.topk(assigned_weights, k=self.mu)
        top_mu_weights = top_mu.values
        top_mu_indices = top_mu.indices

        # Compute the weighted recombination in local coordinate space
        local_m_displacement = torch.sum(top_mu_weights.unsqueeze(-1) * zs[top_mu_indices], dim=0)
        # Compute the weighted recombination in shaped coordinate space
        shaped_m_displacement = torch.sum(top_mu_weights.unsqueeze(-1) * ys[top_mu_indices], dim=0)

        # Update m
        self.m = self.m + self.c_m * self.sigma * shaped_m_displacement

        # Return the weighted recombinations
        return local_m_displacement, shaped_m_displacement

    def update_p_sigma(self, local_m_displacement: torch.Tensor) -> None:
        """Update the evolution path for sigma, p_sigma
        This operation is bounded O(d), as is simply the sum of vectors
        Args:
            local_m_displacement (torch.Tensor): The weighted recombination of local samples zs, corresponding to
                (1/sigma) (C^-1/2) (m' - m) where m' is the updated m
        """
        self.p_sigma = (1 - self.c_sigma) * self.p_sigma + self.variance_discount_sigma * local_m_displacement

    def update_sigma(self) -> None:
        """Update the step size sigma according to its evolution path p_sigma
        This operation is bounded O(d), with the most expensive component being the norm of the evolution path, a d-dimensional vector.
        """
        d = self._problem.solution_length
        # Compute the exponential update
        if self.csa_squared:
            # Exponential update based on natural gradient maximizing squared norm of p_sigma
            exponential_update = (torch.norm(self.p_sigma).pow(2.0) / d - 1) / 2
        else:
            # Exponential update increasing likelihood p_sigma having expected norm
            exponential_update = torch.norm(self.p_sigma) / self.unbiased_expectation - 1
        # Rescale exponential update based on learning rate + damping factor
        exponential_update = (self.c_sigma / self.damp_sigma) * exponential_update
        # Multiplicative update to sigma
        self.sigma = self.sigma * torch.exp(exponential_update)

    def update_p_c(self, shaped_m_displacement: torch.Tensor, h_sig: torch.Tensor) -> None:
        """Update the evolution path for rank-1 update, p_c
        This operation is bounded O(d), as is simply the sum of vectors
        Args:
            local_m_displacement (torch.Tensor): The weighted recombination of shaped samples ys, corresponding to
                (1/sigma) (m' - m) where m' is the updated m
            h_sig (torch.Tensor): Whether to stall the update based on the evolution path on sigma, p_sigma, expressed as a torch float
        """
        self.p_c = (1 - self.c_c) * self.p_c + h_sig * self.variance_discount_c * shaped_m_displacement

    def update_C(self, zs: torch.Tensor, ys: torch.Tensor, assigned_weights: torch.Tensor, h_sig: torch.Tensor) -> None:
        """Update the covariance shape matrix C based on rank-1 and rank-mu updates
        This operation is bounded O(d^2 popsize), which is associated with computing the rank-mu update (summing across popsize d*d matrices)
        Args:
            zs (torch.Tensor): A tensor of shape [popsize, d] of samples from the local coordinate space e.g. z_i ~ N(0, I_d)
            ys (torch.Tensor): A tensor of shape [popsize, d] of samples from the shaped coordinate space e.g. y_i ~ N(0, C)
            assigned_weights (torch.Tensor): A [popsize, ] dimensional tensor of ordered weights
            h_sig (torch.Tensor): Whether to stall the update based on the evolution path on sigma, p_sigma, expressed as a torch float
        """
        d = self._problem.solution_length
        # If using Active CMA-ES, reweight negative weights
        if self.active:
            assigned_weights = torch.where(
                assigned_weights > 0, assigned_weights, d * assigned_weights / torch.norm(zs, dim=-1).pow(2.0)
            )
        c1a = self.c_1 * (1 - (1 - h_sig**2) * self.c_c * (2 - self.c_c))  # adjust for variance loss
        weighted_pc = (self.c_1 / (c1a + 1e-23)) ** 0.5
        if self.separable:
            # Rank-1 update
            r1_update = c1a * (self.p_c.pow(2.0) - self.C)
            # Rank-mu update
            rmu_update = self.c_mu * torch.sum(
                assigned_weights.unsqueeze(-1) * (ys.pow(2.0) - self.C.unsqueeze(0)), dim=0
            )
        else:
            # Rank-1 update
            r1_update = c1a * (torch.outer(weighted_pc * self.p_c, weighted_pc * self.p_c) - self.C)
            # Rank-mu update
            rmu_update = self.c_mu * (
                torch.sum(assigned_weights.unsqueeze(-1).unsqueeze(-1) * (ys.unsqueeze(1) * ys.unsqueeze(2)), dim=0)
                - torch.sum(self.weights) * self.C
            )

        # Update C
        self.C = self.C + r1_update + rmu_update

    def decompose_C(self) -> None:
        """Perform the decomposition C = AA^T using a cholesky decomposition
        Note that traditionally CMA-ES uses the eigendecomposition C = BDDB^-1. In our case,
        we keep track of zs, ys and xs when sampling, so we never need C^-1/2.
        Therefore, a cholesky decomposition is all that is necessary. This generally requires
        O(d^3/3) operations, rather than the more costly O(d^3) operations associated with the eigendecomposition.
        """
        if self.separable:
            self.A = self.C.pow(0.5)
        else:
            self.A = torch.linalg.cholesky(self.C)

    def _step(self):
        """Perform a step of the CMA-ES solver"""

        # === Sampling, evaluation and ranking ===

        # Sample the search distribution
        zs, ys, xs = self.sample_distribution()
        # Get the weights assigned to each solution
        assigned_weights = self.get_population_weights(xs)

        # === Center adaption ===

        local_m_displacement, shaped_m_displacement = self.update_m(zs, ys, assigned_weights)

        # === Step size adaption ===

        # Update evolution path p_sigma
        self.update_p_sigma(local_m_displacement)
        # Update sigma
        self.update_sigma()

        # Compute h_sig, a boolean flag for stalling the update to p_c
        h_sig = _h_sig(self.p_sigma, self.c_sigma, self._steps_count)

        # === Unscaled covariance adapation ===

        # Update evolution path p_c
        self.update_p_c(shaped_m_displacement, h_sig)
        # Update the covariance shape C
        self.update_C(zs, ys, assigned_weights, h_sig)

        # === Post-step corrections ===

        # Limit element-wise standard deviation of sigma^2 C
        if self.stdev_min is not None or self.stdev_max is not None:
            self.C = _limit_stdev(self.sigma, self.C, self.stdev_min, self.stdev_max)

        # Decompose C
        if (self._steps_count + 1) % self.decompose_C_freq == 0:
            self.decompose_C()
obj_index: int property readonly

Index of the objective being focused on

population: SolutionBatch property readonly

Population generated by the CMA-ES algorithm

__init__(self, problem, *, stdev_init, popsize=None, center_init=None, c_m=1.0, c_sigma=None, c_sigma_ratio=1.0, damp_sigma=None, damp_sigma_ratio=1.0, c_c=None, c_c_ratio=1.0, c_1=None, c_1_ratio=1.0, c_mu=None, c_mu_ratio=1.0, active=True, csa_squared=False, stdev_min=None, stdev_max=None, separable=False, limit_C_decomposition=True, obj_index=None) special

__init__(...): Initialize the CMAES solver.

Parameters:

Name Type Description Default
problem Problem

The problem object which is being worked on.

required
stdev_init Real

Initial step-size

required
popsize Optional[int]

Population size. Can be specified as an int, or can be left as None in which case the CMA-ES rule of thumb is applied: popsize = 4 + floor(3 log d) where d is the dimension

None
center_init Union[Iterable[float], torch.Tensor]

Initial center point of the search distribution. Can be given as a Solution or as a 1-D array. If left as None, an initial center point is generated with the help of the problem object's generate_values(...) method.

None
c_m Real

Learning rate for updating the mean of the search distribution. By default the value is 1.

1.0
c_sigma Optional[Real]

Learning rate for updating the step size. If None, then the CMA-ES rules of thumb will be applied.

None
c_sigma_ratio Real

Multiplier on the learning rate for the step size. if c_sigma has been left as None, can be used to rescale the default c_sigma value.

1.0
damp_sigma Optional[Real]

Damping factor for updating the step size. If None, then the CMA-ES rules of thumb will be applied.

None
damp_sigma_ratio Real

Multiplier on the damping factor for the step size. if damp_sigma has been left as None, can be used to rescale the default damp_sigma value.

1.0
c_c Optional[Real]

Learning rate for updating the rank-1 evolution path. If None, then the CMA-ES rules of thumb will be applied.

None
c_c_ratio Real

Multiplier on the learning rate for the rank-1 evolution path. if c_c has been left as None, can be used to rescale the default c_c value.

1.0
c_1 Optional[Real]

Learning rate for the rank-1 update to the covariance matrix. If None, then the CMA-ES rules of thumb will be applied.

None
c_1_ratio Real

Multiplier on the learning rate for the rank-1 update to the covariance matrix. if c_1 has been left as None, can be used to rescale the default c_1 value.

1.0
c_mu Optional[Real]

Learning rate for the rank-mu update to the covariance matrix. If None, then the CMA-ES rules of thumb will be applied.

None
c_mu_ratio Real

Multiplier on the learning rate for the rank-mu update to the covariance matrix. if c_mu has been left as None, can be used to rescale the default c_mu value.

1.0
active bool

Whether to use Active CMA-ES. Defaults to True, consistent with the tutorial paper and pycma.

True
csa_squared bool

Whether to use the squared rule ("CSA_squared" in pycma) for the step-size adapation. This effectively corresponds to taking the natural gradient for the evolution path on the step size, rather than the default CMA-ES rule of thumb.

False
stdev_min Optional[Real]

Minimum allowed standard deviation of the search distribution. Leaving this as None means that no such boundary is to be used. Can be given as None or as a scalar.

None
stdev_max Optional[Real]

Maximum allowed standard deviation of the search distribution. Leaving this as None means that no such boundary is to be used. Can be given as None or as a scalar.

None
separable bool

Provide this as True if you would like the problem to be treated as a separable one. Treating a problem as separable means to adapt only the diagonal parts of the covariance matrix and to keep the non-diagonal parts 0. High dimensional problems result in large covariance matrices on which operating is computationally expensive. Therefore, for such high dimensional problems, setting separable as True might be useful.

False
limit_C_decomposition bool

Whether to limit the frequency of decomposition of the shape matrix C Setting this to True (default) means that C will not be decomposed every generation This degrades the quality of the sampling and updates, but provides a guarantee of O(d^2) time complexity. This option can be used with separable=True (e.g. for experimental reasons) but the performance will only degrade without time-complexity benefits.

True
obj_index Optional[int]

Objective index according to which evaluation of the solution will be done.

None
Source code in evotorch/algorithms/cmaes.py
def __init__(
    self,
    problem: Problem,
    *,
    stdev_init: Real,
    popsize: Optional[int] = None,
    center_init: Optional[Vector] = None,
    c_m: Real = 1.0,
    c_sigma: Optional[Real] = None,
    c_sigma_ratio: Real = 1.0,
    damp_sigma: Optional[Real] = None,
    damp_sigma_ratio: Real = 1.0,
    c_c: Optional[Real] = None,
    c_c_ratio: Real = 1.0,
    c_1: Optional[Real] = None,
    c_1_ratio: Real = 1.0,
    c_mu: Optional[Real] = None,
    c_mu_ratio: Real = 1.0,
    active: bool = True,
    csa_squared: bool = False,
    stdev_min: Optional[Real] = None,
    stdev_max: Optional[Real] = None,
    separable: bool = False,
    limit_C_decomposition: bool = True,
    obj_index: Optional[int] = None,
):
    """
    `__init__(...)`: Initialize the CMAES solver.

    Args:
        problem (Problem): The problem object which is being worked on.
        stdev_init (Real): Initial step-size
        popsize: Population size. Can be specified as an int,
            or can be left as None in which case the CMA-ES rule of thumb is applied:
            popsize = 4 + floor(3 log d) where d is the dimension
        center_init: Initial center point of the search distribution.
            Can be given as a Solution or as a 1-D array.
            If left as None, an initial center point is generated
            with the help of the problem object's `generate_values(...)`
            method.
        c_m (Real): Learning rate for updating the mean
            of the search distribution. By default the value is 1.

        c_sigma (Optional[Real]): Learning rate for updating the step size. If None,
            then the CMA-ES rules of thumb will be applied.
        c_sigma_ratio (Real): Multiplier on the learning rate for the step size.
            if c_sigma has been left as None, can be used to rescale the default c_sigma value.

        damp_sigma (Optional[Real]): Damping factor for updating the step size. If None,
            then the CMA-ES rules of thumb will be applied.
        damp_sigma_ratio (Real): Multiplier on the damping factor for the step size.
            if damp_sigma has been left as None, can be used to rescale the default damp_sigma value.

        c_c (Optional[Real]): Learning rate for updating the rank-1 evolution path.
            If None, then the CMA-ES rules of thumb will be applied.
        c_c_ratio (Real): Multiplier on the learning rate for the rank-1 evolution path.
            if c_c has been left as None, can be used to rescale the default c_c value.

        c_1 (Optional[Real]): Learning rate for the rank-1 update to the covariance matrix.
            If None, then the CMA-ES rules of thumb will be applied.
        c_1_ratio (Real): Multiplier on the learning rate for the rank-1 update to the covariance matrix.
            if c_1 has been left as None, can be used to rescale the default c_1 value.

        c_mu (Optional[Real]): Learning rate for the rank-mu update to the covariance matrix.
            If None, then the CMA-ES rules of thumb will be applied.
        c_mu_ratio (Real): Multiplier on the learning rate for the rank-mu update to the covariance matrix.
            if c_mu has been left as None, can be used to rescale the default c_mu value.

        active (bool): Whether to use Active CMA-ES. Defaults to True, consistent with the tutorial paper and pycma.
        csa_squared (bool): Whether to use the squared rule ("CSA_squared" in pycma) for the step-size adapation.
            This effectively corresponds to taking the natural gradient for the evolution path on the step size,
            rather than the default CMA-ES rule of thumb.

        stdev_min (Optional[Real]): Minimum allowed standard deviation of the search
            distribution. Leaving this as None means that no such
            boundary is to be used.
            Can be given as None or as a scalar.
        stdev_max (Optional[Real]): Maximum allowed standard deviation of the search
            distribution. Leaving this as None means that no such
            boundary is to be used.
            Can be given as None or as a scalar.

        separable (bool): Provide this as True if you would like the problem
            to be treated as a separable one. Treating a problem
            as separable means to adapt only the diagonal parts
            of the covariance matrix and to keep the non-diagonal
            parts 0. High dimensional problems result in large
            covariance matrices on which operating is computationally
            expensive. Therefore, for such high dimensional problems,
            setting `separable` as True might be useful.

        limit_C_decomposition (bool): Whether to limit the frequency of decomposition of the shape matrix C
            Setting this to True (default) means that C will not be decomposed every generation
            This degrades the quality of the sampling and updates, but provides a guarantee of O(d^2) time complexity.
            This option can be used with separable=True (e.g. for experimental reasons) but the performance will only degrade
            without time-complexity benefits.


        obj_index (Optional[int]): Objective index according to which evaluation
            of the solution will be done.
    """

    # Initialize the base class
    SearchAlgorithm.__init__(self, problem, center=self._get_center, stepsize=self._get_sigma)

    # Ensure that the problem is numeric
    problem.ensure_numeric()

    # Store the objective index
    self._obj_index = problem.normalize_obj_index(obj_index)

    # Track d = solution length for reference in initialization of hyperparameters
    d = self._problem.solution_length

    # === Initialize population ===
    if not popsize:
        # Default value used in CMA-ES literature 4 + floor(3 log n)
        popsize = 4 + int(np.floor(3 * np.log(d)))
    self.popsize = int(popsize)
    # Half popsize, referred to as mu in CMA-ES literature
    self.mu = int(np.floor(popsize / 2))
    self._population = problem.generate_batch(popsize=popsize)

    # === Initialize search distribution ===

    self.separable = separable

    # If `center_init` is not given, generate an initial solution
    # with the help of the problem object.
    # If it is given as a Solution, then clone the solution's values
    # as a PyTorch tensor.
    # Otherwise, use the given initial solution as the starting
    # point in the search space.
    if center_init is None:
        center_init = self._problem.generate_values(1)
    elif isinstance(center_init, Solution):
        center_init = center_init.values.clone()

    # Store the center
    self.m = self._problem.make_tensor(center_init)

    # Store the initial step size
    self.sigma = self._problem.make_tensor(stdev_init)

    if separable:
        # Initialize C as the diagonal vector. Note that when separable, the eigendecomposition is not needed
        self.C = self._problem.make_ones(d)
        # In this case A is simply the square root of elements of C
        self.A = self._problem.make_ones(d)
    else:
        # Initialize C = AA^T all diagonal.
        self.C = self._problem.make_I(d)
        self.A = self.C.clone()

    # === Initialize raw weights ===
    # Conditioned on popsize

    # w_i = log((lambda + 1) / 2) - log(i) for i = 1 ... lambda
    raw_weights = self.problem.make_tensor(np.log((popsize + 1) / 2) - torch.log(torch.arange(popsize) + 1))
    # positive valued weights are the first mu
    positive_weights = raw_weights[: self.mu]
    negative_weights = raw_weights[self.mu :]

    # Variance effective selection mass of positive weights
    # Not affected by future updates to raw_weights
    self.mu_eff = torch.sum(positive_weights).pow(2.0) / torch.sum(positive_weights.pow(2.0))

    # === Initialize search parameters ===
    # Conditioned on weights

    # Store fixed information
    self.c_m = c_m
    self.active = active
    self.csa_squared = csa_squared
    self.stdev_min = stdev_min
    self.stdev_max = stdev_max

    # Learning rate for step-size adaption
    if c_sigma is None:
        c_sigma = (self.mu_eff + 2.0) / (d + self.mu_eff + 3)
    self.c_sigma = c_sigma_ratio * c_sigma

    # Damping factor for step-size adapation
    if damp_sigma is None:
        damp_sigma = 1 + 2 * max(0, torch.sqrt((self.mu_eff - 1) / (d + 1)) - 1) + self.c_sigma
    self.damp_sigma = damp_sigma_ratio * damp_sigma

    # Learning rate for evolution path for rank-1 update
    if c_c is None:
        # Branches on separability
        if separable:
            c_c = (1 + (1 / d) + (self.mu_eff / d)) / (d**0.5 + (1 / d) + 2 * (self.mu_eff / d))
        else:
            c_c = (4 + self.mu_eff / d) / (d + (4 + 2 * self.mu_eff / d))
    self.c_c = c_c_ratio * c_c

    # Learning rate for rank-1 update to covariance matrix
    if c_1 is None:
        # Branches on separability
        if separable:
            c_1 = 1.0 / (d + 2.0 * np.sqrt(d) + self.mu_eff / d)
        else:
            c_1 = min(1, popsize / 6) * 2 / ((d + 1.3) ** 2.0 + self.mu_eff)
    self.c_1 = c_1_ratio * c_1

    # Learning rate for rank-mu update to covariance matrix
    if c_mu is None:
        # Branches on separability
        if separable:
            c_mu = (0.25 + self.mu_eff + (1.0 / self.mu_eff) - 2) / (d + 4 * np.sqrt(d) + (self.mu_eff / 2.0))
        else:
            c_mu = min(
                1 - self.c_1, 2 * ((0.25 + self.mu_eff - 2 + (1 / self.mu_eff)) / ((d + 2) ** 2.0 + self.mu_eff))
            )
    self.c_mu = c_mu_ratio * c_mu

    # The 'variance aware' coefficient used for the additive component of the evolution path for sigma
    self.variance_discount_sigma = torch.sqrt(self.c_sigma * (2 - self.c_sigma) * self.mu_eff)
    # The 'variance aware' coefficient used for the additive component of the evolution path for rank-1 updates
    self.variance_discount_c = torch.sqrt(self.c_c * (2 - self.c_c) * self.mu_eff)

    # === Finalize weights ===
    # Conditioned on search parameters and raw weights

    # Positive weights always sum to 1
    positive_weights = positive_weights / torch.sum(positive_weights)

    if self.active:
        # Active CMA-ES: negative weights sum to alpha

        # Get the variance effective selection mass of negative weights
        mu_eff_neg = torch.sum(negative_weights).pow(2.0) / torch.sum(negative_weights.pow(2.0))

        # Alpha is the minimum of the following 3 terms
        alpha_mu = 1 + self.c_1 / self.c_mu
        alpha_mu_eff = 1 + 2 * mu_eff_neg / (self.mu_eff + 2)
        alpha_pos_def = (1 - self.c_mu - self.c_1) / (d * self.c_mu)
        alpha = min([alpha_mu, alpha_mu_eff, alpha_pos_def])

        # Rescale negative weights
        negative_weights = alpha * negative_weights / torch.sum(torch.abs(negative_weights))
    else:
        # Negative weights are simply zero
        negative_weights = torch.zeros_like(negative_weights)

    # Concatenate final weights
    self.weights = torch.cat([positive_weights, negative_weights], dim=-1)

    # === Some final setup ===

    # Initialize the evolution paths
    self.p_sigma = 0.0
    self.p_c = 0.0

    # Hansen's approximation to the expectation of ||x|| x ~ N(0, I_d).
    # Note that we could use the exact formulation with Gamma functions, but we'll retain this form for consistency
    self.unbiased_expectation = np.sqrt(d) * (1 - (1 / (4 * d)) + 1 / (21 * d**2))

    self.last_ex = None

    # How often to decompose C
    if limit_C_decomposition:
        self.decompose_C_freq = max(1, int(np.floor(_safe_divide(1, 10 * d * (self.c_1.cpu() + self.c_mu.cpu())))))
    else:
        self.decompose_C_freq = 1

    # Use the SinglePopulationAlgorithmMixin to enable additional status reports regarding the population.
    SinglePopulationAlgorithmMixin.__init__(self)
decompose_C(self)

Perform the decomposition C = AA^T using a cholesky decomposition Note that traditionally CMA-ES uses the eigendecomposition C = BDDB^-1. In our case, we keep track of zs, ys and xs when sampling, so we never need C^-½. Therefore, a cholesky decomposition is all that is necessary. This generally requires O(d^3/3) operations, rather than the more costly O(d^3) operations associated with the eigendecomposition.

Source code in evotorch/algorithms/cmaes.py
def decompose_C(self) -> None:
    """Perform the decomposition C = AA^T using a cholesky decomposition
    Note that traditionally CMA-ES uses the eigendecomposition C = BDDB^-1. In our case,
    we keep track of zs, ys and xs when sampling, so we never need C^-1/2.
    Therefore, a cholesky decomposition is all that is necessary. This generally requires
    O(d^3/3) operations, rather than the more costly O(d^3) operations associated with the eigendecomposition.
    """
    if self.separable:
        self.A = self.C.pow(0.5)
    else:
        self.A = torch.linalg.cholesky(self.C)
get_population_weights(self, xs)

Get the assigned weights of the population (e.g. evaluate, rank and return)

Parameters:

Name Type Description Default
xs torch.Tensor

The population samples drawn from N(mu, sigma^2 C)

required

Returns:

Type Description
assigned_weights (torch.Tensor)

A [popsize, ] dimensional tensor of ordered weights

Source code in evotorch/algorithms/cmaes.py
def get_population_weights(self, xs: torch.Tensor) -> torch.Tensor:
    """Get the assigned weights of the population (e.g. evaluate, rank and return)
    Args:
        xs (torch.Tensor): The population samples drawn from N(mu, sigma^2 C)
    Returns:
        assigned_weights (torch.Tensor): A [popsize, ] dimensional tensor of ordered weights
    """
    # Computation is O(popsize * F_time) where F_time is the evalutation time per sample
    # Fill the population
    self._population.set_values(xs)
    # Evaluate the current population
    self.problem.evaluate(self._population)
    # Sort the population
    indices = self._population.argsort(obj_index=self.obj_index)
    # Invert the sorting of the population to obtain the ranks
    # Note that these ranks start at zero, but this is fine as we are just using them for indexing
    ranks = torch.zeros_like(indices)
    ranks[indices] = torch.arange(self.popsize, dtype=indices.dtype, device=indices.device)
    # Get weights corresponding to each rank
    assigned_weights = self.weights[ranks]
    return assigned_weights
sample_distribution(self, num_samples=None)

Sample the population. All 3 representations of solutions are returned for easy calculations of updates. Note that the computation time of this operation of O(d^2 num_samples) unless separable, in which case O(d num_samples)

Parameters:

Name Type Description Default
num_samples Optional[int]

The number of samples to draw. If None, then the population size is used

None

Returns:

Type Description
zs (torch.Tensor)

A tensor of shape [num_samples, d] of samples from the local coordinate space e.g. z_i ~ N(0, I_d) ys (torch.Tensor): A tensor of shape [num_samples, d] of samples from the shaped coordinate space e.g. y_i ~ N(0, C) xs (torch.Tensor): A tensor of shape [num_samples, d] of samples from the search space e.g. x_i ~ N(m, sigma^2 C)

Source code in evotorch/algorithms/cmaes.py
def sample_distribution(self, num_samples: Optional[int] = None) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
    """Sample the population. All 3 representations of solutions are returned for easy calculations of updates.
    Note that the computation time of this operation of O(d^2 num_samples) unless separable, in which case O(d num_samples)
    Args:
        num_samples (Optional[int]): The number of samples to draw. If None, then the population size is used
    Returns:
        zs (torch.Tensor): A tensor of shape [num_samples, d] of samples from the local coordinate space e.g. z_i ~ N(0, I_d)
        ys (torch.Tensor): A tensor of shape [num_samples, d] of samples from the shaped coordinate space e.g. y_i ~ N(0, C)
        xs (torch.Tensor): A tensor of shape [num_samples, d] of samples from the search space e.g. x_i ~ N(m, sigma^2 C)
    """
    if num_samples is None:
        num_samples = self.popsize
    # Generate z values
    zs = self._problem.make_gaussian(num_solutions=num_samples)
    # Construct ys = A zs
    if self.separable:
        # In the separable case A is diagonal so is represented as a single vector
        ys = self.A.unsqueeze(0) * zs
    else:
        ys = (self.A @ zs.T).T
    # Construct xs = m + sigma ys
    xs = self.m.unsqueeze(0) + self.sigma * ys
    return zs, ys, xs
update_C(self, zs, ys, assigned_weights, h_sig)

Update the covariance shape matrix C based on rank-1 and rank-mu updates This operation is bounded O(d^2 popsize), which is associated with computing the rank-mu update (summing across popsize d*d matrices)

Parameters:

Name Type Description Default
zs torch.Tensor

A tensor of shape [popsize, d] of samples from the local coordinate space e.g. z_i ~ N(0, I_d)

required
ys torch.Tensor

A tensor of shape [popsize, d] of samples from the shaped coordinate space e.g. y_i ~ N(0, C)

required
assigned_weights torch.Tensor

A [popsize, ] dimensional tensor of ordered weights

required
h_sig torch.Tensor

Whether to stall the update based on the evolution path on sigma, p_sigma, expressed as a torch float

required
Source code in evotorch/algorithms/cmaes.py
def update_C(self, zs: torch.Tensor, ys: torch.Tensor, assigned_weights: torch.Tensor, h_sig: torch.Tensor) -> None:
    """Update the covariance shape matrix C based on rank-1 and rank-mu updates
    This operation is bounded O(d^2 popsize), which is associated with computing the rank-mu update (summing across popsize d*d matrices)
    Args:
        zs (torch.Tensor): A tensor of shape [popsize, d] of samples from the local coordinate space e.g. z_i ~ N(0, I_d)
        ys (torch.Tensor): A tensor of shape [popsize, d] of samples from the shaped coordinate space e.g. y_i ~ N(0, C)
        assigned_weights (torch.Tensor): A [popsize, ] dimensional tensor of ordered weights
        h_sig (torch.Tensor): Whether to stall the update based on the evolution path on sigma, p_sigma, expressed as a torch float
    """
    d = self._problem.solution_length
    # If using Active CMA-ES, reweight negative weights
    if self.active:
        assigned_weights = torch.where(
            assigned_weights > 0, assigned_weights, d * assigned_weights / torch.norm(zs, dim=-1).pow(2.0)
        )
    c1a = self.c_1 * (1 - (1 - h_sig**2) * self.c_c * (2 - self.c_c))  # adjust for variance loss
    weighted_pc = (self.c_1 / (c1a + 1e-23)) ** 0.5
    if self.separable:
        # Rank-1 update
        r1_update = c1a * (self.p_c.pow(2.0) - self.C)
        # Rank-mu update
        rmu_update = self.c_mu * torch.sum(
            assigned_weights.unsqueeze(-1) * (ys.pow(2.0) - self.C.unsqueeze(0)), dim=0
        )
    else:
        # Rank-1 update
        r1_update = c1a * (torch.outer(weighted_pc * self.p_c, weighted_pc * self.p_c) - self.C)
        # Rank-mu update
        rmu_update = self.c_mu * (
            torch.sum(assigned_weights.unsqueeze(-1).unsqueeze(-1) * (ys.unsqueeze(1) * ys.unsqueeze(2)), dim=0)
            - torch.sum(self.weights) * self.C
        )

    # Update C
    self.C = self.C + r1_update + rmu_update
update_m(self, zs, ys, assigned_weights)

Update the center of the search distribution m With zs and ys retained from sampling, this operation is O(popsize d), as it involves summing across popsize d-dimensional vectors.

Parameters:

Name Type Description Default
zs torch.Tensor

A tensor of shape [popsize, d] of samples from the local coordinate space e.g. z_i ~ N(0, I_d)

required
ys torch.Tensor

A tensor of shape [popsize, d] of samples from the shaped coordinate space e.g. y_i ~ N(0, C)

required
assigned_weights torch.Tensor

A [popsize, ] dimensional tensor of ordered weights

required

Returns:

Type Description
local_m_displacement (torch.Tensor)

A tensor of shape [d], corresponding to the local transformation of m, (1/sigma) (C^-½) (m' - m) where m' is the updated m shaped_m_displacement (torch.Tensor): A tensor of shape [d], corresponding to the shaped transformation of m, (1/sigma) (m' - m) where m' is the updated m

Source code in evotorch/algorithms/cmaes.py
def update_m(self, zs: torch.Tensor, ys: torch.Tensor, assigned_weights: torch.Tensor) -> torch.Tensor:
    """Update the center of the search distribution m
    With zs and ys retained from sampling, this operation is O(popsize d), as it involves summing across popsize d-dimensional vectors.
    Args:
        zs (torch.Tensor): A tensor of shape [popsize, d] of samples from the local coordinate space e.g. z_i ~ N(0, I_d)
        ys (torch.Tensor): A tensor of shape [popsize, d] of samples from the shaped coordinate space e.g. y_i ~ N(0, C)
        assigned_weights (torch.Tensor): A [popsize, ] dimensional tensor of ordered weights
    Returns:
        local_m_displacement (torch.Tensor): A tensor of shape [d], corresponding to the local transformation of m,
            (1/sigma) (C^-1/2) (m' - m) where m' is the updated m
        shaped_m_displacement (torch.Tensor): A tensor of shape [d], corresponding to the shaped transformation of m,
            (1/sigma) (m' - m) where m' is the updated m
    """
    # Get the top-mu weights
    top_mu = torch.topk(assigned_weights, k=self.mu)
    top_mu_weights = top_mu.values
    top_mu_indices = top_mu.indices

    # Compute the weighted recombination in local coordinate space
    local_m_displacement = torch.sum(top_mu_weights.unsqueeze(-1) * zs[top_mu_indices], dim=0)
    # Compute the weighted recombination in shaped coordinate space
    shaped_m_displacement = torch.sum(top_mu_weights.unsqueeze(-1) * ys[top_mu_indices], dim=0)

    # Update m
    self.m = self.m + self.c_m * self.sigma * shaped_m_displacement

    # Return the weighted recombinations
    return local_m_displacement, shaped_m_displacement
update_p_c(self, shaped_m_displacement, h_sig)

Update the evolution path for rank-1 update, p_c This operation is bounded O(d), as is simply the sum of vectors

Parameters:

Name Type Description Default
local_m_displacement torch.Tensor

The weighted recombination of shaped samples ys, corresponding to (1/sigma) (m' - m) where m' is the updated m

required
h_sig torch.Tensor

Whether to stall the update based on the evolution path on sigma, p_sigma, expressed as a torch float

required
Source code in evotorch/algorithms/cmaes.py
def update_p_c(self, shaped_m_displacement: torch.Tensor, h_sig: torch.Tensor) -> None:
    """Update the evolution path for rank-1 update, p_c
    This operation is bounded O(d), as is simply the sum of vectors
    Args:
        local_m_displacement (torch.Tensor): The weighted recombination of shaped samples ys, corresponding to
            (1/sigma) (m' - m) where m' is the updated m
        h_sig (torch.Tensor): Whether to stall the update based on the evolution path on sigma, p_sigma, expressed as a torch float
    """
    self.p_c = (1 - self.c_c) * self.p_c + h_sig * self.variance_discount_c * shaped_m_displacement
update_p_sigma(self, local_m_displacement)

Update the evolution path for sigma, p_sigma This operation is bounded O(d), as is simply the sum of vectors

Parameters:

Name Type Description Default
local_m_displacement torch.Tensor

The weighted recombination of local samples zs, corresponding to (1/sigma) (C^-½) (m' - m) where m' is the updated m

required
Source code in evotorch/algorithms/cmaes.py
def update_p_sigma(self, local_m_displacement: torch.Tensor) -> None:
    """Update the evolution path for sigma, p_sigma
    This operation is bounded O(d), as is simply the sum of vectors
    Args:
        local_m_displacement (torch.Tensor): The weighted recombination of local samples zs, corresponding to
            (1/sigma) (C^-1/2) (m' - m) where m' is the updated m
    """
    self.p_sigma = (1 - self.c_sigma) * self.p_sigma + self.variance_discount_sigma * local_m_displacement
update_sigma(self)

Update the step size sigma according to its evolution path p_sigma This operation is bounded O(d), with the most expensive component being the norm of the evolution path, a d-dimensional vector.

Source code in evotorch/algorithms/cmaes.py
def update_sigma(self) -> None:
    """Update the step size sigma according to its evolution path p_sigma
    This operation is bounded O(d), with the most expensive component being the norm of the evolution path, a d-dimensional vector.
    """
    d = self._problem.solution_length
    # Compute the exponential update
    if self.csa_squared:
        # Exponential update based on natural gradient maximizing squared norm of p_sigma
        exponential_update = (torch.norm(self.p_sigma).pow(2.0) / d - 1) / 2
    else:
        # Exponential update increasing likelihood p_sigma having expected norm
        exponential_update = torch.norm(self.p_sigma) / self.unbiased_expectation - 1
    # Rescale exponential update based on learning rate + damping factor
    exponential_update = (self.c_sigma / self.damp_sigma) * exponential_update
    # Multiplicative update to sigma
    self.sigma = self.sigma * torch.exp(exponential_update)

distributed special

gaussian

CEM (GaussianSearchAlgorithm)

The cross-entropy method (CEM) (Rubinstein, 1999).

This CEM implementation is focused on continuous optimization, and follows the variant explained in Duan et al. (2016).

The adaptive population size mechanism explained in Toklu et al. (2020) (and previously used in the accompanying source code of the study Salimans et al. (2017)) is supported, where the population size in an iteration keeps increasing until a certain numberof interactions with the simulator of the reinforcement learning environment is made. See the initialization arguments num_interactions, popsize_max.

References:

Rubinstein, R. (1999). The cross-entropy method for combinatorial
and continuous optimization.
Methodology and computing in applied probability, 1(2), 127-190.

Duan, Y., Chen, X., Houthooft, R., Schulman, J., Abbeel, P. (2016).
Benchmarking deep reinforcement learning for continuous control.
International conference on machine learning. PMLR, 2016.

Salimans, T., Ho, J., Chen, X., Sidor, S. and Sutskever, I. (2017).
Evolution Strategies as a Scalable Alternative to
Reinforcement Learning.

Toklu, N.E., Liskowski, P., Srivastava, R.K. (2020).
ClipUp: A Simple and Powerful Optimizer
for Distribution-based Policy Evolution.
Parallel Problem Solving from Nature (PPSN 2020).
Source code in evotorch/algorithms/distributed/gaussian.py
class CEM(GaussianSearchAlgorithm):
    """
    The cross-entropy method (CEM) (Rubinstein, 1999).

    This CEM implementation is focused on continuous optimization,
    and follows the variant explained in Duan et al. (2016).

    The adaptive population size mechanism explained in Toklu et al. (2020)
    (and previously used in the accompanying source code of the study
    Salimans et al. (2017)) is supported, where the population size in an
    iteration keeps increasing until a certain numberof interactions with
    the simulator of the reinforcement learning environment is made.
    See the initialization arguments `num_interactions`, `popsize_max`.

    References:

        Rubinstein, R. (1999). The cross-entropy method for combinatorial
        and continuous optimization.
        Methodology and computing in applied probability, 1(2), 127-190.

        Duan, Y., Chen, X., Houthooft, R., Schulman, J., Abbeel, P. (2016).
        Benchmarking deep reinforcement learning for continuous control.
        International conference on machine learning. PMLR, 2016.

        Salimans, T., Ho, J., Chen, X., Sidor, S. and Sutskever, I. (2017).
        Evolution Strategies as a Scalable Alternative to
        Reinforcement Learning.

        Toklu, N.E., Liskowski, P., Srivastava, R.K. (2020).
        ClipUp: A Simple and Powerful Optimizer
        for Distribution-based Policy Evolution.
        Parallel Problem Solving from Nature (PPSN 2020).
    """

    DISTRIBUTION_TYPE = SeparableGaussian
    DISTRIBUTION_PARAMS = NotImplemented  # To be filled by the CEM instance

    def __init__(
        self,
        problem: Problem,
        *,
        popsize: int,
        parenthood_ratio: float,
        stdev_init: Optional[RealOrVector] = None,
        radius_init: Optional[RealOrVector] = None,
        num_interactions: Optional[int] = None,
        popsize_max: Optional[int] = None,
        center_init: Optional[RealOrVector] = None,
        stdev_min: Optional[RealOrVector] = None,
        stdev_max: Optional[RealOrVector] = None,
        stdev_max_change: Optional[Union[float, RealOrVector]] = None,
        obj_index: Optional[int] = None,
        distributed: bool = False,
        popsize_weighted_grad_avg: Optional[bool] = None,
    ):
        """
        `__init__(...)`: Initialize the search algorithm.

        Args:
            problem: The problem object to work on.
            popsize: The population size.
            parenthood_ratio: Expected as a float larger than 0 and smaller
                than 1. For example, setting this value to 0.1 means that
                the top 10% of the population will be declared as the parents,
                and those parents will be used for updating the population.
                The amount of parents is always computed according to the
                specified `popsize`, not according to the adapted population
                size, and not according to `popsize_max`.
            stdev_init: The initial standard deviation of the search
                distribution, expressed as a scalar or as an array.
                Determines the initial coverage area of the search
                distribution.
                If one wishes to configure the coverage area via the
                argument `radius_init` instead, then `stdev_init` is expected
                as None.
            radius_init: The initial radius of the search distribution,
                expressed as a scalar.
                Determines the initial coverage area of the search
                distribution.
                Here, "radius" is defined as the norm of the search
                distribution.
                If one wishes to configure the coverage area via the
                argument `stdev_init` instead, then `radius_init` is expected
                as None.
            num_interactions: When given as an integer n,
                it is ensured that a population has interacted with
                the GymProblem's environment n times. If this target
                has not been reached yet, then the population is declared
                too small, and gets extended with more samples,
                until n amount of interactions is reached.
                When given as None, popsize is the only configuration
                affecting the size of a population.
            popsize_max: Having `num_interactions` set as an integer
                might cause the effective population size jump to
                unnecesarily large numbers. To prevent this,
                one can set `popsize_max` to specify an upper
                bound for the effective population size.
            center_init: The initial center solution.
                Can be left as None.
            stdev_min: The minimum value for the standard deviation
                values of the Gaussian search distribution.
                Can be left as None (which is the default),
                or can be given as a scalar or as a 1-dimensional array.
            stdev_max: The maximum value for the standard deviation
                values of the Gaussian search distribution.
                Can be left as None (which is the default),
                or can be given as a scalar or as a 1-dimensional array.
            stdev_max_change: The maximum update ratio allowed on the
                standard deviation. Expected as None if no such limiter
                is needed, or as a real number within 0.0 and 1.0 otherwise.
                In the PGPE implementation of Ha (2017, 2018), a value of
                0.2 (20%) was used.
                For this CEM implementation, the default is None.
            obj_index: Index of the objective according to which the
                gradient estimations will be done.
                For single-objective problems, this can be left as None.
            distributed: Whether or not the gradient computation will
                be distributed. If `distributed` is given as False and
                the problem is not parallelized, then everything will
                be centralized (i.e. the entire computation will happen
                in the main process).
                If `distributed` is given as False, and the problem
                is parallelized, then the population will be created
                in the main process and then sent to remote workers
                for parallelized evaluation, and then the remote fitnesses
                will be collected by the main process again for computing
                the search gradients.
                If `distributed` is given as True, and the problem
                is parallelized, then the search algorithm itself will
                be distributed, in the sense that each remote actor will
                generate its own population (such that the total population
                size across all these actors becomes equal to `popsize`)
                and will compute its own gradient, and then the main process
                will collect these gradients, compute the averaged gradients
                and update the main search distribution.
                Non-distributed mode has the advantage of keeping the
                population in the main process, which is good when one wishes
                to do detailed monitoring during the evolutionary process,
                but has the disadvantage of having to pass the solutions to
                the remote actors and having to collect fitnesses, which
                might result in increased interprocess communication traffic.
                On the other hand, while it is not possible to monitor the
                population in distributed mode, the distributed mode has the
                advantage of significantly reducing the interprocess
                communication traffic, since the only things communicated
                with the remote actors are the search distributions (not the
                solutions) and the gradients.
            popsize_weighted_grad_avg: Only to be used in distributed mode.
                (where being in distributed mode means `distributed` is given
                as True). In distributed mode, each actor remotely samples
                its own solution batches and computes its own gradients.
                These gradients are then collected, and a final average
                gradient is computed.
                If `popsize_weighted_grad_avg` is True, then, while averaging
                over the gradients, each gradient will have its own weight
                that is computed according to how many solutions were sampled
                by the actor that produced the gradient.
                If `popsize_weighted_grad_avg` is False, then, there will not
                be weighted averaging (or, each gradient will have equal
                weight).
                If `popsize_weighted_grad_avg` is None, then, the gradient
                weights will be equal a value for `num_interactions` is given
                (because `num_interactions` affects the number of solutions
                according to the episode lengths, and popsize-weighting the
                gradients could be misleading); and the gradient weights will
                be weighted according to the sub-population (i.e. sub-batch)
                sizes if `num_interactions` is left as None.
                The default value for `popsize_weighted_grad_avg` is None.
                When the distributed mode is disabled (i.e. when `distributed`
                is False), then the argument `popsize_weighted_grad_avg` is
                expected as None.
        """

        self.DISTRIBUTION_PARAMS = {"parenthood_ratio": float(parenthood_ratio)}

        super().__init__(
            problem,
            popsize=popsize,
            center_learning_rate=1.0,
            stdev_learning_rate=1.0,
            stdev_init=stdev_init,
            radius_init=radius_init,
            popsize_max=popsize_max,
            num_interactions=num_interactions,
            optimizer=None,
            optimizer_config=None,
            ranking_method=None,
            center_init=center_init,
            stdev_min=stdev_min,
            stdev_max=stdev_max,
            stdev_max_change=stdev_max_change,
            obj_index=obj_index,
            distributed=distributed,
            popsize_weighted_grad_avg=popsize_weighted_grad_avg,
        )
DISTRIBUTION_TYPE (Distribution)

Separable Multivariate Gaussian, as used by PGPE

Source code in evotorch/algorithms/distributed/gaussian.py
class SeparableGaussian(Distribution):
    """Separable Multivariate Gaussian, as used by PGPE"""

    MANDATORY_PARAMETERS = {"mu", "sigma"}
    OPTIONAL_PARAMETERS = {"divide_mu_grad_by", "divide_sigma_grad_by", "parenthood_ratio"}

    def __init__(
        self,
        parameters: dict,
        *,
        solution_length: Optional[int] = None,
        device: Optional[Device] = None,
        dtype: Optional[DType] = None,
    ):
        [mu_length] = parameters["mu"].shape
        [sigma_length] = parameters["sigma"].shape

        if solution_length is None:
            solution_length = mu_length
        else:
            if solution_length != mu_length:
                raise ValueError(
                    f"The argument `solution_length` does not match the length of `mu` provided in `parameters`."
                    f" solution_length={solution_length},"
                    f' parameters["mu"]={mu_length}.'
                )

        if mu_length != sigma_length:
            raise ValueError(
                f"The tensors `mu` and `sigma` provided within `parameters` have mismatching lengths."
                f' parameters["mu"]={mu_length},'
                f' parameters["sigma"]={sigma_length}.'
            )

        super().__init__(
            solution_length=solution_length,
            parameters=parameters,
            device=device,
            dtype=dtype,
        )

    @property
    def mu(self) -> torch.Tensor:
        return self.parameters["mu"]

    @mu.setter
    def mu(self, new_mu: Iterable):
        self.parameters["mu"] = torch.as_tensor(new_mu, dtype=self.dtype, device=self.device)

    @property
    def sigma(self) -> torch.Tensor:
        return self.parameters["sigma"]

    @sigma.setter
    def sigma(self, new_sigma: Iterable):
        self.parameters["sigma"] = torch.as_tensor(new_sigma, dtype=self.dtype, device=self.device)

    def _fill(self, out: torch.Tensor, *, generator: Optional[torch.Generator] = None):
        self.make_gaussian(out=out, center=self.mu, stdev=self.sigma, generator=generator)

    def _divide_grad(self, param_name: str, grad: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
        option = f"divide_{param_name}_grad_by"
        if option in self.parameters:
            div_by_what = self.parameters[option]
            if div_by_what == "num_solutions":
                [num_solutions] = weights.shape
                grad = grad / num_solutions
            elif div_by_what == "num_directions":
                [num_solutions] = weights.shape
                num_directions = num_solutions // 2
                grad = grad / num_directions
            elif div_by_what == "total_weight":
                total_weight = torch.sum(torch.abs(weights))
                grad = grad / total_weight
            elif div_by_what == "weight_stdev":
                weight_stdev = torch.std(weights)
                grad = grad / weight_stdev
            else:
                raise ValueError(f"The parameter {option} has an unrecognized value: {div_by_what}")
        return grad

    def _compute_gradients_via_parenthood_ratio(self, samples: torch.Tensor, weights: torch.Tensor) -> dict:
        [num_samples, _] = samples.shape
        num_elites = math.floor(num_samples * self.parameters["parenthood_ratio"])
        elite_indices = weights.argsort(descending=True)[:num_elites]
        elites = samples[elite_indices, :]
        return {
            "mu": torch.mean(elites, dim=0) - self.parameters["mu"],
            "sigma": torch.std(elites, dim=0) - self.parameters["sigma"],
        }

    def _compute_gradients(self, samples: torch.Tensor, weights: torch.Tensor, ranking_used: Optional[str]) -> dict:
        if "parenthood_ratio" in self.parameters:
            return self._compute_gradients_via_parenthood_ratio(samples, weights)
        else:
            mu = self.mu
            sigma = self.sigma

            # Compute the scaled noises, that is, the noise vectors which
            # were used for generating the solutions
            # (solution = scaled_noise + center)
            scaled_noises = samples - mu

            # Make sure that the weights (utilities) are 0-centered
            # (Otherwise the formulations would have to consider a bias term)
            if ranking_used not in ("centered", "normalized"):
                weights = weights - torch.mean(weights)

            mu_grad = self._divide_grad(
                "mu",
                total(dot(weights, scaled_noises)),
                weights,
            )
            sigma_grad = self._divide_grad(
                "sigma",
                total(dot(weights, ((scaled_noises**2) - (sigma**2)) / sigma)),
                weights,
            )

            return {
                "mu": mu_grad,
                "sigma": sigma_grad,
            }

    def update_parameters(
        self,
        gradients: dict,
        *,
        learning_rates: Optional[dict] = None,
        optimizers: Optional[dict] = None,
    ) -> "SeparableGaussian":
        mu_grad = gradients["mu"]
        sigma_grad = gradients["sigma"]

        new_mu = self.mu + self._follow_gradient("mu", mu_grad, learning_rates=learning_rates, optimizers=optimizers)
        new_sigma = self.sigma + self._follow_gradient(
            "sigma", sigma_grad, learning_rates=learning_rates, optimizers=optimizers
        )

        return self.modified_copy(mu=new_mu, sigma=new_sigma)

    def relative_entropy(dist_0: "SeparableGaussian", dist_1: "SeparableGaussian") -> float:
        mu_0 = dist_0.parameters["mu"]
        mu_1 = dist_1.parameters["mu"]
        sigma_0 = dist_0.parameters["sigma"]
        sigma_1 = dist_1.parameters["sigma"]
        cov_0 = sigma_0.pow(2.0)
        cov_1 = sigma_1.pow(2.0)

        mu_delta = mu_1 - mu_0

        trace_cov = torch.sum(cov_0 / cov_1)
        k = dist_0.solution_length
        scaled_mu = torch.sum(mu_delta.pow(2.0) / cov_1)
        log_det = torch.sum(torch.log(cov_1)) - torch.sum(torch.log(cov_0))

        return 0.5 * (trace_cov - k + scaled_mu + log_det)
update_parameters(self, gradients, *, learning_rates=None, optimizers=None)

Do an update on the distribution by following the given gradients.

It is expected that the inheriting class has its own implementation for this method.

Parameters:

Name Type Description Default
gradients dict

Gradients, as a dictionary, which will be used for computing the necessary updates.

required
learning_rates Optional[dict]

A dictionary which contains learning rates for parameters that will be updated using a learning rate coefficient.

None
optimizers Optional[dict]

A dictionary which contains optimizer objects for parameters that will be updated using an adaptive optimizer.

None

Returns:

Type Description
SeparableGaussian

The updated copy of the distribution.

Source code in evotorch/algorithms/distributed/gaussian.py
def update_parameters(
    self,
    gradients: dict,
    *,
    learning_rates: Optional[dict] = None,
    optimizers: Optional[dict] = None,
) -> "SeparableGaussian":
    mu_grad = gradients["mu"]
    sigma_grad = gradients["sigma"]

    new_mu = self.mu + self._follow_gradient("mu", mu_grad, learning_rates=learning_rates, optimizers=optimizers)
    new_sigma = self.sigma + self._follow_gradient(
        "sigma", sigma_grad, learning_rates=learning_rates, optimizers=optimizers
    )

    return self.modified_copy(mu=new_mu, sigma=new_sigma)
__init__(self, problem, *, popsize, parenthood_ratio, stdev_init=None, radius_init=None, num_interactions=None, popsize_max=None, center_init=None, stdev_min=None, stdev_max=None, stdev_max_change=None, obj_index=None, distributed=False, popsize_weighted_grad_avg=None) special

__init__(...): Initialize the search algorithm.

Parameters:

Name Type Description Default
problem Problem

The problem object to work on.

required
popsize int

The population size.

required
parenthood_ratio float

Expected as a float larger than 0 and smaller than 1. For example, setting this value to 0.1 means that the top 10% of the population will be declared as the parents, and those parents will be used for updating the population. The amount of parents is always computed according to the specified popsize, not according to the adapted population size, and not according to popsize_max.

required
stdev_init Union[float, Iterable[float], torch.Tensor]

The initial standard deviation of the search distribution, expressed as a scalar or as an array. Determines the initial coverage area of the search distribution. If one wishes to configure the coverage area via the argument radius_init instead, then stdev_init is expected as None.

None
radius_init Union[float, Iterable[float], torch.Tensor]

The initial radius of the search distribution, expressed as a scalar. Determines the initial coverage area of the search distribution. Here, "radius" is defined as the norm of the search distribution. If one wishes to configure the coverage area via the argument stdev_init instead, then radius_init is expected as None.

None
num_interactions Optional[int]

When given as an integer n, it is ensured that a population has interacted with the GymProblem's environment n times. If this target has not been reached yet, then the population is declared too small, and gets extended with more samples, until n amount of interactions is reached. When given as None, popsize is the only configuration affecting the size of a population.

None
popsize_max Optional[int]

Having num_interactions set as an integer might cause the effective population size jump to unnecesarily large numbers. To prevent this, one can set popsize_max to specify an upper bound for the effective population size.

None
center_init Union[float, Iterable[float], torch.Tensor]

The initial center solution. Can be left as None.

None
stdev_min Union[float, Iterable[float], torch.Tensor]

The minimum value for the standard deviation values of the Gaussian search distribution. Can be left as None (which is the default), or can be given as a scalar or as a 1-dimensional array.

None
stdev_max Union[float, Iterable[float], torch.Tensor]

The maximum value for the standard deviation values of the Gaussian search distribution. Can be left as None (which is the default), or can be given as a scalar or as a 1-dimensional array.

None
stdev_max_change Union[float, Iterable[float], torch.Tensor]

The maximum update ratio allowed on the standard deviation. Expected as None if no such limiter is needed, or as a real number within 0.0 and 1.0 otherwise. In the PGPE implementation of Ha (2017, 2018), a value of 0.2 (20%) was used. For this CEM implementation, the default is None.

None
obj_index Optional[int]

Index of the objective according to which the gradient estimations will be done. For single-objective problems, this can be left as None.

None
distributed bool

Whether or not the gradient computation will be distributed. If distributed is given as False and the problem is not parallelized, then everything will be centralized (i.e. the entire computation will happen in the main process). If distributed is given as False, and the problem is parallelized, then the population will be created in the main process and then sent to remote workers for parallelized evaluation, and then the remote fitnesses will be collected by the main process again for computing the search gradients. If distributed is given as True, and the problem is parallelized, then the search algorithm itself will be distributed, in the sense that each remote actor will generate its own population (such that the total population size across all these actors becomes equal to popsize) and will compute its own gradient, and then the main process will collect these gradients, compute the averaged gradients and update the main search distribution. Non-distributed mode has the advantage of keeping the population in the main process, which is good when one wishes to do detailed monitoring during the evolutionary process, but has the disadvantage of having to pass the solutions to the remote actors and having to collect fitnesses, which might result in increased interprocess communication traffic. On the other hand, while it is not possible to monitor the population in distributed mode, the distributed mode has the advantage of significantly reducing the interprocess communication traffic, since the only things communicated with the remote actors are the search distributions (not the solutions) and the gradients.

False
popsize_weighted_grad_avg Optional[bool]

Only to be used in distributed mode. (where being in distributed mode means distributed is given as True). In distributed mode, each actor remotely samples its own solution batches and computes its own gradients. These gradients are then collected, and a final average gradient is computed. If popsize_weighted_grad_avg is True, then, while averaging over the gradients, each gradient will have its own weight that is computed according to how many solutions were sampled by the actor that produced the gradient. If popsize_weighted_grad_avg is False, then, there will not be weighted averaging (or, each gradient will have equal weight). If popsize_weighted_grad_avg is None, then, the gradient weights will be equal a value for num_interactions is given (because num_interactions affects the number of solutions according to the episode lengths, and popsize-weighting the gradients could be misleading); and the gradient weights will be weighted according to the sub-population (i.e. sub-batch) sizes if num_interactions is left as None. The default value for popsize_weighted_grad_avg is None. When the distributed mode is disabled (i.e. when distributed is False), then the argument popsize_weighted_grad_avg is expected as None.

None
Source code in evotorch/algorithms/distributed/gaussian.py
def __init__(
    self,
    problem: Problem,
    *,
    popsize: int,
    parenthood_ratio: float,
    stdev_init: Optional[RealOrVector] = None,
    radius_init: Optional[RealOrVector] = None,
    num_interactions: Optional[int] = None,
    popsize_max: Optional[int] = None,
    center_init: Optional[RealOrVector] = None,
    stdev_min: Optional[RealOrVector] = None,
    stdev_max: Optional[RealOrVector] = None,
    stdev_max_change: Optional[Union[float, RealOrVector]] = None,
    obj_index: Optional[int] = None,
    distributed: bool = False,
    popsize_weighted_grad_avg: Optional[bool] = None,
):
    """
    `__init__(...)`: Initialize the search algorithm.

    Args:
        problem: The problem object to work on.
        popsize: The population size.
        parenthood_ratio: Expected as a float larger than 0 and smaller
            than 1. For example, setting this value to 0.1 means that
            the top 10% of the population will be declared as the parents,
            and those parents will be used for updating the population.
            The amount of parents is always computed according to the
            specified `popsize`, not according to the adapted population
            size, and not according to `popsize_max`.
        stdev_init: The initial standard deviation of the search
            distribution, expressed as a scalar or as an array.
            Determines the initial coverage area of the search
            distribution.
            If one wishes to configure the coverage area via the
            argument `radius_init` instead, then `stdev_init` is expected
            as None.
        radius_init: The initial radius of the search distribution,
            expressed as a scalar.
            Determines the initial coverage area of the search
            distribution.
            Here, "radius" is defined as the norm of the search
            distribution.
            If one wishes to configure the coverage area via the
            argument `stdev_init` instead, then `radius_init` is expected
            as None.
        num_interactions: When given as an integer n,
            it is ensured that a population has interacted with
            the GymProblem's environment n times. If this target
            has not been reached yet, then the population is declared
            too small, and gets extended with more samples,
            until n amount of interactions is reached.
            When given as None, popsize is the only configuration
            affecting the size of a population.
        popsize_max: Having `num_interactions` set as an integer
            might cause the effective population size jump to
            unnecesarily large numbers. To prevent this,
            one can set `popsize_max` to specify an upper
            bound for the effective population size.
        center_init: The initial center solution.
            Can be left as None.
        stdev_min: The minimum value for the standard deviation
            values of the Gaussian search distribution.
            Can be left as None (which is the default),
            or can be given as a scalar or as a 1-dimensional array.
        stdev_max: The maximum value for the standard deviation
            values of the Gaussian search distribution.
            Can be left as None (which is the default),
            or can be given as a scalar or as a 1-dimensional array.
        stdev_max_change: The maximum update ratio allowed on the
            standard deviation. Expected as None if no such limiter
            is needed, or as a real number within 0.0 and 1.0 otherwise.
            In the PGPE implementation of Ha (2017, 2018), a value of
            0.2 (20%) was used.
            For this CEM implementation, the default is None.
        obj_index: Index of the objective according to which the
            gradient estimations will be done.
            For single-objective problems, this can be left as None.
        distributed: Whether or not the gradient computation will
            be distributed. If `distributed` is given as False and
            the problem is not parallelized, then everything will
            be centralized (i.e. the entire computation will happen
            in the main process).
            If `distributed` is given as False, and the problem
            is parallelized, then the population will be created
            in the main process and then sent to remote workers
            for parallelized evaluation, and then the remote fitnesses
            will be collected by the main process again for computing
            the search gradients.
            If `distributed` is given as True, and the problem
            is parallelized, then the search algorithm itself will
            be distributed, in the sense that each remote actor will
            generate its own population (such that the total population
            size across all these actors becomes equal to `popsize`)
            and will compute its own gradient, and then the main process
            will collect these gradients, compute the averaged gradients
            and update the main search distribution.
            Non-distributed mode has the advantage of keeping the
            population in the main process, which is good when one wishes
            to do detailed monitoring during the evolutionary process,
            but has the disadvantage of having to pass the solutions to
            the remote actors and having to collect fitnesses, which
            might result in increased interprocess communication traffic.
            On the other hand, while it is not possible to monitor the
            population in distributed mode, the distributed mode has the
            advantage of significantly reducing the interprocess
            communication traffic, since the only things communicated
            with the remote actors are the search distributions (not the
            solutions) and the gradients.
        popsize_weighted_grad_avg: Only to be used in distributed mode.
            (where being in distributed mode means `distributed` is given
            as True). In distributed mode, each actor remotely samples
            its own solution batches and computes its own gradients.
            These gradients are then collected, and a final average
            gradient is computed.
            If `popsize_weighted_grad_avg` is True, then, while averaging
            over the gradients, each gradient will have its own weight
            that is computed according to how many solutions were sampled
            by the actor that produced the gradient.
            If `popsize_weighted_grad_avg` is False, then, there will not
            be weighted averaging (or, each gradient will have equal
            weight).
            If `popsize_weighted_grad_avg` is None, then, the gradient
            weights will be equal a value for `num_interactions` is given
            (because `num_interactions` affects the number of solutions
            according to the episode lengths, and popsize-weighting the
            gradients could be misleading); and the gradient weights will
            be weighted according to the sub-population (i.e. sub-batch)
            sizes if `num_interactions` is left as None.
            The default value for `popsize_weighted_grad_avg` is None.
            When the distributed mode is disabled (i.e. when `distributed`
            is False), then the argument `popsize_weighted_grad_avg` is
            expected as None.
    """

    self.DISTRIBUTION_PARAMS = {"parenthood_ratio": float(parenthood_ratio)}

    super().__init__(
        problem,
        popsize=popsize,
        center_learning_rate=1.0,
        stdev_learning_rate=1.0,
        stdev_init=stdev_init,
        radius_init=radius_init,
        popsize_max=popsize_max,
        num_interactions=num_interactions,
        optimizer=None,
        optimizer_config=None,
        ranking_method=None,
        center_init=center_init,
        stdev_min=stdev_min,
        stdev_max=stdev_max,
        stdev_max_change=stdev_max_change,
        obj_index=obj_index,
        distributed=distributed,
        popsize_weighted_grad_avg=popsize_weighted_grad_avg,
    )
GaussianSearchAlgorithm (SearchAlgorithm, SinglePopulationAlgorithmMixin)

Base class for search algorithms based on Gaussian distribution.

Source code in evotorch/algorithms/distributed/gaussian.py
class GaussianSearchAlgorithm(SearchAlgorithm, SinglePopulationAlgorithmMixin):
    """
    Base class for search algorithms based on Gaussian distribution.
    """

    DISTRIBUTION_TYPE = NotImplemented
    DISTRIBUTION_PARAMS = NotImplemented

    def __init__(
        self,
        problem: Problem,
        *,
        popsize: int,
        center_learning_rate: float,
        stdev_learning_rate: float,
        stdev_init: Optional[RealOrVector] = None,
        radius_init: Optional[RealOrVector] = None,
        num_interactions: Optional[int] = None,
        popsize_max: Optional[int] = None,
        optimizer=None,
        optimizer_config: Optional[dict] = None,
        ranking_method: Optional[str] = None,
        center_init: Optional[RealOrVector] = None,
        stdev_min: Optional[RealOrVector] = None,
        stdev_max: Optional[RealOrVector] = None,
        stdev_max_change: Optional[RealOrVector] = None,
        obj_index: Optional[int] = None,
        distributed: bool = False,
        popsize_weighted_grad_avg: Optional[bool] = None,
        ensure_even_popsize: bool = False,
    ):
        # Ensure that the problem is numeric
        problem.ensure_numeric()

        # The distribution-based algorithms we consider here cannot handle strict lower and upper bound constraints.
        # Therefore, we ensure that the given problem is unbounded.
        problem.ensure_unbounded()

        # Initialize the SearchAlgorithm, which is the parent class
        SearchAlgorithm.__init__(
            self,
            problem,
            center=self._get_mu,
            stdev=self._get_sigma,
            mean_eval=self._get_mean_eval,
        )

        self._ensure_even_popsize = bool(ensure_even_popsize)

        if not distributed:
            # self.add_status_getters({"median_eval": self._get_median_eval})
            if num_interactions is not None:
                self.add_status_getters({"popsize": self._get_popsize})
            if self._ensure_even_popsize:
                if (popsize % 2) != 0:
                    raise ValueError(
                        f"`popsize` was expected as an even number. However, the received `popsize` is {popsize}."
                    )

        if center_init is None:
            # If a starting point for the search distribution is not given,
            # then we use the problem object to generate us one.
            mu = problem.generate_values(1).reshape(-1)
        else:
            # If a starting point for the search distribution is given,
            # then we make sure that its length, dtype, and device
            # are correct.
            mu = problem.ensure_tensor_length_and_dtype(center_init, allow_scalar=False, about="center_init")

        # Get the standard deviation or the radius configuration from the arguments
        stdev_init = to_stdev_init(
            solution_length=problem.solution_length, stdev_init=stdev_init, radius_init=radius_init
        )

        # Make sure that the provided initial standard deviation is
        # of correct length, dtype, and device.
        sigma = problem.ensure_tensor_length_and_dtype(stdev_init, about="stdev_init", allow_scalar=False)

        # Create the distribution
        dist_cls = self.DISTRIBUTION_TYPE
        dist_params = deepcopy(self.DISTRIBUTION_PARAMS) if self.DISTRIBUTION_PARAMS is not None else {}
        dist_params.update({"mu": mu, "sigma": sigma})
        self._distribution: Distribution = dist_cls(dist_params, dtype=problem.dtype, device=problem.device)

        # Store the following keyword arguments to use later
        self._popsize = int(popsize)
        self._popsize_max = None if popsize_max is None else int(popsize_max)
        self._num_interactions = None if num_interactions is None else int(num_interactions)

        self._center_learning_rate = float(center_learning_rate)
        self._stdev_learning_rate = float(stdev_learning_rate)
        self._optimizer = self._initialize_optimizer(self._center_learning_rate, optimizer, optimizer_config)
        self._ranking_method = None if ranking_method is None else str(ranking_method)

        self._stdev_min = (
            None
            if stdev_min is None
            else problem.ensure_tensor_length_and_dtype(stdev_min, about="stdev_min", allow_scalar=True)
        )

        self._stdev_max = (
            None
            if stdev_max is None
            else problem.ensure_tensor_length_and_dtype(stdev_max, about="stdev_max", allow_scalar=True)
        )

        self._stdev_max_change = (
            None
            if stdev_max_change is None
            else problem.ensure_tensor_length_and_dtype(stdev_max_change, about="stdev_max_change", allow_scalar=True)
        )

        self._obj_index = problem.normalize_obj_index(obj_index)

        if distributed and (problem.num_actors > 0):
            # If the algorithm is initialized in distributed mode, and also if the problem is configured
            # for parallelization, then the _step method becomes an alias for _step_distributed
            self._step = self._step_distributed
        else:
            # Otherwise, the _step method becomes an alias for _step_non_distributed
            self._step = self._step_non_distributed

        if popsize_weighted_grad_avg is None:
            self._popsize_weighted_grad_avg = num_interactions is None
        else:
            if not distributed:
                raise ValueError(
                    "The argument `popsize_weighted_grad_avg` can only be used in distributed mode."
                    " (i.e. when the argument `distributed` is given as True)."
                    " When `distributed` is False, please leave `popsize_weighted_grad_avg` as None."
                )
            self._popsize_weighted_grad_avg = bool(popsize_weighted_grad_avg)

        self._mean_eval: Optional[float] = None
        self._population: Optional[SolutionBatch] = None
        self._first_iter: bool = True

        # We would like to add the reporting capabilities of the mixin class `singlePopulationAlgorithmMixin`.
        # However, we exclude "mean_eval" from the reporting services requested from `SinglePopulationAlgorithmMixin`
        # because this class has its own reporting mechanism for `mean_eval`.
        # Additionally, we enable the reporting services of `SinglePopulationAlgorithmMixin` only when we are
        # in the non-distributed mode. This is because we do not have a centrally stored population at all in the
        # distributed mode.
        SinglePopulationAlgorithmMixin.__init__(self, exclude="mean_eval", enable=(not distributed))

    def _initialize_optimizer(
        self, learning_rate: float, optimizer=None, optimizer_config: Optional[dict] = None
    ) -> object:
        if optimizer is None:
            return None
        elif isinstance(optimizer, str):
            center_optim_cls = get_optimizer_class(optimizer, optimizer_config)
            return center_optim_cls(
                stepsize=float(learning_rate),
                dtype=self._distribution.dtype,
                solution_length=self._distribution.solution_length,
                device=self._distribution.device,
            )
        else:
            return optimizer

    def _step(self):
        raise NotImplementedError

    def _step_distributed(self):
        # Use the problem object's `sample_and_compute_gradients` method
        # to do parallelized and distributed gradient computation
        fetched = self.problem.sample_and_compute_gradients(
            self._distribution,
            self._popsize,
            popsize_max=self._popsize_max,
            obj_index=self._obj_index,
            num_interactions=self._num_interactions,
            ranking_method=self._ranking_method,
            ensure_even_popsize=self._ensure_even_popsize,
        )

        # The method `sample_and_compute_gradients(...)` returns a list of dictionaries, each dictionary being
        # the result of a different remote computation.
        # For each remote computation, the list will contain a dictionary that looks like this:
        # {"gradients": <gradients dictionary here>, "num_solutions": ..., "mean_eval": ...}

        # We will now accumulate all the gradients, num_solutions, and mean_evals in their own lists.
        # So, in the end, we will have a list of gradients, a list of num_solutions, and a list of
        # mean_eval.
        # These lists will be stored by the following temporary class:
        class list_of:
            gradients = []
            num_solutions = []
            mean_eval = []

        # We are now filling the lists declared above
        n = len(fetched)
        for i in range(n):
            list_of.gradients.append(fetched[i]["gradients"])
            list_of.num_solutions.append(fetched[i]["num_solutions"])
            list_of.mean_eval.append(fetched[i]["mean_eval"])

        # Here, we get the keys of our gradient dictionaries.
        # For most simple Gaussian distributions, grad_keys should be {"mu", "sigma"}.
        grad_keys = set(list_of.gradients[0].keys())

        # We now find the total number of solutions and the overall average mean_eval.
        # The overall average mean will be reported to the user.
        total_num_solutions = 0
        total_weighted_eval = 0
        for i in range(n):
            total_num_solutions += list_of.num_solutions[i]
            total_weighted_eval += float(list_of.num_solutions[i] * list_of.mean_eval[i])
        avg_mean_eval = total_weighted_eval / total_num_solutions

        # For each gradient (in most cases among 'mu' and 'sigma'), we allocate a new 0-filled tensor.
        avg_gradients = {}
        for key in grad_keys:
            avg_gradients[key] = self._distribution.make_zeros(num_solutions=1).reshape(-1)

        # Below, we iterate over all collected results and add their gradients, in a weighted manner, onto the
        # `avg_gradients` we allocated above.
        # At the end, `avg_gradients` will store the weighted-averaged gradients to be followed by the algorithm.
        for i in range(n):
            # For each collected result, we compute a weight for the gradient, which is the number of solutions
            # sampled divided by the total number of sampled solutions.
            num_solutions = list_of.num_solutions[i]
            if self._popsize_weighted_grad_avg:
                # If we are to weigh each gradient by its popsize (i.e. by its sample size)
                # then the its weight is computed as its number of solutions divided by the
                # total number of solutions
                weight = num_solutions / total_num_solutions
            else:
                # If we are NOT to weigh each gradient by its popsize (i.e. by its sample size)
                # then the weight of this gradient simply becomes 1 divided by the number of gradients.
                weight = 1 / n
            for key in grad_keys:
                grad = list_of.gradients[i][key]
                avg_gradients[key] += weight * grad

        self._update_distribution(avg_gradients)
        self._mean_eval = avg_mean_eval

    def _step_non_distributed(self):
        # First, we define an inner function which fills the current population by sampling from the distribution.
        def fill_and_eval_pop():
            # This inner function is responsible for filling the main population with samples
            # and evaluate them.
            if self._num_interactions is None:
                # If num_interactions is configured as None, this means that we are not going to adapt
                # the population size according to the number of simulation interactions reported
                # by the problem object.

                # We first make sure that the population (which is to be of constant size, since we are
                # not in the adaptive population size mode) is allocated.
                if self._population is None:
                    self._population = SolutionBatch(
                        self.problem, popsize=self._popsize, device=self._distribution.device, empty=True
                    )

                # Now, we do in-place sampling on the population.
                self._distribution.sample(out=self._population.access_values(), generator=self.problem)

                # Finally, here, the solutions are evaluated.
                self.problem.evaluate(self._population)
            else:
                # If num_interactions is not None, then this means that we have a threshold for the number
                # of simulator interactions to reach before declaring the phase of sampling complete.
                # In other words, we have to adapt our population size according to the number of simulator
                # interactions reported by the problem object.

                # The 'total_interaction_count' status reported by the problem object shows the global interaction count.
                # Therefore, to properly count the simulator interactions we made during this generation, we need
                # to get the interaction count before starting our sampling and evaluation operations.
                first_num_interactions = self.problem.status.get("total_interaction_count", 0)

                # We will keep allocating and evaluating new populations until the interaction count threshold is reached.
                # These newly allocated populations will eventually concatenated into one.
                # The not-yet-concatenated populations and the total allocated population size will be stored below:
                populations = []
                total_popsize = 0

                # Below, we repeatedly allocate, sample, and evaluate, until our thresholds are reached.
                while True:
                    # Allocate a new population
                    newpop = SolutionBatch(
                        self.problem,
                        popsize=self._popsize,
                        like=self._population,
                        empty=True,
                    )

                    # Update the total population size
                    total_popsize += len(newpop)

                    # Sample new solutions within the newly allocated population
                    self._distribution.sample(out=newpop.access_values(), generator=self.problem)

                    # Evaluate the new population
                    self.problem.evaluate(newpop)

                    # Add the newly allocated and evaluated population into the populations list
                    populations.append(newpop)

                    # In addition to the num_interactions threshold, we might also have a popsize_max threshold.
                    # We now check this threshold.
                    if (self._popsize_max is not None) and (total_popsize >= self._popsize_max):
                        # If the popsize_max threshold is reached, we leave the loop.
                        break

                    # We now compute the number of interactions we have made during this while loop.
                    interactions_made = self.problem.status["total_interaction_count"] - first_num_interactions

                    if interactions_made > self._num_interactions:
                        # If the number of interactions exceeds our threshold, we leave the loop.
                        break

                # Finally, we concatenate all our populations into one.
                self._population = SolutionBatch.cat(populations)

        if self._first_iter:
            # If we are computing the first generation, we just sample from our distribution and evaluate
            # the solutions.
            fill_and_eval_pop()
            self._first_iter = False
        else:
            # If we are computing next generations, then we need to compute the gradients of the last
            # generation, sample a new population, and evaluate the new population's solutions.
            samples = self._population.access_values(keep_evals=True)
            fitnesses = self._population.access_evals()[:, self._obj_index]
            obj_sense = self.problem.senses[self._obj_index]
            ranking_method = self._ranking_method
            gradients = self._distribution.compute_gradients(
                samples, fitnesses, objective_sense=obj_sense, ranking_method=ranking_method
            )
            self._update_distribution(gradients)
            fill_and_eval_pop()

    def _update_distribution(self, gradients: dict):
        # This is where we follow the gradients with the help of the stored Distribution object.

        # First, we check whether or not we will need to do a controlled update on the
        # standard deviation (do we have imposed lower and upper bounds for the standard deviation,
        # and do we have a maximum change limiter?)
        controlled_stdev_update = (
            (self._stdev_min is not None) or (self._stdev_max is not None) or (self._stdev_max_change is not None)
        )

        if controlled_stdev_update:
            # If the standard deviation update needs to be controlled, we store the standard deviation just before
            # the update. We will use this later.
            old_sigma = self._distribution.sigma

        # Here, we determine for which distribution parameter we have a learning rate and for which distribution
        # parameter we have an optimizer.
        learning_rates = {}
        optimizers = {}

        if self._optimizer is not None:
            # If there is an optimizer, then we declare that "mu" has an optimizer
            optimizers["mu"] = self._optimizer
        else:
            # If we do not have an optimizer, then we declare that "mu" has a raw learning rate coefficient
            learning_rates["mu"] = self._center_learning_rate

        # Here, we declare that "sigma" has a learning rate
        learning_rates["sigma"] = self._stdev_learning_rate

        # With the help of the Distribution object's `update_parameters(...)` method, we follow the gradients
        updated_dist = self._distribution.update_parameters(
            gradients, learning_rates=learning_rates, optimizers=optimizers
        )

        if controlled_stdev_update:
            # If our standard deviation update needs to be controlled, then, considering the pre-update
            # standard deviation, we ensure that the update constraints (lower and upper bounds and maximum change)
            # are not violated.
            updated_dist = updated_dist.modified_copy(
                sigma=modify_tensor(
                    old_sigma,
                    updated_dist.sigma,
                    lb=self._stdev_min,
                    ub=self._stdev_max,
                    max_change=self._stdev_max_change,
                )
            )

        # Now we can declare that our main distribution is the updated one
        self._distribution = updated_dist

    def _get_mu(self) -> torch.Tensor:
        return self._distribution.parameters["mu"]

    def _get_sigma(self) -> torch.Tensor:
        return self._distribution.parameters["sigma"]

    def _get_mean_eval(self) -> Optional[float]:
        if self._population is None:
            return self._mean_eval
        else:
            return float(torch.mean(self._population.evals[:, self._obj_index]))

    # def _get_median_eval(self) -> Optional[float]:
    #    if self._population is None:
    #        return None
    #    else:
    #        return float(torch.median(self._population.evals[:, self._obj_index]))

    def _get_popsize(self) -> int:
        return 0 if self._population is None else len(self._population)

    @property
    def optimizer(self):
        """
        Get the optimizer used by this search algorithm.

        If an optimizer is not being used, the result will be `None`.
        If a PyTorch optimizer is being used, the result will be an instance of
        `torch.optim.Optimizer`.
        If the returned optimizer is "clipup", then the returned object will be
        an instance of `evotorch.optimizers.ClipUp`.

        The returned optimizer object can be used for reading/writing the
        hyperparameters. For example, to read the learning of the optimizer,
        one can do:

        ```python
        learning_rate = my_search_algorithm.optimizer.param_groups[0]["lr"]
        ```

        One can also update the learning rate like this:

        ```python
        my_search_algorithm.optimizer.param_groups[0]["lr"] = new_learning_rate
        ```

        **Note for when updating the learning rate of ClipUp.**
        At the moment of initialization, if one provides `center_learning_rate`
        but the maximum speed is not specified (i.e. the search algorithm is not
        given something like `optimizer_config={"max_speed": ...}`), then, the
        maximum speed is initialized as `2*center_learning_rate`. However, when
        this `center_learning_rate` is later modified (via
        `my_search_algorithm.optimizer.param_groups[0]["lr"] = new_center_learning_rate`
        the maximum speed will NOT be automatically adjusted.
        Therefore, when updating the center learning rate of ClipUp, consider
        also adjusting the maximum speed of ClipUp via:
        `my_search_algorithm.optimizer.param_groups[0]["max_speed"] = ...`
        """
        return None if self._optimizer is None else self._optimizer.contained_optimizer

    @property
    def population(self) -> Optional[SolutionBatch]:
        """
        The population, represented by a SolutionBatch.

        If the population is not initialized yet, the retrieved value will
        be None.
        Also note that, if this algorithm is in distributed mode, the
        retrieved value will be None, since the distributed mode causes the
        population to be generated in the remote actors, and not in the main
        process.
        """
        return self._population

    @property
    def obj_index(self) -> int:
        """
        Index of the focused objective
        """
        return self._obj_index
obj_index: int property readonly

Index of the focused objective

optimizer property readonly

Get the optimizer used by this search algorithm.

If an optimizer is not being used, the result will be None. If a PyTorch optimizer is being used, the result will be an instance of torch.optim.Optimizer. If the returned optimizer is "clipup", then the returned object will be an instance of evotorch.optimizers.ClipUp.

The returned optimizer object can be used for reading/writing the hyperparameters. For example, to read the learning of the optimizer, one can do:

learning_rate = my_search_algorithm.optimizer.param_groups[0]["lr"]

One can also update the learning rate like this:

my_search_algorithm.optimizer.param_groups[0]["lr"] = new_learning_rate

Note for when updating the learning rate of ClipUp. At the moment of initialization, if one provides center_learning_rate but the maximum speed is not specified (i.e. the search algorithm is not given something like optimizer_config={"max_speed": ...}), then, the maximum speed is initialized as 2*center_learning_rate. However, when this center_learning_rate is later modified (via my_search_algorithm.optimizer.param_groups[0]["lr"] = new_center_learning_rate the maximum speed will NOT be automatically adjusted. Therefore, when updating the center learning rate of ClipUp, consider also adjusting the maximum speed of ClipUp via: my_search_algorithm.optimizer.param_groups[0]["max_speed"] = ...

population: Optional[evotorch.core.SolutionBatch] property readonly

The population, represented by a SolutionBatch.

If the population is not initialized yet, the retrieved value will be None. Also note that, if this algorithm is in distributed mode, the retrieved value will be None, since the distributed mode causes the population to be generated in the remote actors, and not in the main process.

PGPE (GaussianSearchAlgorithm)

This implementation is the symmetric-sampling variant proposed in the paper Sehnke et al. (2010).

Inspired by the PGPE implementations used in the studies of Ha (2017, 2019), and by the evolution strategy variant of Salimans et al. (2017), this PGPE implementation uses 0-centered ranking by default. The default optimizer for this PGPE implementation is ClipUp (Toklu et al., 2020).

References:

Frank Sehnke, Christian Osendorfer, Thomas Ruckstiess,
Alex Graves, Jan Peters, Jurgen Schmidhuber (2010).
Parameter-exploring Policy Gradients.
Neural Networks 23(4), 551-559.

David Ha (2017). Evolving Stable Strategies.
<http://blog.otoro.net/2017/11/12/evolving-stable-strategies/>

Salimans, T., Ho, J., Chen, X., Sidor, S. and Sutskever, I. (2017).
Evolution Strategies as a Scalable Alternative to
Reinforcement Learning.

David Ha (2019). Reinforcement Learning for Improving Agent Design.
Artificial life 25 (4), 352-365.

Toklu, N.E., Liskowski, P., Srivastava, R.K. (2020).
ClipUp: A Simple and Powerful Optimizer
for Distribution-based Policy Evolution.
Parallel Problem Solving from Nature (PPSN 2020).
Source code in evotorch/algorithms/distributed/gaussian.py
class PGPE(GaussianSearchAlgorithm):
    """
    PGPE: Policy gradient with parameter-based exploration.

    This implementation is the symmetric-sampling variant proposed
    in the paper Sehnke et al. (2010).

    Inspired by the PGPE implementations used in the studies
    of Ha (2017, 2019), and by the evolution strategy variant of
    Salimans et al. (2017), this PGPE implementation uses 0-centered
    ranking by default.
    The default optimizer for this PGPE implementation is ClipUp
    (Toklu et al., 2020).

    References:

        Frank Sehnke, Christian Osendorfer, Thomas Ruckstiess,
        Alex Graves, Jan Peters, Jurgen Schmidhuber (2010).
        Parameter-exploring Policy Gradients.
        Neural Networks 23(4), 551-559.

        David Ha (2017). Evolving Stable Strategies.
        <http://blog.otoro.net/2017/11/12/evolving-stable-strategies/>

        Salimans, T., Ho, J., Chen, X., Sidor, S. and Sutskever, I. (2017).
        Evolution Strategies as a Scalable Alternative to
        Reinforcement Learning.

        David Ha (2019). Reinforcement Learning for Improving Agent Design.
        Artificial life 25 (4), 352-365.

        Toklu, N.E., Liskowski, P., Srivastava, R.K. (2020).
        ClipUp: A Simple and Powerful Optimizer
        for Distribution-based Policy Evolution.
        Parallel Problem Solving from Nature (PPSN 2020).
    """

    DISTRIBUTION_TYPE = NotImplemented  # To be filled by the PGPE instance
    DISTRIBUTION_PARAMS = NotImplemented  # To be filled by the PGPE instance

    def __init__(
        self,
        problem: Problem,
        *,
        popsize: int,
        center_learning_rate: float,
        stdev_learning_rate: float,
        stdev_init: Optional[RealOrVector] = None,
        radius_init: Optional[RealOrVector] = None,
        num_interactions: Optional[int] = None,
        popsize_max: Optional[int] = None,
        optimizer="clipup",
        optimizer_config: Optional[dict] = None,
        ranking_method: Optional[str] = "centered",
        center_init: Optional[RealOrVector] = None,
        stdev_min: Optional[RealOrVector] = None,
        stdev_max: Optional[RealOrVector] = None,
        stdev_max_change: Optional[RealOrVector] = 0.2,
        symmetric: bool = True,
        obj_index: Optional[int] = None,
        distributed: bool = False,
        popsize_weighted_grad_avg: Optional[bool] = None,
    ):
        """
        `__init__(...)`: Initialize the PGPE algorithm.

        Args:
            problem: The problem object which is being worked on.
                The problem must have its dtype defined
                (which means it works on Solution objects,
                not with custom Solution objects).
                Also, the problem must be single-objective.
            popsize: The population size.
                In the case of PGPE, `popsize` is expected as an even number
                in non-distributed mode. In distributed mode, PGPE will
                ensure that each sub-population size assigned to a remote
                actor is an even number.
                This behavior is because PGPE does symmetric sampling
                (i.e. solutions are sampled in pairs).
            center_learning_rate: The learning rate for the center
                of the search distribution.
            stdev_learning_rate: The learning rate for the standard
                deviation values of the search distribution.
            stdev_init: The initial standard deviation of the search
                distribution, expressed as a scalar or as an array.
                Determines the initial coverage area of the search
                distribution.
                If one wishes to configure the coverage area via the
                argument `radius_init` instead, then `stdev_init` is expected
                as None.
            radius_init: The initial radius of the search distribution,
                expressed as a scalar.
                Determines the initial coverage area of the search
                distribution.
                Here, "radius" is defined as the norm of the search
                distribution.
                If one wishes to configure the coverage area via the
                argument `stdev_init` instead, then `radius_init` is expected
                as None.
            num_interactions: When given as an integer n,
                it is ensured that a population has interacted with
                the GymProblem's environment n times. If this target
                has not been reached yet, then the population is declared
                too small, and gets extended with more samples,
                until n amount of interactions is reached.
                When given as None, popsize is the only configuration
                affecting the size of a population.
            popsize_max: Having `num_interactions` set as an integer
                might cause the effective population size jump to
                unnecesarily large numbers. To prevent this,
                one can set `popsize_max` to specify an upper
                bound for the effective population size.
            optimizer: The optimizer to be used while following the
                estimated the gradients.
                Can be given as None if a momentum-based optimizer
                is not required.
                Otherwise, can be given as a str containing the name
                of the optimizer (e.g. 'adam', 'clipup');
                or as an instance of evotorch.optimizers.TorchOptimizer
                or evotorch.optimizers.ClipUp.
                The default is 'clipup'.
                Note that, for ClipUp, the default maximum speed is set
                as twice the given `center_learning_rate`.
                This maximum speed can be configured by passing
                `{"max_speed": ...}` to `optimizer_config`.
            optimizer_config: Configuration which will be passed
                to the optimizer as keyword arguments.
                See `evotorch.optimizers` for details about
                which optimizer accepts which keyword arguments.
            ranking_method: Which ranking method will be used for
                fitness shaping. See the documentation of
                `evotorch.ranking.rank(...)` for details.
                As in the study of Salimans et al. (2017),
                the default is 'centered'.
                Can be given as None if no such ranking is required.
            center_init: The initial center solution.
                Can be left as None.
            stdev_min: Lower bound for the standard deviation value/array.
                Can be given as a real number, or as an array of real numbers.
            stdev_max: Upper bound for the standard deviation value/array.
                Can be given as a real number, or as an array of real numbers.
            stdev_max_change: The maximum update ratio allowed on the
                standard deviation. Expected as None if no such limiter
                is needed, or as a real number within 0.0 and 1.0 otherwise.
                Like in the implementation of Ha (2017, 2018),
                the default value for this setting is 0.2, meaning that
                the update on the standard deviation values can not be
                more than 20% of their original values.
            symmetric: Whether or not the solutions will be sampled
                in a symmetric/mirrored/antithetic manner.
                The default is True.
            obj_index: Index of the objective according to which the
                gradient estimations will be done.
                For single-objective problems, this can be left as None.
            distributed: Whether or not the gradient computation will
                be distributed. If `distributed` is given as False and
                the problem is not parallelized, then everything will
                be centralized (i.e. the entire computation will happen
                in the main process).
                If `distributed` is given as False, and the problem
                is parallelized, then the population will be created
                in the main process and then sent to remote workers
                for parallelized evaluation, and then the remote fitnesses
                will be collected by the main process again for computing
                the search gradients.
                If `distributed` is given as True, and the problem
                is parallelized, then the search algorithm itself will
                be distributed, in the sense that each remote actor will
                generate its own population (such that the total population
                size across all these actors becomes equal to `popsize`)
                and will compute its own gradient, and then the main process
                will collect these gradients, compute the averaged gradients
                and update the main search distribution.
                Non-distributed mode has the advantage of keeping the
                population in the main process, which is good when one wishes
                to do detailed monitoring during the evolutionary process,
                but has the disadvantage of having to pass the solutions to
                the remote actors and having to collect fitnesses, which
                might result in increased interprocess communication traffic.
                On the other hand, while it is not possible to monitor the
                population in distributed mode, the distributed mode has the
                advantage of significantly reducing the interprocess
                communication traffic, since the only things communicated
                with the remote actors are the search distributions (not the
                solutions) and the gradients.
            popsize_weighted_grad_avg: Only to be used in distributed mode.
                (where being in distributed mode means `distributed` is given
                as True). In distributed mode, each actor remotely samples
                its own solution batches and computes its own gradients.
                These gradients are then collected, and a final average
                gradient is computed.
                If `popsize_weighted_grad_avg` is True, then, while averaging
                over the gradients, each gradient will have its own weight
                that is computed according to how many solutions were sampled
                by the actor that produced the gradient.
                If `popsize_weighted_grad_avg` is False, then, there will not
                be weighted averaging (or, each gradient will have equal
                weight).
                If `popsize_weighted_grad_avg` is None, then, the gradient
                weights will be equal a value for `num_interactions` is given
                (because `num_interactions` affects the number of solutions
                according to the episode lengths, and popsize-weighting the
                gradients could be misleading); and the gradient weights will
                be weighted according to the sub-population (i.e. sub-batch)
                sizes if `num_interactions` is left as None.
                The default value for `popsize_weighted_grad_avg` is None.
                When the distributed mode is disabled (i.e. when `distributed`
                is False), then the argument `popsize_weighted_grad_avg` is
                expected as None.
        """

        if symmetric:
            self.DISTRIBUTION_TYPE = SymmetricSeparableGaussian
            divide_by = "num_directions"
        else:
            self.DISTRIBUTION_TYPE = SeparableGaussian
            divide_by = "num_solutions"

        self.DISTRIBUTION_PARAMS = {"divide_mu_grad_by": divide_by, "divide_sigma_grad_by": divide_by}

        super().__init__(
            problem,
            popsize=popsize,
            center_learning_rate=center_learning_rate,
            stdev_learning_rate=stdev_learning_rate,
            stdev_init=stdev_init,
            radius_init=radius_init,
            popsize_max=popsize_max,
            num_interactions=num_interactions,
            optimizer=optimizer,
            optimizer_config=optimizer_config,
            ranking_method=ranking_method,
            center_init=center_init,
            stdev_min=stdev_min,
            stdev_max=stdev_max,
            stdev_max_change=stdev_max_change,
            obj_index=obj_index,
            distributed=distributed,
            popsize_weighted_grad_avg=popsize_weighted_grad_avg,
            ensure_even_popsize=symmetric,
        )
__init__(self, problem, *, popsize, center_learning_rate, stdev_learning_rate, stdev_init=None, radius_init=None, num_interactions=None, popsize_max=None, optimizer='clipup', optimizer_config=None, ranking_method='centered', center_init=None, stdev_min=None, stdev_max=None, stdev_max_change=0.2, symmetric=True, obj_index=None, distributed=False, popsize_weighted_grad_avg=None) special

__init__(...): Initialize the PGPE algorithm.

Parameters:

Name Type Description Default
problem Problem

The problem object which is being worked on. The problem must have its dtype defined (which means it works on Solution objects, not with custom Solution objects). Also, the problem must be single-objective.

required
popsize int

The population size. In the case of PGPE, popsize is expected as an even number in non-distributed mode. In distributed mode, PGPE will ensure that each sub-population size assigned to a remote actor is an even number. This behavior is because PGPE does symmetric sampling (i.e. solutions are sampled in pairs).

required
center_learning_rate float

The learning rate for the center of the search distribution.

required
stdev_learning_rate float

The learning rate for the standard deviation values of the search distribution.

required
stdev_init Union[float, Iterable[float], torch.Tensor]

The initial standard deviation of the search distribution, expressed as a scalar or as an array. Determines the initial coverage area of the search distribution. If one wishes to configure the coverage area via the argument radius_init instead, then stdev_init is expected as None.

None
radius_init Union[float, Iterable[float], torch.Tensor]

The initial radius of the search distribution, expressed as a scalar. Determines the initial coverage area of the search distribution. Here, "radius" is defined as the norm of the search distribution. If one wishes to configure the coverage area via the argument stdev_init instead, then radius_init is expected as None.

None
num_interactions Optional[int]

When given as an integer n, it is ensured that a population has interacted with the GymProblem's environment n times. If this target has not been reached yet, then the population is declared too small, and gets extended with more samples, until n amount of interactions is reached. When given as None, popsize is the only configuration affecting the size of a population.

None
popsize_max Optional[int]

Having num_interactions set as an integer might cause the effective population size jump to unnecesarily large numbers. To prevent this, one can set popsize_max to specify an upper bound for the effective population size.

None
optimizer

The optimizer to be used while following the estimated the gradients. Can be given as None if a momentum-based optimizer is not required. Otherwise, can be given as a str containing the name of the optimizer (e.g. 'adam', 'clipup'); or as an instance of evotorch.optimizers.TorchOptimizer or evotorch.optimizers.ClipUp. The default is 'clipup'. Note that, for ClipUp, the default maximum speed is set as twice the given center_learning_rate. This maximum speed can be configured by passing {"max_speed": ...} to optimizer_config.

'clipup'
optimizer_config Optional[dict]

Configuration which will be passed to the optimizer as keyword arguments. See evotorch.optimizers for details about which optimizer accepts which keyword arguments.

None
ranking_method Optional[str]

Which ranking method will be used for fitness shaping. See the documentation of evotorch.ranking.rank(...) for details. As in the study of Salimans et al. (2017), the default is 'centered'. Can be given as None if no such ranking is required.

'centered'
center_init Union[float, Iterable[float], torch.Tensor]

The initial center solution. Can be left as None.

None
stdev_min Union[float, Iterable[float], torch.Tensor]

Lower bound for the standard deviation value/array. Can be given as a real number, or as an array of real numbers.

None
stdev_max Union[float, Iterable[float], torch.Tensor]

Upper bound for the standard deviation value/array. Can be given as a real number, or as an array of real numbers.

None
stdev_max_change Union[float, Iterable[float], torch.Tensor]

The maximum update ratio allowed on the standard deviation. Expected as None if no such limiter is needed, or as a real number within 0.0 and 1.0 otherwise. Like in the implementation of Ha (2017, 2018), the default value for this setting is 0.2, meaning that the update on the standard deviation values can not be more than 20% of their original values.

0.2
symmetric bool

Whether or not the solutions will be sampled in a symmetric/mirrored/antithetic manner. The default is True.

True
obj_index Optional[int]

Index of the objective according to which the gradient estimations will be done. For single-objective problems, this can be left as None.

None
distributed bool

Whether or not the gradient computation will be distributed. If distributed is given as False and the problem is not parallelized, then everything will be centralized (i.e. the entire computation will happen in the main process). If distributed is given as False, and the problem is parallelized, then the population will be created in the main process and then sent to remote workers for parallelized evaluation, and then the remote fitnesses will be collected by the main process again for computing the search gradients. If distributed is given as True, and the problem is parallelized, then the search algorithm itself will be distributed, in the sense that each remote actor will generate its own population (such that the total population size across all these actors becomes equal to popsize) and will compute its own gradient, and then the main process will collect these gradients, compute the averaged gradients and update the main search distribution. Non-distributed mode has the advantage of keeping the population in the main process, which is good when one wishes to do detailed monitoring during the evolutionary process, but has the disadvantage of having to pass the solutions to the remote actors and having to collect fitnesses, which might result in increased interprocess communication traffic. On the other hand, while it is not possible to monitor the population in distributed mode, the distributed mode has the advantage of significantly reducing the interprocess communication traffic, since the only things communicated with the remote actors are the search distributions (not the solutions) and the gradients.

False
popsize_weighted_grad_avg Optional[bool]

Only to be used in distributed mode. (where being in distributed mode means distributed is given as True). In distributed mode, each actor remotely samples its own solution batches and computes its own gradients. These gradients are then collected, and a final average gradient is computed. If popsize_weighted_grad_avg is True, then, while averaging over the gradients, each gradient will have its own weight that is computed according to how many solutions were sampled by the actor that produced the gradient. If popsize_weighted_grad_avg is False, then, there will not be weighted averaging (or, each gradient will have equal weight). If popsize_weighted_grad_avg is None, then, the gradient weights will be equal a value for num_interactions is given (because num_interactions affects the number of solutions according to the episode lengths, and popsize-weighting the gradients could be misleading); and the gradient weights will be weighted according to the sub-population (i.e. sub-batch) sizes if num_interactions is left as None. The default value for popsize_weighted_grad_avg is None. When the distributed mode is disabled (i.e. when distributed is False), then the argument popsize_weighted_grad_avg is expected as None.

None
Source code in evotorch/algorithms/distributed/gaussian.py
def __init__(
    self,
    problem: Problem,
    *,
    popsize: int,
    center_learning_rate: float,
    stdev_learning_rate: float,
    stdev_init: Optional[RealOrVector] = None,
    radius_init: Optional[RealOrVector] = None,
    num_interactions: Optional[int] = None,
    popsize_max: Optional[int] = None,
    optimizer="clipup",
    optimizer_config: Optional[dict] = None,
    ranking_method: Optional[str] = "centered",
    center_init: Optional[RealOrVector] = None,
    stdev_min: Optional[RealOrVector] = None,
    stdev_max: Optional[RealOrVector] = None,
    stdev_max_change: Optional[RealOrVector] = 0.2,
    symmetric: bool = True,
    obj_index: Optional[int] = None,
    distributed: bool = False,
    popsize_weighted_grad_avg: Optional[bool] = None,
):
    """
    `__init__(...)`: Initialize the PGPE algorithm.

    Args:
        problem: The problem object which is being worked on.
            The problem must have its dtype defined
            (which means it works on Solution objects,
            not with custom Solution objects).
            Also, the problem must be single-objective.
        popsize: The population size.
            In the case of PGPE, `popsize` is expected as an even number
            in non-distributed mode. In distributed mode, PGPE will
            ensure that each sub-population size assigned to a remote
            actor is an even number.
            This behavior is because PGPE does symmetric sampling
            (i.e. solutions are sampled in pairs).
        center_learning_rate: The learning rate for the center
            of the search distribution.
        stdev_learning_rate: The learning rate for the standard
            deviation values of the search distribution.
        stdev_init: The initial standard deviation of the search
            distribution, expressed as a scalar or as an array.
            Determines the initial coverage area of the search
            distribution.
            If one wishes to configure the coverage area via the
            argument `radius_init` instead, then `stdev_init` is expected
            as None.
        radius_init: The initial radius of the search distribution,
            expressed as a scalar.
            Determines the initial coverage area of the search
            distribution.
            Here, "radius" is defined as the norm of the search
            distribution.
            If one wishes to configure the coverage area via the
            argument `stdev_init` instead, then `radius_init` is expected
            as None.
        num_interactions: When given as an integer n,
            it is ensured that a population has interacted with
            the GymProblem's environment n times. If this target
            has not been reached yet, then the population is declared
            too small, and gets extended with more samples,
            until n amount of interactions is reached.
            When given as None, popsize is the only configuration
            affecting the size of a population.
        popsize_max: Having `num_interactions` set as an integer
            might cause the effective population size jump to
            unnecesarily large numbers. To prevent this,
            one can set `popsize_max` to specify an upper
            bound for the effective population size.
        optimizer: The optimizer to be used while following the
            estimated the gradients.
            Can be given as None if a momentum-based optimizer
            is not required.
            Otherwise, can be given as a str containing the name
            of the optimizer (e.g. 'adam', 'clipup');
            or as an instance of evotorch.optimizers.TorchOptimizer
            or evotorch.optimizers.ClipUp.
            The default is 'clipup'.
            Note that, for ClipUp, the default maximum speed is set
            as twice the given `center_learning_rate`.
            This maximum speed can be configured by passing
            `{"max_speed": ...}` to `optimizer_config`.
        optimizer_config: Configuration which will be passed
            to the optimizer as keyword arguments.
            See `evotorch.optimizers` for details about
            which optimizer accepts which keyword arguments.
        ranking_method: Which ranking method will be used for
            fitness shaping. See the documentation of
            `evotorch.ranking.rank(...)` for details.
            As in the study of Salimans et al. (2017),
            the default is 'centered'.
            Can be given as None if no such ranking is required.
        center_init: The initial center solution.
            Can be left as None.
        stdev_min: Lower bound for the standard deviation value/array.
            Can be given as a real number, or as an array of real numbers.
        stdev_max: Upper bound for the standard deviation value/array.
            Can be given as a real number, or as an array of real numbers.
        stdev_max_change: The maximum update ratio allowed on the
            standard deviation. Expected as None if no such limiter
            is needed, or as a real number within 0.0 and 1.0 otherwise.
            Like in the implementation of Ha (2017, 2018),
            the default value for this setting is 0.2, meaning that
            the update on the standard deviation values can not be
            more than 20% of their original values.
        symmetric: Whether or not the solutions will be sampled
            in a symmetric/mirrored/antithetic manner.
            The default is True.
        obj_index: Index of the objective according to which the
            gradient estimations will be done.
            For single-objective problems, this can be left as None.
        distributed: Whether or not the gradient computation will
            be distributed. If `distributed` is given as False and
            the problem is not parallelized, then everything will
            be centralized (i.e. the entire computation will happen
            in the main process).
            If `distributed` is given as False, and the problem
            is parallelized, then the population will be created
            in the main process and then sent to remote workers
            for parallelized evaluation, and then the remote fitnesses
            will be collected by the main process again for computing
            the search gradients.
            If `distributed` is given as True, and the problem
            is parallelized, then the search algorithm itself will
            be distributed, in the sense that each remote actor will
            generate its own population (such that the total population
            size across all these actors becomes equal to `popsize`)
            and will compute its own gradient, and then the main process
            will collect these gradients, compute the averaged gradients
            and update the main search distribution.
            Non-distributed mode has the advantage of keeping the
            population in the main process, which is good when one wishes
            to do detailed monitoring during the evolutionary process,
            but has the disadvantage of having to pass the solutions to
            the remote actors and having to collect fitnesses, which
            might result in increased interprocess communication traffic.
            On the other hand, while it is not possible to monitor the
            population in distributed mode, the distributed mode has the
            advantage of significantly reducing the interprocess
            communication traffic, since the only things communicated
            with the remote actors are the search distributions (not the
            solutions) and the gradients.
        popsize_weighted_grad_avg: Only to be used in distributed mode.
            (where being in distributed mode means `distributed` is given
            as True). In distributed mode, each actor remotely samples
            its own solution batches and computes its own gradients.
            These gradients are then collected, and a final average
            gradient is computed.
            If `popsize_weighted_grad_avg` is True, then, while averaging
            over the gradients, each gradient will have its own weight
            that is computed according to how many solutions were sampled
            by the actor that produced the gradient.
            If `popsize_weighted_grad_avg` is False, then, there will not
            be weighted averaging (or, each gradient will have equal
            weight).
            If `popsize_weighted_grad_avg` is None, then, the gradient
            weights will be equal a value for `num_interactions` is given
            (because `num_interactions` affects the number of solutions
            according to the episode lengths, and popsize-weighting the
            gradients could be misleading); and the gradient weights will
            be weighted according to the sub-population (i.e. sub-batch)
            sizes if `num_interactions` is left as None.
            The default value for `popsize_weighted_grad_avg` is None.
            When the distributed mode is disabled (i.e. when `distributed`
            is False), then the argument `popsize_weighted_grad_avg` is
            expected as None.
    """

    if symmetric:
        self.DISTRIBUTION_TYPE = SymmetricSeparableGaussian
        divide_by = "num_directions"
    else:
        self.DISTRIBUTION_TYPE = SeparableGaussian
        divide_by = "num_solutions"

    self.DISTRIBUTION_PARAMS = {"divide_mu_grad_by": divide_by, "divide_sigma_grad_by": divide_by}

    super().__init__(
        problem,
        popsize=popsize,
        center_learning_rate=center_learning_rate,
        stdev_learning_rate=stdev_learning_rate,
        stdev_init=stdev_init,
        radius_init=radius_init,
        popsize_max=popsize_max,
        num_interactions=num_interactions,
        optimizer=optimizer,
        optimizer_config=optimizer_config,
        ranking_method=ranking_method,
        center_init=center_init,
        stdev_min=stdev_min,
        stdev_max=stdev_max,
        stdev_max_change=stdev_max_change,
        obj_index=obj_index,
        distributed=distributed,
        popsize_weighted_grad_avg=popsize_weighted_grad_avg,
        ensure_even_popsize=symmetric,
    )
SNES (GaussianSearchAlgorithm)

Inspired by the implementation at: http://schaul.site44.com/code/snes.py

Reference:

Schaul, T., Glasmachers, T., Schmidhuber, J. (2011).
High Dimensions and Heavy Tails for Natural Evolution Strategies.
Proceedings of the 13th annual conference on Genetic and evolutionary
computation (GECCO 2011).
Source code in evotorch/algorithms/distributed/gaussian.py
class SNES(GaussianSearchAlgorithm):
    """
    SNES: Separable Natural Evolution Strategies

    Inspired by the implementation at: http://schaul.site44.com/code/snes.py

    Reference:

        Schaul, T., Glasmachers, T., Schmidhuber, J. (2011).
        High Dimensions and Heavy Tails for Natural Evolution Strategies.
        Proceedings of the 13th annual conference on Genetic and evolutionary
        computation (GECCO 2011).
    """

    DISTRIBUTION_TYPE = ExpSeparableGaussian
    DISTRIBUTION_PARAMS = None

    def __init__(
        self,
        problem: Problem,
        *,
        stdev_init: Optional[RealOrVector] = None,
        radius_init: Optional[RealOrVector] = None,
        popsize: Optional[int] = None,
        center_learning_rate: Optional[float] = None,
        stdev_learning_rate: Optional[float] = None,
        scale_learning_rate: bool = True,
        num_interactions: Optional[int] = None,
        popsize_max: Optional[int] = None,
        optimizer=None,
        optimizer_config: Optional[dict] = None,
        ranking_method: Optional[str] = "nes",
        center_init: Optional[RealOrVector] = None,
        stdev_min: Optional[RealOrVector] = None,
        stdev_max: Optional[RealOrVector] = None,
        stdev_max_change: Optional[RealOrVector] = None,
        obj_index: Optional[int] = None,
        distributed: bool = False,
        popsize_weighted_grad_avg: Optional[bool] = None,
    ):
        """
        `__init__(...)`: Initialize the SNES algorithm.

        Args:
            problem: The problem object which is being worked on.
            stdev_init: The initial standard deviation of the search
                distribution, expressed as a scalar or as an array.
                Determines the initial coverage area of the search
                distribution.
                If one wishes to configure the coverage area via the
                argument `radius_init` instead, then `stdev_init` is expected
                as None.
            radius_init: The initial radius of the search distribution,
                expressed as a scalar.
                Determines the initial coverage area of the search
                distribution.
                Here, "radius" is defined as the norm of the search
                distribution.
                If one wishes to configure the coverage area via the
                argument `stdev_init` instead, then `radius_init` is expected
                as None.
            popsize: Population size. Can be specified as an int,
                or can be left as None to let the solver decide.
                In the case of SNES, `popsize` can be left as None,
                in which case the default `popsize` will be computed
                as `4 + floor(3 * log(n))` where `n` is the length
                of a solution.
            center_learning_rate: Learning rate for updating the mean
                of the search distribution. Default value is 1.0
            stdev_learning_rate: Learning rate for updating the covariance
                matrix of the search distribution.
                The default value is `0.2 * (3 + log(n)) / sqrt(n)`
                where `n` is the length of a solution.
            scale_learning_rate: For SNES, there is a default standard
                deviation learning rate value which is computed as
                `0.2 * (3 + log(n)) / sqrt(n)` (where `n` is the solution
                length).
                If scale_learning_rate is True (which is the default),
                then the effective learning rate for the standard deviation
                becomes the provided `stdev_learning_rate` multiplied by this
                default value. If `scale_learning_rate` is False, then the
                effective standard deviation learning rate becomes
                equal to the provided `stdev_learning_rate` value.
            num_interactions: When given as an integer n,
                it is ensured that a population has interacted with
                the GymProblem's environment n times. If this target
                has not been reached yet, then the population is declared
                too small, and gets extended with more samples,
                until n amount of interactions is reached.
                When given as None, popsize is the only configuration
                affecting the size of a population.
            popsize_max: Having `num_interactions` set as an integer
                might cause the effective population size jump to
                unnecesarily large numbers. To prevent this,
                one can set `popsize_max` to specify an upper
                bound for the effective population size.
            num_interactions: When given as an integer n,
                it is ensured that a population has interacted with
                the GymProblem's environment n times. If this target
                has not been reached yet, then the population is declared
                too small, and gets extended with more samples,
                until n amount of interactions is reached.
                When given as None, popsize is the only configuration
                affecting the size of a population.
            popsize_max: Having `num_interactions` set as an integer
                might cause the effective population size jump to
                unnecesarily large numbers. To prevent this,
                one can set `popsize_max` to specify an upper
                bound for the effective population size.
            optimizer: The optimizer to be used while following the
                estimated the gradients.
                Can be given as None if a momentum-based optimizer
                is not required.
                Otherwise, can be given as a str containing the name
                of the optimizer (e.g. 'adam', 'clipup');
                or as an instance of evotorch.optimizers.TorchOptimizer
                or evotorch.optimizers.ClipUp.
                The default is None.
                Note that, for ClipUp, the default maximum speed is set
                as twice the given `center_learning_rate`.
                This maximum speed can be configured by passing
                `{"max_speed": ...}` to `optimizer_config`.
            optimizer_config: Configuration which will be passed
                to the optimizer as keyword arguments.
                See `evotorch.optimizers` for details about
                which optimizer accepts which keyword arguments.
            ranking_method: Which ranking method will be used for
                fitness shaping. See the documentation of
                `evotorch.ranking.rank(...)` for details.
                The default is 'nes'.
                Can be given as None if no such ranking is required.
            center_init: The initial center solution.
                Can be left as None.
            stdev_min: Minimum values for the standard deviation.
                Expected as a 1-dimensional array to serve as a limiter
                to the diagonals of the covariance matrix's square root.
            stdev_max: Maximum values for the standard deviation.
                Expected as a 1-dimensional array to serve as a limiter
                to the diagonals of the covariance matrix's square root.
            stdev_max_change: Maximum change allowed for when updating
                the square roort of the covariance matrix.
            obj_index: Index of the objective according to which the
                gradient estimations will be done.
                For single-objective problems, this can be left as None.
            distributed: Whether or not the gradient computation will
                be distributed. If `distributed` is given as False and
                the problem is not parallelized, then everything will
                be centralized (i.e. the entire computation will happen
                in the main process).
                If `distributed` is given as False, and the problem
                is parallelized, then the population will be created
                in the main process and then sent to remote workers
                for parallelized evaluation, and then the remote fitnesses
                will be collected by the main process again for computing
                the search gradients.
                If `distributed` is given as True, and the problem
                is parallelized, then the search algorithm itself will
                be distributed, in the sense that each remote actor will
                generate its own population (such that the total population
                size across all these actors becomes equal to `popsize`)
                and will compute its own gradient, and then the main process
                will collect these gradients, compute the averaged gradients
                and update the main search distribution.
                Non-distributed mode has the advantage of keeping the
                population in the main process, which is good when one wishes
                to do detailed monitoring during the evolutionary process,
                but has the disadvantage of having to pass the solutions to
                the remote actors and having to collect fitnesses, which
                might result in increased interprocess communication traffic.
                On the other hand, while it is not possible to monitor the
                population in distributed mode, the distributed mode has the
                advantage of significantly reducing the interprocess
                communication traffic, since the only things communicated
                with the remote actors are the search distributions (not the
                solutions) and the gradients.
            popsize_weighted_grad_avg: Only to be used in distributed mode.
                (where being in distributed mode means `distributed` is given
                as True). In distributed mode, each actor remotely samples
                its own solution batches and computes its own gradients.
                These gradients are then collected, and a final average
                gradient is computed.
                If `popsize_weighted_grad_avg` is True, then, while averaging
                over the gradients, each gradient will have its own weight
                that is computed according to how many solutions were sampled
                by the actor that produced the gradient.
                If `popsize_weighted_grad_avg` is False, then, there will not
                be weighted averaging (or, each gradient will have equal
                weight).
                If `popsize_weighted_grad_avg` is None, then, the gradient
                weights will be equal a value for `num_interactions` is given
                (because `num_interactions` affects the number of solutions
                according to the episode lengths, and popsize-weighting the
                gradients could be misleading); and the gradient weights will
                be weighted according to the sub-population (i.e. sub-batch)
                sizes if `num_interactions` is left as None.
                The default value for `popsize_weighted_grad_avg` is None.
                When the distributed mode is disabled (i.e. when `distributed`
                is False), then the argument `popsize_weighted_grad_avg` is
                expected as None.
        """

        if popsize is None:
            popsize = int(4 + math.floor(3 * math.log(problem.solution_length)))

        if center_learning_rate is None:
            center_learning_rate = 1.0

        def default_stdev_lr():
            n = problem.solution_length
            return 0.2 * (3 + math.log(n)) / math.sqrt(n)

        if stdev_learning_rate is None:
            stdev_learning_rate = default_stdev_lr()
        else:
            stdev_learning_rate = float(stdev_learning_rate)
            if scale_learning_rate:
                stdev_learning_rate *= default_stdev_lr()

        super().__init__(
            problem,
            popsize=popsize,
            center_learning_rate=center_learning_rate,
            stdev_learning_rate=stdev_learning_rate,
            stdev_init=stdev_init,
            radius_init=radius_init,
            popsize_max=popsize_max,
            num_interactions=num_interactions,
            optimizer=optimizer,
            optimizer_config=optimizer_config,
            ranking_method=ranking_method,
            center_init=center_init,
            stdev_min=stdev_min,
            stdev_max=stdev_max,
            stdev_max_change=stdev_max_change,
            obj_index=obj_index,
            distributed=distributed,
            popsize_weighted_grad_avg=popsize_weighted_grad_avg,
        )
DISTRIBUTION_TYPE (SeparableGaussian)

exponentialseparable Multivariate Gaussian, as used by SNES

Source code in evotorch/algorithms/distributed/gaussian.py
class ExpSeparableGaussian(SeparableGaussian):
    """exponentialseparable Multivariate Gaussian, as used by SNES"""

    MANDATORY_PARAMETERS = {"mu", "sigma"}
    OPTIONAL_PARAMETERS = set()

    def _compute_gradients(self, samples: torch.Tensor, weights: torch.Tensor, ranking_used: Optional[str]) -> dict:
        if ranking_used != "nes":
            weights = weights / torch.sum(torch.abs(weights))

        scaled_noises = samples - self.mu
        raw_noises = scaled_noises / self.sigma

        mu_grad = total(dot(weights, scaled_noises))
        sigma_grad = total(dot(weights, (raw_noises**2) - 1))

        return {"mu": mu_grad, "sigma": sigma_grad}

    def update_parameters(
        self,
        gradients: dict,
        *,
        learning_rates: Optional[dict] = None,
        optimizers: Optional[dict] = None,
    ) -> "ExpSeparableGaussian":
        mu_grad = gradients["mu"]
        sigma_grad = gradients["sigma"]

        new_mu = self.mu + self._follow_gradient("mu", mu_grad, learning_rates=learning_rates, optimizers=optimizers)
        new_sigma = self.sigma * torch.exp(
            0.5 * self._follow_gradient("sigma", sigma_grad, learning_rates=learning_rates, optimizers=optimizers)
        )

        return self.modified_copy(mu=new_mu, sigma=new_sigma)
update_parameters(self, gradients, *, learning_rates=None, optimizers=None)

Do an update on the distribution by following the given gradients.

It is expected that the inheriting class has its own implementation for this method.

Parameters:

Name Type Description Default
gradients dict

Gradients, as a dictionary, which will be used for computing the necessary updates.

required
learning_rates Optional[dict]

A dictionary which contains learning rates for parameters that will be updated using a learning rate coefficient.

None
optimizers Optional[dict]

A dictionary which contains optimizer objects for parameters that will be updated using an adaptive optimizer.

None

Returns:

Type Description
ExpSeparableGaussian

The updated copy of the distribution.

Source code in evotorch/algorithms/distributed/gaussian.py
def update_parameters(
    self,
    gradients: dict,
    *,
    learning_rates: Optional[dict] = None,
    optimizers: Optional[dict] = None,
) -> "ExpSeparableGaussian":
    mu_grad = gradients["mu"]
    sigma_grad = gradients["sigma"]

    new_mu = self.mu + self._follow_gradient("mu", mu_grad, learning_rates=learning_rates, optimizers=optimizers)
    new_sigma = self.sigma * torch.exp(
        0.5 * self._follow_gradient("sigma", sigma_grad, learning_rates=learning_rates, optimizers=optimizers)
    )

    return self.modified_copy(mu=new_mu, sigma=new_sigma)
__init__(self, problem, *, stdev_init=None, radius_init=None, popsize=None, center_learning_rate=None, stdev_learning_rate=None, scale_learning_rate=True, num_interactions=None, popsize_max=None, optimizer=None, optimizer_config=None, ranking_method='nes', center_init=None, stdev_min=None, stdev_max=None, stdev_max_change=None, obj_index=None, distributed=False, popsize_weighted_grad_avg=None) special

__init__(...): Initialize the SNES algorithm.

Parameters:

Name Type Description Default
problem Problem

The problem object which is being worked on.

required
stdev_init Union[float, Iterable[float], torch.Tensor]

The initial standard deviation of the search distribution, expressed as a scalar or as an array. Determines the initial coverage area of the search distribution. If one wishes to configure the coverage area via the argument radius_init instead, then stdev_init is expected as None.

None
radius_init Union[float, Iterable[float], torch.Tensor]

The initial radius of the search distribution, expressed as a scalar. Determines the initial coverage area of the search distribution. Here, "radius" is defined as the norm of the search distribution. If one wishes to configure the coverage area via the argument stdev_init instead, then radius_init is expected as None.

None
popsize Optional[int]

Population size. Can be specified as an int, or can be left as None to let the solver decide. In the case of SNES, popsize can be left as None, in which case the default popsize will be computed as 4 + floor(3 * log(n)) where n is the length of a solution.

None
center_learning_rate Optional[float]

Learning rate for updating the mean of the search distribution. Default value is 1.0

None
stdev_learning_rate Optional[float]

Learning rate for updating the covariance matrix of the search distribution. The default value is 0.2 * (3 + log(n)) / sqrt(n) where n is the length of a solution.

None
scale_learning_rate bool

For SNES, there is a default standard deviation learning rate value which is computed as 0.2 * (3 + log(n)) / sqrt(n) (where n is the solution length). If scale_learning_rate is True (which is the default), then the effective learning rate for the standard deviation becomes the provided stdev_learning_rate multiplied by this default value. If scale_learning_rate is False, then the effective standard deviation learning rate becomes equal to the provided stdev_learning_rate value.

True
num_interactions Optional[int]

When given as an integer n, it is ensured that a population has interacted with the GymProblem's environment n times. If this target has not been reached yet, then the population is declared too small, and gets extended with more samples, until n amount of interactions is reached. When given as None, popsize is the only configuration affecting the size of a population.

None
popsize_max Optional[int]

Having num_interactions set as an integer might cause the effective population size jump to unnecesarily large numbers. To prevent this, one can set popsize_max to specify an upper bound for the effective population size.

None
num_interactions Optional[int]

When given as an integer n, it is ensured that a population has interacted with the GymProblem's environment n times. If this target has not been reached yet, then the population is declared too small, and gets extended with more samples, until n amount of interactions is reached. When given as None, popsize is the only configuration affecting the size of a population.

None
popsize_max Optional[int]

Having num_interactions set as an integer might cause the effective population size jump to unnecesarily large numbers. To prevent this, one can set popsize_max to specify an upper bound for the effective population size.

None
optimizer

The optimizer to be used while following the estimated the gradients. Can be given as None if a momentum-based optimizer is not required. Otherwise, can be given as a str containing the name of the optimizer (e.g. 'adam', 'clipup'); or as an instance of evotorch.optimizers.TorchOptimizer or evotorch.optimizers.ClipUp. The default is None. Note that, for ClipUp, the default maximum speed is set as twice the given center_learning_rate. This maximum speed can be configured by passing {"max_speed": ...} to optimizer_config.

None
optimizer_config Optional[dict]

Configuration which will be passed to the optimizer as keyword arguments. See evotorch.optimizers for details about which optimizer accepts which keyword arguments.

None
ranking_method Optional[str]

Which ranking method will be used for fitness shaping. See the documentation of evotorch.ranking.rank(...) for details. The default is 'nes'. Can be given as None if no such ranking is required.

'nes'
center_init Union[float, Iterable[float], torch.Tensor]

The initial center solution. Can be left as None.

None
stdev_min Union[float, Iterable[float], torch.Tensor]

Minimum values for the standard deviation. Expected as a 1-dimensional array to serve as a limiter to the diagonals of the covariance matrix's square root.

None
stdev_max Union[float, Iterable[float], torch.Tensor]

Maximum values for the standard deviation. Expected as a 1-dimensional array to serve as a limiter to the diagonals of the covariance matrix's square root.

None
stdev_max_change Union[float, Iterable[float], torch.Tensor]

Maximum change allowed for when updating the square roort of the covariance matrix.

None
obj_index Optional[int]

Index of the objective according to which the gradient estimations will be done. For single-objective problems, this can be left as None.

None
distributed bool

Whether or not the gradient computation will be distributed. If distributed is given as False and the problem is not parallelized, then everything will be centralized (i.e. the entire computation will happen in the main process). If distributed is given as False, and the problem is parallelized, then the population will be created in the main process and then sent to remote workers for parallelized evaluation, and then the remote fitnesses will be collected by the main process again for computing the search gradients. If distributed is given as True, and the problem is parallelized, then the search algorithm itself will be distributed, in the sense that each remote actor will generate its own population (such that the total population size across all these actors becomes equal to popsize) and will compute its own gradient, and then the main process will collect these gradients, compute the averaged gradients and update the main search distribution. Non-distributed mode has the advantage of keeping the population in the main process, which is good when one wishes to do detailed monitoring during the evolutionary process, but has the disadvantage of having to pass the solutions to the remote actors and having to collect fitnesses, which might result in increased interprocess communication traffic. On the other hand, while it is not possible to monitor the population in distributed mode, the distributed mode has the advantage of significantly reducing the interprocess communication traffic, since the only things communicated with the remote actors are the search distributions (not the solutions) and the gradients.

False
popsize_weighted_grad_avg Optional[bool]

Only to be used in distributed mode. (where being in distributed mode means distributed is given as True). In distributed mode, each actor remotely samples its own solution batches and computes its own gradients. These gradients are then collected, and a final average gradient is computed. If popsize_weighted_grad_avg is True, then, while averaging over the gradients, each gradient will have its own weight that is computed according to how many solutions were sampled by the actor that produced the gradient. If popsize_weighted_grad_avg is False, then, there will not be weighted averaging (or, each gradient will have equal weight). If popsize_weighted_grad_avg is None, then, the gradient weights will be equal a value for num_interactions is given (because num_interactions affects the number of solutions according to the episode lengths, and popsize-weighting the gradients could be misleading); and the gradient weights will be weighted according to the sub-population (i.e. sub-batch) sizes if num_interactions is left as None. The default value for popsize_weighted_grad_avg is None. When the distributed mode is disabled (i.e. when distributed is False), then the argument popsize_weighted_grad_avg is expected as None.

None
Source code in evotorch/algorithms/distributed/gaussian.py
def __init__(
    self,
    problem: Problem,
    *,
    stdev_init: Optional[RealOrVector] = None,
    radius_init: Optional[RealOrVector] = None,
    popsize: Optional[int] = None,
    center_learning_rate: Optional[float] = None,
    stdev_learning_rate: Optional[float] = None,
    scale_learning_rate: bool = True,
    num_interactions: Optional[int] = None,
    popsize_max: Optional[int] = None,
    optimizer=None,
    optimizer_config: Optional[dict] = None,
    ranking_method: Optional[str] = "nes",
    center_init: Optional[RealOrVector] = None,
    stdev_min: Optional[RealOrVector] = None,
    stdev_max: Optional[RealOrVector] = None,
    stdev_max_change: Optional[RealOrVector] = None,
    obj_index: Optional[int] = None,
    distributed: bool = False,
    popsize_weighted_grad_avg: Optional[bool] = None,
):
    """
    `__init__(...)`: Initialize the SNES algorithm.

    Args:
        problem: The problem object which is being worked on.
        stdev_init: The initial standard deviation of the search
            distribution, expressed as a scalar or as an array.
            Determines the initial coverage area of the search
            distribution.
            If one wishes to configure the coverage area via the
            argument `radius_init` instead, then `stdev_init` is expected
            as None.
        radius_init: The initial radius of the search distribution,
            expressed as a scalar.
            Determines the initial coverage area of the search
            distribution.
            Here, "radius" is defined as the norm of the search
            distribution.
            If one wishes to configure the coverage area via the
            argument `stdev_init` instead, then `radius_init` is expected
            as None.
        popsize: Population size. Can be specified as an int,
            or can be left as None to let the solver decide.
            In the case of SNES, `popsize` can be left as None,
            in which case the default `popsize` will be computed
            as `4 + floor(3 * log(n))` where `n` is the length
            of a solution.
        center_learning_rate: Learning rate for updating the mean
            of the search distribution. Default value is 1.0
        stdev_learning_rate: Learning rate for updating the covariance
            matrix of the search distribution.
            The default value is `0.2 * (3 + log(n)) / sqrt(n)`
            where `n` is the length of a solution.
        scale_learning_rate: For SNES, there is a default standard
            deviation learning rate value which is computed as
            `0.2 * (3 + log(n)) / sqrt(n)` (where `n` is the solution
            length).
            If scale_learning_rate is True (which is the default),
            then the effective learning rate for the standard deviation
            becomes the provided `stdev_learning_rate` multiplied by this
            default value. If `scale_learning_rate` is False, then the
            effective standard deviation learning rate becomes
            equal to the provided `stdev_learning_rate` value.
        num_interactions: When given as an integer n,
            it is ensured that a population has interacted with
            the GymProblem's environment n times. If this target
            has not been reached yet, then the population is declared
            too small, and gets extended with more samples,
            until n amount of interactions is reached.
            When given as None, popsize is the only configuration
            affecting the size of a population.
        popsize_max: Having `num_interactions` set as an integer
            might cause the effective population size jump to
            unnecesarily large numbers. To prevent this,
            one can set `popsize_max` to specify an upper
            bound for the effective population size.
        num_interactions: When given as an integer n,
            it is ensured that a population has interacted with
            the GymProblem's environment n times. If this target
            has not been reached yet, then the population is declared
            too small, and gets extended with more samples,
            until n amount of interactions is reached.
            When given as None, popsize is the only configuration
            affecting the size of a population.
        popsize_max: Having `num_interactions` set as an integer
            might cause the effective population size jump to
            unnecesarily large numbers. To prevent this,
            one can set `popsize_max` to specify an upper
            bound for the effective population size.
        optimizer: The optimizer to be used while following the
            estimated the gradients.
            Can be given as None if a momentum-based optimizer
            is not required.
            Otherwise, can be given as a str containing the name
            of the optimizer (e.g. 'adam', 'clipup');
            or as an instance of evotorch.optimizers.TorchOptimizer
            or evotorch.optimizers.ClipUp.
            The default is None.
            Note that, for ClipUp, the default maximum speed is set
            as twice the given `center_learning_rate`.
            This maximum speed can be configured by passing
            `{"max_speed": ...}` to `optimizer_config`.
        optimizer_config: Configuration which will be passed
            to the optimizer as keyword arguments.
            See `evotorch.optimizers` for details about
            which optimizer accepts which keyword arguments.
        ranking_method: Which ranking method will be used for
            fitness shaping. See the documentation of
            `evotorch.ranking.rank(...)` for details.
            The default is 'nes'.
            Can be given as None if no such ranking is required.
        center_init: The initial center solution.
            Can be left as None.
        stdev_min: Minimum values for the standard deviation.
            Expected as a 1-dimensional array to serve as a limiter
            to the diagonals of the covariance matrix's square root.
        stdev_max: Maximum values for the standard deviation.
            Expected as a 1-dimensional array to serve as a limiter
            to the diagonals of the covariance matrix's square root.
        stdev_max_change: Maximum change allowed for when updating
            the square roort of the covariance matrix.
        obj_index: Index of the objective according to which the
            gradient estimations will be done.
            For single-objective problems, this can be left as None.
        distributed: Whether or not the gradient computation will
            be distributed. If `distributed` is given as False and
            the problem is not parallelized, then everything will
            be centralized (i.e. the entire computation will happen
            in the main process).
            If `distributed` is given as False, and the problem
            is parallelized, then the population will be created
            in the main process and then sent to remote workers
            for parallelized evaluation, and then the remote fitnesses
            will be collected by the main process again for computing
            the search gradients.
            If `distributed` is given as True, and the problem
            is parallelized, then the search algorithm itself will
            be distributed, in the sense that each remote actor will
            generate its own population (such that the total population
            size across all these actors becomes equal to `popsize`)
            and will compute its own gradient, and then the main process
            will collect these gradients, compute the averaged gradients
            and update the main search distribution.
            Non-distributed mode has the advantage of keeping the
            population in the main process, which is good when one wishes
            to do detailed monitoring during the evolutionary process,
            but has the disadvantage of having to pass the solutions to
            the remote actors and having to collect fitnesses, which
            might result in increased interprocess communication traffic.
            On the other hand, while it is not possible to monitor the
            population in distributed mode, the distributed mode has the
            advantage of significantly reducing the interprocess
            communication traffic, since the only things communicated
            with the remote actors are the search distributions (not the
            solutions) and the gradients.
        popsize_weighted_grad_avg: Only to be used in distributed mode.
            (where being in distributed mode means `distributed` is given
            as True). In distributed mode, each actor remotely samples
            its own solution batches and computes its own gradients.
            These gradients are then collected, and a final average
            gradient is computed.
            If `popsize_weighted_grad_avg` is True, then, while averaging
            over the gradients, each gradient will have its own weight
            that is computed according to how many solutions were sampled
            by the actor that produced the gradient.
            If `popsize_weighted_grad_avg` is False, then, there will not
            be weighted averaging (or, each gradient will have equal
            weight).
            If `popsize_weighted_grad_avg` is None, then, the gradient
            weights will be equal a value for `num_interactions` is given
            (because `num_interactions` affects the number of solutions
            according to the episode lengths, and popsize-weighting the
            gradients could be misleading); and the gradient weights will
            be weighted according to the sub-population (i.e. sub-batch)
            sizes if `num_interactions` is left as None.
            The default value for `popsize_weighted_grad_avg` is None.
            When the distributed mode is disabled (i.e. when `distributed`
            is False), then the argument `popsize_weighted_grad_avg` is
            expected as None.
    """

    if popsize is None:
        popsize = int(4 + math.floor(3 * math.log(problem.solution_length)))

    if center_learning_rate is None:
        center_learning_rate = 1.0

    def default_stdev_lr():
        n = problem.solution_length
        return 0.2 * (3 + math.log(n)) / math.sqrt(n)

    if stdev_learning_rate is None:
        stdev_learning_rate = default_stdev_lr()
    else:
        stdev_learning_rate = float(stdev_learning_rate)
        if scale_learning_rate:
            stdev_learning_rate *= default_stdev_lr()

    super().__init__(
        problem,
        popsize=popsize,
        center_learning_rate=center_learning_rate,
        stdev_learning_rate=stdev_learning_rate,
        stdev_init=stdev_init,
        radius_init=radius_init,
        popsize_max=popsize_max,
        num_interactions=num_interactions,
        optimizer=optimizer,
        optimizer_config=optimizer_config,
        ranking_method=ranking_method,
        center_init=center_init,
        stdev_min=stdev_min,
        stdev_max=stdev_max,
        stdev_max_change=stdev_max_change,
        obj_index=obj_index,
        distributed=distributed,
        popsize_weighted_grad_avg=popsize_weighted_grad_avg,
    )
XNES (GaussianSearchAlgorithm)

Inspired by the implementation at: http://schaul.site44.com/code/xnes.py https://github.com/pybrain/pybrain/blob/master/pybrain/optimization/distributionbased/xnes.py

Reference

Glasmachers, Tobias, et al. Exponential natural evolution strategies. Proceedings of the 12th annual conference on Genetic and evolutionary computation (GECCO 2010).

Source code in evotorch/algorithms/distributed/gaussian.py
class XNES(GaussianSearchAlgorithm):
    """
    XNES: Exponential Natural Evolution Strategies

    Inspired by the implementation at:
    http://schaul.site44.com/code/xnes.py
    https://github.com/pybrain/pybrain/blob/master/pybrain/optimization/distributionbased/xnes.py

    Reference:
        Glasmachers, Tobias, et al.
        Exponential natural evolution strategies.
        Proceedings of the 12th annual conference on Genetic and evolutionary
        computation (GECCO 2010).
    """

    DISTRIBUTION_TYPE = ExpGaussian
    DISTRIBUTION_PARAMS = None

    def __init__(
        self,
        problem: Problem,
        *,
        stdev_init: Optional[RealOrVector] = None,
        radius_init: Optional[RealOrVector] = None,
        popsize: Optional[int] = None,
        center_learning_rate: Optional[float] = None,
        stdev_learning_rate: Optional[float] = None,
        scale_learning_rate: bool = True,
        num_interactions: Optional[int] = None,
        popsize_max: Optional[int] = None,
        optimizer=None,
        optimizer_config: Optional[dict] = None,
        ranking_method: Optional[str] = "nes",
        center_init: Optional[RealOrVector] = None,
        obj_index: Optional[int] = None,
        distributed: bool = False,
        popsize_weighted_grad_avg: Optional[bool] = None,
    ):
        """
        `__init__(...)`: Initialize the XNES algorithm.

        Args:
            problem: The problem object which is being worked on.
            stdev_init: The initial standard deviation of the search
                distribution, expressed as a scalar or as an array.
                Determines the initial coverage area of the search
                distribution.
                If one wishes to configure the coverage area via the
                argument `radius_init` instead, then `stdev_init` is expected
                as None.
            radius_init: The initial radius of the search distribution,
                expressed as a scalar.
                Determines the initial coverage area of the search
                distribution.
                Here, "radius" is defined as the norm of the search
                distribution.
                If one wishes to configure the coverage area via the
                argument `stdev_init` instead, then `radius_init` is expected
                as None.
            popsize: Population size. Can be specified as an int,
                or can be left as None to let the solver decide.
                In the case of SNES, `popsize` can be left as None,
                in which case the default `popsize` will be computed
                as `4 + floor(3 * log(n))` where `n` is the length
                of a solution.
            center_learning_rate: Learning rate for updating the mean
                of the search distribution. Default value is 1.0
            stdev_learning_rate: Learning rate for updating the covariance
                matrix of the search distribution.
                The default value is `0.6 * (3 + log(n)) / (n * sqrt(n))`
                where `n` is the length of a solution.
            scale_learning_rate: For SNES, there is a default standard
                deviation learning rate value which is computed as
                `0.6 * (3 + log(n)) / (n * sqrt(n))` (where `n` is the solution
                length).
                If scale_learning_rate is True (which is the default),
                then the effective learning rate for the standard deviation
                becomes the provided `stdev_learning_rate` multiplied by this
                default value. If `scale_learning_rate` is False, then the
                effective standard deviation learning rate becomes
                equal to the provided `stdev_learning_rate` value.
            num_interactions: When given as an integer n,
                it is ensured that a population has interacted with
                the GymProblem's environment n times. If this target
                has not been reached yet, then the population is declared
                too small, and gets extended with more samples,
                until n amount of interactions is reached.
                When given as None, popsize is the only configuration
                affecting the size of a population.
            popsize_max: Having `num_interactions` set as an integer
                might cause the effective population size jump to
                unnecesarily large numbers. To prevent this,
                one can set `popsize_max` to specify an upper
                bound for the effective population size.
            num_interactions: When given as an integer n,
                it is ensured that a population has interacted with
                the GymProblem's environment n times. If this target
                has not been reached yet, then the population is declared
                too small, and gets extended with more samples,
                until n amount of interactions is reached.
                When given as None, popsize is the only configuration
                affecting the size of a population.
            optimizer: The optimizer to be used while following the
                estimated the gradients.
                Can be given as None if a momentum-based optimizer
                is not required.
                Otherwise, can be given as a str containing the name
                of the optimizer (e.g. 'adam', 'clipup');
                or as an instance of evotorch.optimizers.TorchOptimizer
                or evotorch.optimizers.ClipUp.
                The default is None.
                Note that, for ClipUp, the default maximum speed is set
                as twice the given `center_learning_rate`.
                This maximum speed can be configured by passing
                `{"max_speed": ...}` to `optimizer_config`.
            optimizer_config: Configuration which will be passed
                to the optimizer as keyword arguments.
                See `evotorch.optimizers` for details about
                which optimizer accepts which keyword arguments.
            ranking_method: Which ranking method will be used for
                fitness shaping. See the documentation of
                `evotorch.ranking.rank(...)` for details.
                The default is 'nes'.
                Can be given as None if no such ranking is required.
            center_init: The initial center solution.
                Can be left as None.
            obj_index: Index of the objective according to which the
                gradient estimations will be done.
                For single-objective problems, this can be left as None.
            distributed: Whether or not the gradient computation will
                be distributed. If `distributed` is given as False and
                the problem is not parallelized, then everything will
                be centralized (i.e. the entire computation will happen
                in the main process).
                If `distributed` is given as False, and the problem
                is parallelized, then the population will be created
                in the main process and then sent to remote workers
                for parallelized evaluation, and then the remote fitnesses
                will be collected by the main process again for computing
                the search gradients.
                If `distributed` is given as True, and the problem
                is parallelized, then the search algorithm itself will
                be distributed, in the sense that each remote actor will
                generate its own population (such that the total population
                size across all these actors becomes equal to `popsize`)
                and will compute its own gradient, and then the main process
                will collect these gradients, compute the averaged gradients
                and update the main search distribution.
                Non-distributed mode has the advantage of keeping the
                population in the main process, which is good when one wishes
                to do detailed monitoring during the evolutionary process,
                but has the disadvantage of having to pass the solutions to
                the remote actors and having to collect fitnesses, which
                might result in increased interprocess communication traffic.
                On the other hand, while it is not possible to monitor the
                population in distributed mode, the distributed mode has the
                advantage of significantly reducing the interprocess
                communication traffic, since the only things communicated
                with the remote actors are the search distributions (not the
                solutions) and the gradients.
            popsize_weighted_grad_avg: Only to be used in distributed mode.
                (where being in distributed mode means `distributed` is given
                as True). In distributed mode, each actor remotely samples
                its own solution batches and computes its own gradients.
                These gradients are then collected, and a final average
                gradient is computed.
                If `popsize_weighted_grad_avg` is True, then, while averaging
                over the gradients, each gradient will have its own weight
                that is computed according to how many solutions were sampled
                by the actor that produced the gradient.
                If `popsize_weighted_grad_avg` is False, then, there will not
                be weighted averaging (or, each gradient will have equal
                weight).
                If `popsize_weighted_grad_avg` is None, then, the gradient
                weights will be equal a value for `num_interactions` is given
                (because `num_interactions` affects the number of solutions
                according to the episode lengths, and popsize-weighting the
                gradients could be misleading); and the gradient weights will
                be weighted according to the sub-population (i.e. sub-batch)
                sizes if `num_interactions` is left as None.
                The default value for `popsize_weighted_grad_avg` is None.
                When the distributed mode is disabled (i.e. when `distributed`
                is False), then the argument `popsize_weighted_grad_avg` is
                expected as None.
        """

        if popsize is None:
            popsize = int(4 + math.floor(3 * math.log(problem.solution_length)))

        if center_learning_rate is None:
            center_learning_rate = 1.0

        def default_stdev_lr():
            n = problem.solution_length
            return 0.6 * (3 + math.log(n)) / (n * math.sqrt(n))

        if stdev_learning_rate is None:
            stdev_learning_rate = default_stdev_lr()
        else:
            stdev_learning_rate = float(stdev_learning_rate)
            if scale_learning_rate:
                stdev_learning_rate *= default_stdev_lr()

        super().__init__(
            problem,
            popsize=popsize,
            center_learning_rate=center_learning_rate,
            stdev_learning_rate=stdev_learning_rate,
            stdev_init=stdev_init,
            radius_init=radius_init,
            popsize_max=popsize_max,
            num_interactions=num_interactions,
            optimizer=optimizer,
            optimizer_config=optimizer_config,
            ranking_method=ranking_method,
            center_init=center_init,
            stdev_min=None,
            stdev_max=None,
            stdev_max_change=None,
            obj_index=obj_index,
            distributed=distributed,
            popsize_weighted_grad_avg=popsize_weighted_grad_avg,
        )
DISTRIBUTION_TYPE (Distribution)

exponential Multivariate Gaussian, as used by XNES

Source code in evotorch/algorithms/distributed/gaussian.py
class ExpGaussian(Distribution):
    """exponential Multivariate Gaussian, as used by XNES"""

    # Corresponding to mu and A in symbols used in xNES paper
    MANDATORY_PARAMETERS = {"mu", "sigma"}

    # Inverse of sigma, numerically more stable to track this independently to sigma
    OPTIONAL_PARAMETERS = {"sigma_inv"}

    def __init__(
        self,
        parameters: dict,
        *,
        solution_length: Optional[int] = None,
        device: Optional[Device] = None,
        dtype: Optional[DType] = None,
    ):
        [mu_length] = parameters["mu"].shape

        # Make sigma 2D
        if len(parameters["sigma"].shape) == 1:
            parameters["sigma"] = torch.diag(parameters["sigma"])

        # Automatically generate sigma_inv if not provided
        if "sigma_inv" not in parameters:
            parameters["sigma_inv"] = torch.inverse(parameters["sigma"])

        [sigma_length, _] = parameters["sigma"].shape

        if solution_length is None:
            solution_length = mu_length
        else:
            if solution_length != mu_length:
                raise ValueError(
                    f"The argument `solution_length` does not match the length of `mu` provided in `parameters`."
                    f" solution_length={solution_length},"
                    f' parameters["mu"]={mu_length}.'
                )

        if mu_length != sigma_length:
            raise ValueError(
                f"The tensors `mu` and `sigma` provided within `parameters` have mismatching lengths."
                f' parameters["mu"]={mu_length},'
                f' parameters["sigma"]={sigma_length}.'
            )

        super().__init__(
            solution_length=solution_length,
            parameters=parameters,
            device=device,
            dtype=dtype,
        )
        # Make identity matrix as this is used throughout in gradient computation
        self.eye = self.make_I(solution_length)

    @property
    def mu(self) -> torch.Tensor:
        """Getter for mu
        Returns:
            mu (torch.Tensor): The center of the search distribution
        """
        return self.parameters["mu"]

    @mu.setter
    def mu(self, new_mu: Iterable):
        """Setter for mu
        Args:
            new_mu (torch.Tensor): The new value of mu
        """
        self.parameters["mu"] = torch.as_tensor(new_mu, dtype=self.dtype, device=self.device)

    @property
    def cov(self) -> torch.Tensor:
        """The covariance matrix A^T A"""
        return self.sigma.transpose(0, 1) @ self.sigma

    @property
    def sigma(self) -> torch.Tensor:
        """Getter for sigma
        Returns:
            sigma (torch.Tensor): The square root of the covariance matrix
        """
        return self.parameters["sigma"]

    @property
    def sigma_inv(self) -> torch.Tensor:
        """Getter for sigma_inv
        Returns:
            sigma_inv (torch.Tensor): The inverse square root of the covariance matrix
        """
        if "sigma_inv" in self.parameters:
            return self.parameters["sigma_inv"]
        else:
            return torch.inverse(self.parameters["sigma"])

    @property
    def A(self) -> torch.Tensor:
        """Alias for self.sigma, for notational consistency with paper"""
        return self.sigma

    @property
    def A_inv(self) -> torch.Tensor:
        """Alias for self.sigma_inv, for notational consistency with paper"""
        return self.sigma_inv

    @sigma.setter
    def sigma(self, new_sigma: Iterable):
        """Setter for sigma
        Args:
            new_sigma (torch.Tensor): The new value of sigma, the square root of the covariance matrix
        """
        self.parameters["sigma"] = torch.as_tensor(new_sigma, dtype=self.dtype, device=self.device)

    def to_global_coordinates(self, local_coordinates: torch.Tensor) -> torch.Tensor:
        """Map samples from local coordinate space N(0, I_d) to global coordinate space N(mu, A^T A)
        This function is the inverse of to_local_coordinates
        Args:
            local_coordinates (torch.Tensor): The local coordinates sampled from N(0, I_d)
        Returns:
            global_coordinates (torch.Tensor): The global coordinates sampled from N(mu, A^T A)
        """
        # Global samples are constructed as x = mu + A z where z is local coordinate
        # We use transpose here to simplify the batched application of A
        return self.mu.unsqueeze(0) + (self.A @ local_coordinates.T).T

    def to_local_coordinates(self, global_coordinates: torch.Tensor) -> torch.Tensor:
        """Map samples from global coordinate space N(mu, A^T A) to local coordinate space N(0, I_d)
        This function is the inverse of to_global_coordinates
        Args:
            global_coordinates (torch.Tensor): The global coordinates sampled from N(mu, A^T A)
        Returns:
            local_coordinates (torch.Tensor): The local coordinates sampled from N(0, I_d)
        """
        # Global samples are constructed as x = mu + A z where z is local coordinate
        # Therefore, we can recover z according to z = A_inv (x - mu)
        return (self.A_inv @ (global_coordinates - self.mu.unsqueeze(0)).T).T

    def _fill(self, out: torch.Tensor, *, generator: Optional[torch.Generator] = None):
        """Fill a tensor with samples from N(mu, A^T A)
        Args:
            out (torch.Tensor): The tensor to fill
            generator (Optional[torch.Generator]): A generator to use to generate random values
        """
        # Fill with local coordinates from N(0, I_d)
        self.make_gaussian(out=out, generator=generator)
        # Map local coordinates to global coordinate system
        out[:] = self.to_global_coordinates(out)

    def _compute_gradients(self, samples: torch.Tensor, weights: torch.Tensor, ranking_used: Optional[str]) -> dict:
        """Compute the gradients with respect to a given set of samples and weights
        Args:
            samples (torch.Tensor): Samples drawn from N(mu, A^T A), ideally using self._fill
            weights (torch.Tensor): Weights e.g. fitnesses or utilities assigned to samples
            ranking_used (optional[str]): The ranking method used to compute weights
        Returns:
            grads (dict): A dictionary containing the approximated natural gradient on d and M
        """
        # Compute the local coordinates
        local_coordinates = self.to_local_coordinates(samples)

        # Make sure that the weights (utilities) are 0-centered
        # (Otherwise the formulations would have to consider a bias term)
        if ranking_used not in ("centered", "normalized"):
            weights = weights - torch.mean(weights)

        d_grad = total(dot(weights, local_coordinates))
        local_coordinates_outer = local_coordinates.unsqueeze(1) * local_coordinates.unsqueeze(2)
        M_grad = torch.sum(
            weights.unsqueeze(-1).unsqueeze(-1) * (local_coordinates_outer - self.eye.unsqueeze(0)), dim=0
        )

        return {
            "d": d_grad,
            "M": M_grad,
        }

    def update_parameters(
        self,
        gradients: dict,
        *,
        learning_rates: Optional[dict] = None,
        optimizers: Optional[dict] = None,
    ) -> "ExpGaussian":
        d_grad = gradients["d"]
        M_grad = gradients["M"]

        if "d" not in learning_rates:
            learning_rates["d"] = learning_rates["mu"]
        if "M" not in learning_rates:
            learning_rates["M"] = learning_rates["sigma"]

        # Follow gradients for d, and M
        update_d = self._follow_gradient("d", d_grad, learning_rates=learning_rates, optimizers=optimizers)
        update_M = self._follow_gradient("M", M_grad, learning_rates=learning_rates, optimizers=optimizers)

        # Fold into parameters mu, A and A inv
        new_mu = self.mu + torch.mv(self.A, update_d)
        new_A = self.A @ torch.matrix_exp(0.5 * update_M)
        new_A_inv = torch.matrix_exp(-0.5 * update_M) @ self.A_inv

        # Return modified distribution
        return self.modified_copy(mu=new_mu, sigma=new_A, sigma_inv=new_A_inv)
A: Tensor property readonly

Alias for self.sigma, for notational consistency with paper

A_inv: Tensor property readonly

Alias for self.sigma_inv, for notational consistency with paper

cov: Tensor property readonly

The covariance matrix A^T A

mu: Tensor property writable

Getter for mu

Returns:

Type Description
mu (torch.Tensor)

The center of the search distribution

sigma: Tensor property writable

Getter for sigma

Returns:

Type Description
sigma (torch.Tensor)

The square root of the covariance matrix

sigma_inv: Tensor property readonly

Getter for sigma_inv

Returns:

Type Description
sigma_inv (torch.Tensor)

The inverse square root of the covariance matrix

to_global_coordinates(self, local_coordinates)

Map samples from local coordinate space N(0, I_d) to global coordinate space N(mu, A^T A) This function is the inverse of to_local_coordinates

Parameters:

Name Type Description Default
local_coordinates torch.Tensor

The local coordinates sampled from N(0, I_d)

required

Returns:

Type Description
global_coordinates (torch.Tensor)

The global coordinates sampled from N(mu, A^T A)

Source code in evotorch/algorithms/distributed/gaussian.py
def to_global_coordinates(self, local_coordinates: torch.Tensor) -> torch.Tensor:
    """Map samples from local coordinate space N(0, I_d) to global coordinate space N(mu, A^T A)
    This function is the inverse of to_local_coordinates
    Args:
        local_coordinates (torch.Tensor): The local coordinates sampled from N(0, I_d)
    Returns:
        global_coordinates (torch.Tensor): The global coordinates sampled from N(mu, A^T A)
    """
    # Global samples are constructed as x = mu + A z where z is local coordinate
    # We use transpose here to simplify the batched application of A
    return self.mu.unsqueeze(0) + (self.A @ local_coordinates.T).T
to_local_coordinates(self, global_coordinates)

Map samples from global coordinate space N(mu, A^T A) to local coordinate space N(0, I_d) This function is the inverse of to_global_coordinates

Parameters:

Name Type Description Default
global_coordinates torch.Tensor

The global coordinates sampled from N(mu, A^T A)

required

Returns:

Type Description
local_coordinates (torch.Tensor)

The local coordinates sampled from N(0, I_d)

Source code in evotorch/algorithms/distributed/gaussian.py
def to_local_coordinates(self, global_coordinates: torch.Tensor) -> torch.Tensor:
    """Map samples from global coordinate space N(mu, A^T A) to local coordinate space N(0, I_d)
    This function is the inverse of to_global_coordinates
    Args:
        global_coordinates (torch.Tensor): The global coordinates sampled from N(mu, A^T A)
    Returns:
        local_coordinates (torch.Tensor): The local coordinates sampled from N(0, I_d)
    """
    # Global samples are constructed as x = mu + A z where z is local coordinate
    # Therefore, we can recover z according to z = A_inv (x - mu)
    return (self.A_inv @ (global_coordinates - self.mu.unsqueeze(0)).T).T
update_parameters(self, gradients, *, learning_rates=None, optimizers=None)

Do an update on the distribution by following the given gradients.

It is expected that the inheriting class has its own implementation for this method.

Parameters:

Name Type Description Default
gradients dict

Gradients, as a dictionary, which will be used for computing the necessary updates.

required
learning_rates Optional[dict]

A dictionary which contains learning rates for parameters that will be updated using a learning rate coefficient.

None
optimizers Optional[dict]

A dictionary which contains optimizer objects for parameters that will be updated using an adaptive optimizer.

None

Returns:

Type Description
ExpGaussian

The updated copy of the distribution.

Source code in evotorch/algorithms/distributed/gaussian.py
def update_parameters(
    self,
    gradients: dict,
    *,
    learning_rates: Optional[dict] = None,
    optimizers: Optional[dict] = None,
) -> "ExpGaussian":
    d_grad = gradients["d"]
    M_grad = gradients["M"]

    if "d" not in learning_rates:
        learning_rates["d"] = learning_rates["mu"]
    if "M" not in learning_rates:
        learning_rates["M"] = learning_rates["sigma"]

    # Follow gradients for d, and M
    update_d = self._follow_gradient("d", d_grad, learning_rates=learning_rates, optimizers=optimizers)
    update_M = self._follow_gradient("M", M_grad, learning_rates=learning_rates, optimizers=optimizers)

    # Fold into parameters mu, A and A inv
    new_mu = self.mu + torch.mv(self.A, update_d)
    new_A = self.A @ torch.matrix_exp(0.5 * update_M)
    new_A_inv = torch.matrix_exp(-0.5 * update_M) @ self.A_inv

    # Return modified distribution
    return self.modified_copy(mu=new_mu, sigma=new_A, sigma_inv=new_A_inv)
__init__(self, problem, *, stdev_init=None, radius_init=None, popsize=None, center_learning_rate=None, stdev_learning_rate=None, scale_learning_rate=True, num_interactions=None, popsize_max=None, optimizer=None, optimizer_config=None, ranking_method='nes', center_init=None, obj_index=None, distributed=False, popsize_weighted_grad_avg=None) special

__init__(...): Initialize the XNES algorithm.

Parameters:

Name Type Description Default
problem Problem

The problem object which is being worked on.

required
stdev_init Union[float, Iterable[float], torch.Tensor]

The initial standard deviation of the search distribution, expressed as a scalar or as an array. Determines the initial coverage area of the search distribution. If one wishes to configure the coverage area via the argument radius_init instead, then stdev_init is expected as None.

None
radius_init Union[float, Iterable[float], torch.Tensor]

The initial radius of the search distribution, expressed as a scalar. Determines the initial coverage area of the search distribution. Here, "radius" is defined as the norm of the search distribution. If one wishes to configure the coverage area via the argument stdev_init instead, then radius_init is expected as None.

None
popsize Optional[int]

Population size. Can be specified as an int, or can be left as None to let the solver decide. In the case of SNES, popsize can be left as None, in which case the default popsize will be computed as 4 + floor(3 * log(n)) where n is the length of a solution.

None
center_learning_rate Optional[float]

Learning rate for updating the mean of the search distribution. Default value is 1.0

None
stdev_learning_rate Optional[float]

Learning rate for updating the covariance matrix of the search distribution. The default value is 0.6 * (3 + log(n)) / (n * sqrt(n)) where n is the length of a solution.

None
scale_learning_rate bool

For SNES, there is a default standard deviation learning rate value which is computed as 0.6 * (3 + log(n)) / (n * sqrt(n)) (where n is the solution length). If scale_learning_rate is True (which is the default), then the effective learning rate for the standard deviation becomes the provided stdev_learning_rate multiplied by this default value. If scale_learning_rate is False, then the effective standard deviation learning rate becomes equal to the provided stdev_learning_rate value.

True
num_interactions Optional[int]

When given as an integer n, it is ensured that a population has interacted with the GymProblem's environment n times. If this target has not been reached yet, then the population is declared too small, and gets extended with more samples, until n amount of interactions is reached. When given as None, popsize is the only configuration affecting the size of a population.

None
popsize_max Optional[int]

Having num_interactions set as an integer might cause the effective population size jump to unnecesarily large numbers. To prevent this, one can set popsize_max to specify an upper bound for the effective population size.

None
num_interactions Optional[int]

When given as an integer n, it is ensured that a population has interacted with the GymProblem's environment n times. If this target has not been reached yet, then the population is declared too small, and gets extended with more samples, until n amount of interactions is reached. When given as None, popsize is the only configuration affecting the size of a population.

None
optimizer

The optimizer to be used while following the estimated the gradients. Can be given as None if a momentum-based optimizer is not required. Otherwise, can be given as a str containing the name of the optimizer (e.g. 'adam', 'clipup'); or as an instance of evotorch.optimizers.TorchOptimizer or evotorch.optimizers.ClipUp. The default is None. Note that, for ClipUp, the default maximum speed is set as twice the given center_learning_rate. This maximum speed can be configured by passing {"max_speed": ...} to optimizer_config.

None
optimizer_config Optional[dict]

Configuration which will be passed to the optimizer as keyword arguments. See evotorch.optimizers for details about which optimizer accepts which keyword arguments.

None
ranking_method Optional[str]

Which ranking method will be used for fitness shaping. See the documentation of evotorch.ranking.rank(...) for details. The default is 'nes'. Can be given as None if no such ranking is required.

'nes'
center_init Union[float, Iterable[float], torch.Tensor]

The initial center solution. Can be left as None.

None
obj_index Optional[int]

Index of the objective according to which the gradient estimations will be done. For single-objective problems, this can be left as None.

None
distributed bool

Whether or not the gradient computation will be distributed. If distributed is given as False and the problem is not parallelized, then everything will be centralized (i.e. the entire computation will happen in the main process). If distributed is given as False, and the problem is parallelized, then the population will be created in the main process and then sent to remote workers for parallelized evaluation, and then the remote fitnesses will be collected by the main process again for computing the search gradients. If distributed is given as True, and the problem is parallelized, then the search algorithm itself will be distributed, in the sense that each remote actor will generate its own population (such that the total population size across all these actors becomes equal to popsize) and will compute its own gradient, and then the main process will collect these gradients, compute the averaged gradients and update the main search distribution. Non-distributed mode has the advantage of keeping the population in the main process, which is good when one wishes to do detailed monitoring during the evolutionary process, but has the disadvantage of having to pass the solutions to the remote actors and having to collect fitnesses, which might result in increased interprocess communication traffic. On the other hand, while it is not possible to monitor the population in distributed mode, the distributed mode has the advantage of significantly reducing the interprocess communication traffic, since the only things communicated with the remote actors are the search distributions (not the solutions) and the gradients.

False
popsize_weighted_grad_avg Optional[bool]

Only to be used in distributed mode. (where being in distributed mode means distributed is given as True). In distributed mode, each actor remotely samples its own solution batches and computes its own gradients. These gradients are then collected, and a final average gradient is computed. If popsize_weighted_grad_avg is True, then, while averaging over the gradients, each gradient will have its own weight that is computed according to how many solutions were sampled by the actor that produced the gradient. If popsize_weighted_grad_avg is False, then, there will not be weighted averaging (or, each gradient will have equal weight). If popsize_weighted_grad_avg is None, then, the gradient weights will be equal a value for num_interactions is given (because num_interactions affects the number of solutions according to the episode lengths, and popsize-weighting the gradients could be misleading); and the gradient weights will be weighted according to the sub-population (i.e. sub-batch) sizes if num_interactions is left as None. The default value for popsize_weighted_grad_avg is None. When the distributed mode is disabled (i.e. when distributed is False), then the argument popsize_weighted_grad_avg is expected as None.

None
Source code in evotorch/algorithms/distributed/gaussian.py
def __init__(
    self,
    problem: Problem,
    *,
    stdev_init: Optional[RealOrVector] = None,
    radius_init: Optional[RealOrVector] = None,
    popsize: Optional[int] = None,
    center_learning_rate: Optional[float] = None,
    stdev_learning_rate: Optional[float] = None,
    scale_learning_rate: bool = True,
    num_interactions: Optional[int] = None,
    popsize_max: Optional[int] = None,
    optimizer=None,
    optimizer_config: Optional[dict] = None,
    ranking_method: Optional[str] = "nes",
    center_init: Optional[RealOrVector] = None,
    obj_index: Optional[int] = None,
    distributed: bool = False,
    popsize_weighted_grad_avg: Optional[bool] = None,
):
    """
    `__init__(...)`: Initialize the XNES algorithm.

    Args:
        problem: The problem object which is being worked on.
        stdev_init: The initial standard deviation of the search
            distribution, expressed as a scalar or as an array.
            Determines the initial coverage area of the search
            distribution.
            If one wishes to configure the coverage area via the
            argument `radius_init` instead, then `stdev_init` is expected
            as None.
        radius_init: The initial radius of the search distribution,
            expressed as a scalar.
            Determines the initial coverage area of the search
            distribution.
            Here, "radius" is defined as the norm of the search
            distribution.
            If one wishes to configure the coverage area via the
            argument `stdev_init` instead, then `radius_init` is expected
            as None.
        popsize: Population size. Can be specified as an int,
            or can be left as None to let the solver decide.
            In the case of SNES, `popsize` can be left as None,
            in which case the default `popsize` will be computed
            as `4 + floor(3 * log(n))` where `n` is the length
            of a solution.
        center_learning_rate: Learning rate for updating the mean
            of the search distribution. Default value is 1.0
        stdev_learning_rate: Learning rate for updating the covariance
            matrix of the search distribution.
            The default value is `0.6 * (3 + log(n)) / (n * sqrt(n))`
            where `n` is the length of a solution.
        scale_learning_rate: For SNES, there is a default standard
            deviation learning rate value which is computed as
            `0.6 * (3 + log(n)) / (n * sqrt(n))` (where `n` is the solution
            length).
            If scale_learning_rate is True (which is the default),
            then the effective learning rate for the standard deviation
            becomes the provided `stdev_learning_rate` multiplied by this
            default value. If `scale_learning_rate` is False, then the
            effective standard deviation learning rate becomes
            equal to the provided `stdev_learning_rate` value.
        num_interactions: When given as an integer n,
            it is ensured that a population has interacted with
            the GymProblem's environment n times. If this target
            has not been reached yet, then the population is declared
            too small, and gets extended with more samples,
            until n amount of interactions is reached.
            When given as None, popsize is the only configuration
            affecting the size of a population.
        popsize_max: Having `num_interactions` set as an integer
            might cause the effective population size jump to
            unnecesarily large numbers. To prevent this,
            one can set `popsize_max` to specify an upper
            bound for the effective population size.
        num_interactions: When given as an integer n,
            it is ensured that a population has interacted with
            the GymProblem's environment n times. If this target
            has not been reached yet, then the population is declared
            too small, and gets extended with more samples,
            until n amount of interactions is reached.
            When given as None, popsize is the only configuration
            affecting the size of a population.
        optimizer: The optimizer to be used while following the
            estimated the gradients.
            Can be given as None if a momentum-based optimizer
            is not required.
            Otherwise, can be given as a str containing the name
            of the optimizer (e.g. 'adam', 'clipup');
            or as an instance of evotorch.optimizers.TorchOptimizer
            or evotorch.optimizers.ClipUp.
            The default is None.
            Note that, for ClipUp, the default maximum speed is set
            as twice the given `center_learning_rate`.
            This maximum speed can be configured by passing
            `{"max_speed": ...}` to `optimizer_config`.
        optimizer_config: Configuration which will be passed
            to the optimizer as keyword arguments.
            See `evotorch.optimizers` for details about
            which optimizer accepts which keyword arguments.
        ranking_method: Which ranking method will be used for
            fitness shaping. See the documentation of
            `evotorch.ranking.rank(...)` for details.
            The default is 'nes'.
            Can be given as None if no such ranking is required.
        center_init: The initial center solution.
            Can be left as None.
        obj_index: Index of the objective according to which the
            gradient estimations will be done.
            For single-objective problems, this can be left as None.
        distributed: Whether or not the gradient computation will
            be distributed. If `distributed` is given as False and
            the problem is not parallelized, then everything will
            be centralized (i.e. the entire computation will happen
            in the main process).
            If `distributed` is given as False, and the problem
            is parallelized, then the population will be created
            in the main process and then sent to remote workers
            for parallelized evaluation, and then the remote fitnesses
            will be collected by the main process again for computing
            the search gradients.
            If `distributed` is given as True, and the problem
            is parallelized, then the search algorithm itself will
            be distributed, in the sense that each remote actor will
            generate its own population (such that the total population
            size across all these actors becomes equal to `popsize`)
            and will compute its own gradient, and then the main process
            will collect these gradients, compute the averaged gradients
            and update the main search distribution.
            Non-distributed mode has the advantage of keeping the
            population in the main process, which is good when one wishes
            to do detailed monitoring during the evolutionary process,
            but has the disadvantage of having to pass the solutions to
            the remote actors and having to collect fitnesses, which
            might result in increased interprocess communication traffic.
            On the other hand, while it is not possible to monitor the
            population in distributed mode, the distributed mode has the
            advantage of significantly reducing the interprocess
            communication traffic, since the only things communicated
            with the remote actors are the search distributions (not the
            solutions) and the gradients.
        popsize_weighted_grad_avg: Only to be used in distributed mode.
            (where being in distributed mode means `distributed` is given
            as True). In distributed mode, each actor remotely samples
            its own solution batches and computes its own gradients.
            These gradients are then collected, and a final average
            gradient is computed.
            If `popsize_weighted_grad_avg` is True, then, while averaging
            over the gradients, each gradient will have its own weight
            that is computed according to how many solutions were sampled
            by the actor that produced the gradient.
            If `popsize_weighted_grad_avg` is False, then, there will not
            be weighted averaging (or, each gradient will have equal
            weight).
            If `popsize_weighted_grad_avg` is None, then, the gradient
            weights will be equal a value for `num_interactions` is given
            (because `num_interactions` affects the number of solutions
            according to the episode lengths, and popsize-weighting the
            gradients could be misleading); and the gradient weights will
            be weighted according to the sub-population (i.e. sub-batch)
            sizes if `num_interactions` is left as None.
            The default value for `popsize_weighted_grad_avg` is None.
            When the distributed mode is disabled (i.e. when `distributed`
            is False), then the argument `popsize_weighted_grad_avg` is
            expected as None.
    """

    if popsize is None:
        popsize = int(4 + math.floor(3 * math.log(problem.solution_length)))

    if center_learning_rate is None:
        center_learning_rate = 1.0

    def default_stdev_lr():
        n = problem.solution_length
        return 0.6 * (3 + math.log(n)) / (n * math.sqrt(n))

    if stdev_learning_rate is None:
        stdev_learning_rate = default_stdev_lr()
    else:
        stdev_learning_rate = float(stdev_learning_rate)
        if scale_learning_rate:
            stdev_learning_rate *= default_stdev_lr()

    super().__init__(
        problem,
        popsize=popsize,
        center_learning_rate=center_learning_rate,
        stdev_learning_rate=stdev_learning_rate,
        stdev_init=stdev_init,
        radius_init=radius_init,
        popsize_max=popsize_max,
        num_interactions=num_interactions,
        optimizer=optimizer,
        optimizer_config=optimizer_config,
        ranking_method=ranking_method,
        center_init=center_init,
        stdev_min=None,
        stdev_max=None,
        stdev_max_change=None,
        obj_index=obj_index,
        distributed=distributed,
        popsize_weighted_grad_avg=popsize_weighted_grad_avg,
    )

ga

Genetic algorithm variants: GeneticAlgorithm, Cosyne.

Cosyne (SearchAlgorithm, SinglePopulationAlgorithmMixin)

Implementation of the CoSyNE algorithm.

References:

F.Gomez, J.Schmidhuber, R.Miikkulainen, M.Mitchell (2008).
Accelerated Neural Evolution through Cooperatively Coevolved Synapses.
Journal of Machine Learning Research 9 (5).
Source code in evotorch/algorithms/ga.py
class Cosyne(SearchAlgorithm, SinglePopulationAlgorithmMixin):
    """
    Implementation of the CoSyNE algorithm.

    References:

        F.Gomez, J.Schmidhuber, R.Miikkulainen, M.Mitchell (2008).
        Accelerated Neural Evolution through Cooperatively Coevolved Synapses.
        Journal of Machine Learning Research 9 (5).
    """

    def __init__(
        self,
        problem: Problem,
        *,
        popsize: int,
        tournament_size: int,
        mutation_stdev: Optional[float],
        mutation_probability: Optional[float] = None,
        permute_all: bool = False,
        num_elites: Optional[int] = None,
        elitism_ratio: Optional[float] = None,
        eta: Optional[float] = None,
        num_children: Optional[int] = None,
    ):
        """
        `__init__(...)`: Initialize the Cosyne instance.

        Args:
            problem: The problem object to work on.
            popsize: Population size, as an integer.
            tournament_size: Tournament size, for tournament selection.
            mutation_stdev: Standard deviation of the Gaussian mutation.
                See [GaussianMutation][evotorch.operators.real.GaussianMutation] for more information.
            mutation_probability: Elementwise Gaussian mutation probability.
                Defaults to None.
                See [GaussianMutation][evotorch.operators.real.GaussianMutation] for more information.
            permute_all: If given as True, all solutions are subject to
                permutation. If given as False (which is the default),
                there will be a selection procedure for each decision
                variable.
            num_elites: Optionally expected as an integer, specifying the
                number of elites to pass to the next generation.
                Cannot be used together with the argument `elitism_ratio`.
            elitism_ratio: Optionally expected as a real number between
                0 and 1, specifying the amount of elites to pass to the
                next generation. For example, 0.1 means that the best 10%
                of the population are accepted as elites and passed onto
                the next generation.
                Cannot be used together with the argument `num_elites`.
            eta: Optionally expected as an integer, specifying the eta
                hyperparameter for the simulated binary cross-over (SBX).
                If left as None, one-point cross-over will be used instead.
            num_children: Number of children to generate at each iteration.
                If left as None, then this number is half of the population
                size.
        """

        problem.ensure_numeric()

        SearchAlgorithm.__init__(self, problem)

        if mutation_stdev is None:
            if mutation_probability is not None:
                raise ValueError(
                    f"`mutation_probability` was set to {mutation_probability}, but `mutation_stdev` is None, "
                    "which means, mutation is disabled. If you want to enable the mutation, be sure to provide "
                    "`mutation_stdev` as well."
                )
            self.mutation_op = None
        else:
            self.mutation_op = GaussianMutation(
                self._problem,
                stdev=mutation_stdev,
                mutation_probability=mutation_probability,
            )

        cross_over_kwargs = {"tournament_size": tournament_size}
        if num_children is None:
            cross_over_kwargs["cross_over_rate"] = 2.0
        else:
            cross_over_kwargs["num_children"] = num_children

        if eta is None:
            self._cross_over_op = OnePointCrossOver(self._problem, **cross_over_kwargs)
        else:
            self._cross_over_op = SimulatedBinaryCrossOver(self._problem, eta=eta, **cross_over_kwargs)

        self._permutation_op = CosynePermutation(self._problem, permute_all=permute_all)

        self._popsize = int(popsize)

        if num_elites is not None and elitism_ratio is None:
            self._num_elites = int(num_elites)
        elif num_elites is None and elitism_ratio is not None:
            self._num_elites = int(self._popsize * elitism_ratio)
        elif num_elites is None and elitism_ratio is None:
            self._num_elites = None
        else:
            raise ValueError(
                "Received both `num_elites` and `elitism_ratio`. Please provide only one of them, or none of them."
            )

        self._population = SolutionBatch(problem, device=problem.device, popsize=self._popsize)
        self._first_generation: bool = True

        # GAStatusMixin.__init__(self)
        SinglePopulationAlgorithmMixin.__init__(self)

    @property
    def population(self) -> SolutionBatch:
        return self._population

    def _step(self):
        if self._first_generation:
            self._first_generation = False
            self._problem.evaluate(self._population)

        to_merge = []

        num_elites = self._num_elites
        num_parents = int(self._popsize / 4)
        num_relevant = max((0 if num_elites is None else num_elites), num_parents)

        sorted_relevant = self._population.take_best(num_relevant)

        if self._num_elites is not None and self._num_elites >= 1:
            to_merge.append(sorted_relevant[:num_elites].clone())

        parents = sorted_relevant[:num_parents]
        children = self._cross_over_op(parents)
        if self.mutation_op is not None:
            children = self.mutation_op(children)

        permuted = self._permutation_op(self._population)

        to_merge.extend([children, permuted])

        extended_population = SolutionBatch(merging_of=to_merge)
        self._problem.evaluate(extended_population)
        self._population = extended_population.take_best(self._popsize)
__init__(self, problem, *, popsize, tournament_size, mutation_stdev, mutation_probability=None, permute_all=False, num_elites=None, elitism_ratio=None, eta=None, num_children=None) special

__init__(...): Initialize the Cosyne instance.

Parameters:

Name Type Description Default
problem Problem

The problem object to work on.

required
popsize int

Population size, as an integer.

required
tournament_size int

Tournament size, for tournament selection.

required
mutation_stdev Optional[float]

Standard deviation of the Gaussian mutation. See GaussianMutation for more information.

required
mutation_probability Optional[float]

Elementwise Gaussian mutation probability. Defaults to None. See GaussianMutation for more information.

None
permute_all bool

If given as True, all solutions are subject to permutation. If given as False (which is the default), there will be a selection procedure for each decision variable.

False
num_elites Optional[int]

Optionally expected as an integer, specifying the number of elites to pass to the next generation. Cannot be used together with the argument elitism_ratio.

None
elitism_ratio Optional[float]

Optionally expected as a real number between 0 and 1, specifying the amount of elites to pass to the next generation. For example, 0.1 means that the best 10% of the population are accepted as elites and passed onto the next generation. Cannot be used together with the argument num_elites.

None
eta Optional[float]

Optionally expected as an integer, specifying the eta hyperparameter for the simulated binary cross-over (SBX). If left as None, one-point cross-over will be used instead.

None
num_children Optional[int]

Number of children to generate at each iteration. If left as None, then this number is half of the population size.

None
Source code in evotorch/algorithms/ga.py
def __init__(
    self,
    problem: Problem,
    *,
    popsize: int,
    tournament_size: int,
    mutation_stdev: Optional[float],
    mutation_probability: Optional[float] = None,
    permute_all: bool = False,
    num_elites: Optional[int] = None,
    elitism_ratio: Optional[float] = None,
    eta: Optional[float] = None,
    num_children: Optional[int] = None,
):
    """
    `__init__(...)`: Initialize the Cosyne instance.

    Args:
        problem: The problem object to work on.
        popsize: Population size, as an integer.
        tournament_size: Tournament size, for tournament selection.
        mutation_stdev: Standard deviation of the Gaussian mutation.
            See [GaussianMutation][evotorch.operators.real.GaussianMutation] for more information.
        mutation_probability: Elementwise Gaussian mutation probability.
            Defaults to None.
            See [GaussianMutation][evotorch.operators.real.GaussianMutation] for more information.
        permute_all: If given as True, all solutions are subject to
            permutation. If given as False (which is the default),
            there will be a selection procedure for each decision
            variable.
        num_elites: Optionally expected as an integer, specifying the
            number of elites to pass to the next generation.
            Cannot be used together with the argument `elitism_ratio`.
        elitism_ratio: Optionally expected as a real number between
            0 and 1, specifying the amount of elites to pass to the
            next generation. For example, 0.1 means that the best 10%
            of the population are accepted as elites and passed onto
            the next generation.
            Cannot be used together with the argument `num_elites`.
        eta: Optionally expected as an integer, specifying the eta
            hyperparameter for the simulated binary cross-over (SBX).
            If left as None, one-point cross-over will be used instead.
        num_children: Number of children to generate at each iteration.
            If left as None, then this number is half of the population
            size.
    """

    problem.ensure_numeric()

    SearchAlgorithm.__init__(self, problem)

    if mutation_stdev is None:
        if mutation_probability is not None:
            raise ValueError(
                f"`mutation_probability` was set to {mutation_probability}, but `mutation_stdev` is None, "
                "which means, mutation is disabled. If you want to enable the mutation, be sure to provide "
                "`mutation_stdev` as well."
            )
        self.mutation_op = None
    else:
        self.mutation_op = GaussianMutation(
            self._problem,
            stdev=mutation_stdev,
            mutation_probability=mutation_probability,
        )

    cross_over_kwargs = {"tournament_size": tournament_size}
    if num_children is None:
        cross_over_kwargs["cross_over_rate"] = 2.0
    else:
        cross_over_kwargs["num_children"] = num_children

    if eta is None:
        self._cross_over_op = OnePointCrossOver(self._problem, **cross_over_kwargs)
    else:
        self._cross_over_op = SimulatedBinaryCrossOver(self._problem, eta=eta, **cross_over_kwargs)

    self._permutation_op = CosynePermutation(self._problem, permute_all=permute_all)

    self._popsize = int(popsize)

    if num_elites is not None and elitism_ratio is None:
        self._num_elites = int(num_elites)
    elif num_elites is None and elitism_ratio is not None:
        self._num_elites = int(self._popsize * elitism_ratio)
    elif num_elites is None and elitism_ratio is None:
        self._num_elites = None
    else:
        raise ValueError(
            "Received both `num_elites` and `elitism_ratio`. Please provide only one of them, or none of them."
        )

    self._population = SolutionBatch(problem, device=problem.device, popsize=self._popsize)
    self._first_generation: bool = True

    # GAStatusMixin.__init__(self)
    SinglePopulationAlgorithmMixin.__init__(self)

ExtendedPopulationMixin

A mixin class that provides the method _make_extended_population(...).

This mixin class assumes that the inheriting class has the properties problem (of type Problem), which provide and population (of type SolutionBatch), which provide the associated problem object and the current population, respectively.

The class which inherits this mixin class gains the method _make_extended_population(...). This new method applies the operators specified during the initialization phase of this mixin class on the current population, produces children, and then returns an extended population.

Source code in evotorch/algorithms/ga.py
class ExtendedPopulationMixin:
    """
    A mixin class that provides the method `_make_extended_population(...)`.

    This mixin class assumes that the inheriting class has the properties
    `problem` (of type [Problem][evotorch.core.Problem]), which provide
    and `population` (of type [SolutionBatch][evotorch.core.SolutionBatch]),
    which provide the associated problem object and the current population,
    respectively.

    The class which inherits this mixin class gains the method
    `_make_extended_population(...)`. This new method applies the operators
    specified during the initialization phase of this mixin class on the
    current population, produces children, and then returns an extended
    population.
    """

    def __init__(
        self,
        *,
        re_evaluate: bool,
        re_evaluate_parents_first: Optional[bool] = None,
        operators: Optional[Iterable] = None,
        allow_empty_operators_list: bool = False,
    ):
        """
        `__init__(...)`: Initialize the ExtendedPopulationMixin.

        Args:
            re_evaluate: Whether or not to re-evaluate the parent population
                at every generation. When dealing with problems where the
                fitness and/or feature evaluations are stochastic, one might
                want to set this as True. On the other hand, for when the
                fitness and/or feature evaluations are deterministic, one
                might prefer to set this as False for efficiency.
            re_evaluate_parents_first: This is to be specified only when
                `re_evaluate` is True (otherwise to be left as None).
                If this is given as True, then it will be assumed that the
                provided operators require the parents to be evaluated.
                If this is given as False, then it will be assumed that the
                provided operators work without looking at the parents'
                fitnesses (in which case both parents and children can be
                evaluated in a single vectorized computation cycle).
                If this is left as None, then whether or not the operators
                need to know the parent evaluations will be determined
                automatically as follows:
                if the operators contain at least one cross-over operator
                then `re_evaluate_parents_first` will be internally set as
                True; otherwise `re_evaluate_parents_first` will be internally
                set as False.
            operators: List of operators to apply on the current population
                for generating a new extended population.
            allow_empty_operators_list: Whether or not to allow the operator
                list to be empty. The default and the recommended value
                is False. For cases where the inheriting class wants to
                decide the operators later (via the attribute `_operators`)
                this can be set as True.
        """
        self._operators = [] if operators is None else list(operators)
        if (not allow_empty_operators_list) and (len(self._operators) == 0):
            raise ValueError("Received `operators` as an empty sequence. Please provide at least one operator.")

        self._using_cross_over: bool = False
        for operator in self._operators:
            if isinstance(operator, CrossOver):
                self._using_cross_over = True
                break

        self._re_evaluate: bool = bool(re_evaluate)

        if re_evaluate_parents_first is None:
            self._re_evaluate_parents_first = self._using_cross_over
        else:
            if not self._re_evaluate:
                raise ValueError(
                    "Encountered the argument `re_evaluate_parents_first` as something other than None."
                    " However, `re_evaluate` is given as False."
                    " Please use `re_evaluate_parents_first` only when `re_evaluate` is True."
                )
            self._re_evaluate_parents_first = bool(re_evaluate_parents_first)

        self._first_iter: bool = True

    def _make_extended_population(self, split: bool = False) -> Union[SolutionBatch, tuple]:
        """
        Make and return a new extended population that is evaluated.

        Args:
            split: If False, then the extended population will be returned
                as a single SolutionBatch object which contains both the
                parents and the children.
                If True, then the extended population will be returned
                as a pair of SolutionBatch objects, the first one being
                the parents and the second one being the children.
        Returns:
            The extended population.
        """

        # Get the problem object and the population
        problem: Problem = self.problem
        population: SolutionBatch = self.population

        if self._re_evaluate:
            # This is the case where our mixin is configured to re-evaluate the parents at every generation.

            # Set the first iteration indicator to False
            self._first_iter = False

            if self._re_evaluate_parents_first:
                # This is the case where our mixin is configured to evaluate the parents separately first.
                # This is a sub-case of `_re_evaluate=True`.

                # Evaluate the population, which stores the parents
                problem.evaluate(population)

                # Now that our parents are evaluated, we use the operators on them and get the children.
                children = _use_operators(population, self._operators)

                # Evaluate the children
                problem.evaluate(children)

                if split:
                    # If our mixin is configured to return the population and the children, then we return a tuple
                    # containing them as separate items.
                    return population, children
                else:
                    # If our mixin is configured to return the population and the children in a single batch,
                    # then we concatenate the population and the children and return the resulting combined batch.
                    return SolutionBatch.cat([population, children])
            else:
                # This is the case where our mixin is configured to evaluate the parents and the children in one go.
                # This is a sub-case of `_re_evaluate=True`.

                # Use the operators on the parent solutions. It does not matter whether or not the parents are evaluated.
                children = _use_operators(population, self._operators)

                # Form an extended population by concatenating the population and the children.
                extended_population = SolutionBatch.cat([population, children])

                # Evaluate the extended population in one go.
                problem.evaluate(extended_population)

                if split:
                    # The method was configured to return the parents and the children separately.
                    # Because we combined them earlier for evaluating them in one go, we will split them now.

                    # Get the number of parents
                    num_parents = len(population)

                    # Get the newly evaluated copies of the parents from the extended population
                    parents = extended_population[:num_parents]

                    # Get the children from the extended population
                    children = extended_population[num_parents:]

                    # Return the newly evaluated copies of the parents and the children separately.
                    return parents, children
                else:
                    # The method was configured to return the parents and the children in a single SolutionBatch.
                    # Here, we just return the extended population that we already have produced.
                    return extended_population
        else:
            # This is the case where our mixin is configured NOT to re-evaluate the parents at every generation.
            if self._first_iter:
                # The first iteration indicator (`_first_iter`) is True. So, this is the first iteration.
                # We set `_first_iter` to False for future generations.
                self._first_iter = False
                # We not evaluate the parent population (because the parents are expected to be non-evaluated at the
                # beginning).
                problem.evaluate(population)

            # Here, we assume that the parents are already evaluated. We apply our operators on the parents.
            children = _use_operators(population, self._operators)

            # Now, we evaluate the children.
            problem.evaluate(children)

            if split:
                # Return the population and the children separately if `split=True`.
                return population, children
            else:
                # Return the population and the children in a single SolutionBatch if `split=False`.
                return SolutionBatch.cat([population, children])

    @property
    def re_evaluate(self) -> bool:
        """
        Whether or not this search algorithm re-evaluates the parents
        """
        return self._re_evaluate

    @property
    def re_evaluate_parents_first(self) -> Optional[bool]:
        """
        Whether or not this search algorithm re-evaluates the parents separately.
        This property is relevant only when `re_evaluate` is True.
        If `re_evaluate` is False, then this property will return None.
        """
        if self._re_evaluate:
            return self._re_evaluate_parents_first
        else:
            return None
re_evaluate: bool property readonly

Whether or not this search algorithm re-evaluates the parents

re_evaluate_parents_first: Optional[bool] property readonly

Whether or not this search algorithm re-evaluates the parents separately. This property is relevant only when re_evaluate is True. If re_evaluate is False, then this property will return None.

__init__(self, *, re_evaluate, re_evaluate_parents_first=None, operators=None, allow_empty_operators_list=False) special

__init__(...): Initialize the ExtendedPopulationMixin.

Parameters:

Name Type Description Default
re_evaluate bool

Whether or not to re-evaluate the parent population at every generation. When dealing with problems where the fitness and/or feature evaluations are stochastic, one might want to set this as True. On the other hand, for when the fitness and/or feature evaluations are deterministic, one might prefer to set this as False for efficiency.

required
re_evaluate_parents_first Optional[bool]

This is to be specified only when re_evaluate is True (otherwise to be left as None). If this is given as True, then it will be assumed that the provided operators require the parents to be evaluated. If this is given as False, then it will be assumed that the provided operators work without looking at the parents' fitnesses (in which case both parents and children can be evaluated in a single vectorized computation cycle). If this is left as None, then whether or not the operators need to know the parent evaluations will be determined automatically as follows: if the operators contain at least one cross-over operator then re_evaluate_parents_first will be internally set as True; otherwise re_evaluate_parents_first will be internally set as False.

None
operators Optional[Iterable]

List of operators to apply on the current population for generating a new extended population.

None
allow_empty_operators_list bool

Whether or not to allow the operator list to be empty. The default and the recommended value is False. For cases where the inheriting class wants to decide the operators later (via the attribute _operators) this can be set as True.

False
Source code in evotorch/algorithms/ga.py
def __init__(
    self,
    *,
    re_evaluate: bool,
    re_evaluate_parents_first: Optional[bool] = None,
    operators: Optional[Iterable] = None,
    allow_empty_operators_list: bool = False,
):
    """
    `__init__(...)`: Initialize the ExtendedPopulationMixin.

    Args:
        re_evaluate: Whether or not to re-evaluate the parent population
            at every generation. When dealing with problems where the
            fitness and/or feature evaluations are stochastic, one might
            want to set this as True. On the other hand, for when the
            fitness and/or feature evaluations are deterministic, one
            might prefer to set this as False for efficiency.
        re_evaluate_parents_first: This is to be specified only when
            `re_evaluate` is True (otherwise to be left as None).
            If this is given as True, then it will be assumed that the
            provided operators require the parents to be evaluated.
            If this is given as False, then it will be assumed that the
            provided operators work without looking at the parents'
            fitnesses (in which case both parents and children can be
            evaluated in a single vectorized computation cycle).
            If this is left as None, then whether or not the operators
            need to know the parent evaluations will be determined
            automatically as follows:
            if the operators contain at least one cross-over operator
            then `re_evaluate_parents_first` will be internally set as
            True; otherwise `re_evaluate_parents_first` will be internally
            set as False.
        operators: List of operators to apply on the current population
            for generating a new extended population.
        allow_empty_operators_list: Whether or not to allow the operator
            list to be empty. The default and the recommended value
            is False. For cases where the inheriting class wants to
            decide the operators later (via the attribute `_operators`)
            this can be set as True.
    """
    self._operators = [] if operators is None else list(operators)
    if (not allow_empty_operators_list) and (len(self._operators) == 0):
        raise ValueError("Received `operators` as an empty sequence. Please provide at least one operator.")

    self._using_cross_over: bool = False
    for operator in self._operators:
        if isinstance(operator, CrossOver):
            self._using_cross_over = True
            break

    self._re_evaluate: bool = bool(re_evaluate)

    if re_evaluate_parents_first is None:
        self._re_evaluate_parents_first = self._using_cross_over
    else:
        if not self._re_evaluate:
            raise ValueError(
                "Encountered the argument `re_evaluate_parents_first` as something other than None."
                " However, `re_evaluate` is given as False."
                " Please use `re_evaluate_parents_first` only when `re_evaluate` is True."
            )
        self._re_evaluate_parents_first = bool(re_evaluate_parents_first)

    self._first_iter: bool = True

GeneticAlgorithm (SearchAlgorithm, SinglePopulationAlgorithmMixin, ExtendedPopulationMixin)

A genetic algorithm implementation.

Basic usage. Let us consider a single-objective optimization problem where the goal is to minimize the L2 norm of a continuous tensor of length 10:

from evotorch import Problem
from evotorch.algorithms import GeneticAlgorithm
from evotorch.operators import OnePointCrossOver, GaussianMutation

import torch


def f(x: torch.Tensor) -> torch.Tensor:
    return torch.linalg.norm(x)


problem = Problem(
    "min",
    f,
    initial_bounds=(-10.0, 10.0),
    solution_length=10,
)

For solving this problem, a genetic algorithm could be instantiated as follows:

ga = GeneticAlgorithm(
    problem,
    popsize=100,
    operators=[
        OnePointCrossOver(problem, tournament_size=4),
        GaussianMutation(problem, stdev=0.1),
    ],
)

The genetic algorithm instantiated above is configured to have a population size of 100, and is configured to perform the following operations on the population at each generation: (i) select solutions with a tournament of size 4, and produce children from the selected solutions by applying one-point cross-over; (ii) apply a gaussian mutation on the values of the solutions produced by the previous step, the amount of the mutation being sampled according to a standard deviation of 0.1. Once instantiated, this GeneticAlgorithm instance can be used with an API compatible with other search algorithms, as shown below:

from evotorch.logging import StdOutLogger

_ = StdOutLogger(ga)  # Report the evolution's progress to standard output
ga.run(100)  # Run the algorithm for 100 generations
print("Solution with best fitness ever:", ga.status["best"])
print("Current population's best:", ga.status["pop_best"])

Please also note:

  • The operators are always executed according to the order specified within the operators argument.
  • There are more operators available in the namespace evotorch.operators.
  • By default, GeneticAlgorithm is elitist. In the elitist mode, an extended population is formed from parent solutions and child solutions, and the best n solutions of this extended population are accepted as the next generation. If you wish to switch to a non-elitist mode (where children unconditionally replace the worst-performing parents), you can use the initialization argument elitist=False.
  • It is not mandatory to specify a cross-over operator. When a cross-over operator is missing, the GeneticAlgorithm will work like a simple evolution strategy implementation which produces children by mutating the parents, and then replaces the parents (where the criteria for replacing the parents depend on whether or not elitism is enabled).
  • To be able to deal with stochastic fitness functions correctly, GeneticAlgorithm re-evaluates previously evaluated parents as well. When you are sure that the fitness function is deterministic, you can pass the initialization argument re_evaluate=False to prevent unnecessary computations.

Integer decision variables. GeneticAlgorithm can be used on problems with dtype declared as integer (e.g. torch.int32, torch.int64, etc.). Within the field of discrete optimization, it is common to encounter one or more of these scenarios:

  • The search space of the problem has a special structure that one will wish to exploit (within the cross-over and/or mutation operators) to be able to reach the (near-)optimum within a reasonable amount of time.
  • The problem is partially or fully combinatorial.
  • The problem is constrained in such a way that arbitrarily sampling discrete values for its decision variables might cause infeasibility.

Considering all these scenarios, it is difficult to come up with general cross-over and mutation operators that will work across various discrete optimization problems, and it is common to design problem-specific operators. In EvoTorch, it is possible to define custom operators and use them with GeneticAlgorithm, which is required when using GeneticAlgorithm on a problem with a non-float dtype.

As an example, let us consider the following discrete optimization problem:

def f(x: torch.Tensor) -> torch.Tensor:
    return torch.sum(x)


problem = Problem(
    "min",
    f,
    bounds=(-10, 10),
    solution_length=10,
    dtype=torch.int64,
)

Although EvoTorch does provide a very simple and generic (usable with float and int dtypes) cross-over named OnePointCrossOver (a cross-over which randomly decides a cutting point for each pair of parents, cuts them from those points and recombines them), it can be desirable and necessary to implement a custom cross-over operator. One can inherit from CrossOver to define a custom cross-over operator, as shown below:

from evotorch import SolutionBatch
from evotorch.operators import CrossOver


class CustomCrossOver(CrossOver):
    def _do_cross_over(
        self,
        parents1: torch.Tensor,
        parents2: torch.Tensor,
    ) -> SolutionBatch:
        # parents1 is a tensor storing the decision values of the first
        # half of the chosen parents.
        # parents2 is a tensor storing the decision values of the second
        # half of the chosen parents.

        # We expect that the lengths of parents1 and parents2 are equal.
        assert len(parents1) == len(parents2)

        # Allocate an empty SolutionBatch that will store the children
        childpop = SolutionBatch(self.problem, popsize=num_parents, empty=True)

        # Gain access to the decision values tensor of the newly allocated
        # childpop
        childpop_values = childpop.access_values()

        # Here we somehow fill `childpop_values` by recombining the parents.
        # The most common thing to do is to produce two children by
        # combining parents1[0] and parents2[0], to produce the next two
        # children parents1[1] and parents2[1], and so on.
        childpop_values[:] = ...

        # Return the child population
        return childpop

One can define a custom mutation operator by inheriting from Operator, as shown below:

class CustomMutation(Operator):
    def _do(self, solutions: SolutionBatch):
        # Get the decision values tensor of the solutions
        sln_values = solutions.access_values()

        # do in-place modifications to the decision values
        sln_values[:] = ...

Alternatively, you could define the mutation operator as a function:

def my_mutation_function(original_values: torch.Tensor) -> torch.Tensor:
    # Somehow produce mutated copies of the original values
    mutated_values = ...

    # Return the mutated values
    return mutated_values

With these defined operators, we are now ready to instantiate our GeneticAlgorithm:

ga = GeneticAlgorithm(
    problem,
    popsize=100,
    operators=[
        CustomCrossOver(problem, tournament_size=4),
        CustomMutation(problem),
        # -- or, if you chose to define the mutation as a function: --
        # my_mutation_function,
    ],
)

Non-numeric or variable-length solutions. GeneticAlgorithm can also work on problems whose dtype is declared as object, where dtype=object means that a solution's value(s) can be expressed via a tensor, a numpy array, a scalar, a tuple, a list, a dictionary.

Like in the previously discussed case (where dtype is an integer type), one has to define custom operators when working on problems with dtype=object. A custom cross-over definition specialized for dtype=object looks like this:

from evotorch.tools import ObjectArray


class CrossOverForObjectDType(CrossOver):
    def _do_cross_over(
        self,
        parents1: ObjectArray,
        parents2: ObjectArray,
    ) -> SolutionBatch:
        # Allocate an empty SolutionBatch that will store the children
        childpop = SolutionBatch(self.problem, popsize=num_parents, empty=True)

        # Gain access to the decision values ObjectArray of the newly allocated
        # childpop
        childpop_values = childpop.access_values()

        # Here we somehow fill `childpop_values` by recombining the parents.
        # The most common thing to do is to produce two children by
        # combining parents1[0] and parents2[0], to produce the next two
        # children parents1[1] and parents2[1], and so on.
        childpop_values[:] = ...

        # Return the child population
        return childpop

A custom mutation operator specialized for dtype=object looks like this:

class MutationForObjectDType(Operator):
    def _do(self, solutions: SolutionBatch):
        # Get the decision values ObjectArray of the solutions
        sln_values = solutions.access_values()

        # do in-place modifications to the decision values
        sln_values[:] = ...

A custom mutation function specialized for dtype=object looks like this:

def mutation_for_object_dtype(original_values: ObjectArray) -> ObjectArray:
    # Somehow produce mutated copies of the original values
    mutated_values = ...

    # Return the mutated values
    return mutated_values

With these operators defined, one can instantiate the GeneticAlgorithm:

ga = GeneticAlgorithm(
    problem_with_object_dtype,
    popsize=100,
    operators=[
        CrossOverForObjectDType(problem_with_object_dtype, tournament_size=4),
        MutationForObjectDType(problem_with_object_dtype),
        # -- or, if you chose to define the mutation as a function: --
        # mutation_for_object_dtype,
    ],
)

Multiple objectives. GeneticAlgorithm can work on problems with multiple objectives. When there are multiple objectives, GeneticAlgorithm will compare the solutions according to their pareto-ranks and their crowding distances, like done by the NSGA-II algorithm (Deb, 2002).

References:

Sean Luke, 2013, Essentials of Metaheuristics, Lulu, second edition
available for free at http://cs.gmu.edu/~sean/book/metaheuristics/

Kalyanmoy Deb, Amrit Pratap, Sameer Agarwal, T. Meyarivan (2002).
A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II.
Source code in evotorch/algorithms/ga.py
class GeneticAlgorithm(SearchAlgorithm, SinglePopulationAlgorithmMixin, ExtendedPopulationMixin):
    """
    A genetic algorithm implementation.

    **Basic usage.**
    Let us consider a single-objective optimization problem where the goal is to
    minimize the L2 norm of a continuous tensor of length 10:

    ```python
    from evotorch import Problem
    from evotorch.algorithms import GeneticAlgorithm
    from evotorch.operators import OnePointCrossOver, GaussianMutation

    import torch


    def f(x: torch.Tensor) -> torch.Tensor:
        return torch.linalg.norm(x)


    problem = Problem(
        "min",
        f,
        initial_bounds=(-10.0, 10.0),
        solution_length=10,
    )
    ```

    For solving this problem, a genetic algorithm could be instantiated as
    follows:

    ```python
    ga = GeneticAlgorithm(
        problem,
        popsize=100,
        operators=[
            OnePointCrossOver(problem, tournament_size=4),
            GaussianMutation(problem, stdev=0.1),
        ],
    )
    ```

    The genetic algorithm instantiated above is configured to have a population
    size of 100, and is configured to perform the following operations on the
    population at each generation:
    (i) select solutions with a tournament of size 4, and produce children from
    the selected solutions by applying one-point cross-over;
    (ii) apply a gaussian mutation on the values of the solutions produced by
    the previous step, the amount of the mutation being sampled according to a
    standard deviation of 0.1.
    Once instantiated, this GeneticAlgorithm instance can be used with an API
    compatible with other search algorithms, as shown below:

    ```python
    from evotorch.logging import StdOutLogger

    _ = StdOutLogger(ga)  # Report the evolution's progress to standard output
    ga.run(100)  # Run the algorithm for 100 generations
    print("Solution with best fitness ever:", ga.status["best"])
    print("Current population's best:", ga.status["pop_best"])
    ```

    Please also note:

    - The operators are always executed according to the order specified within
      the `operators` argument.
    - There are more operators available in the namespace
      [evotorch.operators][evotorch.operators].
    - By default, GeneticAlgorithm is elitist. In the elitist mode, an extended
      population is formed from parent solutions and child solutions, and the
      best n solutions of this extended population are accepted as the next
      generation. If you wish to switch to a non-elitist mode (where children
      unconditionally replace the worst-performing parents), you can use the
      initialization argument `elitist=False`.
    - It is not mandatory to specify a cross-over operator. When a cross-over
      operator is missing, the GeneticAlgorithm will work like a simple
      evolution strategy implementation which produces children by mutating
      the parents, and then replaces the parents (where the criteria for
      replacing the parents depend on whether or not elitism is enabled).
    - To be able to deal with stochastic fitness functions correctly,
      GeneticAlgorithm re-evaluates previously evaluated parents as well.
      When you are sure that the fitness function is deterministic,
      you can pass the initialization argument `re_evaluate=False` to prevent
      unnecessary computations.

    **Integer decision variables.**
    GeneticAlgorithm can be used on problems with `dtype` declared as integer
    (e.g. `torch.int32`, `torch.int64`, etc.).
    Within the field of discrete optimization, it is common to encounter
    one or more of these scenarios:

    - The search space of the problem has a special structure that one will
      wish to exploit (within the cross-over and/or mutation operators) to
      be able to reach the (near-)optimum within a reasonable amount of time.
    - The problem is partially or fully combinatorial.
    - The problem is constrained in such a way that arbitrarily sampling
      discrete values for its decision variables might cause infeasibility.

    Considering all these scenarios, it is difficult to come up with general
    cross-over and mutation operators that will work across various discrete
    optimization problems, and it is common to design problem-specific
    operators. In EvoTorch, it is possible to define custom operators and
    use them with GeneticAlgorithm, which is required when using
    GeneticAlgorithm on a problem with a non-float dtype.

    As an example, let us consider the following discrete optimization
    problem:

    ```python
    def f(x: torch.Tensor) -> torch.Tensor:
        return torch.sum(x)


    problem = Problem(
        "min",
        f,
        bounds=(-10, 10),
        solution_length=10,
        dtype=torch.int64,
    )
    ```

    Although EvoTorch does provide a very simple and generic (usable with float
    and int dtypes) cross-over named
    [OnePointCrossOver][evotorch.operators.real.OnePointCrossOver]
    (a cross-over which randomly decides a cutting point for each pair of
    parents, cuts them from those points and recombines them), it can be
    desirable and necessary to implement a custom cross-over operator.
    One can inherit from [CrossOver][evotorch.operators.base.CrossOver] to
    define a custom cross-over operator, as shown below:

    ```python
    from evotorch import SolutionBatch
    from evotorch.operators import CrossOver


    class CustomCrossOver(CrossOver):
        def _do_cross_over(
            self,
            parents1: torch.Tensor,
            parents2: torch.Tensor,
        ) -> SolutionBatch:
            # parents1 is a tensor storing the decision values of the first
            # half of the chosen parents.
            # parents2 is a tensor storing the decision values of the second
            # half of the chosen parents.

            # We expect that the lengths of parents1 and parents2 are equal.
            assert len(parents1) == len(parents2)

            # Allocate an empty SolutionBatch that will store the children
            childpop = SolutionBatch(self.problem, popsize=num_parents, empty=True)

            # Gain access to the decision values tensor of the newly allocated
            # childpop
            childpop_values = childpop.access_values()

            # Here we somehow fill `childpop_values` by recombining the parents.
            # The most common thing to do is to produce two children by
            # combining parents1[0] and parents2[0], to produce the next two
            # children parents1[1] and parents2[1], and so on.
            childpop_values[:] = ...

            # Return the child population
            return childpop
    ```

    One can define a custom mutation operator by inheriting from
    [Operator][evotorch.operators.base.Operator], as shown below:

    ```python
    class CustomMutation(Operator):
        def _do(self, solutions: SolutionBatch):
            # Get the decision values tensor of the solutions
            sln_values = solutions.access_values()

            # do in-place modifications to the decision values
            sln_values[:] = ...
    ```

    Alternatively, you could define the mutation operator as a function:

    ```python
    def my_mutation_function(original_values: torch.Tensor) -> torch.Tensor:
        # Somehow produce mutated copies of the original values
        mutated_values = ...

        # Return the mutated values
        return mutated_values
    ```

    With these defined operators, we are now ready to instantiate our
    GeneticAlgorithm:

    ```python
    ga = GeneticAlgorithm(
        problem,
        popsize=100,
        operators=[
            CustomCrossOver(problem, tournament_size=4),
            CustomMutation(problem),
            # -- or, if you chose to define the mutation as a function: --
            # my_mutation_function,
        ],
    )
    ```

    **Non-numeric or variable-length solutions.**
    GeneticAlgorithm can also work on problems whose `dtype` is declared
    as `object`, where `dtype=object` means that a solution's value(s) can be
    expressed via a tensor, a numpy array, a scalar, a tuple, a list, a
    dictionary.

    Like in the previously discussed case (where dtype is an integer type),
    one has to define custom operators when working on problems with
    `dtype=object`. A custom cross-over definition specialized for
    `dtype=object` looks like this:

    ```python
    from evotorch.tools import ObjectArray


    class CrossOverForObjectDType(CrossOver):
        def _do_cross_over(
            self,
            parents1: ObjectArray,
            parents2: ObjectArray,
        ) -> SolutionBatch:
            # Allocate an empty SolutionBatch that will store the children
            childpop = SolutionBatch(self.problem, popsize=num_parents, empty=True)

            # Gain access to the decision values ObjectArray of the newly allocated
            # childpop
            childpop_values = childpop.access_values()

            # Here we somehow fill `childpop_values` by recombining the parents.
            # The most common thing to do is to produce two children by
            # combining parents1[0] and parents2[0], to produce the next two
            # children parents1[1] and parents2[1], and so on.
            childpop_values[:] = ...

            # Return the child population
            return childpop
    ```

    A custom mutation operator specialized for `dtype=object` looks like this:

    ```python
    class MutationForObjectDType(Operator):
        def _do(self, solutions: SolutionBatch):
            # Get the decision values ObjectArray of the solutions
            sln_values = solutions.access_values()

            # do in-place modifications to the decision values
            sln_values[:] = ...
    ```

    A custom mutation function specialized for `dtype=object` looks like this:

    ```python
    def mutation_for_object_dtype(original_values: ObjectArray) -> ObjectArray:
        # Somehow produce mutated copies of the original values
        mutated_values = ...

        # Return the mutated values
        return mutated_values
    ```

    With these operators defined, one can instantiate the GeneticAlgorithm:

    ```python
    ga = GeneticAlgorithm(
        problem_with_object_dtype,
        popsize=100,
        operators=[
            CrossOverForObjectDType(problem_with_object_dtype, tournament_size=4),
            MutationForObjectDType(problem_with_object_dtype),
            # -- or, if you chose to define the mutation as a function: --
            # mutation_for_object_dtype,
        ],
    )
    ```

    **Multiple objectives.**
    GeneticAlgorithm can work on problems with multiple objectives.
    When there are multiple objectives, GeneticAlgorithm will compare the
    solutions according to their pareto-ranks and their crowding distances,
    like done by the NSGA-II algorithm (Deb, 2002).

    References:

        Sean Luke, 2013, Essentials of Metaheuristics, Lulu, second edition
        available for free at http://cs.gmu.edu/~sean/book/metaheuristics/

        Kalyanmoy Deb, Amrit Pratap, Sameer Agarwal, T. Meyarivan (2002).
        A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II.
    """

    def __init__(
        self,
        problem: Problem,
        *,
        operators: Iterable,
        popsize: int,
        elitist: bool = True,
        re_evaluate: bool = True,
        re_evaluate_parents_first: Optional[bool] = None,
        _allow_empty_operator_list: bool = False,
    ):
        """
        `__init__(...)`: Initialize the GeneticAlgorithm.

        Args:
            problem: The problem to optimize.
            operators: Operators to be used by the genetic algorithm.
                Expected as an iterable, such as a list or a tuple.
                Each item within this iterable object is expected either
                as an instance of [Operator][evotorch.operators.base.Operator],
                or as a function which receives the decision values of
                multiple solutions in a PyTorch tensor (or in an
                [ObjectArray][evotorch.tools.objectarray.ObjectArray]
                for when dtype is `object`) and returns a modified copy.
            popsize: Population size.
            elitist: Whether or not this genetic algorithm will behave in an
                elitist manner. This argument controls how the genetic
                algorithm will form the next generation from the parents
                and the children. In elitist mode (i.e. with `elitist=True`),
                the procedure to be followed by this genetic algorithm is:
                (i) form an extended population which consists of
                both the parents and the children,
                (ii) sort the extended population from best to worst,
                (iii) select the best `n` solutions as the new generation where
                `n` is `popsize`.
                In non-elitist mode (i.e. with `elitist=False`), the worst `m`
                solutions within the parent population are replaced with
                the children, `m` being the number of produced children.
            re_evaluate: Whether or not to evaluate the solutions
                that were already evaluated in the previous generations.
                By default, this is set as True.
                The reason behind this default setting is that,
                in problems where the evaluation procedure is noisy,
                by re-evaluating the already-evaluated solutions,
                we prevent the bad solutions that were luckily evaluated
                from hanging onto the population.
                Instead, at every generation, each solution must go through
                the evaluation procedure again and prove their worth.
                For problems whose evaluation procedures are NOT noisy,
                the user might consider turning re_evaluate to False
                for saving computational cycles.
            re_evaluate_parents_first: This is to be specified only when
                `re_evaluate` is True (otherwise to be left as None).
                If this is given as True, then it will be assumed that the
                provided operators require the parents to be evaluated.
                If this is given as False, then it will be assumed that the
                provided operators work without looking at the parents'
                fitnesses (in which case both parents and children can be
                evaluated in a single vectorized computation cycle).
                If this is left as None, then whether or not the operators
                need to know the parent evaluations will be determined
                automatically as follows:
                if the operators contain at least one cross-over operator
                then `re_evaluate_parents_first` will be internally set as
                True; otherwise `re_evaluate_parents_first` will be internally
                set as False.
        """
        SearchAlgorithm.__init__(self, problem)

        self._popsize = int(popsize)
        self._elitist: bool = bool(elitist)
        self._population = problem.generate_batch(self._popsize)

        ExtendedPopulationMixin.__init__(
            self,
            re_evaluate=re_evaluate,
            re_evaluate_parents_first=re_evaluate_parents_first,
            operators=operators,
            allow_empty_operators_list=_allow_empty_operator_list,
        )

        SinglePopulationAlgorithmMixin.__init__(self)

    @property
    def population(self) -> SolutionBatch:
        """Get the population"""
        return self._population

    def _step(self):
        # Get the population size
        popsize = self._popsize

        if self._elitist:
            # This is where we handle the elitist mode.

            # Produce and get an extended population in a single SolutionBatch
            extended_population = self._make_extended_population(split=False)

            # From the extended population, take the best n solutions, n being the popsize.
            self._population = extended_population.take_best(popsize)
        else:
            # This is where we handle the non-elitist mode.

            # Take the parent solutions (ensured to be evaluated) and the children separately.
            parents, children = self._make_extended_population(split=True)

            # Get the number of children
            num_children = len(children)

            if num_children < popsize:
                # If the number of children is less than the population size, then we keep the best m solutions from
                # the parents, m being `popsize - num_children`.
                chosen_parents = self._population.take_best(popsize - num_children)

                # Combine the children with the chosen parents, and declare them as the new population.
                self._population = SolutionBatch.cat([chosen_parents, children])
            elif num_children == popsize:
                # If the number of children is the same with the population size, then these children are declared as
                # the new population.
                self._population = children
            else:
                # If the number of children is more than the population size, then we take the best n solutions from
                # these children, n being the population size. These chosen children are then declared as the new
                # population.
                self._population = children.take_best(self._popsize)
population: SolutionBatch property readonly

Get the population

__init__(self, problem, *, operators, popsize, elitist=True, re_evaluate=True, re_evaluate_parents_first=None, _allow_empty_operator_list=False) special

__init__(...): Initialize the GeneticAlgorithm.

Parameters:

Name Type Description Default
problem Problem

The problem to optimize.

required
operators Iterable

Operators to be used by the genetic algorithm. Expected as an iterable, such as a list or a tuple. Each item within this iterable object is expected either as an instance of Operator, or as a function which receives the decision values of multiple solutions in a PyTorch tensor (or in an ObjectArray for when dtype is object) and returns a modified copy.

required
popsize int

Population size.

required
elitist bool

Whether or not this genetic algorithm will behave in an elitist manner. This argument controls how the genetic algorithm will form the next generation from the parents and the children. In elitist mode (i.e. with elitist=True), the procedure to be followed by this genetic algorithm is: (i) form an extended population which consists of both the parents and the children, (ii) sort the extended population from best to worst, (iii) select the best n solutions as the new generation where n is popsize. In non-elitist mode (i.e. with elitist=False), the worst m solutions within the parent population are replaced with the children, m being the number of produced children.

True
re_evaluate bool

Whether or not to evaluate the solutions that were already evaluated in the previous generations. By default, this is set as True. The reason behind this default setting is that, in problems where the evaluation procedure is noisy, by re-evaluating the already-evaluated solutions, we prevent the bad solutions that were luckily evaluated from hanging onto the population. Instead, at every generation, each solution must go through the evaluation procedure again and prove their worth. For problems whose evaluation procedures are NOT noisy, the user might consider turning re_evaluate to False for saving computational cycles.

True
re_evaluate_parents_first Optional[bool]

This is to be specified only when re_evaluate is True (otherwise to be left as None). If this is given as True, then it will be assumed that the provided operators require the parents to be evaluated. If this is given as False, then it will be assumed that the provided operators work without looking at the parents' fitnesses (in which case both parents and children can be evaluated in a single vectorized computation cycle). If this is left as None, then whether or not the operators need to know the parent evaluations will be determined automatically as follows: if the operators contain at least one cross-over operator then re_evaluate_parents_first will be internally set as True; otherwise re_evaluate_parents_first will be internally set as False.

None
Source code in evotorch/algorithms/ga.py
def __init__(
    self,
    problem: Problem,
    *,
    operators: Iterable,
    popsize: int,
    elitist: bool = True,
    re_evaluate: bool = True,
    re_evaluate_parents_first: Optional[bool] = None,
    _allow_empty_operator_list: bool = False,
):
    """
    `__init__(...)`: Initialize the GeneticAlgorithm.

    Args:
        problem: The problem to optimize.
        operators: Operators to be used by the genetic algorithm.
            Expected as an iterable, such as a list or a tuple.
            Each item within this iterable object is expected either
            as an instance of [Operator][evotorch.operators.base.Operator],
            or as a function which receives the decision values of
            multiple solutions in a PyTorch tensor (or in an
            [ObjectArray][evotorch.tools.objectarray.ObjectArray]
            for when dtype is `object`) and returns a modified copy.
        popsize: Population size.
        elitist: Whether or not this genetic algorithm will behave in an
            elitist manner. This argument controls how the genetic
            algorithm will form the next generation from the parents
            and the children. In elitist mode (i.e. with `elitist=True`),
            the procedure to be followed by this genetic algorithm is:
            (i) form an extended population which consists of
            both the parents and the children,
            (ii) sort the extended population from best to worst,
            (iii) select the best `n` solutions as the new generation where
            `n` is `popsize`.
            In non-elitist mode (i.e. with `elitist=False`), the worst `m`
            solutions within the parent population are replaced with
            the children, `m` being the number of produced children.
        re_evaluate: Whether or not to evaluate the solutions
            that were already evaluated in the previous generations.
            By default, this is set as True.
            The reason behind this default setting is that,
            in problems where the evaluation procedure is noisy,
            by re-evaluating the already-evaluated solutions,
            we prevent the bad solutions that were luckily evaluated
            from hanging onto the population.
            Instead, at every generation, each solution must go through
            the evaluation procedure again and prove their worth.
            For problems whose evaluation procedures are NOT noisy,
            the user might consider turning re_evaluate to False
            for saving computational cycles.
        re_evaluate_parents_first: This is to be specified only when
            `re_evaluate` is True (otherwise to be left as None).
            If this is given as True, then it will be assumed that the
            provided operators require the parents to be evaluated.
            If this is given as False, then it will be assumed that the
            provided operators work without looking at the parents'
            fitnesses (in which case both parents and children can be
            evaluated in a single vectorized computation cycle).
            If this is left as None, then whether or not the operators
            need to know the parent evaluations will be determined
            automatically as follows:
            if the operators contain at least one cross-over operator
            then `re_evaluate_parents_first` will be internally set as
            True; otherwise `re_evaluate_parents_first` will be internally
            set as False.
    """
    SearchAlgorithm.__init__(self, problem)

    self._popsize = int(popsize)
    self._elitist: bool = bool(elitist)
    self._population = problem.generate_batch(self._popsize)

    ExtendedPopulationMixin.__init__(
        self,
        re_evaluate=re_evaluate,
        re_evaluate_parents_first=re_evaluate_parents_first,
        operators=operators,
        allow_empty_operators_list=_allow_empty_operator_list,
    )

    SinglePopulationAlgorithmMixin.__init__(self)

SteadyStateGA (GeneticAlgorithm)

Thin wrapper around GeneticAlgorithm for compatibility with old code.

This SteadyStateGA class is equivalent to GeneticAlgorithm except that SteadyStateGA provides an additional method named use(...) for specifying a cross-over and/or a mutation operator. The method use(...) exists only for API compatibility with the previous versions of EvoTorch. It is recommended to specify the operators via the keyword argument operators instead.

Source code in evotorch/algorithms/ga.py
class SteadyStateGA(GeneticAlgorithm):
    """
    Thin wrapper around GeneticAlgorithm for compatibility with old code.

    This `SteadyStateGA` class is equivalent to
    [GeneticAlgorithm][evotorch.algorithms.ga.GeneticAlgorithm] except that
    `SteadyStateGA` provides an additional method named `use(...)` for
    specifying a cross-over and/or a mutation operator.
    The method `use(...)` exists only for API compatibility with the previous
    versions of EvoTorch. It is recommended to specify the operators via
    the keyword argument `operators` instead.
    """

    def __init__(
        self,
        problem: Problem,
        *,
        popsize: int,
        operators: Optional[Iterable] = None,
        elitist: bool = True,
        re_evaluate: bool = True,
        re_evaluate_parents_first: Optional[bool] = None,
    ):
        """
        `__init__(...)`: Initialize the SteadyStateGA.

        Args:
            problem: The problem to optimize.
            operators: Optionally, an iterable of operators to be used by the
                genetic algorithm. Each item within this iterable object is
                expected either as an instance of
                [Operator][evotorch.operators.base.Operator],
                or as a function which receives the decision values of
                multiple solutions in a PyTorch tensor (or in an
                [ObjectArray][evotorch.tools.objectarray.ObjectArray]
                for when dtype is `object`) and returns a modified copy.
                If this is omitted, then it will be required to specify the
                operators via the `use(...)` method.
            popsize: Population size.
            elitist: Whether or not this genetic algorithm will behave in an
                elitist manner. This argument controls how the genetic
                algorithm will form the next generation from the parents
                and the children. In elitist mode (i.e. with `elitist=True`),
                the procedure to be followed by this genetic algorithm is:
                (i) form an extended population which consists of
                both the parents and the children,
                (ii) sort the extended population from best to worst,
                (iii) select the best `n` solutions as the new generation where
                `n` is `popsize`.
                In non-elitist mode (i.e. with `elitist=False`), the worst `m`
                solutions within the parent population are replaced with
                the children, `m` being the number of produced children.
            re_evaluate: Whether or not to evaluate the solutions
                that were already evaluated in the previous generations.
                By default, this is set as True.
                The reason behind this default setting is that,
                in problems where the evaluation procedure is noisy,
                by re-evaluating the already-evaluated solutions,
                we prevent the bad solutions that were luckily evaluated
                from hanging onto the population.
                Instead, at every generation, each solution must go through
                the evaluation procedure again and prove their worth.
                For problems whose evaluation procedures are NOT noisy,
                the user might consider turning re_evaluate to False
                for saving computational cycles.
            re_evaluate_parents_first: This is to be specified only when
                `re_evaluate` is True (otherwise to be left as None).
                If this is given as True, then it will be assumed that the
                provided operators require the parents to be evaluated.
                If this is given as False, then it will be assumed that the
                provided operators work without looking at the parents'
                fitnesses (in which case both parents and children can be
                evaluated in a single vectorized computation cycle).
                If this is left as None, then whether or not the operators
                need to know the parent evaluations will be determined
                automatically as follows:
                if the operators contain at least one cross-over operator
                then `re_evaluate_parents_first` will be internally set as
                True; otherwise `re_evaluate_parents_first` will be internally
                set as False.
                Additional note specific to `SteadyStateGA`: if the argument
                `operators` is not given (or is given as an empty list), and
                also `re_evaluate_parents_first` is left as None, then
                `SteadyStateGA` will assume that the operators will be later
                given via the `use(...)` method, and that these operators will
                require the parents to be evaluated first (equivalent to
                setting `re_evaluate_parents_first` as True).
        """
        if operators is None:
            operators = []

        self._cross_over_op: Optional[Callable] = None
        self._mutation_op: Optional[Callable] = None
        self._forbid_use_method: bool = False
        self._prepare_ops: bool = False

        if (len(operators) == 0) and re_evaluate and (re_evaluate_parents_first is None):
            re_evaluate_parents_first = True

        super().__init__(
            problem,
            operators=operators,
            popsize=popsize,
            elitist=elitist,
            re_evaluate=re_evaluate,
            re_evaluate_parents_first=re_evaluate_parents_first,
            _allow_empty_operator_list=True,
        )

    def use(self, operator: Callable):
        """
        Specify the cross-over or the mutation operator to use.

        This method exists for compatibility with previous EvoTorch code.
        Instead of using this method, it is recommended to specify the
        operators via the `operators` keyword argument while initializing
        this class.

        Using this method, one can specify one cross-over operator and one
        mutation operator that will be used during the evolutionary search.
        Specifying multiple cross-over operators or multiple mutation operators
        is not allowed. When the cross-over and mutation operators are
        specified via `use(...)`, the order of execution will always be
        arranged such that the cross-over comes first and the mutation comes
        comes second. If desired, one can specify only the cross-over operator
        or only the mutation operator.

        Please note that the `operators` keyword argument works differently,
        and offers more flexibility for defining the procedure to follow at
        each generation. In more details, the `operators` keyword argument
        allows one to specify multiple cross-over and/or multiple mutation
        operators, and those operators will be executed in the specified
        order.

        Args:
            operator: The operator to be registered to SteadyStateGA.
                If the specified operator is cross-over (i.e. an instance
                of [CrossOver][evotorch.operators.base.CrossOver]),
                then this operator will be registered for the cross-over
                phase. If the specified operator is an operator that is
                not of the cross-over type (i.e. any instance of
                [Operator][evotorch.operators.base.Operator] that is not
                [CrossOver][evotorch.operators.base.CrossOver]) or if it is
                just a function which receives the decision values as a PyTorch
                tensor (or, in the case where `dtype` of the problem is
                `object` as an instance of
                [ObjectArray][evotorch.tools.objectarray.ObjectArray]) and
                returns a modified copy, then that operator will be registered
                for the mutation phase of the genetic algorithm.
        """
        if self._forbid_use_method:
            raise RuntimeError(
                "The method `use(...)` cannot be called anymore, because the evolutionary search has started."
            )

        if len(self._operators) > 0:
            raise RuntimeError(
                f"The method `use(...)` cannot be called"
                f" because an operator list was provided while initializing this {type(self).__name__} instance."
            )

        if isinstance(operator, CrossOver):
            if self._cross_over_op is not None:
                raise ValueError(
                    f"The method `use(...)` received this cross-over operator as its argument:"
                    f" {operator} (of type {type(operator)})."
                    f" However, a cross-over operator was already set:"
                    f" {self._cross_over_op} (of type {type(self._cross_over_op)})."
                )
            self._cross_over_op = operator
            self._prepare_ops = True
        else:
            if self._mutation_op is not None:
                raise ValueError(
                    f"The method `use(...)` received this mutation operator as its argument:"
                    f" {operator} (of type {type(operator)})."
                    f" However, a mutation operator was already set:"
                    f" {self._mutation_op} (of type {type(self._mutation_op)})."
                )
            self._mutation_op = operator
            self._prepare_ops = True

    def _step(self):
        self._forbid_use_method = True

        if self._prepare_ops:
            self._prepare_ops = False
            if self._cross_over_op is not None:
                self._operators.append(self._cross_over_op)
            if self._mutation_op is not None:
                self._operators.append(self._mutation_op)
        else:
            if len(self._operators) == 0:
                raise RuntimeError(
                    f"This {type(self).__name__} instance does not know how to proceed, "
                    f" because neither the `operators` keyword argument was used during initialization"
                    f" nor was the `use(...)` method called later."
                )

        super()._step()
__init__(self, problem, *, popsize, operators=None, elitist=True, re_evaluate=True, re_evaluate_parents_first=None) special

__init__(...): Initialize the SteadyStateGA.

Parameters:

Name Type Description Default
problem Problem

The problem to optimize.

required
operators Optional[Iterable]

Optionally, an iterable of operators to be used by the genetic algorithm. Each item within this iterable object is expected either as an instance of Operator, or as a function which receives the decision values of multiple solutions in a PyTorch tensor (or in an ObjectArray for when dtype is object) and returns a modified copy. If this is omitted, then it will be required to specify the operators via the use(...) method.

None
popsize int

Population size.

required
elitist bool

Whether or not this genetic algorithm will behave in an elitist manner. This argument controls how the genetic algorithm will form the next generation from the parents and the children. In elitist mode (i.e. with elitist=True), the procedure to be followed by this genetic algorithm is: (i) form an extended population which consists of both the parents and the children, (ii) sort the extended population from best to worst, (iii) select the best n solutions as the new generation where n is popsize. In non-elitist mode (i.e. with elitist=False), the worst m solutions within the parent population are replaced with the children, m being the number of produced children.

True
re_evaluate bool

Whether or not to evaluate the solutions that were already evaluated in the previous generations. By default, this is set as True. The reason behind this default setting is that, in problems where the evaluation procedure is noisy, by re-evaluating the already-evaluated solutions, we prevent the bad solutions that were luckily evaluated from hanging onto the population. Instead, at every generation, each solution must go through the evaluation procedure again and prove their worth. For problems whose evaluation procedures are NOT noisy, the user might consider turning re_evaluate to False for saving computational cycles.

True
re_evaluate_parents_first Optional[bool]

This is to be specified only when re_evaluate is True (otherwise to be left as None). If this is given as True, then it will be assumed that the provided operators require the parents to be evaluated. If this is given as False, then it will be assumed that the provided operators work without looking at the parents' fitnesses (in which case both parents and children can be evaluated in a single vectorized computation cycle). If this is left as None, then whether or not the operators need to know the parent evaluations will be determined automatically as follows: if the operators contain at least one cross-over operator then re_evaluate_parents_first will be internally set as True; otherwise re_evaluate_parents_first will be internally set as False. Additional note specific to SteadyStateGA: if the argument operators is not given (or is given as an empty list), and also re_evaluate_parents_first is left as None, then SteadyStateGA will assume that the operators will be later given via the use(...) method, and that these operators will require the parents to be evaluated first (equivalent to setting re_evaluate_parents_first as True).

None
Source code in evotorch/algorithms/ga.py
def __init__(
    self,
    problem: Problem,
    *,
    popsize: int,
    operators: Optional[Iterable] = None,
    elitist: bool = True,
    re_evaluate: bool = True,
    re_evaluate_parents_first: Optional[bool] = None,
):
    """
    `__init__(...)`: Initialize the SteadyStateGA.

    Args:
        problem: The problem to optimize.
        operators: Optionally, an iterable of operators to be used by the
            genetic algorithm. Each item within this iterable object is
            expected either as an instance of
            [Operator][evotorch.operators.base.Operator],
            or as a function which receives the decision values of
            multiple solutions in a PyTorch tensor (or in an
            [ObjectArray][evotorch.tools.objectarray.ObjectArray]
            for when dtype is `object`) and returns a modified copy.
            If this is omitted, then it will be required to specify the
            operators via the `use(...)` method.
        popsize: Population size.
        elitist: Whether or not this genetic algorithm will behave in an
            elitist manner. This argument controls how the genetic
            algorithm will form the next generation from the parents
            and the children. In elitist mode (i.e. with `elitist=True`),
            the procedure to be followed by this genetic algorithm is:
            (i) form an extended population which consists of
            both the parents and the children,
            (ii) sort the extended population from best to worst,
            (iii) select the best `n` solutions as the new generation where
            `n` is `popsize`.
            In non-elitist mode (i.e. with `elitist=False`), the worst `m`
            solutions within the parent population are replaced with
            the children, `m` being the number of produced children.
        re_evaluate: Whether or not to evaluate the solutions
            that were already evaluated in the previous generations.
            By default, this is set as True.
            The reason behind this default setting is that,
            in problems where the evaluation procedure is noisy,
            by re-evaluating the already-evaluated solutions,
            we prevent the bad solutions that were luckily evaluated
            from hanging onto the population.
            Instead, at every generation, each solution must go through
            the evaluation procedure again and prove their worth.
            For problems whose evaluation procedures are NOT noisy,
            the user might consider turning re_evaluate to False
            for saving computational cycles.
        re_evaluate_parents_first: This is to be specified only when
            `re_evaluate` is True (otherwise to be left as None).
            If this is given as True, then it will be assumed that the
            provided operators require the parents to be evaluated.
            If this is given as False, then it will be assumed that the
            provided operators work without looking at the parents'
            fitnesses (in which case both parents and children can be
            evaluated in a single vectorized computation cycle).
            If this is left as None, then whether or not the operators
            need to know the parent evaluations will be determined
            automatically as follows:
            if the operators contain at least one cross-over operator
            then `re_evaluate_parents_first` will be internally set as
            True; otherwise `re_evaluate_parents_first` will be internally
            set as False.
            Additional note specific to `SteadyStateGA`: if the argument
            `operators` is not given (or is given as an empty list), and
            also `re_evaluate_parents_first` is left as None, then
            `SteadyStateGA` will assume that the operators will be later
            given via the `use(...)` method, and that these operators will
            require the parents to be evaluated first (equivalent to
            setting `re_evaluate_parents_first` as True).
    """
    if operators is None:
        operators = []

    self._cross_over_op: Optional[Callable] = None
    self._mutation_op: Optional[Callable] = None
    self._forbid_use_method: bool = False
    self._prepare_ops: bool = False

    if (len(operators) == 0) and re_evaluate and (re_evaluate_parents_first is None):
        re_evaluate_parents_first = True

    super().__init__(
        problem,
        operators=operators,
        popsize=popsize,
        elitist=elitist,
        re_evaluate=re_evaluate,
        re_evaluate_parents_first=re_evaluate_parents_first,
        _allow_empty_operator_list=True,
    )
use(self, operator)

Specify the cross-over or the mutation operator to use.

This method exists for compatibility with previous EvoTorch code. Instead of using this method, it is recommended to specify the operators via the operators keyword argument while initializing this class.

Using this method, one can specify one cross-over operator and one mutation operator that will be used during the evolutionary search. Specifying multiple cross-over operators or multiple mutation operators is not allowed. When the cross-over and mutation operators are specified via use(...), the order of execution will always be arranged such that the cross-over comes first and the mutation comes comes second. If desired, one can specify only the cross-over operator or only the mutation operator.

Please note that the operators keyword argument works differently, and offers more flexibility for defining the procedure to follow at each generation. In more details, the operators keyword argument allows one to specify multiple cross-over and/or multiple mutation operators, and those operators will be executed in the specified order.

Parameters:

Name Type Description Default
operator Callable

The operator to be registered to SteadyStateGA. If the specified operator is cross-over (i.e. an instance of CrossOver), then this operator will be registered for the cross-over phase. If the specified operator is an operator that is not of the cross-over type (i.e. any instance of Operator that is not CrossOver) or if it is just a function which receives the decision values as a PyTorch tensor (or, in the case where dtype of the problem is object as an instance of ObjectArray) and returns a modified copy, then that operator will be registered for the mutation phase of the genetic algorithm.

required
Source code in evotorch/algorithms/ga.py
def use(self, operator: Callable):
    """
    Specify the cross-over or the mutation operator to use.

    This method exists for compatibility with previous EvoTorch code.
    Instead of using this method, it is recommended to specify the
    operators via the `operators` keyword argument while initializing
    this class.

    Using this method, one can specify one cross-over operator and one
    mutation operator that will be used during the evolutionary search.
    Specifying multiple cross-over operators or multiple mutation operators
    is not allowed. When the cross-over and mutation operators are
    specified via `use(...)`, the order of execution will always be
    arranged such that the cross-over comes first and the mutation comes
    comes second. If desired, one can specify only the cross-over operator
    or only the mutation operator.

    Please note that the `operators` keyword argument works differently,
    and offers more flexibility for defining the procedure to follow at
    each generation. In more details, the `operators` keyword argument
    allows one to specify multiple cross-over and/or multiple mutation
    operators, and those operators will be executed in the specified
    order.

    Args:
        operator: The operator to be registered to SteadyStateGA.
            If the specified operator is cross-over (i.e. an instance
            of [CrossOver][evotorch.operators.base.CrossOver]),
            then this operator will be registered for the cross-over
            phase. If the specified operator is an operator that is
            not of the cross-over type (i.e. any instance of
            [Operator][evotorch.operators.base.Operator] that is not
            [CrossOver][evotorch.operators.base.CrossOver]) or if it is
            just a function which receives the decision values as a PyTorch
            tensor (or, in the case where `dtype` of the problem is
            `object` as an instance of
            [ObjectArray][evotorch.tools.objectarray.ObjectArray]) and
            returns a modified copy, then that operator will be registered
            for the mutation phase of the genetic algorithm.
    """
    if self._forbid_use_method:
        raise RuntimeError(
            "The method `use(...)` cannot be called anymore, because the evolutionary search has started."
        )

    if len(self._operators) > 0:
        raise RuntimeError(
            f"The method `use(...)` cannot be called"
            f" because an operator list was provided while initializing this {type(self).__name__} instance."
        )

    if isinstance(operator, CrossOver):
        if self._cross_over_op is not None:
            raise ValueError(
                f"The method `use(...)` received this cross-over operator as its argument:"
                f" {operator} (of type {type(operator)})."
                f" However, a cross-over operator was already set:"
                f" {self._cross_over_op} (of type {type(self._cross_over_op)})."
            )
        self._cross_over_op = operator
        self._prepare_ops = True
    else:
        if self._mutation_op is not None:
            raise ValueError(
                f"The method `use(...)` received this mutation operator as its argument:"
                f" {operator} (of type {type(operator)})."
                f" However, a mutation operator was already set:"
                f" {self._mutation_op} (of type {type(self._mutation_op)})."
            )
        self._mutation_op = operator
        self._prepare_ops = True

mapelites

MAPElites (SearchAlgorithm, SinglePopulationAlgorithmMixin, ExtendedPopulationMixin)

Implementation of the MAPElites algorithm.

In MAPElites, we deal with optimization problems where, in addition to the fitness, there are additional evaluation data ("features") that are computed during the phase of evaluation. To ensure a diversity of the solutions, the population is organized into cells of features.

Reference:

Jean-Baptiste Mouret and Jeff Clune (2015).
Illuminating search spaces by mapping elites.
arXiv preprint arXiv:1504.04909 (2015).

As an example, let us imagine that our problem has two features. Let us call these features feat0 and feat1. Let us also imagine that we wish to organize feat0 according to the boundaries [(-inf, 0), (0, 10), (10, 20), (20, +inf)] and feat1 according to the boundaries [(-inf, 0), (0, 50), (50, +inf)]. Our population gets organized into:

     +inf
          ^
          |
  f       |           |        |         |
  e       |    pop[0] | pop[1] | pop[ 2] | pop[ 3]
  a   50 -|-  --------+--------+---------+---------
  t       |    pop[4] | pop[5] | pop[ 6] | pop[ 7]
  1    0 -|-  --------+--------|---------+---------
          |    pop[8] | pop[9] | pop[10] | pop[11]
          |           |        |         |
    <-----------------|--------|---------|----------->
 -inf     |           0       10        20            +inf
          |                  feat0
          |
          v
      -inf

where pop[i] is short for population[i], that is, the i-th solution of the population.

Which problems can be solved by MAPElites? The problems that can be addressed by MAPElites are the problems with one objective, and with its eval_data_length (additional evaluation data length) set as an integer greater than or equal to 1. For example, let us imagine an optimization problem where we handle 2 features. The evaluation function for such a problem could look like:

def f(x: torch.Tensor) -> torch.Tensor:
    # Somehow compute the fitness
    fitness = ...

    # Somehow compute the value for the first feature
    feat0 = ...

    # Somehow compute the value for the second feature
    feat1 = ...

    # Prepare an evaluation result tensor for the solution
    eval_result = torch.tensor([fitness, feat0, feat1], device=x.device)

    # Here, we return the eval_result.
    # Notice that `eval_result` is a 1-dimensional tensor of length 3,
    # where the item with index 0 is the fitness, and the items with
    # indices 1 and 2 represent the two features of the solution.
    # Please also note that, in vectorized mode, we would receive `n`
    # solutions, and the evaluation result tensor would have to be of shape
    # (n, 3).
    return eval_result

The problem definition then would look like this:

from evotorch import Problem

problem = Problem(
    "min",
    f,
    initial_bounds=(..., ...),
    solution_length=...,
    eval_data_length=2,  # we have 2 features
)

Using MAPElites. Let us continue using the example problem shown above, where we have two features. The first step towards configuring MAPElites is to come up with a hypergrid tensor, from in the lower and upper bound for each feature on each cell will be expressed. The hypergrid tensor is structured like this:

hypergrid = torch.tensor(
    [
        [
            [
                feat0_lower_bound_for_cell0,
                feat0_upper_bound_for_cell0,
            ],
            [
                feat1_lower_bound_for_cell0,
                feat1_upper_bound_for_cell0,
            ],
        ],
        [
            [
                feat0_lower_bound_for_cell1,
                feat0_upper_bound_for_cell1,
            ],
            [
                feat1_lower_bound_for_cell1,
                feat1_upper_bound_for_cell1,
            ],
        ],
        [
            [
                feat0_lower_bound_for_cell2,
                feat0_upper_bound_for_cell2,
            ],
            [
                feat1_lower_bound_for_cell2,
                feat1_upper_bound_for_cell2,
            ],
        ],
        ...,
    ],
    dtype=problem.eval_dtype,
    device=problem.device,
)

that is, the item with index i,j,0 represents the lower bound for the j-th feature in i-th cell, and the item with index i,j,1 represents the upper bound for the j-th feature in i-th cell.

Specifying lower and upper bounds for each feature and for each cell can be tedious. MAPElites provides a static helper function named make_feature_grid which asks for how many bins are desired for each feature, and then produces a hypergrid tensor. For example, if we want 10 bins for feature feat0 and 5 bins for feature feat1, then, we could do:

hypergrid = MAPElites.make_feature_grid(
    lower_bounds=[
        global_lower_bound_for_feat0,
        global_lower_bound_for_feat1,
    ],
    upper_bounds=[
        global_upper_bound_for_feat0,
        global_upper_bound_for_feat1,
    ],
    num_bins=[10, 5],
    dtype=problem.eval_dtype,
    device=problem.device,
)

Now that hypergrid is prepared, one can instantiate MAPElites like this:

searcher = MAPElites(
    problem,
    operators=[...],  # list of operators like in GeneticAlgorithm
    feature_grid=hypergrid,
)

where the keyword argument operators is a list that contains functions or instances of Operator, like expected by GeneticAlgorithm.

Once MAPElites is instantiated, it can be run like most of the search algorithm implementations provided by EvoTorch, as shown below:

from evotorch.logging import StdOutLogger

_ = StdOutLogger(ga)  # Report the evolution's progress to standard output
searcher.run(100)  # Run MAPElites for 100 generations
print(dict(searcher.status))  # Show the final status dictionary

Vectorization capabilities. According to the basic definition of the MAPElites algorithm, a cell is first chosen, then mutated, and then the mutated solution is placed back into the most suitable cell (if the cell is not filled yet or if the fitness of the newly mutated solution is better than the existing solution in that cell). When vectorization, and especially GPU-based parallelization is available, picking and mutating solutions one by one can be wasteful in terms of performance. Therefore, this MAPElites implementation mutates the entire population, evaluates all of the mutated solutions, and places all of them back into the most suitable cells, all in such a way that the vectorization and/or GPU-based parallelization can be exploited.

Source code in evotorch/algorithms/mapelites.py
class MAPElites(SearchAlgorithm, SinglePopulationAlgorithmMixin, ExtendedPopulationMixin):
    """
    Implementation of the MAPElites algorithm.

    In MAPElites, we deal with optimization problems where, in addition
    to the fitness, there are additional evaluation data ("features") that
    are computed during the phase of evaluation. To ensure a diversity
    of the solutions, the population is organized into cells of features.

    Reference:

        Jean-Baptiste Mouret and Jeff Clune (2015).
        Illuminating search spaces by mapping elites.
        arXiv preprint arXiv:1504.04909 (2015).

    As an example, let us imagine that our problem has two features.
    Let us call these features `feat0` and `feat1`.
    Let us also imagine that we wish to organize `feat0` according to
    the boundaries `[(-inf, 0), (0, 10), (10, 20), (20, +inf)]` and `feat1`
    according to the boundaries `[(-inf, 0), (0, 50), (50, +inf)]`.
    Our population gets organized into:

    ```text

         +inf
              ^
              |
      f       |           |        |         |
      e       |    pop[0] | pop[1] | pop[ 2] | pop[ 3]
      a   50 -|-  --------+--------+---------+---------
      t       |    pop[4] | pop[5] | pop[ 6] | pop[ 7]
      1    0 -|-  --------+--------|---------+---------
              |    pop[8] | pop[9] | pop[10] | pop[11]
              |           |        |         |
        <-----------------|--------|---------|----------->
     -inf     |           0       10        20            +inf
              |                  feat0
              |
              v
          -inf
    ```

    where `pop[i]` is short for `population[i]`, that is, the i-th solution
    of the population.

    **Which problems can be solved by MAPElites?**
    The problems that can be addressed by MAPElites are the problems with
    one objective, and with its `eval_data_length` (additional evaluation
    data length) set as an integer greater than or equal to 1.
    For example, let us imagine an optimization problem where we handle
    2 features. The evaluation function for such a problem could look like:

    ```python
    def f(x: torch.Tensor) -> torch.Tensor:
        # Somehow compute the fitness
        fitness = ...

        # Somehow compute the value for the first feature
        feat0 = ...

        # Somehow compute the value for the second feature
        feat1 = ...

        # Prepare an evaluation result tensor for the solution
        eval_result = torch.tensor([fitness, feat0, feat1], device=x.device)

        # Here, we return the eval_result.
        # Notice that `eval_result` is a 1-dimensional tensor of length 3,
        # where the item with index 0 is the fitness, and the items with
        # indices 1 and 2 represent the two features of the solution.
        # Please also note that, in vectorized mode, we would receive `n`
        # solutions, and the evaluation result tensor would have to be of shape
        # (n, 3).
        return eval_result
    ```

    The problem definition then would look like this:

    ```python
    from evotorch import Problem

    problem = Problem(
        "min",
        f,
        initial_bounds=(..., ...),
        solution_length=...,
        eval_data_length=2,  # we have 2 features
    )
    ```

    **Using MAPElites.**
    Let us continue using the example `problem` shown above, where we have
    two features.
    The first step towards configuring MAPElites is to come up with a
    hypergrid tensor, from in the lower and upper bound for each
    feature on each cell will be expressed. The hypergrid tensor is structured
    like this:

    ```python
    hypergrid = torch.tensor(
        [
            [
                [
                    feat0_lower_bound_for_cell0,
                    feat0_upper_bound_for_cell0,
                ],
                [
                    feat1_lower_bound_for_cell0,
                    feat1_upper_bound_for_cell0,
                ],
            ],
            [
                [
                    feat0_lower_bound_for_cell1,
                    feat0_upper_bound_for_cell1,
                ],
                [
                    feat1_lower_bound_for_cell1,
                    feat1_upper_bound_for_cell1,
                ],
            ],
            [
                [
                    feat0_lower_bound_for_cell2,
                    feat0_upper_bound_for_cell2,
                ],
                [
                    feat1_lower_bound_for_cell2,
                    feat1_upper_bound_for_cell2,
                ],
            ],
            ...,
        ],
        dtype=problem.eval_dtype,
        device=problem.device,
    )
    ```

    that is, the item with index `i,j,0` represents the lower bound for the
    j-th feature in i-th cell, and the item with index `i,j,1` represents the
    upper bound for the j-th feature in i-th cell.

    Specifying lower and upper bounds for each feature and for each cell can
    be tedious. MAPElites provides a static helper function named
    [make_feature_grid][evotorch.algorithms.mapelites.MAPElites.make_feature_grid]
    which asks for how many bins are desired for each feature, and then
    produces a hypergrid tensor. For example, if we want 10 bins for feature
    `feat0` and 5 bins for feature `feat1`, then, we could do:

    ```python
    hypergrid = MAPElites.make_feature_grid(
        lower_bounds=[
            global_lower_bound_for_feat0,
            global_lower_bound_for_feat1,
        ],
        upper_bounds=[
            global_upper_bound_for_feat0,
            global_upper_bound_for_feat1,
        ],
        num_bins=[10, 5],
        dtype=problem.eval_dtype,
        device=problem.device,
    )
    ```

    Now that `hypergrid` is prepared, one can instantiate `MAPElites` like
    this:

    ```python
    searcher = MAPElites(
        problem,
        operators=[...],  # list of operators like in GeneticAlgorithm
        feature_grid=hypergrid,
    )
    ```

    where the keyword argument `operators` is a list that contains functions
    or instances of [Operator][evotorch.operators.base.Operator], like expected
    by [GeneticAlgorithm][evotorch.algorithms.ga.GeneticAlgorithm].

    Once `MAPElites` is instantiated, it can be run like most of the search
    algorithm implementations provided by EvoTorch, as shown below:

    ```python
    from evotorch.logging import StdOutLogger

    _ = StdOutLogger(ga)  # Report the evolution's progress to standard output
    searcher.run(100)  # Run MAPElites for 100 generations
    print(dict(searcher.status))  # Show the final status dictionary
    ```

    **Vectorization capabilities.**
    According to the basic definition of the MAPElites algorithm, a cell is
    first chosen, then mutated, and then the mutated solution is placed back
    into the most suitable cell (if the cell is not filled yet or if the
    fitness of the newly mutated solution is better than the existing solution
    in that cell). When vectorization, and especially GPU-based parallelization
    is available, picking and mutating solutions one by one can be wasteful in
    terms of performance. Therefore, this MAPElites implementation mutates the
    entire population, evaluates all of the mutated solutions, and places all
    of them back into the most suitable cells, all in such a way that the
    vectorization and/or GPU-based parallelization can be exploited.
    """

    def __init__(
        self,
        problem: Problem,
        *,
        operators: Iterable,
        feature_grid: Iterable,
        re_evaluate: bool = True,
        re_evaluate_parents_first: Optional[bool] = None,
    ):
        """
        `__init__(...)`: Initialize the MAPElites algorithm.

        Args:
            problem: The problem object to work on. This problem object
                is expected to have one objective, and also have its
                `eval_data_length` set as an integer that is greater than
                or equal to 1.
            operators: Operators to be used by the MAPElites algorithm.
                Expected as an iterable, such as a list or a tuple.
                Each item within this iterable object is expected either
                as an instance of [Operator][evotorch.operators.base.Operator],
                or as a function which receives the decision values of
                multiple solutions in a PyTorch tensor and returns a modified
                copy.
            re_evaluate: Whether or not to evaluate the solutions
                that were already evaluated in the previous generations.
                By default, this is set as True.
                The reason behind this default setting is that,
                in problems where the evaluation procedure is noisy,
                by re-evaluating the already-evaluated solutions,
                we prevent the bad solutions that were luckily evaluated
                from hanging onto the population.
                Instead, at every generation, each solution must go through
                the evaluation procedure again and prove their worth.
                For problems whose evaluation procedures are NOT noisy,
                the user might consider turning re_evaluate to False
                for saving computational cycles.
            re_evaluate_parents_first: This is to be specified only when
                `re_evaluate` is True (otherwise to be left as None).
                If this is given as True, then it will be assumed that the
                provided operators require the parents to be evaluated.
                If this is given as False, then it will be assumed that the
                provided operators work without looking at the parents'
                fitnesses (in which case both parents and children can be
                evaluated in a single vectorized computation cycle).
                If this is left as None, then whether or not the operators
                need to know the parent evaluations will be determined
                automatically as follows:
                if the operators contain at least one cross-over operator
                then `re_evaluate_parents_first` will be internally set as
                True; otherwise `re_evaluate_parents_first` will be internally
                set as False.
        """
        problem.ensure_single_objective()
        problem.ensure_numeric()

        SearchAlgorithm.__init__(self, problem)

        self._feature_grid = problem.as_tensor(feature_grid, use_eval_dtype=True)
        self._sense = self._problem.senses[0]
        self._popsize = self._feature_grid.shape[0]

        self._population = problem.generate_batch(self._popsize)
        self._filled = torch.zeros(self._popsize, dtype=torch.bool, device=self._population.device)

        ExtendedPopulationMixin.__init__(
            self,
            re_evaluate=re_evaluate,
            re_evaluate_parents_first=re_evaluate_parents_first,
            operators=operators,
            allow_empty_operators_list=False,
        )

        SinglePopulationAlgorithmMixin.__init__(self)

    @property
    def population(self) -> SolutionBatch:
        """
        Get the population as a SolutionBatch object

        In this MAP-Elites implementation, i-th solution corresponds to the
        solution belonging to the i-th cell of the MAP-Elites hypergrid.
        If `filled[i]` is True, then this means that the i-th cell is filled,
        and therefore `population[i]` will get the solution belonging to the
        i-th cell.
        """
        return self._population

    @property
    def filled(self) -> torch.Tensor:
        """
        Get a boolean tensor specifying whether or not the i-th cell is filled.

        In this MAP-Elites implementation, i-th solution within the population
        corresponds to the solution belonging to the i-th cell of the MAP-Elites
        hypergrid. If `filled[i]` is True, then the solution stored in the i-th
        cell satisfies the feature boundaries imposed by that cell.
        If `filled[i]` is False, then the solution stored in the i-th cell
        does not satisfy those boundaries, and therefore does not really belong
        in that cell.
        """
        from ..tools.readonlytensor import as_read_only_tensor

        with torch.no_grad():
            return as_read_only_tensor(self._filled)

    def _step(self):
        # Form an extended population from the parents and from the children
        extended_population = self._make_extended_population(split=False)

        # Get the most suitable solutions for each cell of the hypergrid.
        # values[i, :] stores the decision values most suitable for the i-th cell.
        # evals[i, :] stores the evaluation results most suitable for the i-th cell.
        # if the suggested i-th solution completely satisfies the boundaries imposed by the i-th cell,
        # then suitable_mask[i] will be True.
        values, evals, suitable = _best_solution_considering_all_features(
            self._sense,
            extended_population.values.as_subclass(torch.Tensor),
            extended_population.evals.as_subclass(torch.Tensor),
            self._feature_grid,
        )

        # Place the most suitable decision values and evaluation results into the current population.
        self._population.access_values(keep_evals=True)[:] = values
        self._population.access_evals()[:] = evals

        # If there was a suitable solution for the i-th cell, fill[i] is to be set as True.
        self._filled[:] = suitable

    @staticmethod
    def make_feature_grid(
        lower_bounds: Iterable,
        upper_bounds: Iterable,
        num_bins: Union[int, torch.Tensor],
        *,
        device: Optional[Device] = None,
        dtype: Optional[DType] = None,
    ) -> torch.Tensor:
        """
        Make a hypergrid for the MAPElites algorithm.

        The [MAPElites][evotorch.algorithms.mapelites.MAPElites] organizes its
        population not only according to the fitness, but also according to the
        additional evaluation data which are interpreted as the additional features
        of the solutions. To organize the current population according to these
        [MAPElites][evotorch.algorithms.mapelites.MAPElites] requires "cells",
        each cell having a lower and an upper bound for each feature.
        `make_map_elites_grid(...)` is a helper function which generates the
        required hypergrid of features in such a way that each cell, for each
        feature, has the same interval.

        The result of this function is a PyTorch tensor, which can be passed to
        the `feature_grid` keyword argument of
        [MAPElites][evotorch.algorithms.mapelites.MAPElites].

        Args:
            lower_bounds: The lower bounds, as a 1-dimensional sequence of numbers.
                The length of this sequence must be equal to the number of
                features, and the i-th element must express the lower bound
                of the i-th feature.
            upper_bounds: The upper bounds, as a 1-dimensional sequence of numbers.
                The length of this sequence must be equal to the number of
                features, and the i-th element must express the upper bound
                of the i-th feature.
            num_bins: Can be given as an integer or as a sequence of integers.
                If given as an integer `n`, then there will be `n` bins for each
                feature on the hypergrid. If given as a sequence of integers,
                then the i-th element of the sequence will express the number of
                bins for the i-th feature.
        Returns:
            The hypergrid, as a PyTorch tensor.
        """

        cast_args = {}
        if device is not None:
            cast_args["device"] = torch.device(device)
        if dtype is not None:
            cast_args["dtype"] = to_torch_dtype(dtype)

        has_casting = len(cast_args) > 0

        if has_casting:
            lower_bounds = torch.as_tensor(lower_bounds, **cast_args)
            upper_bounds = torch.as_tensor(upper_bounds, **cast_args)

        if (not isinstance(lower_bounds, torch.Tensor)) or (not isinstance(upper_bounds, torch.Tensor)):
            raise TypeError(
                f"While preparing the map elites hypergrid with device={device} and dtype={dtype},"
                f"`lower_bounds` and `upper_bounds` were expected as tensors, but their types are different."
                f" The type of `lower_bounds` is {type(lower_bounds)}."
                f" The type of `upper_bounds` is {type(upper_bounds)}."
            )

        if lower_bounds.device != upper_bounds.device:
            raise ValueError(
                f"Cannot determine on which device to place the map elites grid,"
                f" because `lower_bounds` and `upper_bounds` are on different devices."
                f" The device of `lower_bounds` is {lower_bounds.device}."
                f" The device of `upper_bounds` is {upper_bounds.device}."
            )

        if lower_bounds.dtype != upper_bounds.dtype:
            raise ValueError(
                f"Cannot determine the dtype of the map elites grid,"
                f" because `lower_bounds` and `upper_bounds` have different dtypes."
                f" The dtype of `lower_bounds` is {lower_bounds.dtype}."
                f" The dtype of `upper_bounds` is {upper_bounds.dtype}."
            )

        if lower_bounds.size() != upper_bounds.size():
            raise ValueError("`lower_bounds` and `upper_bounds` have incompatible shapes")

        if lower_bounds.dim() != 1:
            raise ValueError("Only 1D tensors are supported for `lower_bounds` and for `upper_bounds`")

        dtype = lower_bounds.dtype
        device = lower_bounds.device

        num_bins = torch.as_tensor(num_bins, dtype=torch.int64, device=device)
        if num_bins.dim() == 0:
            num_bins = num_bins.expand(lower_bounds.size())

        p_inf = torch.tensor([float("inf")], dtype=dtype, device=device)
        n_inf = torch.tensor([float("-inf")], dtype=dtype, device=device)

        def _make_feature_grid(lb, ub, num_bins):
            sp = torch.linspace(lb, ub, num_bins - 1, device=device)
            sp = torch.cat((n_inf, sp, p_inf))
            return sp.unfold(dimension=0, size=2, step=1).unsqueeze(1)

        f_grids = [_make_feature_grid(*bounds) for bounds in zip(lower_bounds, upper_bounds, num_bins)]
        return torch.stack([torch.cat(c) for c in itertools.product(*f_grids)])
filled: Tensor property readonly

Get a boolean tensor specifying whether or not the i-th cell is filled.

In this MAP-Elites implementation, i-th solution within the population corresponds to the solution belonging to the i-th cell of the MAP-Elites hypergrid. If filled[i] is True, then the solution stored in the i-th cell satisfies the feature boundaries imposed by that cell. If filled[i] is False, then the solution stored in the i-th cell does not satisfy those boundaries, and therefore does not really belong in that cell.

population: SolutionBatch property readonly

Get the population as a SolutionBatch object

In this MAP-Elites implementation, i-th solution corresponds to the solution belonging to the i-th cell of the MAP-Elites hypergrid. If filled[i] is True, then this means that the i-th cell is filled, and therefore population[i] will get the solution belonging to the i-th cell.

__init__(self, problem, *, operators, feature_grid, re_evaluate=True, re_evaluate_parents_first=None) special

__init__(...): Initialize the MAPElites algorithm.

Parameters:

Name Type Description Default
problem Problem

The problem object to work on. This problem object is expected to have one objective, and also have its eval_data_length set as an integer that is greater than or equal to 1.

required
operators Iterable

Operators to be used by the MAPElites algorithm. Expected as an iterable, such as a list or a tuple. Each item within this iterable object is expected either as an instance of Operator, or as a function which receives the decision values of multiple solutions in a PyTorch tensor and returns a modified copy.

required
re_evaluate bool

Whether or not to evaluate the solutions that were already evaluated in the previous generations. By default, this is set as True. The reason behind this default setting is that, in problems where the evaluation procedure is noisy, by re-evaluating the already-evaluated solutions, we prevent the bad solutions that were luckily evaluated from hanging onto the population. Instead, at every generation, each solution must go through the evaluation procedure again and prove their worth. For problems whose evaluation procedures are NOT noisy, the user might consider turning re_evaluate to False for saving computational cycles.

True
re_evaluate_parents_first Optional[bool]

This is to be specified only when re_evaluate is True (otherwise to be left as None). If this is given as True, then it will be assumed that the provided operators require the parents to be evaluated. If this is given as False, then it will be assumed that the provided operators work without looking at the parents' fitnesses (in which case both parents and children can be evaluated in a single vectorized computation cycle). If this is left as None, then whether or not the operators need to know the parent evaluations will be determined automatically as follows: if the operators contain at least one cross-over operator then re_evaluate_parents_first will be internally set as True; otherwise re_evaluate_parents_first will be internally set as False.

None
Source code in evotorch/algorithms/mapelites.py
def __init__(
    self,
    problem: Problem,
    *,
    operators: Iterable,
    feature_grid: Iterable,
    re_evaluate: bool = True,
    re_evaluate_parents_first: Optional[bool] = None,
):
    """
    `__init__(...)`: Initialize the MAPElites algorithm.

    Args:
        problem: The problem object to work on. This problem object
            is expected to have one objective, and also have its
            `eval_data_length` set as an integer that is greater than
            or equal to 1.
        operators: Operators to be used by the MAPElites algorithm.
            Expected as an iterable, such as a list or a tuple.
            Each item within this iterable object is expected either
            as an instance of [Operator][evotorch.operators.base.Operator],
            or as a function which receives the decision values of
            multiple solutions in a PyTorch tensor and returns a modified
            copy.
        re_evaluate: Whether or not to evaluate the solutions
            that were already evaluated in the previous generations.
            By default, this is set as True.
            The reason behind this default setting is that,
            in problems where the evaluation procedure is noisy,
            by re-evaluating the already-evaluated solutions,
            we prevent the bad solutions that were luckily evaluated
            from hanging onto the population.
            Instead, at every generation, each solution must go through
            the evaluation procedure again and prove their worth.
            For problems whose evaluation procedures are NOT noisy,
            the user might consider turning re_evaluate to False
            for saving computational cycles.
        re_evaluate_parents_first: This is to be specified only when
            `re_evaluate` is True (otherwise to be left as None).
            If this is given as True, then it will be assumed that the
            provided operators require the parents to be evaluated.
            If this is given as False, then it will be assumed that the
            provided operators work without looking at the parents'
            fitnesses (in which case both parents and children can be
            evaluated in a single vectorized computation cycle).
            If this is left as None, then whether or not the operators
            need to know the parent evaluations will be determined
            automatically as follows:
            if the operators contain at least one cross-over operator
            then `re_evaluate_parents_first` will be internally set as
            True; otherwise `re_evaluate_parents_first` will be internally
            set as False.
    """
    problem.ensure_single_objective()
    problem.ensure_numeric()

    SearchAlgorithm.__init__(self, problem)

    self._feature_grid = problem.as_tensor(feature_grid, use_eval_dtype=True)
    self._sense = self._problem.senses[0]
    self._popsize = self._feature_grid.shape[0]

    self._population = problem.generate_batch(self._popsize)
    self._filled = torch.zeros(self._popsize, dtype=torch.bool, device=self._population.device)

    ExtendedPopulationMixin.__init__(
        self,
        re_evaluate=re_evaluate,
        re_evaluate_parents_first=re_evaluate_parents_first,
        operators=operators,
        allow_empty_operators_list=False,
    )

    SinglePopulationAlgorithmMixin.__init__(self)
make_feature_grid(lower_bounds, upper_bounds, num_bins, *, device=None, dtype=None) staticmethod

Make a hypergrid for the MAPElites algorithm.

The MAPElites organizes its population not only according to the fitness, but also according to the additional evaluation data which are interpreted as the additional features of the solutions. To organize the current population according to these MAPElites requires "cells", each cell having a lower and an upper bound for each feature. make_map_elites_grid(...) is a helper function which generates the required hypergrid of features in such a way that each cell, for each feature, has the same interval.

The result of this function is a PyTorch tensor, which can be passed to the feature_grid keyword argument of MAPElites.

Parameters:

Name Type Description Default
lower_bounds Iterable

The lower bounds, as a 1-dimensional sequence of numbers. The length of this sequence must be equal to the number of features, and the i-th element must express the lower bound of the i-th feature.

required
upper_bounds Iterable

The upper bounds, as a 1-dimensional sequence of numbers. The length of this sequence must be equal to the number of features, and the i-th element must express the upper bound of the i-th feature.

required
num_bins Union[int, torch.Tensor]

Can be given as an integer or as a sequence of integers. If given as an integer n, then there will be n bins for each feature on the hypergrid. If given as a sequence of integers, then the i-th element of the sequence will express the number of bins for the i-th feature.

required

Returns:

Type Description
Tensor

The hypergrid, as a PyTorch tensor.

Source code in evotorch/algorithms/mapelites.py
@staticmethod
def make_feature_grid(
    lower_bounds: Iterable,
    upper_bounds: Iterable,
    num_bins: Union[int, torch.Tensor],
    *,
    device: Optional[Device] = None,
    dtype: Optional[DType] = None,
) -> torch.Tensor:
    """
    Make a hypergrid for the MAPElites algorithm.

    The [MAPElites][evotorch.algorithms.mapelites.MAPElites] organizes its
    population not only according to the fitness, but also according to the
    additional evaluation data which are interpreted as the additional features
    of the solutions. To organize the current population according to these
    [MAPElites][evotorch.algorithms.mapelites.MAPElites] requires "cells",
    each cell having a lower and an upper bound for each feature.
    `make_map_elites_grid(...)` is a helper function which generates the
    required hypergrid of features in such a way that each cell, for each
    feature, has the same interval.

    The result of this function is a PyTorch tensor, which can be passed to
    the `feature_grid` keyword argument of
    [MAPElites][evotorch.algorithms.mapelites.MAPElites].

    Args:
        lower_bounds: The lower bounds, as a 1-dimensional sequence of numbers.
            The length of this sequence must be equal to the number of
            features, and the i-th element must express the lower bound
            of the i-th feature.
        upper_bounds: The upper bounds, as a 1-dimensional sequence of numbers.
            The length of this sequence must be equal to the number of
            features, and the i-th element must express the upper bound
            of the i-th feature.
        num_bins: Can be given as an integer or as a sequence of integers.
            If given as an integer `n`, then there will be `n` bins for each
            feature on the hypergrid. If given as a sequence of integers,
            then the i-th element of the sequence will express the number of
            bins for the i-th feature.
    Returns:
        The hypergrid, as a PyTorch tensor.
    """

    cast_args = {}
    if device is not None:
        cast_args["device"] = torch.device(device)
    if dtype is not None:
        cast_args["dtype"] = to_torch_dtype(dtype)

    has_casting = len(cast_args) > 0

    if has_casting:
        lower_bounds = torch.as_tensor(lower_bounds, **cast_args)
        upper_bounds = torch.as_tensor(upper_bounds, **cast_args)

    if (not isinstance(lower_bounds, torch.Tensor)) or (not isinstance(upper_bounds, torch.Tensor)):
        raise TypeError(
            f"While preparing the map elites hypergrid with device={device} and dtype={dtype},"
            f"`lower_bounds` and `upper_bounds` were expected as tensors, but their types are different."
            f" The type of `lower_bounds` is {type(lower_bounds)}."
            f" The type of `upper_bounds` is {type(upper_bounds)}."
        )

    if lower_bounds.device != upper_bounds.device:
        raise ValueError(
            f"Cannot determine on which device to place the map elites grid,"
            f" because `lower_bounds` and `upper_bounds` are on different devices."
            f" The device of `lower_bounds` is {lower_bounds.device}."
            f" The device of `upper_bounds` is {upper_bounds.device}."
        )

    if lower_bounds.dtype != upper_bounds.dtype:
        raise ValueError(
            f"Cannot determine the dtype of the map elites grid,"
            f" because `lower_bounds` and `upper_bounds` have different dtypes."
            f" The dtype of `lower_bounds` is {lower_bounds.dtype}."
            f" The dtype of `upper_bounds` is {upper_bounds.dtype}."
        )

    if lower_bounds.size() != upper_bounds.size():
        raise ValueError("`lower_bounds` and `upper_bounds` have incompatible shapes")

    if lower_bounds.dim() != 1:
        raise ValueError("Only 1D tensors are supported for `lower_bounds` and for `upper_bounds`")

    dtype = lower_bounds.dtype
    device = lower_bounds.device

    num_bins = torch.as_tensor(num_bins, dtype=torch.int64, device=device)
    if num_bins.dim() == 0:
        num_bins = num_bins.expand(lower_bounds.size())

    p_inf = torch.tensor([float("inf")], dtype=dtype, device=device)
    n_inf = torch.tensor([float("-inf")], dtype=dtype, device=device)

    def _make_feature_grid(lb, ub, num_bins):
        sp = torch.linspace(lb, ub, num_bins - 1, device=device)
        sp = torch.cat((n_inf, sp, p_inf))
        return sp.unfold(dimension=0, size=2, step=1).unsqueeze(1)

    f_grids = [_make_feature_grid(*bounds) for bounds in zip(lower_bounds, upper_bounds, num_bins)]
    return torch.stack([torch.cat(c) for c in itertools.product(*f_grids)])

pycmaes

This namespace contains the PyCMAES class, which is a wrapper for the CMA-ES implementation of the cma package.

PyCMAES (SearchAlgorithm, SinglePopulationAlgorithmMixin)

This is an interface class between the CMAES implementation within the cma package developed within the GitHub repository CMA-ES/pycma.

References:

Nikolaus Hansen, Youhei Akimoto, and Petr Baudis.
CMA-ES/pycma on Github. Zenodo, DOI:10.5281/zenodo.2559634,
February 2019.
<https://github.com/CMA-ES/pycma>

Nikolaus Hansen, Andreas Ostermeier (2001).
Completely Derandomized Self-Adaptation in Evolution Strategies.
Source code in evotorch/algorithms/pycmaes.py
class PyCMAES(SearchAlgorithm, SinglePopulationAlgorithmMixin):
    """
    PyCMAES: Covariance Matrix Adaptation Evolution Strategy.

    This is an interface class between the CMAES implementation
    within the `cma` package developed within the GitHub repository
    CMA-ES/pycma.

    References:

        Nikolaus Hansen, Youhei Akimoto, and Petr Baudis.
        CMA-ES/pycma on Github. Zenodo, DOI:10.5281/zenodo.2559634,
        February 2019.
        <https://github.com/CMA-ES/pycma>

        Nikolaus Hansen, Andreas Ostermeier (2001).
        Completely Derandomized Self-Adaptation in Evolution Strategies.

    """

    def __init__(
        self,
        problem: Problem,
        *,
        stdev_init: RealOrVector,  # sigma0
        popsize: Optional[int] = None,  # popsize
        center_init: Optional[Vector] = None,  # x0
        center_learning_rate: Optional[float] = None,  # CMA_cmean
        cov_learning_rate: Optional[float] = None,  # CMA_on
        rankmu_learning_rate: Optional[float] = None,  # CMA_rankmu
        rankone_learning_rate: Optional[float] = None,  # CMA_rankone
        stdev_min: Optional[Union[float, np.ndarray]] = None,  # minstd
        stdev_max: Optional[Union[float, np.ndarray]] = None,  # maxstd
        separable: bool = False,  # CMA_diagonal
        obj_index: Optional[int] = None,
        cma_options: dict = {},
    ):
        """
        `__init__(...)`: Initialize the PyCMAES solver.

        Args:
            problem: The problem object which is being worked on.
            stdev_init: Initial standard deviation as a scalar or
                as a 1-dimensional array.
            popsize: Population size. Can be specified as an int,
                or can be left as None to let the CMAES solver
                decide the population size according to the length
                of a solution.
            center_init: Initial center point of the search distribution.
                Can be given as a Solution or as a 1-D array.
                If left as None, an initial center point is generated
                with the help of the problem object's `generate_values(...)`
                method.
            center_learning_rate: Learning rate for updating the mean
                of the search distribution. Leaving this as None
                means that the CMAES solver is to use its own default,
                which is documented as 1.0.
            cov_learning_rate: Learning rate for updating the covariance
                matrix of the search distribution. This hyperparameter
                acts as a common multiplier for rank_one update and rank_mu
                update of the covariance matrix. Leaving this as None
                means that the CMAES solver is to use its own default,
                which is documented as 1.0.
            rankmu_learning_rate: Learning rate for the rank_mu update
                of the covariance matrix of the search distribution.
                Leaving this as None means that the CMAES solver is to use
                its own default, which is documented as 1.0.
            rankone_learning_rate: Learning rate for the rank_one update
                of the covariance matrix of the search distribution.
                Leaving this as None means that the CMAES solver is to use
                its own default, which is documented as 1.0.
            stdev_min: Minimum allowed standard deviation of the search
                distribution. Leaving this as None means that no such
                boundary is to be used.
                Can be given as None, as a scalar, or as a 1-dimensional
                array.
            stdev_max: Maximum allowed standard deviation of the search
                distribution. Leaving this as None means that no such
                boundary is to be used.
                Can be given as None, as a scalar, or as a 1-dimensional
                array.
            separable: Provide this as True if you would like the problem
                to be treated as a separable one. Treating a problem
                as separable means to adapt only the diagonal parts
                of the covariance matrix and to keep the non-diagonal
                parts 0. High dimensional problems result in large
                covariance matrices on which operating is computationally
                expensive. Therefore, for such high dimensional problems,
                setting `separable` as True might be useful.
                If, instead, you would like to configure on which
                iterations the diagonal parts of the covariance matrix
                are to be adapted, then it is recommended to leave
                `separable` as False and set a new value for the key
                "CMA_diagonal" via `cma_options` (see the official
                documentation of pycma for details regarding the
                "CMA_diagonal" setting).
            obj_index: Objective index according to which evaluation
                of the solution will be done.
            cma_options: Any other configuration for the CMAES solver
                can be passed via the cma_options dictionary.
        """

        # Make sure that the cma module is installed
        if cma is None:
            raise ImportError(f"The class {type(self).__name__} is only available if the package `cma` is installed.")

        # Initialize the base class
        SearchAlgorithm.__init__(self, problem, center=self._get_center)

        # Ensure that the problem is numeric
        problem.ensure_numeric()

        # Store the objective index
        self._obj_index = problem.normalize_obj_index(obj_index)

        # If `center_init` is not given, generate an initial solution
        # with the help of the problem object.
        # Otherwise, use the given initial solution as the starting
        # point in the search space.
        if center_init is None:
            x0 = self._problem.generate_values(1).to("cpu").view(-1).numpy().astype(dtype=float)
        else:
            x0 = numpy_copy(center_init, dtype=float)

        # Store the initial standard deviations
        sigma0 = numpy_copy(stdev_init, dtype=float)

        # Generate an options dictionary to pass to the cma solver.
        inopts = {}
        for k, v in cma_options.items():
            if isinstance(v, torch.Tensor):
                v = numpy_copy(v, dtype=float)
            inopts[k] = v

        # Remove the number of iterations boundary
        if "maxiter" not in inopts:
            inopts["maxiter"] = np.inf

        # Below is a temporary helper function for safely storing the configuration items.
        # This inner function updates the `inopts` variable.
        def store_opt(key: str, long_name: str, value: Any, converter: Callable):
            # Here, `key` represents the configuration key used by pycma
            # `long_name` represents the configuration's long name used by this class
            # `value` is the configuration value associated with `key`.

            # Declare that this inner function accesses the `inopts` variable.
            nonlocal inopts

            if value is None:
                # If the provided `value` is None, then there is no configuration to store.
                # So, we just leave this inner function.
                return

            if key in inopts:
                # If the given `key` already exists within `inopts`, this means that the configuration was specified
                # twice: via the keyword argument `cma_options` AND via a keyword argument.
                # We raise an error and inform the user about this redundancy.
                raise ValueError(
                    f"The configuration {repr(key)} was redundantly provided"
                    f" both via the initialization argument {long_name}"
                    f" and via the cma_options dictionary."
                    f" {long_name}={repr(value)};"
                    f" cma_options[{repr(key)}]={repr(inopts[key])}."
                )

            inopts[key] = converter(value)

        # Temporary helper function which makes sure that `x` is a numpy array or a float.
        def array_or_float(x):
            if is_sequence(x):
                return numpy_copy(x)
            else:
                return float(x)

        # Store the cma configuration received through the initialization arguments (and raise error if there is
        # redundancy with the cma_options dictionary).
        store_opt("popsize", "popsize", popsize, int)
        store_opt("CMA_cmean", "center_learning_rate", center_learning_rate, float)
        store_opt("CMA_on", "cov_learning_rate", cov_learning_rate, float)
        store_opt("CMA_rankmu", "rankmu_learning_rate", rankmu_learning_rate, float)
        store_opt("CMA_rankone", "rankone_learning_rate", rankone_learning_rate, float)
        store_opt("minstd", "stdev_min", stdev_min, array_or_float)
        store_opt("maxstd", "stdev_max", stdev_max, array_or_float)
        if separable:
            store_opt("CMA_diagonal", "separable", separable, bool)

        # If the problem defines lower and upper bounds, pass these into the options dict.
        def process_bounds(bounds: RealOrVector) -> np.ndarray:
            if bounds is None:
                return None
            else:
                if is_sequence(bounds):
                    bounds = numpy_copy(bounds)
                else:
                    bounds = np.array(float(bounds)).repeat(self._problem.solution_length)
                return bounds

        lb = process_bounds(self._problem.lower_bounds)
        ub = process_bounds(self._problem.upper_bounds)

        register_bounds = False
        if lb is not None and ub is None:
            ub = np.array(np.inf).repeat(self._problem.solution_length)
            register_bounds = True
        elif lb is None and ub is not None:
            lb = np.array(-(np.inf)).repeat(self._problem.solution_length)
            register_bounds = True
        elif lb is not None and ub is not None:
            register_bounds = True

        if register_bounds:
            inopts["bounds"] = [lb, ub]

        # Generate a random seed using the problem object for the sake of reproducibility.
        if "seed" not in inopts:
            inopts["seed"] = int(self._problem.make_randint(tuple(), n=(2**32) - 100) + 100)

        # Instantiate the CMAEvolutionStrategy with the prepared configuration items.
        self._es = cma.CMAEvolutionStrategy(x0, sigma0, inopts)

        # Initialize the population.
        self._population: SolutionBatch = self._problem.generate_batch(self._es.popsize, empty=True)

        # Use the SinglePopulationAlgorithmMixin to enable additional status reports regarding the population.
        SinglePopulationAlgorithmMixin.__init__(self)

    @property
    def population(self) -> SolutionBatch:
        """Population generated by the CMA-ES algorithm"""
        return self._population

    def _step(self):
        """Perform a step of the CMA-ES solver"""
        asked = self._es.ask()
        self._population.access_values()[:] = torch.as_tensor(
            np.asarray(asked), dtype=self._problem.dtype, device=self._population.device
        )
        self._problem.evaluate(self._population)
        scores = numpy_copy(self._population.utility(self._obj_index), dtype=float)
        self._es.tell(asked, -1.0 * scores)

    def _get_center(self) -> torch.Tensor:
        return torch.as_tensor(self._es.result[5], dtype=self._population.dtype, device=self._population.device)

    @property
    def obj_index(self) -> int:
        """Index of the objective being focused on"""
        return self._obj_index
obj_index: int property readonly

Index of the objective being focused on

population: SolutionBatch property readonly

Population generated by the CMA-ES algorithm

__init__(self, problem, *, stdev_init, popsize=None, center_init=None, center_learning_rate=None, cov_learning_rate=None, rankmu_learning_rate=None, rankone_learning_rate=None, stdev_min=None, stdev_max=None, separable=False, obj_index=None, cma_options={}) special

__init__(...): Initialize the PyCMAES solver.

Parameters:

Name Type Description Default
problem Problem

The problem object which is being worked on.

required
stdev_init Union[float, Iterable[float], torch.Tensor]

Initial standard deviation as a scalar or as a 1-dimensional array.

required
popsize Optional[int]

Population size. Can be specified as an int, or can be left as None to let the CMAES solver decide the population size according to the length of a solution.

None
center_init Union[Iterable[float], torch.Tensor]

Initial center point of the search distribution. Can be given as a Solution or as a 1-D array. If left as None, an initial center point is generated with the help of the problem object's generate_values(...) method.

None
center_learning_rate Optional[float]

Learning rate for updating the mean of the search distribution. Leaving this as None means that the CMAES solver is to use its own default, which is documented as 1.0.

None
cov_learning_rate Optional[float]

Learning rate for updating the covariance matrix of the search distribution. This hyperparameter acts as a common multiplier for rank_one update and rank_mu update of the covariance matrix. Leaving this as None means that the CMAES solver is to use its own default, which is documented as 1.0.

None
rankmu_learning_rate Optional[float]

Learning rate for the rank_mu update of the covariance matrix of the search distribution. Leaving this as None means that the CMAES solver is to use its own default, which is documented as 1.0.

None
rankone_learning_rate Optional[float]

Learning rate for the rank_one update of the covariance matrix of the search distribution. Leaving this as None means that the CMAES solver is to use its own default, which is documented as 1.0.

None
stdev_min Union[float, numpy.ndarray]

Minimum allowed standard deviation of the search distribution. Leaving this as None means that no such boundary is to be used. Can be given as None, as a scalar, or as a 1-dimensional array.

None
stdev_max Union[float, numpy.ndarray]

Maximum allowed standard deviation of the search distribution. Leaving this as None means that no such boundary is to be used. Can be given as None, as a scalar, or as a 1-dimensional array.

None
separable bool

Provide this as True if you would like the problem to be treated as a separable one. Treating a problem as separable means to adapt only the diagonal parts of the covariance matrix and to keep the non-diagonal parts 0. High dimensional problems result in large covariance matrices on which operating is computationally expensive. Therefore, for such high dimensional problems, setting separable as True might be useful. If, instead, you would like to configure on which iterations the diagonal parts of the covariance matrix are to be adapted, then it is recommended to leave separable as False and set a new value for the key "CMA_diagonal" via cma_options (see the official documentation of pycma for details regarding the "CMA_diagonal" setting).

False
obj_index Optional[int]

Objective index according to which evaluation of the solution will be done.

None
cma_options dict

Any other configuration for the CMAES solver can be passed via the cma_options dictionary.

{}
Source code in evotorch/algorithms/pycmaes.py
def __init__(
    self,
    problem: Problem,
    *,
    stdev_init: RealOrVector,  # sigma0
    popsize: Optional[int] = None,  # popsize
    center_init: Optional[Vector] = None,  # x0
    center_learning_rate: Optional[float] = None,  # CMA_cmean
    cov_learning_rate: Optional[float] = None,  # CMA_on
    rankmu_learning_rate: Optional[float] = None,  # CMA_rankmu
    rankone_learning_rate: Optional[float] = None,  # CMA_rankone
    stdev_min: Optional[Union[float, np.ndarray]] = None,  # minstd
    stdev_max: Optional[Union[float, np.ndarray]] = None,  # maxstd
    separable: bool = False,  # CMA_diagonal
    obj_index: Optional[int] = None,
    cma_options: dict = {},
):
    """
    `__init__(...)`: Initialize the PyCMAES solver.

    Args:
        problem: The problem object which is being worked on.
        stdev_init: Initial standard deviation as a scalar or
            as a 1-dimensional array.
        popsize: Population size. Can be specified as an int,
            or can be left as None to let the CMAES solver
            decide the population size according to the length
            of a solution.
        center_init: Initial center point of the search distribution.
            Can be given as a Solution or as a 1-D array.
            If left as None, an initial center point is generated
            with the help of the problem object's `generate_values(...)`
            method.
        center_learning_rate: Learning rate for updating the mean
            of the search distribution. Leaving this as None
            means that the CMAES solver is to use its own default,
            which is documented as 1.0.
        cov_learning_rate: Learning rate for updating the covariance
            matrix of the search distribution. This hyperparameter
            acts as a common multiplier for rank_one update and rank_mu
            update of the covariance matrix. Leaving this as None
            means that the CMAES solver is to use its own default,
            which is documented as 1.0.
        rankmu_learning_rate: Learning rate for the rank_mu update
            of the covariance matrix of the search distribution.
            Leaving this as None means that the CMAES solver is to use
            its own default, which is documented as 1.0.
        rankone_learning_rate: Learning rate for the rank_one update
            of the covariance matrix of the search distribution.
            Leaving this as None means that the CMAES solver is to use
            its own default, which is documented as 1.0.
        stdev_min: Minimum allowed standard deviation of the search
            distribution. Leaving this as None means that no such
            boundary is to be used.
            Can be given as None, as a scalar, or as a 1-dimensional
            array.
        stdev_max: Maximum allowed standard deviation of the search
            distribution. Leaving this as None means that no such
            boundary is to be used.
            Can be given as None, as a scalar, or as a 1-dimensional
            array.
        separable: Provide this as True if you would like the problem
            to be treated as a separable one. Treating a problem
            as separable means to adapt only the diagonal parts
            of the covariance matrix and to keep the non-diagonal
            parts 0. High dimensional problems result in large
            covariance matrices on which operating is computationally
            expensive. Therefore, for such high dimensional problems,
            setting `separable` as True might be useful.
            If, instead, you would like to configure on which
            iterations the diagonal parts of the covariance matrix
            are to be adapted, then it is recommended to leave
            `separable` as False and set a new value for the key
            "CMA_diagonal" via `cma_options` (see the official
            documentation of pycma for details regarding the
            "CMA_diagonal" setting).
        obj_index: Objective index according to which evaluation
            of the solution will be done.
        cma_options: Any other configuration for the CMAES solver
            can be passed via the cma_options dictionary.
    """

    # Make sure that the cma module is installed
    if cma is None:
        raise ImportError(f"The class {type(self).__name__} is only available if the package `cma` is installed.")

    # Initialize the base class
    SearchAlgorithm.__init__(self, problem, center=self._get_center)

    # Ensure that the problem is numeric
    problem.ensure_numeric()

    # Store the objective index
    self._obj_index = problem.normalize_obj_index(obj_index)

    # If `center_init` is not given, generate an initial solution
    # with the help of the problem object.
    # Otherwise, use the given initial solution as the starting
    # point in the search space.
    if center_init is None:
        x0 = self._problem.generate_values(1).to("cpu").view(-1).numpy().astype(dtype=float)
    else:
        x0 = numpy_copy(center_init, dtype=float)

    # Store the initial standard deviations
    sigma0 = numpy_copy(stdev_init, dtype=float)

    # Generate an options dictionary to pass to the cma solver.
    inopts = {}
    for k, v in cma_options.items():
        if isinstance(v, torch.Tensor):
            v = numpy_copy(v, dtype=float)
        inopts[k] = v

    # Remove the number of iterations boundary
    if "maxiter" not in inopts:
        inopts["maxiter"] = np.inf

    # Below is a temporary helper function for safely storing the configuration items.
    # This inner function updates the `inopts` variable.
    def store_opt(key: str, long_name: str, value: Any, converter: Callable):
        # Here, `key` represents the configuration key used by pycma
        # `long_name` represents the configuration's long name used by this class
        # `value` is the configuration value associated with `key`.

        # Declare that this inner function accesses the `inopts` variable.
        nonlocal inopts

        if value is None:
            # If the provided `value` is None, then there is no configuration to store.
            # So, we just leave this inner function.
            return

        if key in inopts:
            # If the given `key` already exists within `inopts`, this means that the configuration was specified
            # twice: via the keyword argument `cma_options` AND via a keyword argument.
            # We raise an error and inform the user about this redundancy.
            raise ValueError(
                f"The configuration {repr(key)} was redundantly provided"
                f" both via the initialization argument {long_name}"
                f" and via the cma_options dictionary."
                f" {long_name}={repr(value)};"
                f" cma_options[{repr(key)}]={repr(inopts[key])}."
            )

        inopts[key] = converter(value)

    # Temporary helper function which makes sure that `x` is a numpy array or a float.
    def array_or_float(x):
        if is_sequence(x):
            return numpy_copy(x)
        else:
            return float(x)

    # Store the cma configuration received through the initialization arguments (and raise error if there is
    # redundancy with the cma_options dictionary).
    store_opt("popsize", "popsize", popsize, int)
    store_opt("CMA_cmean", "center_learning_rate", center_learning_rate, float)
    store_opt("CMA_on", "cov_learning_rate", cov_learning_rate, float)
    store_opt("CMA_rankmu", "rankmu_learning_rate", rankmu_learning_rate, float)
    store_opt("CMA_rankone", "rankone_learning_rate", rankone_learning_rate, float)
    store_opt("minstd", "stdev_min", stdev_min, array_or_float)
    store_opt("maxstd", "stdev_max", stdev_max, array_or_float)
    if separable:
        store_opt("CMA_diagonal", "separable", separable, bool)

    # If the problem defines lower and upper bounds, pass these into the options dict.
    def process_bounds(bounds: RealOrVector) -> np.ndarray:
        if bounds is None:
            return None
        else:
            if is_sequence(bounds):
                bounds = numpy_copy(bounds)
            else:
                bounds = np.array(float(bounds)).repeat(self._problem.solution_length)
            return bounds

    lb = process_bounds(self._problem.lower_bounds)
    ub = process_bounds(self._problem.upper_bounds)

    register_bounds = False
    if lb is not None and ub is None:
        ub = np.array(np.inf).repeat(self._problem.solution_length)
        register_bounds = True
    elif lb is None and ub is not None:
        lb = np.array(-(np.inf)).repeat(self._problem.solution_length)
        register_bounds = True
    elif lb is not None and ub is not None:
        register_bounds = True

    if register_bounds:
        inopts["bounds"] = [lb, ub]

    # Generate a random seed using the problem object for the sake of reproducibility.
    if "seed" not in inopts:
        inopts["seed"] = int(self._problem.make_randint(tuple(), n=(2**32) - 100) + 100)

    # Instantiate the CMAEvolutionStrategy with the prepared configuration items.
    self._es = cma.CMAEvolutionStrategy(x0, sigma0, inopts)

    # Initialize the population.
    self._population: SolutionBatch = self._problem.generate_batch(self._es.popsize, empty=True)

    # Use the SinglePopulationAlgorithmMixin to enable additional status reports regarding the population.
    SinglePopulationAlgorithmMixin.__init__(self)

restarter special

This namespace contains the implementations of various restart mechanisms

modify_restart

IPOP (ModifyingRestart)
Source code in evotorch/algorithms/restarter/modify_restart.py
class IPOP(ModifyingRestart):
    def __init__(
        self,
        problem: Problem,
        algorithm_class: Type[SearchAlgorithm],
        algorithm_args: dict = {},
        min_fitness_stdev: float = 1e-9,
        popsize_multiplier: float = 2,
    ):
        """IPOP restart, terminates algorithm when minimum standard deviation in fitness values is hit, multiplies the population size in that case
        References:
            Glasmachers, Tobias, and Oswin Krause.
            "The hessian estimation evolution strategy."
            PPSN 2020
        Args:
            problem (Problem): A Problem to solve
            algorithm_class (Type[SearchAlgorithm]): The class of the search algorithm to restart
            algorithm_args (dict): Arguments to pass to the search algorithm on restart
            min_fitness_stdev (float): The minimum standard deviation in fitnesses; going below this will trigger a restart
            popsize_multiplier (float): A multiplier on the population size within algorithm_args
        """
        super().__init__(problem, algorithm_class, algorithm_args)

        self.min_fitness_stdev = min_fitness_stdev
        self.popsize_multiplier = popsize_multiplier

    def _search_algorithm_terminated(self) -> bool:
        # Additional check on standard deviation of fitnesses of population
        if self.search_algorithm.population.evals.std() < self.min_fitness_stdev:
            return True
        return super()._search_algorithm_terminated()

    def _modify_algorithm_args(self) -> None:
        # Only modify arguments if this isn't the first restart
        if self.num_restarts >= 1:
            new_args = deepcopy(self._algorithm_args)
            # Multiply population size
            new_args["popsize"] = int(self.popsize_multiplier * len(self.search_algorithm.population))
            self._algorithm_args = new_args
__init__(self, problem, algorithm_class, algorithm_args={}, min_fitness_stdev=1e-09, popsize_multiplier=2) special

IPOP restart, terminates algorithm when minimum standard deviation in fitness values is hit, multiplies the population size in that case

References

Glasmachers, Tobias, and Oswin Krause. "The hessian estimation evolution strategy." PPSN 2020

Parameters:

Name Type Description Default
problem Problem

A Problem to solve

required
algorithm_class Type[SearchAlgorithm]

The class of the search algorithm to restart

required
algorithm_args dict

Arguments to pass to the search algorithm on restart

{}
min_fitness_stdev float

The minimum standard deviation in fitnesses; going below this will trigger a restart

1e-09
popsize_multiplier float

A multiplier on the population size within algorithm_args

2
Source code in evotorch/algorithms/restarter/modify_restart.py
def __init__(
    self,
    problem: Problem,
    algorithm_class: Type[SearchAlgorithm],
    algorithm_args: dict = {},
    min_fitness_stdev: float = 1e-9,
    popsize_multiplier: float = 2,
):
    """IPOP restart, terminates algorithm when minimum standard deviation in fitness values is hit, multiplies the population size in that case
    References:
        Glasmachers, Tobias, and Oswin Krause.
        "The hessian estimation evolution strategy."
        PPSN 2020
    Args:
        problem (Problem): A Problem to solve
        algorithm_class (Type[SearchAlgorithm]): The class of the search algorithm to restart
        algorithm_args (dict): Arguments to pass to the search algorithm on restart
        min_fitness_stdev (float): The minimum standard deviation in fitnesses; going below this will trigger a restart
        popsize_multiplier (float): A multiplier on the population size within algorithm_args
    """
    super().__init__(problem, algorithm_class, algorithm_args)

    self.min_fitness_stdev = min_fitness_stdev
    self.popsize_multiplier = popsize_multiplier

restart

Restart (SearchAlgorithm)
Source code in evotorch/algorithms/restarter/restart.py
class Restart(SearchAlgorithm):
    def __init__(
        self,
        problem: Problem,
        algorithm_class: Type[SearchAlgorithm],
        algorithm_args: dict = {},
        **kwargs: Any,
    ):
        """Base class for independent restarts methods
        Args:
            problem (Problem): A Problem to solve
            algorithm_class (Type[SearchAlgorithm]): The class of the search algorithm to restart
            algorithm_args (dict): Arguments to pass to the search algorithm on restart
        """

        SearchAlgorithm.__init__(
            self,
            problem,
            search_algorithm=self._get_sa_status,
            num_restarts=self._get_num_restarts,
            algorithm_terminated=self._search_algorithm_terminated,
            **kwargs,
        )

        self._algorithm_class = algorithm_class
        self._algorithm_args = algorithm_args

        self.num_restarts = 0
        self._restart()

    def _get_sa_status(self) -> dict:
        """Status dictionary of search algorithm"""
        return self.search_algorithm.status

    def _get_num_restarts(self) -> int:
        """Number of restarts (including the first start) so far"""
        return self.num_restarts

    def _restart(self) -> None:
        """Restart the search algorithm"""
        self.search_algorithm = self._algorithm_class(self._problem, **self._algorithm_args)
        self.num_restarts += 1

    def _search_algorithm_terminated(self) -> bool:
        """Boolean flag for search algorithm terminated"""
        return self.search_algorithm.is_terminated

    def _step(self):
        # Step the search algorithm
        self.search_algorithm.step()

        # If stepping the search algorithm has reached a terminal state, restart the search algorithm
        if self._search_algorithm_terminated():
            self._restart()
__init__(self, problem, algorithm_class, algorithm_args={}, **kwargs) special

Base class for independent restarts methods

Parameters:

Name Type Description Default
problem Problem

A Problem to solve

required
algorithm_class Type[SearchAlgorithm]

The class of the search algorithm to restart

required
algorithm_args dict

Arguments to pass to the search algorithm on restart

{}
Source code in evotorch/algorithms/restarter/restart.py
def __init__(
    self,
    problem: Problem,
    algorithm_class: Type[SearchAlgorithm],
    algorithm_args: dict = {},
    **kwargs: Any,
):
    """Base class for independent restarts methods
    Args:
        problem (Problem): A Problem to solve
        algorithm_class (Type[SearchAlgorithm]): The class of the search algorithm to restart
        algorithm_args (dict): Arguments to pass to the search algorithm on restart
    """

    SearchAlgorithm.__init__(
        self,
        problem,
        search_algorithm=self._get_sa_status,
        num_restarts=self._get_num_restarts,
        algorithm_terminated=self._search_algorithm_terminated,
        **kwargs,
    )

    self._algorithm_class = algorithm_class
    self._algorithm_args = algorithm_args

    self.num_restarts = 0
    self._restart()

searchalgorithm

This namespace contains SearchAlgorithm, the base class for all evolutionary algorithms.

LazyReporter

This class provides an interface of storing and reporting status. This class is designed to be inherited by other classes.

Let us assume that we have the following class inheriting from LazyReporter:

class Example(LazyReporter):
    def __init__(self):
        LazyReporter.__init__(self, a=self._get_a, b=self._get_b)

    def _get_a(self):
        return ...  # return the status 'a'

    def _get_b(self):
        return ...  # return the status 'b'

At its initialization phase, this Example class registers its methods _get_a and _get_b as its status providers. Having the LazyReporter interface, the Example class gains a status property:

ex = Example()
print(ex.status["a"])  # Get the status 'a'
print(ex.status["b"])  # Get the status 'b'

Once a status is queried, its computation result is stored to be re-used later. After running the code above, if we query the status 'a' again:

print(ex.status["a"])  # Getting the status 'a' again

then the status 'a' is not computed again (i.e. _get_a is not called again). Instead, the stored status value of 'a' is re-used.

To force re-computation of the status values, one can execute:

ex.clear_status()

Or the Example instance can clear its status from within one of its methods:

class Example(LazyReporter):
    ...

    def some_method(self):
        ...
        self.clear_status()
Source code in evotorch/algorithms/searchalgorithm.py
class LazyReporter:
    """
    This class provides an interface of storing and reporting status.
    This class is designed to be inherited by other classes.

    Let us assume that we have the following class inheriting from
    LazyReporter:

    ```python
    class Example(LazyReporter):
        def __init__(self):
            LazyReporter.__init__(self, a=self._get_a, b=self._get_b)

        def _get_a(self):
            return ...  # return the status 'a'

        def _get_b(self):
            return ...  # return the status 'b'
    ```

    At its initialization phase, this Example class registers its methods
    ``_get_a`` and ``_get_b`` as its status providers.
    Having the LazyReporter interface, the Example class gains a ``status``
    property:

    ```python
    ex = Example()
    print(ex.status["a"])  # Get the status 'a'
    print(ex.status["b"])  # Get the status 'b'
    ```

    Once a status is queried, its computation result is stored to be re-used
    later. After running the code above, if we query the status 'a' again:

    ```python
    print(ex.status["a"])  # Getting the status 'a' again
    ```

    then the status 'a' is not computed again (i.e. ``_get_a`` is not
    called again). Instead, the stored status value of 'a' is re-used.

    To force re-computation of the status values, one can execute:

    ```python
    ex.clear_status()
    ```

    Or the Example instance can clear its status from within one of its
    methods:

    ```python
    class Example(LazyReporter):
        ...

        def some_method(self):
            ...
            self.clear_status()
    ```
    """

    @staticmethod
    def _missing_status_producer():
        return None

    def __init__(self, **kwargs):
        """
        `__init__(...)`: Initialize the LazyReporter instance.

        Args:
            kwargs: Keyword arguments, mapping the status keys to the
                methods or functions providing the status values.
        """
        self.__getters = kwargs
        self.__computed = {}

    def get_status_value(self, key: Any) -> Any:
        """
        Get the specified status value.

        Args:
            key: The key (i.e. the name) of the status variable.
        """
        if key not in self.__computed:
            self.__computed[key] = self.__getters[key]()
        return self.__computed[key]

    def has_status_key(self, key: Any) -> bool:
        """
        Return True if there is a status variable with the specified key.
        Otherwise, return False.

        Args:
            key: The key (i.e. the name) of the status variable whose
                existence is to be checked.
        Returns:
            True if there is such a key; False otherwise.
        """
        return key in self.__getters

    def iter_status_keys(self):
        """Iterate over the status keys."""
        return self.__getters.keys()

    def clear_status(self):
        """Clear all the stored values of the status variables."""
        self.__computed.clear()

    def is_status_computed(self, key) -> bool:
        """
        Return True if the specified status is computed yet.
        Return False otherwise.

        Args:
            key: The key (i.e. the name) of the status variable.
        Returns:
            True if the status of the given key is computed; False otherwise.
        """
        return key in self.__computed

    def update_status(self, additional_status: Mapping):
        """
        Update the stored status with an external dict-like object.
        The given dict-like object can override existing status keys
        with new values, and also bring new keys to the status.

        Args:
            additional_status: A dict-like object storing the status update.
        """
        for k, v in additional_status.items():
            if k not in self.__getters:
                self.__getters[k] = LazyReporter._missing_status_producer
            self.__computed[k] = v

    def add_status_getters(self, getters: Mapping):
        """
        Register additional status-getting functions.

        Args:
            getters: A dictionary-like object where the keys are the
                additional status variable names, and values are functions
                which are expected to compute/retrieve the values for those
                status variables.
        """
        self.__getters.update(getters)

    @property
    def status(self) -> "LazyStatusDict":
        """Get a LazyStatusDict which is bound to this LazyReporter."""
        return LazyStatusDict(self)
status: LazyStatusDict property readonly

Get a LazyStatusDict which is bound to this LazyReporter.

__init__(self, **kwargs) special

__init__(...): Initialize the LazyReporter instance.

Parameters:

Name Type Description Default
kwargs

Keyword arguments, mapping the status keys to the methods or functions providing the status values.

{}
Source code in evotorch/algorithms/searchalgorithm.py
def __init__(self, **kwargs):
    """
    `__init__(...)`: Initialize the LazyReporter instance.

    Args:
        kwargs: Keyword arguments, mapping the status keys to the
            methods or functions providing the status values.
    """
    self.__getters = kwargs
    self.__computed = {}
add_status_getters(self, getters)

Register additional status-getting functions.

Parameters:

Name Type Description Default
getters Mapping

A dictionary-like object where the keys are the additional status variable names, and values are functions which are expected to compute/retrieve the values for those status variables.

required
Source code in evotorch/algorithms/searchalgorithm.py
def add_status_getters(self, getters: Mapping):
    """
    Register additional status-getting functions.

    Args:
        getters: A dictionary-like object where the keys are the
            additional status variable names, and values are functions
            which are expected to compute/retrieve the values for those
            status variables.
    """
    self.__getters.update(getters)
clear_status(self)

Clear all the stored values of the status variables.

Source code in evotorch/algorithms/searchalgorithm.py
def clear_status(self):
    """Clear all the stored values of the status variables."""
    self.__computed.clear()
get_status_value(self, key)

Get the specified status value.

Parameters:

Name Type Description Default
key Any

The key (i.e. the name) of the status variable.

required
Source code in evotorch/algorithms/searchalgorithm.py
def get_status_value(self, key: Any) -> Any:
    """
    Get the specified status value.

    Args:
        key: The key (i.e. the name) of the status variable.
    """
    if key not in self.__computed:
        self.__computed[key] = self.__getters[key]()
    return self.__computed[key]
has_status_key(self, key)

Return True if there is a status variable with the specified key. Otherwise, return False.

Parameters:

Name Type Description Default
key Any

The key (i.e. the name) of the status variable whose existence is to be checked.

required

Returns:

Type Description
bool

True if there is such a key; False otherwise.

Source code in evotorch/algorithms/searchalgorithm.py
def has_status_key(self, key: Any) -> bool:
    """
    Return True if there is a status variable with the specified key.
    Otherwise, return False.

    Args:
        key: The key (i.e. the name) of the status variable whose
            existence is to be checked.
    Returns:
        True if there is such a key; False otherwise.
    """
    return key in self.__getters
is_status_computed(self, key)

Return True if the specified status is computed yet. Return False otherwise.

Parameters:

Name Type Description Default
key

The key (i.e. the name) of the status variable.

required

Returns:

Type Description
bool

True if the status of the given key is computed; False otherwise.

Source code in evotorch/algorithms/searchalgorithm.py
def is_status_computed(self, key) -> bool:
    """
    Return True if the specified status is computed yet.
    Return False otherwise.

    Args:
        key: The key (i.e. the name) of the status variable.
    Returns:
        True if the status of the given key is computed; False otherwise.
    """
    return key in self.__computed
iter_status_keys(self)

Iterate over the status keys.

Source code in evotorch/algorithms/searchalgorithm.py
def iter_status_keys(self):
    """Iterate over the status keys."""
    return self.__getters.keys()
update_status(self, additional_status)

Update the stored status with an external dict-like object. The given dict-like object can override existing status keys with new values, and also bring new keys to the status.

Parameters:

Name Type Description Default
additional_status Mapping

A dict-like object storing the status update.

required
Source code in evotorch/algorithms/searchalgorithm.py
def update_status(self, additional_status: Mapping):
    """
    Update the stored status with an external dict-like object.
    The given dict-like object can override existing status keys
    with new values, and also bring new keys to the status.

    Args:
        additional_status: A dict-like object storing the status update.
    """
    for k, v in additional_status.items():
        if k not in self.__getters:
            self.__getters[k] = LazyReporter._missing_status_producer
        self.__computed[k] = v

LazyStatusDict (Mapping)

A Mapping subclass used by the status property of a LazyReporter.

The interface of this object is similar to a read-only dictionary.

Source code in evotorch/algorithms/searchalgorithm.py
class LazyStatusDict(Mapping):
    """
    A Mapping subclass used by the `status` property of a `LazyReporter`.

    The interface of this object is similar to a read-only dictionary.
    """

    def __init__(self, lazy_reporter: LazyReporter):
        """
        `__init__(...)`: Initialize the LazyStatusDict object.

        Args:
            lazy_reporter: The LazyReporter object whose status is to be
                accessed.
        """
        super().__init__()
        self.__lazy_reporter = lazy_reporter

    def __getitem__(self, key: Any) -> Any:
        result = self.__lazy_reporter.get_status_value(key)
        if isinstance(result, (torch.Tensor, ObjectArray)):
            result = as_read_only_tensor(result)
        return result

    def __len__(self) -> int:
        return len(list(self.__lazy_reporter.iter_status_keys()))

    def __iter__(self):
        for k in self.__lazy_reporter.iter_status_keys():
            yield k

    def __contains__(self, key: Any) -> bool:
        return self.__lazy_reporter.has_status_key(key)

    def _to_string(self) -> str:
        with io.StringIO() as f:
            print("<" + type(self).__name__, file=f)
            for k in self.__lazy_reporter.iter_status_keys():
                if self.__lazy_reporter.is_status_computed(k):
                    r = repr(self.__lazy_reporter.get_status_value(k))
                else:
                    r = "<not yet computed>"
                print("   ", k, "=", r, file=f)
            print(">", end="", file=f)
            f.seek(0)
            entire_str = f.read()
        return entire_str

    def __str__(self) -> str:
        return self._to_string()

    def __repr__(self) -> str:
        return self._to_string()
__init__(self, lazy_reporter) special

__init__(...): Initialize the LazyStatusDict object.

Parameters:

Name Type Description Default
lazy_reporter LazyReporter

The LazyReporter object whose status is to be accessed.

required
Source code in evotorch/algorithms/searchalgorithm.py
def __init__(self, lazy_reporter: LazyReporter):
    """
    `__init__(...)`: Initialize the LazyStatusDict object.

    Args:
        lazy_reporter: The LazyReporter object whose status is to be
            accessed.
    """
    super().__init__()
    self.__lazy_reporter = lazy_reporter

SearchAlgorithm (LazyReporter)

Base class for all evolutionary search algorithms.

An algorithm developer is expected to inherit from this base class, and override the method named _step() to define how a single step of this new algorithm is performed.

For each core status dictionary element, a new method is expected to exist within the inheriting class. These status reporting methods are then registered via the keyword arguments of the __init__(...) method of SearchAlgorithm.

To sum up, a newly developed algorithm inheriting from this base class is expected in this structure:

from evotorch import Problem


class MyNewAlgorithm(SearchAlgorithm):
    def __init__(self, problem: Problem):
        SearchAlgorithm.__init__(
            self, problem, status1=self._get_status1, status2=self._get_status2, ...
        )

    def _step(self):
        # Code that defines how a step of this algorithm
        # should work goes here.
        ...

    def _get_status1(self):
        # The value returned by this function will be shown
        # in the status dictionary, associated with the key
        # 'status1'.
        return ...

    def _get_status2(self):
        # The value returned by this function will be shown
        # in the status dictionary, associated with the key
        # 'status2'.
        return ...
Source code in evotorch/algorithms/searchalgorithm.py
class SearchAlgorithm(LazyReporter):
    """
    Base class for all evolutionary search algorithms.

    An algorithm developer is expected to inherit from this base class,
    and override the method named `_step()` to define how a single
    step of this new algorithm is performed.

    For each core status dictionary element, a new method is expected
    to exist within the inheriting class. These status reporting
    methods are then registered via the keyword arguments of the
    `__init__(...)` method of `SearchAlgorithm`.

    To sum up, a newly developed algorithm inheriting from this base
    class is expected in this structure:

    ```python
    from evotorch import Problem


    class MyNewAlgorithm(SearchAlgorithm):
        def __init__(self, problem: Problem):
            SearchAlgorithm.__init__(
                self, problem, status1=self._get_status1, status2=self._get_status2, ...
            )

        def _step(self):
            # Code that defines how a step of this algorithm
            # should work goes here.
            ...

        def _get_status1(self):
            # The value returned by this function will be shown
            # in the status dictionary, associated with the key
            # 'status1'.
            return ...

        def _get_status2(self):
            # The value returned by this function will be shown
            # in the status dictionary, associated with the key
            # 'status2'.
            return ...
    ```
    """

    def __init__(self, problem: Problem, **kwargs):
        """
        Initialize the SearchAlgorithm instance.

        Args:
            problem: Problem to work with.
            kwargs: Any additional keyword argument, in the form of `k=f`,
                is accepted in this manner: for each pair of `k` and `f`,
                `k` is accepted as the status key (i.e. a status variable
                name), and `f` is accepted as a function (probably a method
                of the inheriting class) that will generate the value of that
                status variable.
        """
        super().__init__(**kwargs)
        self._problem = problem
        self._before_step_hook = Hook()
        self._after_step_hook = Hook()
        self._log_hook = Hook()
        self._end_of_run_hook = Hook()
        self._steps_count: int = 0
        self._first_step_datetime: Optional[datetime] = None

    @property
    def problem(self) -> Problem:
        """
        The problem object which is being worked on.
        """
        return self._problem

    @property
    def before_step_hook(self) -> Hook:
        """
        Use this Hook to add more behavior to the search algorithm
        to be performed just before executing a step.
        """
        return self._before_step_hook

    @property
    def after_step_hook(self) -> Hook:
        """
        Use this Hook to add more behavior to the search algorithm
        to be performed just after executing a step.

        The dictionaries returned by the functions registered into
        this Hook will be accumulated and added into the status
        dictionary of the search algorithm.
        """
        return self._after_step_hook

    @property
    def log_hook(self) -> Hook:
        """
        Use this Hook to add more behavior to the search algorithm
        at the moment of logging the constructed status dictionary.

        This Hook is executed after the execution of `after_step_hook`
        is complete.

        The functions in this Hook are assumed to expect a single
        argument, that is the status dictionary of the search algorithm.
        """
        return self._log_hook

    @property
    def end_of_run_hook(self) -> Hook:
        """
        Use this Hook to add more behavior to the search algorithm
        at the end of a run.

        This Hook is executed after all the generations of a run
        are done.

        The functions in this Hook are assumed to expect a single
        argument, that is the status dictionary of the search algorithm.
        """
        return self._end_of_run_hook

    @property
    def step_count(self) -> int:
        """
        Number of search steps performed.

        This is equivalent to the number of generations, or to the
        number of iterations.
        """
        return self._steps_count

    @property
    def steps_count(self) -> int:
        """
        Deprecated alias for the `step_count` property.
        It is recommended to use the `step_count` property instead.
        """
        return self._steps_count

    def step(self):
        """
        Perform a step of the search algorithm.
        """
        self._before_step_hook()
        self.clear_status()

        if self._first_step_datetime is None:
            self._first_step_datetime = datetime.now()

        self._step()
        self._steps_count += 1
        self.update_status({"iter": self._steps_count})
        self.update_status(self._problem.status)
        extra_status = self._after_step_hook.accumulate_dict()
        self.update_status(extra_status)
        if len(self._log_hook) >= 1:
            self._log_hook(dict(self.status))

    def _step(self):
        """
        Algorithm developers are expected to override this method
        in an inheriting subclass.

        The code which defines how a step of the evolutionary algorithm
        is performed goes here.
        """
        raise NotImplementedError

    def run(self, num_generations: int, *, reset_first_step_datetime: bool = True):
        """
        Run the algorithm for the given number of generations
        (i.e. iterations).

        Args:
            num_generations: Number of generations.
            reset_first_step_datetime: If this argument is given as True,
                then, the datetime of the first search step will be forgotten.
                Forgetting the first step's datetime means that the first step
                taken by this new run will be the new first step datetime.
        """
        if reset_first_step_datetime:
            self.reset_first_step_datetime()

        for _ in range(int(num_generations)):
            self.step()

        if len(self._end_of_run_hook) >= 1:
            self._end_of_run_hook(dict(self.status))

    @property
    def first_step_datetime(self) -> Optional[datetime]:
        """
        Get the datetime when the algorithm took the first search step.
        If a step is not taken at all, then the result will be None.
        """
        return self._first_step_datetime

    def reset_first_step_datetime(self):
        """
        Reset (or forget) the first step's datetime.
        """
        self._first_step_datetime = None

    @property
    def is_terminated(self) -> bool:
        """Whether the algorithm is in a terminal state"""
        return False
after_step_hook: Hook property readonly

Use this Hook to add more behavior to the search algorithm to be performed just after executing a step.

The dictionaries returned by the functions registered into this Hook will be accumulated and added into the status dictionary of the search algorithm.

before_step_hook: Hook property readonly

Use this Hook to add more behavior to the search algorithm to be performed just before executing a step.

end_of_run_hook: Hook property readonly

Use this Hook to add more behavior to the search algorithm at the end of a run.

This Hook is executed after all the generations of a run are done.

The functions in this Hook are assumed to expect a single argument, that is the status dictionary of the search algorithm.

first_step_datetime: Optional[datetime.datetime] property readonly

Get the datetime when the algorithm took the first search step. If a step is not taken at all, then the result will be None.

is_terminated: bool property readonly

Whether the algorithm is in a terminal state

log_hook: Hook property readonly

Use this Hook to add more behavior to the search algorithm at the moment of logging the constructed status dictionary.

This Hook is executed after the execution of after_step_hook is complete.

The functions in this Hook are assumed to expect a single argument, that is the status dictionary of the search algorithm.

problem: Problem property readonly

The problem object which is being worked on.

step_count: int property readonly

Number of search steps performed.

This is equivalent to the number of generations, or to the number of iterations.

steps_count: int property readonly

Deprecated alias for the step_count property. It is recommended to use the step_count property instead.

__init__(self, problem, **kwargs) special

Initialize the SearchAlgorithm instance.

Parameters:

Name Type Description Default
problem Problem

Problem to work with.

required
kwargs

Any additional keyword argument, in the form of k=f, is accepted in this manner: for each pair of k and f, k is accepted as the status key (i.e. a status variable name), and f is accepted as a function (probably a method of the inheriting class) that will generate the value of that status variable.

{}
Source code in evotorch/algorithms/searchalgorithm.py
def __init__(self, problem: Problem, **kwargs):
    """
    Initialize the SearchAlgorithm instance.

    Args:
        problem: Problem to work with.
        kwargs: Any additional keyword argument, in the form of `k=f`,
            is accepted in this manner: for each pair of `k` and `f`,
            `k` is accepted as the status key (i.e. a status variable
            name), and `f` is accepted as a function (probably a method
            of the inheriting class) that will generate the value of that
            status variable.
    """
    super().__init__(**kwargs)
    self._problem = problem
    self._before_step_hook = Hook()
    self._after_step_hook = Hook()
    self._log_hook = Hook()
    self._end_of_run_hook = Hook()
    self._steps_count: int = 0
    self._first_step_datetime: Optional[datetime] = None
reset_first_step_datetime(self)

Reset (or forget) the first step's datetime.

Source code in evotorch/algorithms/searchalgorithm.py
def reset_first_step_datetime(self):
    """
    Reset (or forget) the first step's datetime.
    """
    self._first_step_datetime = None
run(self, num_generations, *, reset_first_step_datetime=True)

Run the algorithm for the given number of generations (i.e. iterations).

Parameters:

Name Type Description Default
num_generations int

Number of generations.

required
reset_first_step_datetime bool

If this argument is given as True, then, the datetime of the first search step will be forgotten. Forgetting the first step's datetime means that the first step taken by this new run will be the new first step datetime.

True
Source code in evotorch/algorithms/searchalgorithm.py
def run(self, num_generations: int, *, reset_first_step_datetime: bool = True):
    """
    Run the algorithm for the given number of generations
    (i.e. iterations).

    Args:
        num_generations: Number of generations.
        reset_first_step_datetime: If this argument is given as True,
            then, the datetime of the first search step will be forgotten.
            Forgetting the first step's datetime means that the first step
            taken by this new run will be the new first step datetime.
    """
    if reset_first_step_datetime:
        self.reset_first_step_datetime()

    for _ in range(int(num_generations)):
        self.step()

    if len(self._end_of_run_hook) >= 1:
        self._end_of_run_hook(dict(self.status))
step(self)

Perform a step of the search algorithm.

Source code in evotorch/algorithms/searchalgorithm.py
def step(self):
    """
    Perform a step of the search algorithm.
    """
    self._before_step_hook()
    self.clear_status()

    if self._first_step_datetime is None:
        self._first_step_datetime = datetime.now()

    self._step()
    self._steps_count += 1
    self.update_status({"iter": self._steps_count})
    self.update_status(self._problem.status)
    extra_status = self._after_step_hook.accumulate_dict()
    self.update_status(extra_status)
    if len(self._log_hook) >= 1:
        self._log_hook(dict(self.status))

SinglePopulationAlgorithmMixin

A mixin class that can be inherited by a SearchAlgorithm subclass.

This mixin class assumes that the inheriting class has the following members:

  • problem: The problem object that is associated with the search algorithm. This attribute is already provided by the SearchAlgorithm base class.
  • population: An attribute or a (possibly read-only) property which stores the population of the search algorithm as a SolutionBatch instance.

This mixin class also assumes that the inheriting class might contain an attribute (or a property) named obj_index. If there is such an attribute and its value is not None, then this mixin class assumes that obj_index represents the index of the objective that is being focused on.

Upon initialization, this mixin class first determines whether or not the algorithm is a single-objective one. In more details, if there is an attribute named obj_index (and its value is not None), or if the associated problem has only one objective, then this mixin class assumes that the inheriting SearchAlgorithm is a single objective algorithm. Otherwise, it is assumed that the underlying algorithm works (or might work) on multiple objectives.

In the single-objective case, this mixin class brings the inheriting SearchAlgorithm the ability to report the following: pop_best (best solution of the population), pop_best_eval (evaluation result of the population's best solution), mean_eval (mean evaluation result of the population), median_eval (median evaluation result of the population).

In the multi-objective case, for each objective i, this mixin class brings the inheriting SearchAlgorithm the ability to report the following: obj<i>_pop_best (best solution of the population according), obj<i>_pop_best_eval (evaluation result of the population's best solution), obj<i>_mean_eval (mean evaluation result of the population) obj<iP_median_eval (median evaluation result of the population).

Source code in evotorch/algorithms/searchalgorithm.py
class SinglePopulationAlgorithmMixin:
    """
    A mixin class that can be inherited by a SearchAlgorithm subclass.

    This mixin class assumes that the inheriting class has the following
    members:

    - `problem`: The problem object that is associated with the search
      algorithm. This attribute is already provided by the SearchAlgorithm
      base class.
    - `population`: An attribute or a (possibly read-only) property which
      stores the population of the search algorithm as a `SolutionBatch`
      instance.

    This mixin class also assumes that the inheriting class _might_
    contain an attribute (or a property) named `obj_index`.
    If there is such an attribute and its value is not None, then this
    mixin class assumes that `obj_index` represents the index of the
    objective that is being focused on.

    Upon initialization, this mixin class first determines whether or not
    the algorithm is a single-objective one.
    In more details, if there is an attribute named `obj_index` (and its
    value is not None), or if the associated problem has only one objective,
    then this mixin class assumes that the inheriting SearchAlgorithm is a
    single objective algorithm.
    Otherwise, it is assumed that the underlying algorithm works (or might
    work) on multiple objectives.

    In the single-objective case, this mixin class brings the inheriting
    SearchAlgorithm the ability to report the following:
    `pop_best` (best solution of the population),
    `pop_best_eval` (evaluation result of the population's best solution),
    `mean_eval` (mean evaluation result of the population),
    `median_eval` (median evaluation result of the population).

    In the multi-objective case, for each objective `i`, this mixin class
    brings the inheriting SearchAlgorithm the ability to report the following:
    `obj<i>_pop_best` (best solution of the population according),
    `obj<i>_pop_best_eval` (evaluation result of the population's best
    solution),
    `obj<i>_mean_eval` (mean evaluation result of the population)
    `obj<iP_median_eval` (median evaluation result of the population).
    """

    class ObjectiveStatusReporter:
        REPORTABLES = {"pop_best", "pop_best_eval", "mean_eval", "median_eval"}

        def __init__(
            self,
            algorithm: SearchAlgorithm,
            *,
            obj_index: int,
            to_report: str,
        ):
            self.__algorithm = algorithm
            self.__obj_index = int(obj_index)
            if to_report not in self.REPORTABLES:
                raise ValueError(f"Unrecognized report request: {to_report}")
            self.__to_report = to_report

        @property
        def population(self) -> SolutionBatch:
            return self.__algorithm.population

        @property
        def obj_index(self) -> int:
            return self.__obj_index

        def get_status_value(self, status_key: str) -> Any:
            return self.__algorithm.get_status_value(status_key)

        def has_status_key(self, status_key: str) -> bool:
            return self.__algorithm.has_status_key(status_key)

        def _get_pop_best(self):
            i = self.population.argbest(self.obj_index)
            return clone(self.population[i])

        def _get_pop_best_eval(self):
            pop_best = None
            pop_best_keys = ("pop_best", f"obj{self.obj_index}_pop_best")

            for pop_best_key in pop_best_keys:
                if self.has_status_key(pop_best_key):
                    pop_best = self.get_status_value(pop_best_key)
                    break

            if (pop_best is not None) and pop_best.is_evaluated:
                return float(pop_best.evals[self.obj_index])
            else:
                return None

        @torch.no_grad()
        def _get_mean_eval(self):
            return float(torch.mean(self.population.access_evals(self.obj_index)))

        @torch.no_grad()
        def _get_median_eval(self):
            return float(torch.median(self.population.access_evals(self.obj_index)))

        def __call__(self):
            return getattr(self, "_get_" + self.__to_report)()

    def __init__(self, *, exclude: Optional[Iterable] = None, enable: bool = True):
        if not enable:
            return

        ObjectiveStatusReporter = self.ObjectiveStatusReporter
        reportables = ObjectiveStatusReporter.REPORTABLES
        single_obj: Optional[int] = None
        self.__exclude = set() if exclude is None else set(exclude)

        if hasattr(self, "obj_index") and (self.obj_index is not None):
            single_obj = self.obj_index
        elif len(self.problem.senses) == 1:
            single_obj = 0

        if single_obj is not None:
            for reportable in reportables:
                if reportable not in self.__exclude:
                    self.add_status_getters(
                        {reportable: ObjectiveStatusReporter(self, obj_index=single_obj, to_report=reportable)}
                    )
        else:
            for i_obj in range(len(self.problem.senses)):
                for reportable in reportables:
                    if reportable not in self.__exclude:
                        self.add_status_getters(
                            {
                                f"obj{i_obj}_{reportable}": ObjectiveStatusReporter(
                                    self, obj_index=i_obj, to_report=reportable
                                ),
                            }
                        )

core

Definitions of the core classes: Problem, Solution, and SolutionBatch.

ActorSeeds (tuple)

ActorSeeds(py_global, np_global, torch_global, problem)

__getnewargs__(self) special

Return self as a plain tuple. Used by copy and pickle.

Source code in evotorch/core.py
def __getnewargs__(self):
    'Return self as a plain tuple.  Used by copy and pickle.'
    return _tuple(self)

__new__(_cls, py_global, np_global, torch_global, problem) special staticmethod

Create new instance of ActorSeeds(py_global, np_global, torch_global, problem)

__repr__(self) special

Return a nicely formatted representation string

Source code in evotorch/core.py
def __repr__(self):
    'Return a nicely formatted representation string'
    return self.__class__.__name__ + repr_fmt % self

AllRemoteEnvs

Representation of all remote reinforcement learning instances stored by the ray actors.

An instance of this class is to be obtained from a main (i.e. non-remote) Problem object, as follows:

remote_envs = my_problem.all_remote_envs()

A remote method f() on all remote environments can then be executed as follows:

results = remote_envs.f()

Given that there are n actors, results contains n objects, the i-th object being the method's result from the i-th actor. An alternative to the example above is like this:

results = my_problem.all_remote_envs().f()
Source code in evotorch/core.py
class AllRemoteEnvs:
    """
    Representation of all remote reinforcement learning instances
    stored by the ray actors.

    An instance of this class is to be obtained from a main
    (i.e. non-remote) Problem object, as follows:

        remote_envs = my_problem.all_remote_envs()

    A remote method f() on all remote environments can then
    be executed as follows:

        results = remote_envs.f()

    Given that there are `n` actors, `results` contains `n` objects,
    the i-th object being the method's result from the i-th actor.
    An alternative to the example above is like this:

        results = my_problem.all_remote_envs().f()
    """

    def __init__(self, actors: list):
        self._actors = actors

    def __getattr__(self, attr_name: str) -> Any:
        return RemoteMethod(attr_name, self._actors, on_env=True)

AllRemoteProblems

Representation of all remote problem instances stored by the ray actors.

An instance of this class is to be obtained from a main (i.e. non-remote) Problem object, as follows:

remote_probs = my_problem.all_remote_problems()

A remote method f() on all remote Problem instances can then be executed as follows:

results = remote_probs.f()

Given that there are n actors, results contains n objects, the i-th object being the method's result from the i-th actor. An alternative to the example above is like this:

results = my_problem.all_remote_problems().f()
Source code in evotorch/core.py
class AllRemoteProblems:
    """
    Representation of all remote problem instances stored by the ray actors.

    An instance of this class is to be obtained from a main
    (i.e. non-remote) Problem object, as follows:

        remote_probs = my_problem.all_remote_problems()

    A remote method f() on all remote Problem instances can then
    be executed as follows:

        results = remote_probs.f()

    Given that there are `n` actors, `results` contains `n` objects,
    the i-th object being the method's result from the i-th actor.
    An alternative to the example above is like this:

        results = my_problem.all_remote_problems().f()
    """

    def __init__(self, actors: list):
        self._actors = actors

    def __getattr__(self, attr_name: str) -> Any:
        return RemoteMethod(attr_name, self._actors)

BoundsPair (tuple)

BoundsPair(lb, ub)

__getnewargs__(self) special

Return self as a plain tuple. Used by copy and pickle.

Source code in evotorch/core.py
def __getnewargs__(self):
    'Return self as a plain tuple.  Used by copy and pickle.'
    return _tuple(self)

__new__(_cls, lb, ub) special staticmethod

Create new instance of BoundsPair(lb, ub)

__repr__(self) special

Return a nicely formatted representation string

Source code in evotorch/core.py
def __repr__(self):
    'Return a nicely formatted representation string'
    return self.__class__.__name__ + repr_fmt % self

ParetoInfo (tuple)

ParetoInfo(fronts, ranks)

__getnewargs__(self) special

Return self as a plain tuple. Used by copy and pickle.

Source code in evotorch/core.py
def __getnewargs__(self):
    'Return self as a plain tuple.  Used by copy and pickle.'
    return _tuple(self)

__new__(_cls, fronts, ranks) special staticmethod

Create new instance of ParetoInfo(fronts, ranks)

__repr__(self) special

Return a nicely formatted representation string

Source code in evotorch/core.py
def __repr__(self):
    'Return a nicely formatted representation string'
    return self.__class__.__name__ + repr_fmt % self

Problem (TensorMakerMixin, Serializable)

Representation of a problem to be optimized.

The simplest way to use this class is to instantiate it with an external fitness function.

Let us imagine that we have the following fitness function:

import torch

def f(solution: torch.Tensor) -> torch.Tensor:
    return torch.linalg.norm(solution)

A problem definition can be made around this fitness function as follows:

from evotorch import Problem

problem = Problem(
    "min", f,  # Goal is to minimize f (would be "max" for maximization)
    solution_length=10,  # Length of a solution is 10
    initial_bounds=(-5.0, 5.0),  # Bounds for sampling a new solution
    dtype=torch.float32,  # dtype of a solution
)

Vectorized problem definitions. To boost the runtime performance, one might want to define a vectorized fitness function where the fitnesses of multiple solutions are computed in a batched manner using the vectorization capabilities of PyTorch. A vectorized problem definition can be made as follows:

from evotorch.decorators import vectorized

@vectorized
def vf(solutions: torch.Tensor) -> torch.Tensor:
    return torch.linalg.norm(solutions ** 2, dim=-1)

problem = Problem(
    "min", vf,  # Goal is to minimize vf (would be "max" for maximization)
    solution_length=10,  # Length of a solution is 10
    initial_bounds=(-5.0, 5.0),  # Bounds for sampling a new solution
    dtype=torch.float32,  # dtype of a solution
)

Parallelization across multiple CPUs. An optimization problem can be configured to parallelize its evaluation operations across multiple CPUs as follows:

problem = Problem("min", f, ..., num_actors=4)  # will use 4 actors

Exploiting hardware accelerators. As an alternative to CPU-based parallelization, one might prefer to use the parallelized computation capabilities of a hardware accelerator such as CUDA. To load the problem onto a cuda device (for example, onto "cuda:0"), one can do:

from evotorch.decorators import vectorized


@vectorized
def vf(solutions: torch.Tensor) -> torch.Tensor:
    return ...


problem = Problem("min", vf, ..., device="cuda:0")

Exploiting multiple GPUs in parallel. One can also keep the entire population on the CPU, and split and distribute it to multiple GPUs for GPU-accelerated and parallelized fitness evaluation. For this, the main device of the problem is set as CPU, but the fitness function is decorated with evotorch.decorators.on_cuda.

from evotorch.decorators import on_cuda, vectorized


@on_cuda
@vectorized
def vf(solutions: torch.Tensor) -> torch.Tensor:
    return ...


problem = Problem(
    "min",
    vf,
    ...,
    num_actors=N,  # where N>1 and equal to the number of GPUs
    # Note: if you are on a computer or on a ray cluster with multiple
    # GPUs, you might prefer to use the string "num_gpus" instead of an
    # integer N, which will cause the number of available GPUs to be
    # counted, and the number of actors to be configured as that count.
    #
    num_gpus_per_actor=1,  # each GPU is assigned to an actor
    device="cpu",
)

Defining problems via inheritance. A problem can also be defined via inheritance. Using inheritance, one can define problems which carry their own additional data, and/or update their states as more solutions are evaluated, and/or have custom procedures for sampling new solutions, etc.

As a first example, let us define a parameterized problem. In this example problem, the goal is to minimize x^(2q), q being a parameter of the problem. The definition of such a problem can be as follows:

from evotorch import Problem, Solution


class MyProblem(Problem):
    def __init__(self, q: float):
        self.q = float(q)

        super().__init__(
            objective_sense="min",  # the goal is to minimize
            solution_length=10,  # a solution has the length 10
            initial_bounds=(-5.0, 5.0),  # sample new solutions from within [-5, 5]
            dtype=torch.float32,  # the dtype of a solution is float32
            # num_actors=...,  # if parallelization via multiple actors is desired
        )

    def _evaluate(self, solution: Solution):
        # This is where we declare the procedure of evaluating a solution

        # Get the decision values of the solution as a PyTorch tensor
        x = solution.values

        # Compute the fitness
        fitness = torch.sum(x ** (2 * self.q))

        # Register the fitness into the Solution object
        solution.set_evaluation(fitness)

This parameterized problem can be instantiated as follows (let's say with q=3):

problem = MyProblem(q=3)

Defining vectorized problems via inheritance. Vectorization can be used with inheritance-based problem definitions as well. Please see the following example where the method _evaluate_batch is used instead of _evaluate for vectorization:

from evotorch import Problem, SolutionBatch


class MyVectorizedProblem(Problem):
    def __init__(self, q: float):
        self.q = float(q)

        super().__init__(
            objective_sense="min",  # the goal is to minimize
            solution_length=10,  # a solution has the length 10
            initial_bounds=(-5.0, 5.0),  # sample new solutions from within [-5, 5]
            dtype=torch.float32,  # the dtype of a solution is float32
            # num_actors=...,  # if parallelization via multiple actors is desired
            # device="cuda:0",  # if hardware acceleration is desired
        )

    def _evaluate_batch(self, solutions: SolutionBatch):
        # Get the decision values of all the solutions in a 2D PyTorch tensor:
        xs = solutions.values

        # Compute the fitnesses
        fitnesses = torch.sum(x ** (2 * self.q), dim=-1)

        # Register the fitness into the Solution object
        solutions.set_evals(fitnesses)

Using multiple GPUs from a problem defined via inheritance. The previous example demonstrating the use of multiple GPUs showed how an independent fitness function can be decorated via evotorch.decorators.on_cuda. Instead of using an independent fitness function, if one wishes to define a problem by subclassing Problem, the overriden method _evaluate_batch(...) has to be decorated by evotorch.decorators.on_cuda. Like in the previous multi-GPU example, let us assume that we want to parallelize the fitness evaluation across N GPUs (where N>1). The inheritance-based code to achieve this can look like this:

from evotorch import Problem, SolutionBatch
from evotorch.decorators import on_cuda


class MyMultiGPUProblem(Problem):
    def __init__(self):
        ...
        super().__init__(
            objective_sense="min",  # the goal is to minimize
            solution_length=10,  # a solution has the length 10
            initial_bounds=(-5.0, 5.0),  # sample new solutions from within [-5, 5]
            dtype=torch.float32,  # the dtype of a solution is float32
            num_actors=N,  # allocate N actors
            # Note: if you are on a computer or on a ray cluster with multiple
            # GPUs, you might prefer to use the string "num_gpus" instead of an
            # integer N, which will cause the number of available GPUs to be
            # counted, and the number of actors to be configured as that count.
            #
            num_gpus_per_actor=1,  # for each actor, assign a cuda device
            device="cpu",  # keep the main population on the CPU
        )

    @on_cuda
    def _evaluate_batch(self, solutions: SolutionBatch):
        # Get the decision values of all the solutions in a 2D PyTorch tensor:
        xs = solutions.values

        # Compute the fitnesses
        fitnesses = ...

        # Register the fitness into the Solution object
        solutions.set_evals(fitnesses)

Customizing how initial solutions are sampled. Instead of sampling solutions from within an interval, one might wish to define a special procedure for generating new solutions. This can be achieved by overriding the _fill(...) method of the Problem class. Please see the example below.

class MyProblemWithCustomizedFilling(Problem):
    def __init__(self):
        super().__init__(
            objective_sense="min",
            solution_length=10,
            dtype=torch.float32,
            # we do not set initial_bounds because we have a manual procedure
            # for initializing solutions
        )

    def _evaluate_batch(self, solutions: SolutionBatch):
        ...  # code to compute and fill the fitnesses goes here

    def _fill(self, values: torch.Tensor):
        # `values` is an empty tensor of shape (n, m) where n is the number
        # of solutions and m is the solution length.
        # The responsibility of this method is to fill this tensor.
        # In the case of this example, let us say that we wish the new
        # solutions to have values sampled from a standard normal distribution.
        values.normal_()

Defining manually-structured optimization problems. The dtype of an optimization problem can be set as object. When the dtype is set as an object, it means that a solution's value can be a PyTorch tensor, or a numpy array, or a Python list, or a Python dictionary, or a string, or a scalar, or None. This gives the user enough flexibility to express non-numeric optimization problems and/or problems where each solution has its own length, or even its own structure.

In the example below, we define an optimization problem where a solution is represented by a Python list and where each solution can have its own length. For simplicity, we define the fitness function as the sum of the values of a solution.

from evotorch import Problem, SolutionBatch
from evotorch.tools import ObjectArray
import random
import torch


class MyCustomStructuredProblem(Problem):
    def __init__(self):
        super().__init__(
            objective_sense="min",
            dtype=object,
        )

    def _evaluate_batch(self, solutions: SolutionBatch):
        # Get the number of solutions
        n = len(solutions)

        # Allocate a PyTorch tensor that will store the fitnesses
        fitnesses = torch.empty(n, dtype=torch.float32)

        # Fitness is computed as the sum of numeric values stored
        # by a solution.
        for i in range(n):
            # Get the values stored by a solution (which, in the case of
            # this example, is a Python list, because we initialize them
            # so in the _fill method).
            sln_values = solutions[i].values
            fitnesses[i] = sum(sln_values)

        # Set the fitnesses
        solutions.set_evals(fitnesses)

    def _fill(self, values: ObjectArray):
        # At this point, we have an ObjectArray of length `n`.
        # This means, we need to fill the values of `n` solutions.
        # `values[i]` represents the values of the i-th solution.
        # Initially, `values[i]` is None.
        # It is up to us how `values[i]` will be filled.
        # Let us make each solution be initialized as a list of
        # random length, containing random real numbers.

        for i in range(len(values)):
            ith_solution_length = random.randint(1, 10)
            ith_solution_values = [random.random() for _ in range(ith_solution_length)]
            values[i] = ith_solution_values

Multi-objective optimization. A multi-objective optimization problem can be expressed by using multiple objective senses. As an example, let us consider an optimization problem where the first objective sense is minimization and the second objective sense is maximization. When working with an external fitness function, the code to express such an optimization problem would look like this:

from evotorch import Problem
from evotorch.decorators import vectorized
import torch


@vectorized
def f(x: torch.Tensor) -> torch.Tensor:
    # (Note that if dtype is object, x will be of type ObjectArray,
    # and not a PyTorch tensor)

    # Code to compute the fitnesses goes here.
    # Our resulting tensor `fitnesses` is expected to have a shape (n, m)
    # where n is the number of solutions and m is the number of objectives
    # (which is 2 in the case of this example).
    # `fitnesses[i, k]` is expected to store the fitness value belonging
    # to the i-th solution according to the k-th objective.
    fitnesses: torch.Tensor = ...
    return fitnesses


problem = Problem(["min", "max"], f, ...)

A multi-objective problem defined via class inheritance would look like this:

from evotorch import Problem, SolutionBatch


class MyMultiObjectiveProblem(Problem):
    def __init__(self):
        super().__init__(objective_sense=["min", "max"], ...)

    def _evaluate_batch(self, solutions: SolutionBatch):
        # Code to compute the fitnesses goes here.
        # `fitnesses[i, k]` is expected to store the fitness value belonging
        # to the i-th solution according to the k-th objective.
        fitnesses: torch.Tensor = ...

        # Set the fitnesses
        solutions.set_evals(fitnesses)

How to solve a problem. If the optimization problem is single-objective and its dtype is a float (e.g. torch.float32, torch.float64, etc.), then it can be solved using any search algorithm implemented in EvoTorch. Let us assume that we have such an optimization problem stored by the variable prob. We could use the cross entropy method) to solve it:

from evotorch import Problem
from evotorch.algorithms import CEM
from evotorch.logging import StdOutLogger


def f(x: torch.Tensor) -> torch.Tensor:
    ...


prob = Problem("min", f, solution_length=..., dtype=torch.float32)

searcher = CEM(
    problem,
    # The keyword arguments below refer to hyperparameters specific to the
    # cross entropy method algorithm. It is recommended to tune these
    # hyperparameters according to the problem at hand.
    popsize=100,  # population size
    parenthood_ratio=0.5,  # 0.5 means better half of solutions become parents
    stdev_init=10.0,  # initial standard deviation of the search distribution
)

_ = StdOutLogger(searcher)  # to report the progress onto the screen
searcher.run(50)  # run for 50 generations

print("Center of the search distribution:", searcher.status["center"])
print("Solution with best fitness ever:", searcher.status["best"])

See the namespace evotorch.algorithms to see the algorithms implemented within EvoTorch.

If the optimization problem at hand has an integer dtype (e.g. torch.int64), or has the object dtype, or has multiple objectives, then distribution-based search algorithms such as CEM cannot be used (since those algorithms were implemented with continuous decision variables and with single-objective problems in mind). In such cases, one can use the algorithm named SteadyState. Please also note that, while using SteadyStateGA on a problem with an integer dtype or with the object dtype, one will have to define manual cross-over and mutation operators specialized to the solution structure of the problem at hand. Please see the documentation of SteadyStateGA for details.

Source code in evotorch/core.py
class Problem(TensorMakerMixin, Serializable):
    """
    Representation of a problem to be optimized.

    The simplest way to use this class is to instantiate it with an
    external fitness function.

    Let us imagine that we have the following fitness function:

    ```
    import torch

    def f(solution: torch.Tensor) -> torch.Tensor:
        return torch.linalg.norm(solution)
    ```

    A problem definition can be made around this fitness function as follows:

    ```
    from evotorch import Problem

    problem = Problem(
        "min", f,  # Goal is to minimize f (would be "max" for maximization)
        solution_length=10,  # Length of a solution is 10
        initial_bounds=(-5.0, 5.0),  # Bounds for sampling a new solution
        dtype=torch.float32,  # dtype of a solution
    )
    ```

    **Vectorized problem definitions.**
    To boost the runtime performance, one might want to define a vectorized
    fitness function where the fitnesses of multiple solutions are computed
    in a batched manner using the vectorization capabilities of PyTorch.
    A vectorized problem definition can be made as follows:

    ```
    from evotorch.decorators import vectorized

    @vectorized
    def vf(solutions: torch.Tensor) -> torch.Tensor:
        return torch.linalg.norm(solutions ** 2, dim=-1)

    problem = Problem(
        "min", vf,  # Goal is to minimize vf (would be "max" for maximization)
        solution_length=10,  # Length of a solution is 10
        initial_bounds=(-5.0, 5.0),  # Bounds for sampling a new solution
        dtype=torch.float32,  # dtype of a solution
    )
    ```

    **Parallelization across multiple CPUs.**
    An optimization problem can be configured to parallelize its evaluation
    operations across multiple CPUs as follows:

    ```python
    problem = Problem("min", f, ..., num_actors=4)  # will use 4 actors
    ```

    **Exploiting hardware accelerators.**
    As an alternative to CPU-based parallelization, one might prefer to use
    the parallelized computation capabilities of a hardware accelerator such
    as CUDA. To load the problem onto a cuda device (for example, onto
    "cuda:0"), one can do:

    ```python
    from evotorch.decorators import vectorized


    @vectorized
    def vf(solutions: torch.Tensor) -> torch.Tensor:
        return ...


    problem = Problem("min", vf, ..., device="cuda:0")
    ```

    **Exploiting multiple GPUs in parallel.**
    One can also keep the entire population on the CPU, and split and distribute
    it to multiple GPUs for GPU-accelerated and parallelized fitness evaluation.
    For this, the main device of the problem is set as CPU, but the fitness
    function is decorated with `evotorch.decorators.on_cuda`.

    ```python
    from evotorch.decorators import on_cuda, vectorized


    @on_cuda
    @vectorized
    def vf(solutions: torch.Tensor) -> torch.Tensor:
        return ...


    problem = Problem(
        "min",
        vf,
        ...,
        num_actors=N,  # where N>1 and equal to the number of GPUs
        # Note: if you are on a computer or on a ray cluster with multiple
        # GPUs, you might prefer to use the string "num_gpus" instead of an
        # integer N, which will cause the number of available GPUs to be
        # counted, and the number of actors to be configured as that count.
        #
        num_gpus_per_actor=1,  # each GPU is assigned to an actor
        device="cpu",
    )
    ```

    **Defining problems via inheritance.**
    A problem can also be defined via inheritance.
    Using inheritance, one can define problems which carry their own additional
    data, and/or update their states as more solutions are evaluated,
    and/or have custom procedures for sampling new solutions, etc.

    As a first example, let us define a parameterized problem. In this example
    problem, the goal is to minimize `x^(2q)`, `q` being a parameter of the
    problem. The definition of such a problem can be as follows:

    ```python
    from evotorch import Problem, Solution


    class MyProblem(Problem):
        def __init__(self, q: float):
            self.q = float(q)

            super().__init__(
                objective_sense="min",  # the goal is to minimize
                solution_length=10,  # a solution has the length 10
                initial_bounds=(-5.0, 5.0),  # sample new solutions from within [-5, 5]
                dtype=torch.float32,  # the dtype of a solution is float32
                # num_actors=...,  # if parallelization via multiple actors is desired
            )

        def _evaluate(self, solution: Solution):
            # This is where we declare the procedure of evaluating a solution

            # Get the decision values of the solution as a PyTorch tensor
            x = solution.values

            # Compute the fitness
            fitness = torch.sum(x ** (2 * self.q))

            # Register the fitness into the Solution object
            solution.set_evaluation(fitness)
    ```

    This parameterized problem can be instantiated as follows (let's say with q=3):

    ```python
    problem = MyProblem(q=3)
    ```

    **Defining vectorized problems via inheritance.**
    Vectorization can be used with inheritance-based problem definitions as well.
    Please see the following example where the method `_evaluate_batch`
    is used instead of `_evaluate` for vectorization:

    ```python
    from evotorch import Problem, SolutionBatch


    class MyVectorizedProblem(Problem):
        def __init__(self, q: float):
            self.q = float(q)

            super().__init__(
                objective_sense="min",  # the goal is to minimize
                solution_length=10,  # a solution has the length 10
                initial_bounds=(-5.0, 5.0),  # sample new solutions from within [-5, 5]
                dtype=torch.float32,  # the dtype of a solution is float32
                # num_actors=...,  # if parallelization via multiple actors is desired
                # device="cuda:0",  # if hardware acceleration is desired
            )

        def _evaluate_batch(self, solutions: SolutionBatch):
            # Get the decision values of all the solutions in a 2D PyTorch tensor:
            xs = solutions.values

            # Compute the fitnesses
            fitnesses = torch.sum(x ** (2 * self.q), dim=-1)

            # Register the fitness into the Solution object
            solutions.set_evals(fitnesses)
    ```

    **Using multiple GPUs from a problem defined via inheritance.**
    The previous example demonstrating the use of multiple GPUs showed how
    an independent fitness function can be decorated via
    `evotorch.decorators.on_cuda`. Instead of using an independent fitness
    function, if one wishes to define a problem by subclassing `Problem`,
    the overriden method `_evaluate_batch(...)` has to be decorated by
    `evotorch.decorators.on_cuda`. Like in the previous multi-GPU example,
    let us assume that we want to parallelize the fitness evaluation
    across N GPUs (where N>1). The inheritance-based code to achieve this
    can look like this:

    ```python
    from evotorch import Problem, SolutionBatch
    from evotorch.decorators import on_cuda


    class MyMultiGPUProblem(Problem):
        def __init__(self):
            ...
            super().__init__(
                objective_sense="min",  # the goal is to minimize
                solution_length=10,  # a solution has the length 10
                initial_bounds=(-5.0, 5.0),  # sample new solutions from within [-5, 5]
                dtype=torch.float32,  # the dtype of a solution is float32
                num_actors=N,  # allocate N actors
                # Note: if you are on a computer or on a ray cluster with multiple
                # GPUs, you might prefer to use the string "num_gpus" instead of an
                # integer N, which will cause the number of available GPUs to be
                # counted, and the number of actors to be configured as that count.
                #
                num_gpus_per_actor=1,  # for each actor, assign a cuda device
                device="cpu",  # keep the main population on the CPU
            )

        @on_cuda
        def _evaluate_batch(self, solutions: SolutionBatch):
            # Get the decision values of all the solutions in a 2D PyTorch tensor:
            xs = solutions.values

            # Compute the fitnesses
            fitnesses = ...

            # Register the fitness into the Solution object
            solutions.set_evals(fitnesses)
    ```

    **Customizing how initial solutions are sampled.**
    Instead of sampling solutions from within an interval, one might wish to
    define a special procedure for generating new solutions. This can be
    achieved by overriding the `_fill(...)` method of the Problem class.
    Please see the example below.

    ```python
    class MyProblemWithCustomizedFilling(Problem):
        def __init__(self):
            super().__init__(
                objective_sense="min",
                solution_length=10,
                dtype=torch.float32,
                # we do not set initial_bounds because we have a manual procedure
                # for initializing solutions
            )

        def _evaluate_batch(self, solutions: SolutionBatch):
            ...  # code to compute and fill the fitnesses goes here

        def _fill(self, values: torch.Tensor):
            # `values` is an empty tensor of shape (n, m) where n is the number
            # of solutions and m is the solution length.
            # The responsibility of this method is to fill this tensor.
            # In the case of this example, let us say that we wish the new
            # solutions to have values sampled from a standard normal distribution.
            values.normal_()
    ```

    **Defining manually-structured optimization problems.**
    The `dtype` of an optimization problem can be set as `object`.
    When the `dtype` is set as an `object`, it means that a solution's
    value can be a PyTorch tensor, or a numpy array, or a Python list,
    or a Python dictionary, or a string, or a scalar, or `None`.
    This gives the user enough flexibility to express non-numeric
    optimization problems and/or problems where each solution has its
    own length, or even its own structure.

    In the example below, we define an optimization problem where a
    solution is represented by a Python list and where each solution can
    have its own length. For simplicity, we define the fitness function
    as the sum of the values of a solution.

    ```python
    from evotorch import Problem, SolutionBatch
    from evotorch.tools import ObjectArray
    import random
    import torch


    class MyCustomStructuredProblem(Problem):
        def __init__(self):
            super().__init__(
                objective_sense="min",
                dtype=object,
            )

        def _evaluate_batch(self, solutions: SolutionBatch):
            # Get the number of solutions
            n = len(solutions)

            # Allocate a PyTorch tensor that will store the fitnesses
            fitnesses = torch.empty(n, dtype=torch.float32)

            # Fitness is computed as the sum of numeric values stored
            # by a solution.
            for i in range(n):
                # Get the values stored by a solution (which, in the case of
                # this example, is a Python list, because we initialize them
                # so in the _fill method).
                sln_values = solutions[i].values
                fitnesses[i] = sum(sln_values)

            # Set the fitnesses
            solutions.set_evals(fitnesses)

        def _fill(self, values: ObjectArray):
            # At this point, we have an ObjectArray of length `n`.
            # This means, we need to fill the values of `n` solutions.
            # `values[i]` represents the values of the i-th solution.
            # Initially, `values[i]` is None.
            # It is up to us how `values[i]` will be filled.
            # Let us make each solution be initialized as a list of
            # random length, containing random real numbers.

            for i in range(len(values)):
                ith_solution_length = random.randint(1, 10)
                ith_solution_values = [random.random() for _ in range(ith_solution_length)]
                values[i] = ith_solution_values
    ```

    **Multi-objective optimization.**
    A multi-objective optimization problem can be expressed by using multiple
    objective senses. As an example, let us consider an optimization problem
    where the first objective sense is minimization and the second objective
    sense is maximization. When working with an external fitness function,
    the code to express such an optimization problem would look like this:

    ```python
    from evotorch import Problem
    from evotorch.decorators import vectorized
    import torch


    @vectorized
    def f(x: torch.Tensor) -> torch.Tensor:
        # (Note that if dtype is object, x will be of type ObjectArray,
        # and not a PyTorch tensor)

        # Code to compute the fitnesses goes here.
        # Our resulting tensor `fitnesses` is expected to have a shape (n, m)
        # where n is the number of solutions and m is the number of objectives
        # (which is 2 in the case of this example).
        # `fitnesses[i, k]` is expected to store the fitness value belonging
        # to the i-th solution according to the k-th objective.
        fitnesses: torch.Tensor = ...
        return fitnesses


    problem = Problem(["min", "max"], f, ...)
    ```

    A multi-objective problem defined via class inheritance would look like this:

    ```python
    from evotorch import Problem, SolutionBatch


    class MyMultiObjectiveProblem(Problem):
        def __init__(self):
            super().__init__(objective_sense=["min", "max"], ...)

        def _evaluate_batch(self, solutions: SolutionBatch):
            # Code to compute the fitnesses goes here.
            # `fitnesses[i, k]` is expected to store the fitness value belonging
            # to the i-th solution according to the k-th objective.
            fitnesses: torch.Tensor = ...

            # Set the fitnesses
            solutions.set_evals(fitnesses)
    ```

    **How to solve a problem.**
    If the optimization problem is single-objective and its dtype is a float
    (e.g. torch.float32, torch.float64, etc.), then it can be solved using
    any search algorithm implemented in EvoTorch. Let us assume that we have
    such an optimization problem stored by the variable `prob`. We could use
    the [cross entropy method][evotorch.algorithms.distributed.gaussian.CEM])
    to solve it:

    ```python
    from evotorch import Problem
    from evotorch.algorithms import CEM
    from evotorch.logging import StdOutLogger


    def f(x: torch.Tensor) -> torch.Tensor:
        ...


    prob = Problem("min", f, solution_length=..., dtype=torch.float32)

    searcher = CEM(
        problem,
        # The keyword arguments below refer to hyperparameters specific to the
        # cross entropy method algorithm. It is recommended to tune these
        # hyperparameters according to the problem at hand.
        popsize=100,  # population size
        parenthood_ratio=0.5,  # 0.5 means better half of solutions become parents
        stdev_init=10.0,  # initial standard deviation of the search distribution
    )

    _ = StdOutLogger(searcher)  # to report the progress onto the screen
    searcher.run(50)  # run for 50 generations

    print("Center of the search distribution:", searcher.status["center"])
    print("Solution with best fitness ever:", searcher.status["best"])
    ```

    See the namespace [evotorch.algorithms][evotorch.algorithms] to see the
    algorithms implemented within EvoTorch.

    If the optimization problem at hand has an integer dtype (e.g. torch.int64),
    or has the `object` dtype, or has multiple objectives, then distribution-based
    search algorithms such as CEM cannot be used (since those algorithms were
    implemented with continuous decision variables and with single-objective
    problems in mind). In such cases, one can use the algorithm named
    [SteadyState][evotorch.algorithms.ga.SteadyStateGA].
    Please also note that, while using
    [SteadyStateGA][evotorch.algorithms.ga.SteadyStateGA] on a problem with an
    integer dtype or with the `object` dtype, one will have to define manual
    cross-over and mutation operators specialized to the solution structure
    of the problem at hand. Please see the documentation of
    [SteadyStateGA][evotorch.algorithms.ga.SteadyStateGA] for details.
    """

    def __init__(
        self,
        objective_sense: ObjectiveSense,
        objective_func: Optional[Callable] = None,
        *,
        initial_bounds: Optional[BoundsPairLike] = None,
        bounds: Optional[BoundsPairLike] = None,
        solution_length: Optional[int] = None,
        dtype: Optional[DType] = None,
        eval_dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        eval_data_length: Optional[int] = None,
        seed: Optional[int] = None,
        num_actors: Optional[Union[int, str]] = None,
        actor_config: Optional[dict] = None,
        num_gpus_per_actor: Optional[Union[int, float, str]] = None,
        num_subbatches: Optional[int] = None,
        subbatch_size: Optional[int] = None,
        store_solution_stats: Optional[bool] = None,
        vectorized: Optional[bool] = None,
    ):
        """
        `__init__(...)`: Initialize the Problem object.

        Args:
            objective_sense: A string, or a sequence of strings.
                For a single-objective problem, a single string
                ("min" or "max", for minimization or maximization)
                is enough.
                For a problem with `n` objectives, a sequence
                of strings (e.g. a list of strings) of length `n` is
                required, each string in the sequence being "min" or
                "max". This argument specifies the goal of the
                optimization.
            initial_bounds: In which interval will the values of a
                new solution will be initialized.
                Expected as a tuple, each element being either a
                scalar, or a vector of length `n`, `n` being the
                length of a solution.
                If a manual solution initialization is preferred
                (instead of an interval-based initialization),
                one can leave `initial_bounds` as None, and override
                the `_fill(...)` method in the inheriting subclass.
            bounds: Interval in which all the solutions must always
                reside.
                Expected as a tuple, each element being either a
                scalar, or a vector of length `n`, `n` being the
                length of a solution.
                This argument is optional, and can be left as None
                if one does not wish to declare hard bounds on the
                decision values of the problem.
                If `bounds` is specified, `initial_bounds` is missing,
                and `_fill(...)` is not overriden, then `bounds` will
                also serve as the `initial_bounds`.
            solution_length: Length of a solution.
                Required for all fixed-length numeric optimization
                problems.
                For variable-length problems (which might or might not
                be numeric), one is expected to leave `solution_length`
                as None, and declare `dtype` as `object`.
            dtype: dtype (data type) of the data stored by a solution.
                Can be given as a string (e.g. "float32"),
                or as a numpy dtype (e.g. `numpy.dtype("float32")`),
                or as a PyTorch dtype (e.g. `torch.float32`).
                Alternatively, if the problem is variable-length
                and/or non-numeric, one is expected to declare `dtype`
                as `object`.
            eval_dtype: dtype to be used for storing the evaluations
                (or fitnesses, or scores, or costs, or losses)
                of the solutions.
                Can be given as a string (e.g. "float32"),
                or as a numpy dtype (e.g. `numpy.dtype("float32")`),
                or as a PyTorch dtype (e.g. `torch.float32`).
                `eval_dtype` must always refer to a "float" data type,
                therefore, `object` is not accepted as a valid `eval_dtype`.
                If `eval_dtype` is not specified (i.e. left as None),
                then the following actions are taken to determine the
                `eval_dtype`:
                if `dtype` is "float16", `eval_dtype` becomes "float16";
                if `dtype` is "bfloat16", `eval_dtype` becomes "bfloat16";
                if `dtype` is "float32", `eval_dtype` becomes "float32";
                if `dtype` is "float64", `eval_dtype` becomes "float64";
                and for any other `dtype`, `eval_dtype` becomes "float32".
            device: Default device in which a new population will be
                generated. For non-numeric problems, this must be "cpu".
                For numeric problems, this can be any device supported
                by PyTorch (e.g. "cuda").
                Note that, if the number of actors of the problem is configured
                to be more than 1, `device` has to be "cpu" (or, equivalently,
                left as None).
            eval_data_length: In addition to evaluation results
                (which are (un)fitnesses, or scores, or costs, or losses),
                each solution can store extra evaluation data.
                If storage of such extra evaluation data is required,
                one can set this argument to an integer bigger than 0.
            seed: Random seed to be used by the random number generator
                attached to the problem object.
                If left as None, no random number generator will be
                attached, and the global random number generator of
                PyTorch will be used instead.
            num_actors: Number of actors to create for parallelized
                evaluation of the solutions.
                Certain string values are also accepted.
                When given as "max" or as "num_cpus", the number of actors
                will be equal to the number of all available CPUs in the ray
                cluster.
                When given as "num_gpus", the number of actors will be
                equal to the number of all available GPUs in the ray
                cluster, and each actor will be assigned a GPU.
                There is also an option, "num_devices", which means that
                both the numbers of CPUs and GPUs will be analyzed, and
                new actors and GPUs for them will be allocated,
                in a one-to-one mapping manner, if possible.
                In more details, with `num_actors="num_devices"`, if
                `device` is given as a GPU device, then it will be inferred
                that the user wishes to put everything (including the
                population) on a single GPU, and therefore there won't be
                any allocation of actors nor GPUs.
                With `num_actors="num_devices"` and with `device` set as
                "cpu" (or as left as None), if there are multiple CPUs
                and multiple GPUs, then `n` actors will be allocated
                where `n` is the minimum among the number of CPUs
                and the number of GPUs, so that there can be one-to-one
                mapping between CPUs and GPUs (i.e. such that each actor
                can be assigned an entire GPU).
                If `num_actors` is given as "num_gpus" or "num_devices",
                the argument `num_gpus_per_actor` must not be used,
                and the `actor_config` dictionary must not contain the
                key "num_gpus".
                If `num_actors` is given as something other than "num_gpus"
                or "num_devices", and if you wish to assign GPUs to each
                actor, then please see the argument `num_gpus_per_actor`.
            actor_config: A dictionary, representing the keyword arguments
                to be passed to the options(...) used when creating the
                ray actor objects. To be used for explicitly allocating
                resources per each actor.
                For example, for declaring that each actor is to use a GPU,
                one can pass `actor_config=dict(num_gpus=1)`.
                Can also be given as None (which is the default),
                if no such options are to be passed.
            num_gpus_per_actor: Number of GPUs to be allocated by each
                remote actor.
                The default behavior is to NOT allocate any GPU at all
                (which is the default behavior of the ray library as well).
                When given as a number `n`, each actor will be given
                `n` GPUs (where `n` can be an integer, or can be a `float`
                for fractional allocation).
                When given as a string "max", then the available GPUs
                across the entire ray cluster (or within the local computer
                in the simplest cases) will be equally distributed among
                the actors.
                When given as a string "all", then each actor will have
                access to all the GPUs (this will be achieved by suppressing
                the environment variable `CUDA_VISIBLE_DEVICES` for each
                actor).
                When the problem is not distributed (i.e. when there are
                no actors), this argument is expected to be left as None.
            num_subbatches: If `num_subbatches` is None (assuming that
                `subbatch_size` is also None), then, when evaluating a
                population, the population will be split into n pieces, `n`
                being the number of actors, and each actor will evaluate
                its assigned piece. If `num_subbatches` is an integer `m`,
                then the population will be split into `m` pieces,
                and actors will continually accept the next unevaluated
                piece as they finish their current tasks.
                The arguments `num_subbatches` and `subbatch_size` cannot
                be given values other than None at the same time.
                While using a distributed algorithm, this argument determines
                how many sub-batches will be generated, and therefore,
                how many gradients will be computed by the remote actors.
            subbatch_size: If `subbatch_size` is None (assuming that
                `num_subbatches` is also None), then, when evaluating a
                population, the population will be split into `n` pieces, `n`
                being the number of actors, and each actor will evaluate its
                assigned piece. If `subbatch_size` is an integer `m`,
                then the population will be split into pieces of size `m`,
                and actors will continually accept the next unevaluated
                piece as they finish their current tasks.
                When there can be significant difference across the solutions
                in terms of computational requirements, specifying a
                `subbatch_size` can be beneficial, because, while one
                actor is busy with a subbatch containing computationally
                challenging solutions, other actors can accept more
                tasks and save time.
                The arguments `num_subbatches` and `subbatch_size` cannot
                be given values other than None at the same time.
                While using a distributed algorithm, this argument determines
                the size of a sub-batch (or sub-population) sampled by a
                remote actor for computing a gradient.
                In distributed mode, it is expected that the population size
                is divisible by `subbatch_size`.
            store_solution_stats: Whether or not the problem object should
                keep track of the best and worst solutions.
                Can also be left as None (which is the default behavior),
                in which case, it will store the best and worst solutions
                only when the first solution batch it encounters is on the
                cpu. This default behavior is to ensure that there is no
                transfer between the cpu and a foreign computation device
                (like the gpu) just for the sake of keeping the best and
                the worst solutions.
            vectorized: Set this to True if the provided fitness function
                is vectorized but is not decorated via `@vectorized`.
        """

        # Set the dtype for the decision variables of the Problem
        if dtype is None:
            self._dtype = torch.float32
        elif is_dtype_object(dtype):
            self._dtype = object
        else:
            self._dtype = to_torch_dtype(dtype)

        _evolog.info(message_from(self, f"The `dtype` for the problem's decision variables is set as {self._dtype}"))

        # Set the dtype for the solution evaluations (i.e. fitnesses and evaluation data)
        if eval_dtype is not None:
            # If an `eval_dtype` is explicitly stated, then accept it as the `_eval_dtype` of the Problem
            self._eval_dtype = to_torch_dtype(eval_dtype)
        else:
            # This is the case where an `eval_dtype` is not explicitly stated by the user.
            # We need to choose a default.
            if self._dtype in (torch.float16, torch.bfloat16, torch.float64):
                # If the `dtype` of the problem is a non-32-bit float type (i.e. float16, bfloat16, float64)
                # then we use that as our `_eval_dtype` as well.
                self._eval_dtype = self._dtype
            else:
                # For any other `dtype`, we use float32 as our `_eval_dtype`.
                self._eval_dtype = torch.float32

            _evolog.info(
                message_from(
                    self, f"`eval_dtype` (the dtype of the fitnesses and evaluation data) is set as {self._eval_dtype}"
                )
            )

        # Set the main device of the Problem object
        self._device = torch.device("cpu") if device is None else torch.device(device)
        _evolog.info(message_from(self, f"The `device` of the problem is set as {self._device}"))

        # Declare the internal variable that might store the random number generator
        self._generator: Optional[torch.Generator] = None

        # Set the seed of the Problem object, if a seed is provided
        self.manual_seed(seed)

        # Declare the internal variables that will store the bounds and the solution length
        self._initial_lower_bounds: Optional[torch.Tensor] = None
        self._initial_upper_bounds: Optional[torch.Tensor] = None
        self._lower_bounds: Optional[torch.Tensor] = None
        self._upper_bounds: Optional[torch.Tensor] = None
        self._solution_length: Optional[int] = None

        if self._dtype is object:
            # If dtype is given as `object`, then there are some runtime sanity checks to perform
            if bounds is not None or initial_bounds is not None:
                # With dtype as object, if bounds are given then we raise an error.
                # This is because the `object` dtype implies that the decision values are not necessarily numeric,
                # and therefore, we cannot have the guarantee of satisfying numeric bounds.
                raise ValueError(
                    f"With dtype as {repr(dtype)}, expected to receive `initial_bounds` and/or `bounds` as None."
                    f" However, one or both of them is/are set as value(s) other than None."
                )
            if solution_length is not None:
                # With dtype as object, if `solution_length` is provided, then we raise an error.
                # This is because the `object` dtype implies that the solutions can be expressed via various
                # containers, each with its own length, and therefore, a fixed solution length cannot be guaranteed.
                raise ValueError(
                    f"With dtype as {repr(dtype)}, expected to receive `solution_length` as None."
                    f" However, received `solution_length` as {repr(solution_length)}."
                )
            if str(self._device) != "cpu":
                # With dtype as object, if `device` is something other than "cpu", then we raise an error.
                # This is because the `object` dtype implies that the decision values are stored by an ObjectArray,
                # whose device is always "cpu".
                raise ValueError(
                    f"With dtype as {repr(dtype)}, expected to receive `device` as 'cpu'."
                    f" However, received `device` as {repr(device)}."
                )
        else:
            # If dtype is something other than `object`, then we need to make sure that we have a valid length for
            # solutions, and also properly store the given numeric bounds.

            if solution_length is None:
                # With a numeric dtype, if solution length is missing, then we raise an error.
                raise ValueError(
                    f"Together with a numeric dtype ({repr(dtype)}),"
                    f" expected to receive `solution_length` as an integer."
                    f" However, `solution_length` is None."
                )
            else:
                # With a numeric dtype, if a solution length is provided, we make sure that it is integer.
                solution_length = int(solution_length)

            # Store the solution length
            self._solution_length = solution_length

            if (bounds is not None) or (initial_bounds is not None):
                # This is the case where we have a dtype other than `object`, and either `bounds` or `initial_bounds`
                # was provided.
                initbnd_tuple_name = "initial_bounds"
                bnd_tuple_name = "bounds"

                if (bounds is not None) and (initial_bounds is None):
                    # With a numeric dtype, if strict bounds are given but initial bounds are not given, then we assume
                    # that the strict bounds also serve as the initial bounds.
                    # Therefore, we take clones of the strict bounds and use this clones as the initial bounds.
                    initial_bounds = clone(bounds)
                    initbnd_tuple_name = "bounds"

                # Below is an internal helper function for some common operations for the (strict) bounds
                # and for the initial bounds.
                def process_bounds(bounds_tuple: BoundsPairLike, tuple_name: str) -> BoundsPair:
                    # This function receives the bounds_tuple (a tuple containing lower and upper bounds),
                    # and the string name of the bounds argument ("bounds" or "initial_bounds").
                    # What is returned is the bounds expressed as PyTorch tensors in the correct dtype and device.

                    nonlocal solution_length

                    # Extract the lower and upper bounds from the received bounds tuple.
                    lb, ub = bounds_tuple

                    # Make sure that the lower and upper bounds are expressed as tensors of correct dtype and device.
                    lb = self.make_tensor(lb)
                    ub = self.make_tensor(ub)

                    for bound_array in (lb, ub):  # For each boundary tensor (lb and ub)
                        if bound_array.ndim not in (0, 1):
                            # If the boundary tensor is not as scalar and is not a 1-dimensional vector, then raise an
                            # error.
                            raise ValueError(
                                f"Lower and upper bounds are expected as scalars or as 1-dimensional vectors."
                                f" However, these given boundaries have incompatible shape:"
                                f" {bound_array} (of shape {bound_array.shape})."
                            )
                        if bound_array.ndim == 1:
                            if len(bound_array) != solution_length:
                                # In the case where the boundary tensor is a 1-dimensional vector, if this vector's length
                                # is not equal to the solution length, then we raise an error.
                                raise ValueError(
                                    f"When boundaries are expressed as 1-dimensional vectors, their length are"
                                    f" expected as the solution length of the Problem object."
                                    f" However, while the problem's solution length is {solution_length},"
                                    f" these given boundaries have incompatible length:"
                                    f" {bound_array} (of length {len(bound_array)})."
                                )

                    # Return the processed forms of the lower and upper boundary tensors.
                    return lb, ub

                # Process the initial bounds with the help of the internal function `process_bounds(...)`
                init_lb, init_ub = process_bounds(initial_bounds, initbnd_tuple_name)

                # Store the processed initial bounds
                self._initial_lower_bounds = init_lb
                self._initial_upper_bounds = init_ub

                if bounds is not None:
                    # If there are strict bounds, then process those bounds with the help of `process_bounds(...)`.
                    lb, ub = process_bounds(bounds, bnd_tuple_name)
                    # Store the processed bounds
                    self._lower_bounds = lb
                    self._upper_bounds = ub

        # Annotate the variable that will store the objective sense(s) of the problem
        self._objective_sense: ObjectiveSense

        # Below is an internal function which makes sure that a provided objective sense has a valid value
        # (where valid values are "min" or "max")
        def validate_sense(s: str):
            if s not in ("min", "max"):
                raise ValueError(
                    f"Invalid objective sense: {repr(s)}."
                    f"Instead, please provide the objective sense as 'min' or 'max'."
                )

        if not is_sequence(objective_sense):
            # If the provided objective sense is not a sequence, then convert it to a single-element list
            senses = [objective_sense]
            num_senses = 1
        else:
            # If the provided objective sense is a sequence, then take a list copy of it
            senses = list(objective_sense)
            num_senses = len(objective_sense)

            # Ensure that each provided objective sense is valid
            for sense in senses:
                validate_sense(sense)

            if num_senses == 0:
                # If the given sequence of objective senses is empty, then we raise an error.
                raise ValueError(
                    "Encountered an empty sequence via `objective_sense`."
                    " For a single-objective problem, please set `objective_sense` as 'min' or 'max'."
                    " For a multi-objective problem, please set `objective_sense` as a sequence,"
                    " each element being 'min' or 'max'."
                )

        # Store the objective senses
        self._senses: Iterable[str] = senses

        # Store the provided objective function (which can be None)
        self._objective_func: Optional[Callable] = objective_func

        # Declare the instance variable that will store whether or not the external fitness function is
        # vectorized, if such an external fitness function is given.
        self._vectorized: Optional[bool]

        # Store the information which indicates whether or not the given objective function is vectorized
        if self._objective_func is None:
            # This is the case where an external fitness function is not given.
            # In this case, we expect the keyword argument `vectorized` to be left as None.

            if vectorized is not None:
                # If the keyword argument `vectorized` is something other than None, then we raise an error
                # to let the user know.
                raise ValueError(
                    f"This problem object received no external fitness function."
                    f" When not using an external fitness function, the keyword argument `vectorized`"
                    f" is expected to be left as None."
                    f" However, the value of the keyword argument `vectorized` is {vectorized}."
                )

            # At this point, we know that we do not have an external fitness function.
            # The variable which is supposed to tell us whether or not the external fitness function is vectorized
            # is therefore irrelevant. We just set it as None.
            self._vectorized = None
        else:
            # This is the case where an external fitness function is given.

            if (
                hasattr(self._objective_func, "__evotorch_vectorized__")
                and self._objective_func.__evotorch_vectorized__
            ):
                # If the external fitness function has an attribute `__evotorch_vectorized__`, and the value of this
                # attribute evaluates to True, then this is an indication that the fitness function was decorated
                # with `@vectorized`.

                if vectorized is not None:
                    # At this point, we know (or at least have the assumption) that the fitness function was decorated
                    # with `@vectorized`. Any boolean value given via the keyword argument `vectorized` would therefore
                    # be either redundant or conflicting.
                    # Therefore, in this case, if the keyword argument `vectorized` is anything other than None, we
                    # raise an error to inform the user.

                    raise ValueError(
                        f"Received a fitness function that was decorated via @vectorized."
                        f" When using such a fitness function, the keyword argument `vectorized`"
                        f" is expected to be left as None."
                        f" However, the value of the keyword argument `vectorized` is {vectorized}."
                    )

                # Since we know that our fitness function declares itself as vectorized, we set the instance variable
                # _vectorized as True.
                self._vectorized = True
            else:
                # This is the case in which the fitness function does not appear to be decorated via `@vectorized`.
                # In this case, if the keyword argument `vectorized` has a value that is equivalent to True,
                # then the value of `_vectorized` becomes True. On the other hand, if the keyword argument `vectorized`
                # was left as None or if it has a value that is equivalent to False, `_vectorized` becomes False.
                self._vectorized = bool(vectorized)

        # If the evaluation data length is explicitly stated, then convert it to an integer and store it.
        # Otherwise, store the evaluation data length as 0.
        self._eval_data_length = 0 if eval_data_length is None else int(eval_data_length)

        # Initialize the actor index.
        # If the problem is configured to be parallelized and the parallelization is triggered, then each remote
        # copy will have a different integer value for `_actor_index`.
        self._actor_index: Optional[int] = None

        # Initialize the variable that might store the list of actors as None.
        # If the problem is configured to be parallelized and the parallelization is triggered, then this variable
        # will store references to the remote actors (each remote actor storing its own copy of this Problem
        # instance).
        self._actors: Optional[list] = None

        # Initialize the variable that might store the ray ActorPool.
        # If the problem is configured to be parallelized and the parallelization is triggered, then this variable
        # will store the ray ActorPool that is generated out of the remote actors.
        self._actor_pool: Optional[ActorPool] = None

        # Store the ray actor configuration dictionary provided by the user (if any).
        # When (or if) the parallelization is triggered, each actor will be created with this given configuration.
        self._actor_config: Optional[dict] = None if actor_config is None else deepcopy(dict(actor_config))

        # If given, store the sub-batch size or number of sub-batches.
        # When the problem is parallelized, a sub-batch size determines the maximum size for a SolutionBatch
        # that will be sent to a remote actor for parallel solution evaluation.
        # Alternatively, num_subbatches determines into how many pieces will a SolutionBatch be split
        # for parallelization.
        # If both are None, then the main SolutionBatch will be split among the actors.
        if (num_subbatches is not None) and (subbatch_size is not None):
            raise ValueError(
                f"Encountered both `num_subbatches` and `subbatch_size` as values other than None."
                f" num_subbatches={num_subbatches}, subbatch_size={subbatch_size}."
                f" Having both of them as values other than None cannot be accepted."
            )
        self._num_subbatches: Optional[int] = None if num_subbatches is None else int(num_subbatches)
        self._subbatch_size: Optional[int] = None if subbatch_size is None else int(subbatch_size)

        # Initialize the additional states to be loaded by the remote actor as None.
        # If there are such additional states for remote actors, the inheriting class can fill this as a list
        # of dictionaries.
        self._remote_states: Optional[Iterable[dict]] = None

        # Initialize a temporary internal variable which stores the resources available in the ray cluster.
        # Most probably, we are interested in the resources "CPU" and "GPU".
        ray_resources: Optional[dict] = None

        # The following is an internal helper function which returns the amount of availability for a given
        # resource in the ray cluster.
        # If the requested resource is not available at all, None will be returned.
        def get_ray_resource(resource_name: str) -> Any:
            # Ensure that the ray cluster is initialized
            ensure_ray()
            nonlocal ray_resources
            if ray_resources is None:
                # If the ray resource information was not fetched, then fetch them and store them.
                ray_resources = ray.available_resources()
            # Return the information regarding the requested resource from the fetched resource information.
            # If it turns out that the requested resource is not available at all, the result will be None.
            return ray_resources.get(resource_name, None)

        # Annotate the variable that will store the number of actors (to be created when the parallelization
        # is triggered).
        self._num_actors: int

        if num_actors is None:
            # If the argument `num_actors` is left as None, then we set `_num_actors` as 0, which means that
            # there will be no parallelization.
            self._num_actors = 0
        elif isinstance(num_actors, str):
            # This is the case where `num_actors` has a string value
            if num_actors in ("max", "num_cpus"):
                # If the `num_actors` argument was given as "max" or as "num_cpus", then we first read how many CPUs
                # are available in the ray cluster, then convert it to integer (via computing its ceil value), and
                # finally set `_num_actors` as this integer.
                self._num_actors = math.ceil(get_ray_resource("CPU"))
            elif num_actors == "num_gpus":
                # If the `num_actors` argument was given as "num_gpus", then we first read how many GPUs are
                # available in the ray cluster.
                num_gpus = get_ray_resource("GPU")
                if num_gpus is None:
                    # If there are no GPUs at all, then we raise an error
                    raise ValueError(
                        "The argument `num_actors` was encountered as 'num_gpus'."
                        " However, there does not seem to be any GPU available."
                    )
                if num_gpus < 1e-4:
                    # If the number of available GPUs are 0 or close to 0, then we raise an error
                    raise ValueError(
                        f"The argument `num_actors` was encountered as 'num_gpus'."
                        f" However, the number of available GPUs are either 0 or close to 0 (= {num_gpus})."
                    )
                if (actor_config is not None) and ("num_gpus" in actor_config):
                    # With `num_actors` argument given as "num_gpus", we will also allocate each GPU to an actor.
                    # If `actor_config` contains an item with key "num_gpus", then that configuration item would
                    # conflict with the GPU allocation we are about to do here.
                    # So, we raise an error.
                    raise ValueError(
                        "The argument `num_actors` was encountered as 'num_gpus'."
                        " With this configuration, the number of GPUs assigned to an actor is automatically determined."
                        " However, at the same time, the `actor_config` argument was received with the key 'num_gpus',"
                        " which causes a conflict."
                    )
                if num_gpus_per_actor is not None:
                    # With `num_actors` argument given as "num_gpus", we will also allocate each GPU to an actor.
                    # If the argument `num_gpus_per_actor` is also stated, then such a configuration item would
                    # conflict with the GPU allocation we are about to do here.
                    # So, we raise an error.
                    raise ValueError(
                        f"The argument `num_actors` was encountered as 'num_gpus'."
                        f" With this configuration, the number of GPUs assigned to an actor is automatically determined."
                        f" However, at the same time, the `num_gpus_per_actor` argument was received with a value other"
                        f" than None ({repr(num_gpus_per_actor)}), which causes a conflict."
                    )
                # Set the number of actors as the ceiled integer counterpart of the number of available GPUs
                self._num_actors = math.ceil(num_gpus)
                # We assign a GPU for each actor (by overriding the value for the argument `num_gpus_per_actor`).
                num_gpus_per_actor = num_gpus / self._num_actors
            elif num_actors == "num_devices":
                # This is the case where `num_actors` has the string value "num_devices".

                # With `num_actors` set as "num_devices", if there are any GPUs, the behavior is to assign a GPU
                # to each actor. If there are conflicting configurations regarding how many GPUs are to be assigned
                # to each actor, then we raise an error.
                if (actor_config is not None) and ("num_gpus" in actor_config):
                    raise ValueError(
                        "The argument `num_actors` was encountered as 'num_devices'."
                        " With this configuration, the number of GPUs assigned to an actor is automatically determined."
                        " However, at the same time, the `actor_config` argument was received with the key 'num_gpus',"
                        " which causes a conflict."
                    )
                if num_gpus_per_actor is not None:
                    raise ValueError(
                        f"The argument `num_actors` was encountered as 'num_devices'."
                        f" With this configuration, the number of GPUs assigned to an actor is automatically determined."
                        f" However, at the same time, the `num_gpus_per_actor` argument was received with a value other"
                        f" than None ({repr(num_gpus_per_actor)}), which causes a conflict."
                    )

                if self._device != torch.device("cpu"):
                    # If the main device is not CPU, then the user most probably wishes to put all the
                    # computations (both evaluations and the population) on the GPU, without allocating
                    # any actor.
                    # So, we set `_num_actors` as None, and overwrite `num_gpus_per_actor` with None.
                    self._num_actors = None
                    num_gpus_per_actor = None
                else:
                    # If the device argument is "cpu" or left as None, then we assume that actor allocations
                    # might be desired.

                    # Read how many CPUs and GPUs are available in the ray cluster.
                    num_cpus = get_ray_resource("CPU")
                    num_gpus = get_ray_resource("GPU")

                    # If we have multiple CPUs, then we continue with the actor allocation procedures.
                    if (num_gpus is None) or (num_gpus < 1e-4):
                        # If there are no GPUs, then we set the number of actors as the number of CPUs, and we
                        # set the number of GPUs per actor as None (which means that there will be no GPU
                        # assignment)
                        self._num_actors = math.ceil(num_cpus)
                        num_gpus_per_actor = None
                    else:
                        # If there are GPUs available, then we compute the minimum among the number of CPUs and
                        # GPUs, and this minimum value becomes the number of actors (so that there can be
                        # one-to-one mapping between actors and GPUs).
                        self._num_actors = math.ceil(min(num_cpus, num_gpus))

                        # We assign a GPU for each actor (by overriding the value for the argument
                        # `num_gpus_per_actor`).
                        if self._num_actors <= num_gpus:
                            num_gpus_per_actor = 1
                        else:
                            num_gpus_per_actor = num_gpus / self._num_actors
            else:
                # This is the case where `num_actors` is given as an unexpected string. We raise an error here.
                raise ValueError(
                    f"Invalid string value for `num_actors`: {repr(num_actors)}."
                    f" The acceptable string values for `num_actors` are 'max', 'num_cpus', 'num_gpus', 'num_devices'."
                )
        else:
            # This is the case where `num_actors` has a value which is not a string.
            # In this case, we make sure that the given value is an integer, and then use this integer as our
            # number of actors.
            self._num_actors = int(num_actors)

        if self._num_actors == 1:
            _evolog.info(
                message_from(
                    self,
                    (
                        "The number of actors that will be allocated for parallelized evaluation was encountered as 1."
                        " This number is automatically dropped to 0,"
                        " because having only 1 actor does not bring any benefit in terms of parallelization."
                    ),
                )
            )
            # Creating a single actor does not bring any benefit of parallelization.
            # Therefore, at the end of all the computations above regarding the number of actors, if it turns out
            # that the target number of actors is 1, we reduce it to 0 (meaning that no actor will be initialized).
            self._num_actors = 0

            # Since we are to allocate no actor, the value of the argument `num_gpus_per_actor` is meaningless.
            # We therefore overwrite the value of that argument with None.
            num_gpus_per_actor = None

        _evolog.info(
            message_from(
                self, f"The number of actors that will be allocated for parallelized evaluation is {self._num_actors}"
            )
        )

        if (self._num_actors >= 2) and (self._device != torch.device("cpu")):
            detailed_error_msg = (
                f"The number of actors that will be allocated for parallelized evaluation is {self._num_actors}."
                " When the number of actors is at least 2,"
                ' the only supported value for the `device` argument is "cpu".'
                f" However, `device` was received as {self._device}."
                "\n\n---- Possible ways to fix the error: ----"
                "\n\n"
                "(1)"
                " If both the population and the fitness evaluation operations can fit into the same device,"
                f" try setting `device={self._device}` and `num_actors=0`."
                "\n\n"
                "(2)"
                " If you would like to use N number of GPUs in parallel for fitness evaluation (where N>1),"
                ' set `device="cpu"` (so that the main process will keep the population on the cpu), set'
                " `num_actors=N` and `num_gpus_per_actor=1` (to allocate an actor for each of the `N` GPUs),"
                " and then, decorate your fitness function using `evotorch.decorators.on_cuda`"
                " so that the fitness evaluation will be performed on the cuda device assigned to the actor."
                " The code for achieving this can look like this:"
                "\n\n"
                "    from evotorch import Problem\n"
                "    from evotorch.decorators import on_cuda, vectorized\n"
                "    import torch\n"
                "\n"
                "    @on_cuda\n"
                "    @vectorized\n"
                "    def f(x: torch.Tensor) -> torch.Tensor:\n"
                "        ...\n"
                "\n"
                '    problem = Problem("min", f, device="cpu", num_actors=N, num_gpus_per_actor=1)\n'
                "\n"
                "Or, it can look like this:\n"
                "\n"
                "    from evotorch import Problem, SolutionBatch\n"
                "    from evotorch.decorators import on_cuda\n"
                "    import torch\n"
                "\n"
                "    class MyProblem(Problem):\n"
                "        def __init__(self, ...):\n"
                "            super().__init__(\n"
                '                objective_sense="min", device="cpu", num_actors=N, num_gpus_per_actor=1, ...\n'
                "            )\n"
                "\n"
                "        @on_cuda\n"
                "        def _evaluate_batch(self, batch: SolutionBatch):\n"
                "            ...\n"
                "\n"
                "    problem = MyProblem(...)\n"
                "\n"
                "\n"
                "(3)"
                " Similarly to option (2), for when you wish to use N number of GPUs for fitness evaluation,"
                ' set `device="cpu"`, set `num_actors=N` and `num_gpus_per_actor=1`, then, within the evaluation'
                ' function, manually use the device `"cuda"` to accelerate the computation.'
                "\n\n"
                "--------------\n"
                "Note for cases (2) and (3): if you are on a computer or on a ray cluster with multiple GPUs, you"
                ' might prefer to set `num_actors` as the string "num_gpus" instead of an integer N,'
                " which will cause the number of available GPUs to be counted, and the number of actors to be"
                " configured as that count."
            )

            raise ValueError(detailed_error_msg)

        # Annotate the variable which will determine how many GPUs are to be assigned to each actor.
        self._num_gpus_per_actor: Optional[Union[str, int, float]]

        if (actor_config is not None) and ("num_gpus" in actor_config) and (num_gpus_per_actor is not None):
            # If `actor_config` dictionary has the item "num_gpus" and also `num_gpus_per_actor` is not None,
            # then there is a conflicting (or redundant) configuration. We raise an error here.
            raise ValueError(
                'The `actor_config` dictionary contains the key "num_gpus".'
                " At the same time, `num_gpus_per_actor` has a value other than None."
                " These two configurations are conflicting."
                " Please specify the number of GPUs per actor either via the `actor_config` dictionary,"
                " or via the `num_gpus_per_actor` argument, but not via both."
            )

        if num_gpus_per_actor is None:
            # If the argument `num_gpus_per_actor` is not specified, then we set the attribute
            # `_num_gpus_per_actor` as None, which means that no GPUs will be assigned to the actors.
            self._num_gpus_per_actor = None
        elif isinstance(num_gpus_per_actor, str):
            # This is the case where `num_gpus_per_actor` is given as a string.
            if num_gpus_per_actor == "max":
                # This is the case where `num_gpus_per_actor` is given as "max".
                num_gpus = get_ray_resource("GPU")
                if num_gpus is None:
                    # With `num_gpus_per_actor` as "max", if there is no GPU available, then we set the attribute
                    # `_num_gpus_per_actor` as None, which means there will be no GPU assignment to the actors.
                    self._num_gpus_per_actor = None
                else:
                    # With `num_gpus_per_actor` as "max", if there are GPUs available, then the available GPUs will
                    # be shared among the actors.
                    self._num_gpus_per_actor = num_gpus / self._num_actors
            elif num_gpus_per_actor == "all":
                # When `num_gpus_per_actor` is "all", we also set the attribute `_num_gpus_per_actor` as "all".
                # When a remote actor is initialized, the remote actor will see that the Problem instance has its
                # `_num_gpus_per_actor` set as "all", and it will remove the environment variable named
                # "CUDA_VISIBLE_DEVICES" in its own environment.
                # With "CUDA_VISIBLE_DEVICES" removed, an actor will see all the GPUs available in its own
                # environment.
                self._num_gpus_per_actor = "all"
            else:
                # This is the case where `num_gpus_per_actor` argument has an unexpected string value.
                # We raise an error.
                raise ValueError(
                    f"Invalid string value for `num_gpus_per_actor`: {repr(num_gpus_per_actor)}."
                    f' Acceptable string values for `num_gpus_per_actor` are: "max", "all".'
                )
        elif isinstance(num_gpus_per_actor, int):
            # When the argument `num_gpus_per_actor` is set as an integer we just set the attribute
            # `_num_gpus_per_actor` as this integer.
            self._num_gpus_per_actor = num_gpus_per_actor
        else:
            # For anything else, we assume that `num_gpus_per_actor` is an object that is convertible to float.
            # Therefore, we convert it to float and store it in the attribute `_num_gpus_per_actor`.
            # Also, remember that, when `num_actors` is given as "num_gpus" or as "num_devices",
            # the code above overrides the value for the argument `num_gpus_per_actor`, which means,
            # this is the case that is activated when `num_actors` is "num_gpus" or "num_devices".
            self._num_gpus_per_actor = float(num_gpus_per_actor)

        if self._num_actors > 0:
            _evolog.info(
                message_from(self, f"Number of GPUs that will be allocated per actor is {self._num_gpus_per_actor}")
            )

        # Initialize the Hook instances (and the related status dictionary for the `_after_eval_hook`)
        self._before_eval_hook: Hook = Hook()
        self._after_eval_hook: Hook = Hook()
        self._after_eval_status: dict = {}
        self._remote_hook: Hook = Hook()
        self._before_grad_hook: Hook = Hook()
        self._after_grad_hook: Hook = Hook()

        # Initialize various stats regarding the solutions encountered by this Problem instance.
        self._store_solution_stats = None if store_solution_stats is None else bool(store_solution_stats)
        self._best: Optional[list] = None
        self._worst: Optional[list] = None
        self._best_evals: Optional[torch.Tensor] = None
        self._worst_evals: Optional[torch.Tensor] = None

        # Initialize the boolean attribute which indicates whether or not this Problem instance (which can be
        # the main instance or a remote instance on an actor) is "prepared" via the `_prepare` method.
        self._prepared: bool = False

    def manual_seed(self, seed: Optional[int] = None):
        """
        Provide a manual seed for the Problem object.

        If the given seed is None, then the Problem object will remove
        its own stored generator, and start using the global generator
        of PyTorch instead.
        If the given seed is an integer, then the Problem object will
        instantiate its own generator with the given seed.

        Args:
            seed: None for using the global PyTorch generator; an integer
                for instantiating a new PyTorch generator with this given
                integer seed, specific to this Problem object.
        """
        if seed is None:
            self._generator = None
        else:
            if self._generator is None:
                self._generator = torch.Generator(device=self.device)
            self._generator.manual_seed(seed)

    @property
    def dtype(self) -> DType:
        """
        dtype of the Problem object.

        The decision variables of the optimization problem are of this dtype.
        """
        return self._dtype

    @property
    def device(self) -> Device:
        """
        device of the Problem object.

        New solutions and populations will be generated in this device.
        """
        return self._device

    @property
    def aux_device(self) -> Device:
        """
        Auxiliary device to help with the computations, most commonly for
        speeding up the solution evaluations.

        An auxiliary device is different than the main device of the Problem
        object (the main device being expressed by the `device` property).
        While the main device of the Problem object determines where the
        solutions and the populations are stored (and also using which device
        should a SearchAlgorithm instance communicate with the problem),
        an auxiliary device is a device that might be used by the Problem
        instance itself for its own computations (e.g. computations defined
        within the methods `_evaluate(...)` or `_evaluate_batch(...)`).

        If the problem's main device is something other than "cpu", that main
        device is also seen as the auxiliary device, and therefore returned
        by this property.

        If the problem's main device is "cpu", then the auxiliary device
        is decided as follows. If `num_gpus_per_actor` of the Problem object
        was set as "all" and if this instance is a remote instance, then the
        auxiliary device is guessed as "cuda:N" where N is the actor index.
        In all other cases, the auxiliary device is "cuda" if cuda is
        available, and "cpu" otherwise.
        """
        cpu_device = torch.device("cpu")
        if torch.device(self.device) == cpu_device:
            if torch.cuda.is_available():
                if isinstance(self._num_gpus_per_actor, str) and (self._num_gpus_per_actor == "all") and self.is_remote:
                    return torch.device("cuda", self.actor_index)
                else:
                    return torch.device("cuda")
            else:
                return cpu_device
        else:
            return self.device

    @property
    def eval_dtype(self) -> DType:
        """
        evaluation dtype of the Problem object.

        The evaluation results of the solutions are stored according to this
        dtype.
        """
        return self._eval_dtype

    @property
    def generator(self) -> Optional[torch.Generator]:
        """
        Random generator used by this Problem object.

        Can also be None, which means that the Problem object will use the
        global random generator of PyTorch.
        """
        return self._generator

    @property
    def has_own_generator(self) -> bool:
        """
        Whether or not the Problem object has its own random generator.

        If this is True, then the Problem object will use its own
        random generator when creating random values or tensors.
        If this is False, then the Problem object will use the global
        random generator when creating random values or tensors.
        """
        return self.generator is not None

    @property
    def objective_sense(self) -> ObjectiveSense:
        """
        Get the objective sense.

        If the problem is single-objective, then a single string is returned.
        If the problem is multi-objective, then the objective senses will be
        returned in a list.

        The returned string in the single-objective case, or each returned
        string in the multi-objective case, is "min" or "max".
        """
        if len(self.senses) == 1:
            return self.senses[0]
        else:
            return self.senses

    @property
    def senses(self) -> Iterable[str]:
        """
        Get the objective senses.

        The return value is a list of strings, each string being
        "min" or "max".
        """
        return self._senses

    @property
    def is_single_objective(self) -> bool:
        """Whether or not the problem is single-objective"""
        return len(self.senses) == 1

    @property
    def is_multi_objective(self) -> bool:
        """Whether or not the problem is multi-objective"""
        return len(self.senses) > 1

    def get_obj_order_descending(self) -> Iterable[bool]:
        """When sorting the solutions from best to worst according to each objective i, is the ordering descending?"""
        result = []
        for s in self.senses:
            if s == "min":
                result.append(False)
            elif s == "max":
                result.append(True)
            else:
                raise ValueError(f"Invalid sense: {repr(s)}")
        return result

    @property
    def solution_length(self) -> Optional[int]:
        """
        Get the solution length.

        Problems with `dtype=None` do not have solution lengths.
        For such problems, this property returns None.
        """
        return self._solution_length

    @property
    def eval_data_length(self) -> int:
        """
        Length of the extra evaluation data vector for each solution.
        """
        return self._eval_data_length

    @property
    def initial_lower_bounds(self) -> Optional[torch.Tensor]:
        """
        Initial lower bounds, for when initializing a new solution.

        If such a bound was declared during the initialization phase,
        the returned value is a torch tensor (in the form of a vector
        or in the form of a scalar).
        If no such bound was declared, the returned value is None.
        """
        return self._initial_lower_bounds

    @property
    def initial_upper_bounds(self) -> Optional[torch.Tensor]:
        """
        Initial upper bounds, for when initializing a new solution.

        If such a bound was declared during the initialization phase,
        the returned value is a torch tensor (in the form of a vector
        or in the form of a scalar).
        If no such bound was declared, the returned value is None.
        """
        return self._initial_upper_bounds

    @property
    def lower_bounds(self) -> Optional[torch.Tensor]:
        """
        Lower bounds for the allowed values of a solution.

        If such a bound was declared during the initialization phase,
        the returned value is a torch tensor (in the form of a vector
        or in the form of a scalar).
        If no such bound was declared, the returned value is None.
        """
        return self._lower_bounds

    @property
    def upper_bounds(self) -> Optional[torch.Tensor]:
        """
        Upper bounds for the allowed values of a solution.

        If such a bound was declared during the initialization phase,
        the returned value is a torch tensor (in the form of a vector
        or in the form of a scalar).
        If no such bound was declared, the returned value is None.
        """
        return self._upper_bounds

    def generate_values(self, num_solutions: int) -> Union[torch.Tensor, ObjectArray]:
        """
        Generate decision values.

        This function returns a tensor containing the decision values
        for `n` new solutions, `n` being the integer passed as the `num_rows`
        argument.

        For numeric problems, this function generates the decision values
        which respect `initial_bounds` (or `bounds`, if `initial_bounds`
        was not provided).
        If this type of initialization is not desired, one can override
        this function and define a manual initialization scheme in the
        inheriting subclass.

        For non-numeric problems, it is expected that the inheriting subclass
        will override the method `_fill(...)`.

        Args:
            num_solutions: For how many solutions will new decision values be
                generated.
        Returns:
            A PyTorch tensor for numeric problems, an ObjectArray for
            non-numeric problems.
        """
        result = self.make_empty(num_solutions=num_solutions)
        self._fill(result)
        return result

    def _fill(self, values: Iterable):
        """
        Fill the provided `values` tensor with new decision values.

        Inheriting subclasses can override this method to specialize how
        new solutions are generated.

        For numeric problems, this method already has an implementation
        which samples the initial decision values uniformly from the
        interval expressed by `initial_bounds` attribute.
        For non-numeric problems, overriding this method is mandatory.

        Args:
            values: The tensor which is to be filled with the new decision
                values.
        """
        if self.dtype is object:
            raise NotImplementedError(
                "The dtype of this problem is object, therefore a manual implementation of the"
                " method `_fill(...)` needs to be provided by the inheriting class."
            )
        else:
            if (self.initial_lower_bounds is None) or (self.initial_upper_bounds is None):
                raise RuntimeError(
                    "The default implementation of the method `_fill(...)` does not know how to initialize solutions"
                    " because it appears that this Problem object was not given neither `initial_bounds` nor `bounds`"
                    " during the moment of initialization."
                    " Please either instantiate this Problem object with `initial_bounds` and/or `bounds`, or override"
                    " the method `_fill(...)` to specify how solutions should be initialized."
                )
            else:
                return self.make_uniform(
                    out=values,
                    lb=self.initial_lower_bounds,
                    ub=self.initial_upper_bounds,
                )

    def generate_batch(
        self,
        popsize: Optional[int] = None,
        *,
        empty: bool = False,
        center: Optional[RealOrVector] = None,
        stdev: Optional[RealOrVector] = None,
        symmetric: bool = False,
    ) -> "SolutionBatch":
        """
        Generate a new SolutionBatch.

        Args:
            popsize: Number of solutions that will be contained in the new
                batch.
            empty: Set this as True if you would like to receive the solutions
                un-initialized.
            center: Center point of the Gaussian distribution from which
                the decision values will be sampled, as a scalar or as a
                1-dimensional vector.
                Can also be left as None.
                If `center` is None and `stdev` is None, all the decision
                values will be sampled from the interval specified by
                `initial_bounds` (or by `bounds` if `initial_bounds` was not
                specified).
                If `center` is None and `stdev` is not None, a center point
                will be sampled from within the interval specified by
                `initial_bounds` or `bounds`, and the decision values will be
                sampled from a Gaussian distribution around this center point.
            stdev: Can be None (default) if the SolutionBatch is to contain
                decision values sampled from the interval specified by
                `initial_bounds` (or by `bounds` if `initial_bounds` was not
                provided during the initialization phase).
                Alternatively, a scalar or a 1-dimensional vector specifying
                the standard deviation of the Gaussian distribution from which
                the decision values will be sampled.
            symmetric: To be used only when `stdev` is not None.
                If `symmetric` is True, decision values will be sampled from
                the Gaussian distribution in a symmetric (i.e. antithetic)
                manner.
                Otherwise, the decision values will be sampled in the
                non-antithetic manner.
        """
        if (center is None) and (stdev is None):
            if symmetric:
                raise ValueError(
                    f"The argument `symmetric` can be set as True only when `center` and `stdev` are provided."
                    f" Although `center` and `stdev` are None, `symmetric` was received as {symmetric}."
                )
            return SolutionBatch(self, popsize, empty=empty, device=self.device)
        elif (center is not None) and (stdev is not None):
            if empty:
                raise ValueError(
                    f"When `center` and `stdev` are provided, the argument `empty` must be False."
                    f" However, the received value for `empty` is {empty}."
                )
            result = SolutionBatch(self, popsize, device=self.device, empty=True)
            self.make_gaussian(out=result.access_values(), center=center, stdev=stdev, symmetric=symmetric)
            return result
        else:
            raise ValueError(
                f"The arguments `center` and `stdev` were expected to be None or non-None at the same time."
                f" Received `center`: {center}."
                f" Received `stdev`: {stdev}."
            )

    def _parallelize(self):
        """Create ray actors for parallelizing the solution evaluations."""

        # If the problem was explicitly configured for
        # NOT having parallelization, leave this function.
        if (not isinstance(self._num_actors, str)) and (self._num_actors <= 0):
            return

        # If this problem object is a remote one,
        # leave this function
        # (because we do not want the remote worker
        # to parallelize itself further)
        if self._actor_index is not None:
            return

        # If the actors list is not None, then this means
        # that the initialization of the parallelization mechanism
        # was already completed. So, leave this function.
        if self._actors is not None:
            return

        # Make sure that ray is initialized
        ensure_ray()
        number_of_actors = self._num_actors

        # numpy's RandomState uses 32-bit unsigned integers
        # for random seeds.
        # So, the following value is the exclusive upper bound
        # for a random seed.
        supremum_seed = 2**32

        # Generate an integer from the main problem object's
        # random_state. From this integer, further seed integers
        # will be computed, and these generated seeds will be
        # used by the remote actors.
        base_seed = int(self.make_randint(tuple(), n=supremum_seed))

        # The following function returns a seed number for the actor
        # number i.
        def generate_actor_seed(i):
            nonlocal base_seed, supremum_seed
            return (base_seed + (i + 1)) % supremum_seed

        all_seeds = []
        j = 0
        for i in range(number_of_actors):
            actor_seeds = []
            for _ in range(4):
                actor_seeds.append(generate_actor_seed(j))
                j += 1
            all_seeds.append(tuple(actor_seeds))

        if self._remote_states is None:
            remote_states = [{} for _ in range(number_of_actors)]
        else:
            remote_states = self._remote_states

        # Prepare the necessary actor config
        config_per_actor = {}
        if self._actor_config is not None:
            config_per_actor.update(self._actor_config)
        if isinstance(self._num_gpus_per_actor, (int, float)):
            config_per_actor["num_gpus"] = self._num_gpus_per_actor

        # Generate the actors, each with a unique seed.
        if config_per_actor is None:
            actors = [EvaluationActor.remote(self, i, all_seeds[i], remote_states[i]) for i in range(number_of_actors)]
        else:
            actors = [
                EvaluationActor.options(**config_per_actor).remote(self, i, all_seeds[i], remote_states[i])
                for i in range(number_of_actors)
            ]

        self._actors = actors
        self._actor_pool = ActorPool(self._actors)
        self._remote_states = None

    def all_remote_problems(self) -> AllRemoteProblems:
        """
        Get an accessor which is used for running a method
        on all remote clones of this Problem object.

        For example, given a Problem object named `my_problem`,
        also assuming that this Problem object is parallelized,
        and therefore has `n` remote actors, a method `f()`
        can be executed on all the remote instances as follows:

            results = my_problem.all_remote_problems().f()

        The variable `results` is a list of length `n`, the i-th
        item of the list belonging to the method f's result
        from the i-th actor.

        Returns:
            A method accessor for all the remote Problem objects.
        """
        self._parallelize()
        if self.is_remote:
            raise RuntimeError(
                "The method `all_remote_problems()` can only be used on the main (i.e. non-remote)"
                " Problem instance."
                " However, this Problem instance is on a remote actor."
            )
        return AllRemoteProblems(self._actors)

    def all_remote_envs(self) -> AllRemoteEnvs:
        """
        Get an accessor which is used for running a method
        on all remote reinforcement learning environments.

        This method can only be used on parallelized Problem
        objects which have their `get_env()` methods defined.
        For example, one can use this feature on a parallelized
        GymProblem.

        As an example, let us consider a parallelized GymProblem
        object named `my_problem`. Given that `my_problem` has
        `n` remote actors, a method `f()` can be executed
        on all remote reinforcement learning environments as
        follows:

            results = my_problem.all_remote_envs().f()

        The variable `results` is a list of length `n`, the i-th
        item of the list belonging to the method f's result
        from the i-th actor.

        Returns:
            A method accessor for all the remote reinforcement
            learning environments.
        """
        self._parallelize()
        if self.is_remote:
            raise RuntimeError(
                "The method `all_remote_envs()` can only be used on the main (i.e. non-remote)"
                " Problem instance."
                " However, this Problem instance is on a remote actor."
            )
        return AllRemoteEnvs(self._actors)

    def kill_actors(self):
        """
        Kill all the remote actors used by the Problem instance.

        One might use this method to release the resources used by the
        remote actors.
        """
        if not self.is_main:
            raise RuntimeError(
                "The method `kill_actors()` can only be used on the main (i.e. non-remote)"
                " Problem instance."
                " However, this Problem instance is on a remote actor."
            )
        for actor in self._actors:
            ray.kill(actor)
        self._actors = None
        self._actor_pool = None

    @property
    def num_actors(self) -> int:
        """
        Number of actors (to be) used for parallelization.
        If the problem is configured for no parallelization,
        the result will be 0.
        """
        return self._num_actors

    @property
    def actors(self) -> Optional[list]:
        """
        Get the ray actors, if the Problem object is distributed.
        If the Problem object is not distributed and therefore
        has no actors, then, the result will be None.
        """
        return self._actors

    @property
    def actor_index(self) -> Optional[int]:
        """Return the actor index if this is a remote worker.
        If this is not a remote worker, return None.
        """
        return self._actor_index

    @property
    def is_remote(self) -> bool:
        """Returns True if this problem object lives in a remote ray actor.
        Otherwise, returns False.
        """
        return self._actor_index is not None

    @property
    def is_main(self) -> bool:
        """Returns True if this problem object lives in the main process
        and not in a remote actor.
        Otherwise, returns False.
        """
        return self._actor_index is None

    @property
    def before_eval_hook(self) -> Hook:
        """
        Get the Hook which stores the functions to call just before
        evaluating a SolutionBatch.

        The functions to be stored in this hook are expected to
        accept one positional argument, that one argument being the
        SolutionBatch which is about to be evaluated.
        """
        return self._before_eval_hook

    @property
    def after_eval_hook(self) -> Hook:
        """
        Get the Hook which stores the functions to call just after
        evaluating a SolutionBatch.

        The functions to be stored in this hook are expected to
        accept one argument, that one argument being the SolutionBatch
        whose evaluation has just been completed.

        The dictionaries returned by the functions in this hook
        are accumulated, and reported in the status dictionary of this
        problem object.
        """
        return self._after_eval_hook

    @property
    def before_grad_hook(self) -> Hook:
        """
        Get the Hook which stores the functions to call just before
        its `sample_and_compute_gradients(...)` operation.
        """
        return self._before_grad_hook

    @property
    def after_grad_hook(self) -> Hook:
        """
        Get the Hook which stores the functions to call just after
        its `sample_and_compute_gradients(...)` operation.

        The functions to be stored in this hook are expected to
        accept one argument, that one argument being the gradients
        dictionary (which was produced by the Problem object,
        but not yet followed by the search algorithm).

        The dictionaries returned by the functions in this hook
        are accumulated, and reported in the status dictionary of this
        problem object.
        """
        return self._after_grad_hook

    @property
    def remote_hook(self) -> Hook:
        """
        Get the Hook which stores the functions to call when this
        Problem object is (re)created on a remote actor.

        The functions in this hook should expect one positional
        argument, that is the Problem object itself.
        """
        return self._remote_hook

    def _make_sync_data_for_actors(self) -> Any:
        """
        Override this function for providing synchronization between
        the main process and the remote actors.

        The responsibility of this function is to prepare and return the
        data to be sent to the remote actors for synchronization.

        If this function returns NotImplemented, then there will be no
        syncing.
        If this function returns None, there will be no data sent to the
        actors for syncing, however, syncing will still be enabled, and
        the main actor will ask for sync data from the remote actors
        after their jobs are finished.
        """
        return NotImplemented

    def _use_sync_data_from_main(self, received: Any):
        """
        Override this function for providing synchronization between
        the main process and the remote actors.

        The responsibility of this function is to update the state
        of the remote Problem object according to the synchronization
        data received by the main process.
        """
        pass

    def _make_sync_data_for_main(self) -> Any:
        """
        Override this function for providing synchronization between
        the main process and the remote actors.

        The responsibility of this function is to prepare and return the
        data to be sent to the main Problem object by a remote actor.
        """
        return NotImplemented

    def _use_sync_data_from_actors(self, received: list):
        """
        Override this function for providing synchronization between
        the main process and the remote actors.

        The responsibility of this function is to update the state
        of the main Problem object according to the synchronization
        data received by the remote actors.
        """
        pass

    def _make_pickle_data_for_main(self) -> dict:
        """
        Override this function for preserving the state of a remote
        actor in the main state dictionary when pickling a parallelized
        problem.

        The responsibility of this function is to return the state
        of a problem object which lives in a remote actor.

        If the remote clones of this problem do not need to be stateful
        then you probably do not need to override this method.
        """
        return {}

    def _use_pickle_data_from_main(self, state: dict):
        """
        Override this function for re-creating the internal state of
        a problem instance living in a remote actor, by using the
        given state dictionary.

        If the remote clones of this problem do not need to be stateful
        then you probably do not need to override this method.
        """
        pass

    def _sync_before(self) -> bool:
        if self._actors is None:
            return False

        to_send = self._make_sync_data_for_actors()
        if to_send is NotImplemented:
            return False

        if to_send is not None:
            ray.get([actor.call.remote("_use_sync_data_from_main", [to_send], {}) for actor in self._actors])

        return True

    def _sync_after(self):
        if self._actors is None:
            return

        received = ray.get([actor.call.remote("_make_sync_data_for_main", [], {}) for actor in self._actors])

        self._use_sync_data_from_actors(received)

    @torch.no_grad()
    def _get_best_and_worst(self, batch: "SolutionBatch") -> Optional[dict]:
        if self._store_solution_stats is None:
            self._store_solution_stats = str(batch.device) == "cpu"

        if not self._store_solution_stats:
            return {}

        senses = self.senses
        nobjs = len(senses)

        if self._best is None:
            self._best_evals = self.make_empty(nobjs, device=batch.device, use_eval_dtype=True)
            self._worst_evals = self.make_empty(nobjs, device=batch.device, use_eval_dtype=True)
            for i_obj in range(nobjs):
                if senses[i_obj] == "min":
                    self._best_evals[i_obj] = float("inf")
                    self._worst_evals[i_obj] = float("-inf")
                elif senses[i_obj] == "max":
                    self._best_evals[i_obj] = float("-inf")
                    self._worst_evals[i_obj] = float("inf")
                else:
                    raise ValueError(f"Invalid sense: {senses[i_obj]}")
            self._best = [None] * nobjs
            self._worst = [None] * nobjs

        def first_is_better(a, b, i_obj):
            if senses[i_obj] == "min":
                return a < b
            elif senses[i_obj] == "max":
                return a > b
            else:
                raise ValueError(f"Invalid sense: {senses[i_obj]}")

        def first_is_worse(a, b, i_obj):
            if senses[i_obj] == "min":
                return a > b
            elif senses[i_obj] == "max":
                return a < b
            else:
                raise ValueError(f"Invalid sense: {senses[i_obj]}")

        best_sln_indices = [batch.argbest(i) for i in range(nobjs)]
        worst_sln_indices = [batch.argworst(i) for i in range(nobjs)]

        for i_obj in range(nobjs):
            best_sln_index = best_sln_indices[i_obj]
            worst_sln_index = worst_sln_indices[i_obj]
            scores = batch.access_evals(i_obj)
            best_score = scores[best_sln_index]
            worst_score = scores[worst_sln_index]
            if first_is_better(best_score, self._best_evals[i_obj], i_obj):
                self._best_evals[i_obj] = best_score
                self._best[i_obj] = batch[best_sln_index].clone()
            if first_is_worse(worst_score, self._worst_evals[i_obj], i_obj):
                self._worst_evals[i_obj] = worst_score
                self._worst[i_obj] = batch[worst_sln_index].clone()

        if len(senses) == 1:
            return dict(
                best=self._best[0],
                worst=self._worst[0],
                best_eval=float(self._best[0].evals[0]),
                worst_eval=float(self._worst[0].evals[0]),
            )
        else:
            return {"best": self._best, "worst": self._worst}

    def compare_solutions(self, a: "Solution", b: "Solution", obj_index: Optional[int] = None) -> float:
        """
        Compare two solutions.
        It is assumed that both solutions are already evaluated.

        Args:
            a: The first solution.
            b: The second solution.
            obj_index: The objective index according to which the comparison
                will be made.
                Can be left as None if the problem is single-objective.
        Returns:
            A positive number if `a` is better;
            a negative number if `b` is better;
            0 if there is a tie.
        """
        senses = self.senses
        obj_index = self.normalize_obj_index(obj_index)
        sense = senses[obj_index]

        def score(s: Solution):
            return s.evals[obj_index]

        if sense == "max":
            return score(a) - score(b)
        elif sense == "min":
            return score(b) - score(a)
        else:
            raise ValueError("Unrecognized sense: " + repr(sense))

    def is_better(self, a: "Solution", b: "Solution", obj_index: Optional[int] = None) -> bool:
        """
        Check whether or not the first solution is better.
        It is assumed that both solutions are already evaluated.

        Args:
            a: The first solution.
            b: The second solution.
            obj_index: The objective index according to which the comparison
                will be made.
                Can be left as None if the problem is single-objective.
        Returns:
            True if `a` is better; False otherwise.
        """
        return self.compare_solutions(a, b, obj_index) > 0

    def is_worse(self, a: "Solution", b: "Solution", obj_index: Optional[int] = None) -> bool:
        """
        Check whether or not the first solution is worse.
        It is assumed that both solutions are already evaluated.

        Args:
            a: The first solution.
            b: The second solution.
            obj_index: The objective index according to which the comparison
                will be made.
                Can be left as None if the problem is single-objective.
        Returns:
            True if `a` is worse; False otherwise.
        """
        return self.compare_solutions(a, b, obj_index) < 0

    def _prepare(self) -> None:
        """Prepare a worker instance of the problem for evaluation. To be overridden by the user"""
        pass

    def _prepare_main(self) -> None:
        """Prepare the main instance of the problem for evaluation."""
        self._share_attributes()

    def _start_preparations(self) -> None:
        """Prepare the problem for evaluation. Calls self._prepare() if the self._prepared flag is not True."""
        if not self._prepared:
            if self.actors is None or self._num_actors == 0:
                # Call prepare method for any problem class that is expected to do work
                self._prepare()
            if self.is_main:
                # Call share method to distribute shared attributes to actors
                self._prepare_main()

            self._prepared = True

    @property
    def _nonserialized_attribs(self) -> List[str]:
        return []

    def _share_attributes(self) -> None:
        if (self._actors is not None) and (len(self._actors) > 0):
            for attrib_name in self._shared_attribs:
                obj_ref = ray.put(getattr(self, attrib_name))
                for actor in self.actors:
                    actor.call.remote("put_ray_object", [], {"obj_ref": obj_ref, "attrib_name": attrib_name})

    def put_ray_object(self, obj_ref: ray.ObjectRef, attrib_name: str) -> None:
        setattr(self, attrib_name, ray.get(obj_ref))

    @property
    def _shared_attribs(self) -> List[str]:
        return []

    def _device_of_fitness_function(self) -> Optional[Device]:
        def device_of_fn(fn: Optional[Callable]) -> Optional[Device]:
            if fn is None:
                return None
            else:
                if hasattr(fn, "__evotorch_on_aux_device__") and fn.__evotorch_on_aux_device__:
                    return self.aux_device
                elif hasattr(fn, "device"):
                    return fn.device
                else:
                    return None

        for candidate_fn in (self._objective_func, self._evaluate_all, self._evaluate_batch, self._evaluate):
            device = device_of_fn(candidate_fn)
            if device is not None:
                if candidate_fn is self._evaluate_all:
                    raise RuntimeError(
                        "It seems that the `_evaluate_all(...)` method of this Problem object is either decorated"
                        " by @on_aux_device or by @on_device, or it is specifying a target device via a `device`"
                        " attribute. However, these decorators (or the `device` attribute) are not supported in the"
                        " case of `_evaluate_all(...)`."
                        " The reason is that the checking of the target device and the operations of moving the batch"
                        " onto the target device are handled by the default implementation of `_evaluate_all` itself."
                        " To specify a target device, consider decorating `_evaluate_batch(...)` or `_evaluate(...)`"
                        " instead."
                    )
                return device

        return None

    def evaluate(self, x: Union["SolutionBatch", "Solution"]):
        """
        Evaluate the given Solution or SolutionBatch.

        Args:
            x: The SolutionBatch to be evaluated.
        """

        if isinstance(x, Solution):
            batch = x.to_batch()
        elif isinstance(x, SolutionBatch):
            batch = x
        else:
            raise TypeError(
                f"The method `evaluate(...)` expected a Solution or a SolutionBatch as its argument."
                f" However, the received object is {repr(x)}, which is of type {repr(type(x))}."
            )

        self._parallelize()

        if self.is_main:
            self.before_eval_hook(batch)

        must_sync_after = self._sync_before()

        self._start_preparations()

        self._evaluate_all(batch)

        if must_sync_after:
            self._sync_after()

        if self.is_main:
            self._after_eval_status = {}

            best_and_worst = self._get_best_and_worst(batch)
            if best_and_worst is not None:
                self._after_eval_status.update(best_and_worst)

            self._after_eval_status.update(self.after_eval_hook.accumulate_dict(batch))

    def _evaluate_all(self, batch: "SolutionBatch"):
        if self._actors is None:
            fitness_device = self._device_of_fitness_function()
            if fitness_device is None:
                self._evaluate_batch(batch)
            else:
                original_device = batch.device
                moved_batch = batch.to(fitness_device)
                self._evaluate_batch(moved_batch)
                batch.set_evals(moved_batch.evals.to(original_device))
        else:
            if self._num_subbatches is not None:
                pieces = batch.split(self._num_subbatches)
            elif self._subbatch_size is not None:
                pieces = batch.split(max_size=self._subbatch_size)
            else:
                pieces = batch.split(len(self._actors))
            # mapresult = self._actor_pool.map(lambda a, v: a.evaluate_batch.remote(v), list(pieces))
            # for i, evals in enumerate(mapresult):
            #    row_begin, row_end = pieces.indices_of(i)
            #    batch._evdata[row_begin:row_end, :] = evals

            mapresult = self._actor_pool.map_unordered(
                lambda a, v: a.evaluate_batch_piece.remote(v[0], v[1]), list(enumerate(pieces))
            )
            for i, evals in mapresult:
                row_begin, row_end = pieces.indices_of(i)
                batch._evdata[row_begin:row_end, :] = evals

    def _evaluate_batch(self, batch: "SolutionBatch"):
        if self._vectorized and (self._objective_func is not None):
            result = self._objective_func(batch.values)
            if isinstance(result, tuple):
                batch.set_evals(*result)
            else:
                batch.set_evals(result)
        else:
            for sln in batch:
                self._evaluate(sln)

    def _evaluate(self, solution: "Solution"):
        if self._objective_func is not None:
            result = self._objective_func(solution.values)
            if isinstance(result, tuple):
                solution.set_evals(*result)
            else:
                solution.set_evals(result)
        else:
            raise NotImplementedError

    @property
    def stores_solution_stats(self) -> Optional[bool]:
        """
        Whether or not the best and worst solutions are kept.
        """
        return self._store_solution_stats

    @property
    def status(self) -> dict:
        """
        Status dictionary of the problem object, updated after the last
        evaluation operation.

        The dictionaries returned by the functions in `after_eval_hook`
        are accumulated, and reported in this status dictionary.
        """
        return self._after_eval_status

    def ensure_numeric(self):
        """
        Ensure that the problem has a numeric dtype.

        Raises:
            ValueError: if the problem has a non-numeric dtype.
        """
        if is_dtype_object(self.dtype):
            raise ValueError("Expected a problem with numeric dtype, but the dtype is object.")

    def ensure_unbounded(self):
        """
        Ensure that the problem has no strict lower and upper bounds.

        Raises:
            ValueError: if the problem has strict lower and upper bounds.
        """
        if not (self.lower_bounds is None and self.upper_bounds is None):
            raise ValueError("Expected an unbounded problem, but this problem has lower and/or upper bounds.")

    def ensure_single_objective(self):
        """
        Ensure that the problem has only one objective.

        Raises:
            ValueError: if the problem is multi-objective.
        """
        n = len(self.senses)
        if n > 1:
            raise ValueError(f"Expected a single-objective problem, but this problem has {n} objectives.")

    def normalize_obj_index(self, obj_index: Optional[int] = None) -> int:
        """
        Normalize the objective index.

        If the provided index is non-negative, it is ensured that the index
        is valid.

        If the provided index is negative, the objectives are counted in the
        reverse order, and the corresponding non-negative index is returned.
        For example, -1 is converted to a non-negative integer corresponding to
        the last objective.

        If the provided index is None and if the problem is single-objective,
        the returned value is 0, which represents the only objective.

        If the provided index is None and if the problem is multi-objective,
        an error is raised.

        Args:
            obj_index: The non-normalized objective index.
        Returns:
            The normalized objective index, as a non-negative integer.
        """
        if obj_index is None:
            if len(self.senses) == 1:
                return 0
            else:
                raise ValueError(
                    "This problem is multi-objective, therefore, an explicit objective index was expected."
                    " However, `obj_index` was found to be None."
                )
        else:
            obj_index = int(obj_index)
            if obj_index < 0:
                obj_index = len(self.senses) + obj_index
            if obj_index < 0 or obj_index >= len(self.senses):
                raise IndexError("Objective index out of range.")
            return obj_index

    def _get_cloned_state(self, *, memo: dict) -> dict:
        # Collect the inner states of the remote Problem clones
        if self._actors is not None:
            self._remote_states = ray.get(
                [actor.call.remote("_make_pickle_data_for_main", [], {}) for actor in self._actors]
            )

        # Prepare the main state dictionary
        result = {}
        for k, v in self.__dict__.items():
            if k in ("_actors", "_actor_pool") or k in self._nonserialized_attribs:
                result[k] = None
            else:
                v_id = id(v)
                if v_id in memo:
                    result[k] = memo[v_id]
                else:
                    with _no_grad_if_basic_dtype(self.dtype):
                        result[k] = deep_clone(
                            v,
                            otherwise_deepcopy=True,
                            memo=memo,
                        )
        return result

    def _get_local_interaction_count(self) -> int:
        """
        Get the number of simulator interactions this Problem encountered.

        For problems focused on reinforcement learning, it is expected
        that the subclass overrides this method to describe its own way
        of getting the local interaction count.

        When working on parallelized problems, what is returned here is
        not necessarily synchronized with the other parallelized instance.
        """
        raise NotImplementedError

    def _get_local_episode_count(self) -> int:
        """
        Get the number of episodes this Problem encountered.

        For problems focused on reinforcement learning, it is expected
        that the subclass overrides this method to describe its own way
        of getting the local episode count.

        When working on parallelized problems, what is returned here is
        not necessarily synchronized with the other parallelized instance.
        """
        raise NotImplementedError

    def sample_and_compute_gradients(
        self,
        distribution,
        popsize: int,
        *,
        num_interactions: Optional[int] = None,
        popsize_max: Optional[int] = None,
        obj_index: Optional[int] = None,
        ranking_method: Optional[str] = None,
        with_stats: bool = True,
        ensure_even_popsize: bool = False,
    ) -> Union[list, dict]:
        """
        Sample new solutions from the distribution and compute gradients.

        The distribution can then be updated according to the computed
        gradients.

        If the problem is not parallelized, and `with_stats` is False,
        then the result will be a single dictionary of gradients.
        For example, in the case of a Gaussian distribution, the returned
        gradients dictionary would look like this:

            {
                "mu": ...,     # the gradient for the mean
                "sigma": ...,  # the gradient for the standard deviation
            }

        If the problem is not parallelized, and `with_stats` is True,
        then the result will be a dictionary which contains in itself
        the gradients dictionary, and additional elements for providing
        further information. In the case of a Gaussian distribution,
        the returned dictionary with additional stats would look like
        this:

            {
                "gradients": {
                    "mu": ...,     # the gradient for the mean
                    "sigma": ...,  # the gradient for the standard deviation
                },
                "num_solutions": ...,  # how many solutions were sampled
                "mean_eval": ...,      # Mean of all evaluations
            }

        If the problem is parallelized, then the gradient computation will
        be distributed among the remote actors. In more details, each actor
        will sample its own solutions (such that the total population size
        across all remote actors will be near the provided `popsize`)
        and will compute its own gradients, and will produce its own
        additional stats (if `with_stats` is given as True).
        These remote results will then be collected by the main process,
        and the final result of this method will be a list of dictionaries,
        each dictionary being the result of a remote gradient computation.

        The sampled solutions are temporary, and will not be kept
        (and will not be returned).

        To customize how solutions are sampled and how gradients are
        computed, one is encouraged to override
        `_sample_and_compute_gradients(...)` (instead of overriding this
        method directly.

        Args:
            distribution: The search distribution from which the solutions
                will be sampled, and according to which the gradients will
                be computed.
            popsize: The number of solutions which will be sampled.
            num_interactions: Number of simulator interactions that must
                be completed (more solutions will be sampled until this
                threshold is reached). This argument is to be used when
                the problem has characteristics similar to reinforcement
                learning, and an adaptive population size, depending on
                the interactions made, is desired.
                Otherwise, one can leave this argument as None, in which
                case, there will not be any threshold based on number
                of interactions.
            popsize_max: To be used when `num_interactions` is provided,
                as an additional criterion for ending the solution sampling
                phase. This argument can be used to prevent the population
                size from growing too much while trying to satisfy the
                `num_interactions`. If not needed, `popsize_max` can be left
                as None.
            obj_index: Index of the objective according to which the gradients
                will be computed. Can be left as None if the problem has only
                one objective.
            ranking_method: The solution ranking method to be used when
                computing the gradients.
                If not specified, the raw fitnesses will be used.
            with_stats: If given as False, then the results dictionary will
                only contain the gradients information. If given as True,
                then the results dictionary will contain within itself
                the gradients dictionary, and also additional elements for
                providing further information.
                The default is True.
            ensure_even_popsize: If `ensure_even_popsize` is True and the
                problem is not parallelized, then a `popsize` given as an odd
                number will cause an error. If `ensure_even_popsize` is True
                and the problem is parallelized, then the remote actors will
                sample their own sub-populations in such a way that their
                sizes are even.
                If `ensure_even_popsize` is False, whether or not the
                `popsize` is even will not be checked.
                When the provided `distribution` is a symmetric (or
                "mirrored", or "antithetic"), then this argument must be
                given as True.
        Returns:
            A results dictionary when the problem is not parallelized,
            or list of results dictionaries when the problem is parallelized.
        """

        # For problems which are configured for parallelization, make sure that the actors are created.
        self._parallelize()

        # Below we check if there is an inconsistency in arguments.
        if (num_interactions is None) and (popsize_max is not None):
            # If `num_interactions` is None, then we assume that the user does not wish an adaptive population size.
            # However, at the same time, if `popsize_max` is not None, then there is an inconsistency,
            # because, `popsize_max` without `num_interactions` (therefore without adaptive population size)
            # does not make sense.
            # This is probably a configuration error, so, we inform the user by raising an error.
            raise ValueError(
                f"`popsize_max` was expected as None, because `num_interactions` is None."
                f" However, `popsize_max` was found as {popsize_max}."
            )

        # The problem instance in the main process should trigger the `before_grad_hook`.
        if self.is_main:
            self._before_grad_hook()

        if self.is_main and (self._actors is not None) and (len(self._actors) > 0):
            # If this is the main process and the problem is parallelized, then we need to split the request
            # into multiple tasks, and then execute those tasks in parallel using the problem's actor pool.

            if self._subbatch_size is not None:
                # If `subbatch_size` is provided, then we first make sure that `popsize` is divisible by
                # `subbatch_size`
                if (popsize % self._subbatch_size) != 0:
                    raise ValueError(
                        f"This Problem was created with `subbatch_size` as {self._subbatch_size}."
                        f" When doing remote gradient computation, the requested population size must be divisible by"
                        f" the `subbatch_size`."
                        f" However, the requested population size is {popsize}, and the remainder after dividing it"
                        f" by `subbatch_size` is not 0 (it is {popsize % self._subbatch_size})."
                    )
                # After making sure that `popsize` and `subbatch_size` configurations are compatible, we declare that
                # we are going to have n tasks, each task imposing a sample size of `subbatch_size`.
                n = int(popsize // self._subbatch_size)
                popsize_per_task = [self._subbatch_size for _ in range(n)]
            elif self._num_subbatches is not None:
                # If `num_subbatches` is provided, then we are going to have n tasks where n is equal to the given
                # `num_subbatches`.
                popsize_per_task = split_workload(popsize, self._num_subbatches)
            else:
                # If neither `subbatch_size` nor `num_subbatches` is given, then we will split the workload in such
                # a way that each actor will have its share.
                popsize_per_task = split_workload(popsize, len(self._actors))

            if ensure_even_popsize:
                # If `ensure_even_popsize` argument is True, then we need to make sure that each tasks's popsize is
                # an even number.
                for i in range(len(popsize_per_task)):
                    if (popsize_per_task[i] % 2) != 0:
                        # If the i-th actor's assigned popsize is not even, increase its assigned popsize by 1.
                        popsize_per_task[i] += 1

            # The number of tasks is finally determined by the length of `popsize_per_task` list we created above.
            num_tasks = len(popsize_per_task)

            if num_interactions is None:
                # If the argument `num_interactions` is not given, then, for each task, we declare that
                # `num_interactions` is None.
                num_inter_per_task = [None for _ in range(num_tasks)]
            else:
                # If the argument `num_interactions` is given, then we compute each task's target number of
                # interactions from its sample size.
                num_inter_per_task = [
                    math.ceil((popsize_per_task[i] / popsize) * num_interactions) for i in range(num_tasks)
                ]

            if popsize_max is None:
                # If the argument `popsize_max` is not given, then, for each task, we declare that
                # `popsize_max` is None.
                popsize_max_per_task = [None for _ in range(num_tasks)]
            else:
                # If the argument `popsize_max` is given, then we compute each task's target maximum population size
                # from its sample size.
                popsize_max_per_task = [
                    math.ceil((popsize_per_task[i] / popsize) * popsize_max) for i in range(num_tasks)
                ]

            # We trigger the synchronization between the main process and the remote actors.
            # If this problem instance has nothing to synchronize, then `must_sync_after` will be False.
            must_sync_after = self._sync_before()

            # Because we want to send the distribution to remote actors, we first copy the distribution to cpu
            # (unless it is already on cpu)
            dist_on_cpu = distribution.to("cpu")

            # Here, we use our actor pool to execute our tasks in parallel.
            result = list(
                self._actor_pool.map_unordered(
                    (
                        lambda a, v: a.call.remote(
                            "_sample_and_compute_gradients",
                            [dist_on_cpu, v[0]],
                            {
                                "obj_index": obj_index,
                                "num_interactions": v[1],
                                "popsize_max": v[2],
                                "ranking_method": ranking_method,
                            },
                        )
                    ),
                    list(zip(popsize_per_task, num_inter_per_task, popsize_max_per_task)),
                )
            )

            # At this point, all the tensors within our collected results are on the CPU.

            if torch.device(self.device) != torch.device("cpu"):
                # If the main device of this problem instance is not CPU, then we move the tensors to the main device.
                result = cast_tensors_in_container(result, device=self.device)

            if must_sync_after:
                # If a post-gradient synchronization is required, we trigger the synchronization operations.
                self._sync_after()

            # ####################################################
            # # If this is the main process and the problem is parallelized, then we need to split the workload among
            # # the remote actors, and then request each of them to compute their gradients.
            #
            # # We begin by getting the number of actors, and computing the `popsize` for each actor.
            # num_actors = len(self._actors)
            # popsize_per_actor = split_workload(popsize, num_actors)
            #
            # if ensure_even_popsize:
            #     # If `ensure_even_popsize` argument is True, then we need to make sure that each actor's popsize is
            #     # an even number.
            #     for i in range(len(popsize_per_actor)):
            #         if (popsize_per_actor[i] % 2) != 0:
            #             # If the i-th actor's assigned popsize is not even, increase its assigned popsize by 1.
            #             popsize_per_actor[i] += 1
            #
            # if num_interactions is None:
            #     # If `num_interactions` is None, then the `num_interactions` argument for each actor must also be
            #     # passed as None.
            #     num_int_per_actor = [None] * num_actors
            # else:
            #     # If `num_interactions` is not None, then we split the `num_interactions` workload among the actors.
            #     num_int_per_actor = split_workload(num_interactions, num_actors)
            #
            # if popsize_max is None:
            #     # If `popsize_max` is None, then the `popsize_max` argument for each actor must also be None.
            #     popsize_max_per_actor = [None] * num_actors
            # else:
            #     # If `popsize_max` is not None, then we split the `popsize_max` workload among the actors.
            #     popsize_max_per_actor = split_workload(popsize_max, num_actors)
            #
            # # We trigger the synchronization between the main process and the remote actors.
            # # If this problem instance has nothing to synchronize, then `must_sync_after` will be False.
            # must_sync_after = self._sync_before()
            #
            # # Because we want to send the distribution to remote actors, we first copy the distribution to cpu
            # # (unless it is already on cpu)
            # dist_on_cpu = distribution.to("cpu")
            #
            # # To each actor, we send the request of computing the gradients, and then collect the results
            # result = ray.get(
            #     [
            #         self._actors[i].call.remote(
            #             "_gradient_computation_helper",
            #             [dist_on_cpu, popsize_per_actor[i]],
            #             dict(
            #                 num_interactions=num_int_per_actor[i],
            #                 popsize_max=popsize_max_per_actor[i],
            #                 obj_index=obj_index,
            #                 ranking_method=ranking_method,
            #                 with_stats=with_stats,
            #                 move_results_to_device="cpu",
            #             ),
            #         )
            #         for i in range(num_actors)
            #     ]
            # )
            #
            # # At this point, all the tensors within our collected results are on the CPU.
            #
            # if torch.device(self.device) != torch.device("cpu"):
            #     # If the main device of this problem instance is not CPU, then we move the tensors to the main device.
            #     result = cast_tensors_in_container(result, device=device)
            #
            # if must_sync_after:
            #     # If a post-gradient synchronization is required, we trigger the synchronization operations.
            #     self._sync_after()
        else:
            # If the problem is not parallelized, then we request this instance itself to compute the gradients.
            result = self._gradient_computation_helper(
                distribution,
                popsize,
                popsize_max=popsize_max,
                obj_index=obj_index,
                ranking_method=ranking_method,
                num_interactions=num_interactions,
                with_stats=with_stats,
            )

        # The problem instance in the main process should trigger the `after_grad_hook`.
        if self.is_main:
            self._after_eval_status = self._after_grad_hook.accumulate_dict(result)

        # We finally return the results
        return result

    def _gradient_computation_helper(
        self,
        distribution,
        popsize: int,
        *,
        num_interactions: Optional[int] = None,
        popsize_max: Optional[int] = None,
        obj_index: Optional[int] = None,
        ranking_method: Optional[str] = None,
        with_stats: bool = True,
        move_results_to_device: Optional[Device] = None,
    ) -> dict:
        # This is a helper method which makes sure that the provided distribution is in the correct dtype and device.
        # This method also makes sure that the results are moved to the desired device.

        # At first, we make sure that the objective index is normalized
        # (for example, the objective -1 is converted to the index of the last objective).
        obj_index = self.normalize_obj_index(obj_index)

        if (distribution.dtype != self.dtype) or (distribution.device != self.device):
            # Make sure that the distribution is in the correct dtype and device
            distribution = distribution.modified_copy(dtype=self.dtype, device=self.device)

        # Call the protected method responsible for sampling solutions and computing the gradients
        result = self._sample_and_compute_gradients(
            distribution,
            popsize,
            popsize_max=popsize_max,
            obj_index=obj_index,
            num_interactions=num_interactions,
            ranking_method=ranking_method,
        )

        if move_results_to_device is not None:
            # If `move_results_to_device` is provided, move the results to the desired device
            result = cast_tensors_in_container(result, device=move_results_to_device)

        # Finally, return the result
        if with_stats:
            return result
        else:
            return result["gradients"]

    @property
    def _grad_device(self) -> Device:
        """
        Get the device in which new solutions will be made in distributed mode.

        In more details, in distributed mode, each actor creates its own
        sub-populations, evaluates them, and computes its own gradient
        (all such actor gradients eventually being collected by the
        distribution-based search algorithm in the main process).
        For some problem types, it can make sense for the remote actors to
        create their temporary sub-populations on another device
        (e.g. on the GPU that is allocated specifically for them).
        For such situations, one is encouraged to override this property
        and make it return whatever device is to be used.

        Note that this property is used by the default implementation of the
        method named `_sample_and_compute_grad(...)`. If the method named
        `_sample_and_compute_grad(...)` is overriden, this property might not
        be called at all.

        This is the default (i.e. not-yet-overriden) implementation in the
        Problem class, and performs the following operations to decide the
        device:
        (i) if the Problem object was given an external fitness function that
        is decorated by @[on_device][evotorch.decorators.on_device],
        or by @[on_aux_device][evotorch.decorators.on_aux_device],
        or has a `device` attribute, then return the device requested by that
        function; otherwise
        (ii) if either one of the methods `_evaluate_batch`, and `_evaluate`
        was decorated by @[on_device][evotorch.decorators.on_device]
        or by @[on_aux_device][evotorch.decorators.on_aux_device],
        or has a `device` attribute, then return the device requested by that
        method; otherwise
        (iii) return the main device of the Problem object.
        """
        fitness_device = self._device_of_fitness_function()
        return self.device if fitness_device is None else fitness_device

    def _sample_and_compute_gradients(
        self,
        distribution,
        popsize: int,
        *,
        obj_index: int,
        num_interactions: Optional[int] = None,
        popsize_max: Optional[int] = None,
        ranking_method: Optional[str] = None,
    ) -> dict:
        """
        This method contains the description of how the solutions are sampled
        and the gradients are computed according to the given distribution.

        One might override this method for customizing the procedure of
        sampling solutions and the gradient computation, but this method does
        have a default implementation.

        This returns a dictionary which contains the gradients for the given
        distribution, and also further information. For example, considering
        a Gaussian distribution with parameters 'mu' and 'sigma', the result
        is expected to look like this:

            {
                "gradients": {
                    "mu": ...,     # the gradient for the mean (tensor)
                    "sigma": ...,  # the gradient for the std.dev. (tensor)
                },
                "num_solutions": ...,  # how many solutions were sampled (int)
                "mean_eval": ...,      # Mean of all evaluations (float)
            }

        A customized version of this method can add more items to the outer
        dictionary.

        Args:
            distribution: The search distribution from which the solutions
                will be sampled and according to which the gradients will
                be computed. This method assumes that `distribution` is
                given with this problem instance's dtype, and in this problem
                instance's device.
            popsize: Number of solutions to sample.
            obj_index: Objective index, expected as an integer.
            num_interactions: Number of simulator interactions that must be
                reached before computing the gradients.
                Having this argument as an integer implies that adaptive
                population is requested: more solutions are to be sampled
                until this number of simulator interactions are made.
                Can also be None if this threshold is not needed.
            popsize_max: Maximum population size for when the population
                size is adaptive (where the adaptiveness is enabled when
                `num_interactions` is not None).
                Can be left as None if a maximum population size limit
                is not needed.
            ranking_method: Ranking method to be used when computing the
                gradients. Can be left as None, in which case the raw
                fitnesses will be used.
        Returns:
            A dictionary which contains the gradients, number of solutions,
            mean of all the evaluation results, and optionally further
            items (if customized to do so).
        """

        # Annotate the variable which will store the temporary SolutionBatch for computing the local gradient.
        resulting_batch: SolutionBatch

        # Get the device in which the new solutions will be made.
        grad_device = torch.device(self._grad_device)
        distribution = distribution.to(grad_device)

        # Below we define an inner utility function which samples and evaluates a new SolutionBatch.
        # This newly evaluated SolutionBatch is returned.
        def sample_evaluated_batch() -> SolutionBatch:
            batch = SolutionBatch(self, popsize, device=grad_device)
            distribution.sample(out=batch.access_values(), generator=self.generator)
            self.evaluate(batch)
            return batch

        if num_interactions is None:
            # If a `num_interactions` threshold is not given (i.e. is left as None), then we assume that an adaptive
            # population is not desired.
            # We therefore simply sample and evaluate a single SolutionBatch, and declare it as our main batch.
            resulting_batch = sample_evaluated_batch()
        else:
            # If we have a `num_interactions` threshold, then we might have to sample more than one SolutionBatch
            # (until `num_interactions` is reached).
            # We start by defining a list (`batches`) which is to store all the batches we will sample.
            batches = []

            # We will have to count the number of all simulator interactions that we have encountered during the
            # execution of this method. So, to count it correctly, we first get the interaction count that we already
            # have before sampling and evaluating our new solutions.
            interaction_count_at_first = self._get_local_interaction_count()

            # Below is an inner function which returns how many simulator interactions we have done so far.
            # It makes use of the variable `interaction_count_at_first` defined above.
            def current_num_interactions() -> int:
                return self._get_local_interaction_count() - interaction_count_at_first

            # We also keep track of the total number of solutions.
            # We might need this if there is a `popsize_max` threshold.
            current_popsize = 0

            # The main loop of the adaptive sampling.
            while True:
                # Sample and evaluate a new SolutionBatch, and add it to our batches list.
                batches.append(sample_evaluated_batch())

                # Increase our total population size by the size of the most recent batch.
                current_popsize += popsize

                if current_num_interactions() > num_interactions:
                    # If the number of interactions has reached or exceeded the `num_interactions` threshold,
                    # we exit the loop.
                    break
                if (popsize_max is not None) and (current_popsize >= popsize_max):
                    # If we have `popsize_max` threshold and our total population size have reached or exceeded
                    # the `popsize_max` threshold, we exit the loop.
                    break

            if len(batches) == 1:
                # If we have only one batch in our batches list, that batch can be declared as our main batch.
                resulting_batch = batches[0]
            else:
                # If we have multiple batches in our batches list, we concatenate all those batches and
                # declare the result of the concatenation as our main batch.
                resulting_batch = SolutionBatch.cat(batches)

        # We take the solutions (`samples`) and the fitnesses from our main batch.
        samples = resulting_batch.access_values(keep_evals=True)
        fitnesses = resulting_batch.access_evals(obj_index)

        # With the help of `samples` and `fitnesses`, we now compute our gradients.
        grads = distribution.compute_gradients(
            samples, fitnesses, objective_sense=self.senses[obj_index], ranking_method=ranking_method
        )

        if grad_device != self.device:
            grads = cast_tensors_in_container(grads, device=self.device)

        # Finally, we return the result, which is a dictionary containing the gradients and further information.
        return {
            "gradients": grads,
            "num_solutions": len(resulting_batch),
            "mean_eval": float(torch.mean(resulting_batch.access_evals(obj_index))),
        }

    def is_on_cpu(self) -> bool:
        """
        Whether or not the Problem object has its device set as "cpu".
        """
        return str(self.device) == "cpu"

actor_index: Optional[int] property readonly

Return the actor index if this is a remote worker. If this is not a remote worker, return None.

actors: Optional[list] property readonly

Get the ray actors, if the Problem object is distributed. If the Problem object is not distributed and therefore has no actors, then, the result will be None.

after_eval_hook: Hook property readonly

Get the Hook which stores the functions to call just after evaluating a SolutionBatch.

The functions to be stored in this hook are expected to accept one argument, that one argument being the SolutionBatch whose evaluation has just been completed.

The dictionaries returned by the functions in this hook are accumulated, and reported in the status dictionary of this problem object.

after_grad_hook: Hook property readonly

Get the Hook which stores the functions to call just after its sample_and_compute_gradients(...) operation.

The functions to be stored in this hook are expected to accept one argument, that one argument being the gradients dictionary (which was produced by the Problem object, but not yet followed by the search algorithm).

The dictionaries returned by the functions in this hook are accumulated, and reported in the status dictionary of this problem object.

aux_device: Union[str, torch.device] property readonly

Auxiliary device to help with the computations, most commonly for speeding up the solution evaluations.

An auxiliary device is different than the main device of the Problem object (the main device being expressed by the device property). While the main device of the Problem object determines where the solutions and the populations are stored (and also using which device should a SearchAlgorithm instance communicate with the problem), an auxiliary device is a device that might be used by the Problem instance itself for its own computations (e.g. computations defined within the methods _evaluate(...) or _evaluate_batch(...)).

If the problem's main device is something other than "cpu", that main device is also seen as the auxiliary device, and therefore returned by this property.

If the problem's main device is "cpu", then the auxiliary device is decided as follows. If num_gpus_per_actor of the Problem object was set as "all" and if this instance is a remote instance, then the auxiliary device is guessed as "cuda:N" where N is the actor index. In all other cases, the auxiliary device is "cuda" if cuda is available, and "cpu" otherwise.

before_eval_hook: Hook property readonly

Get the Hook which stores the functions to call just before evaluating a SolutionBatch.

The functions to be stored in this hook are expected to accept one positional argument, that one argument being the SolutionBatch which is about to be evaluated.

before_grad_hook: Hook property readonly

Get the Hook which stores the functions to call just before its sample_and_compute_gradients(...) operation.

device: Union[str, torch.device] property readonly

device of the Problem object.

New solutions and populations will be generated in this device.

dtype: Union[str, torch.dtype, numpy.dtype, Type] property readonly

dtype of the Problem object.

The decision variables of the optimization problem are of this dtype.

eval_data_length: int property readonly

Length of the extra evaluation data vector for each solution.

eval_dtype: Union[str, torch.dtype, numpy.dtype, Type] property readonly

evaluation dtype of the Problem object.

The evaluation results of the solutions are stored according to this dtype.

generator: Optional[torch._C.Generator] property readonly

Random generator used by this Problem object.

Can also be None, which means that the Problem object will use the global random generator of PyTorch.

has_own_generator: bool property readonly

Whether or not the Problem object has its own random generator.

If this is True, then the Problem object will use its own random generator when creating random values or tensors. If this is False, then the Problem object will use the global random generator when creating random values or tensors.

initial_lower_bounds: Optional[torch.Tensor] property readonly

Initial lower bounds, for when initializing a new solution.

If such a bound was declared during the initialization phase, the returned value is a torch tensor (in the form of a vector or in the form of a scalar). If no such bound was declared, the returned value is None.

initial_upper_bounds: Optional[torch.Tensor] property readonly

Initial upper bounds, for when initializing a new solution.

If such a bound was declared during the initialization phase, the returned value is a torch tensor (in the form of a vector or in the form of a scalar). If no such bound was declared, the returned value is None.

is_main: bool property readonly

Returns True if this problem object lives in the main process and not in a remote actor. Otherwise, returns False.

is_multi_objective: bool property readonly

Whether or not the problem is multi-objective

is_remote: bool property readonly

Returns True if this problem object lives in a remote ray actor. Otherwise, returns False.

is_single_objective: bool property readonly

Whether or not the problem is single-objective

lower_bounds: Optional[torch.Tensor] property readonly

Lower bounds for the allowed values of a solution.

If such a bound was declared during the initialization phase, the returned value is a torch tensor (in the form of a vector or in the form of a scalar). If no such bound was declared, the returned value is None.

num_actors: int property readonly

Number of actors (to be) used for parallelization. If the problem is configured for no parallelization, the result will be 0.

objective_sense: Union[str, Iterable[str]] property readonly

Get the objective sense.

If the problem is single-objective, then a single string is returned. If the problem is multi-objective, then the objective senses will be returned in a list.

The returned string in the single-objective case, or each returned string in the multi-objective case, is "min" or "max".

remote_hook: Hook property readonly

Get the Hook which stores the functions to call when this Problem object is (re)created on a remote actor.

The functions in this hook should expect one positional argument, that is the Problem object itself.

senses: Iterable[str] property readonly

Get the objective senses.

The return value is a list of strings, each string being "min" or "max".

solution_length: Optional[int] property readonly

Get the solution length.

Problems with dtype=None do not have solution lengths. For such problems, this property returns None.

status: dict property readonly

Status dictionary of the problem object, updated after the last evaluation operation.

The dictionaries returned by the functions in after_eval_hook are accumulated, and reported in this status dictionary.

stores_solution_stats: Optional[bool] property readonly

Whether or not the best and worst solutions are kept.

upper_bounds: Optional[torch.Tensor] property readonly

Upper bounds for the allowed values of a solution.

If such a bound was declared during the initialization phase, the returned value is a torch tensor (in the form of a vector or in the form of a scalar). If no such bound was declared, the returned value is None.

__init__(self, objective_sense, objective_func=None, *, initial_bounds=None, bounds=None, solution_length=None, dtype=None, eval_dtype=None, device=None, eval_data_length=None, seed=None, num_actors=None, actor_config=None, num_gpus_per_actor=None, num_subbatches=None, subbatch_size=None, store_solution_stats=None, vectorized=None) special

__init__(...): Initialize the Problem object.

Parameters:

Name Type Description Default
objective_sense Union[str, Iterable[str]]

A string, or a sequence of strings. For a single-objective problem, a single string ("min" or "max", for minimization or maximization) is enough. For a problem with n objectives, a sequence of strings (e.g. a list of strings) of length n is required, each string in the sequence being "min" or "max". This argument specifies the goal of the optimization.

required
initial_bounds Union[Iterable[Union[float, Iterable[float], torch.Tensor]], evotorch.core.BoundsPair]

In which interval will the values of a new solution will be initialized. Expected as a tuple, each element being either a scalar, or a vector of length n, n being the length of a solution. If a manual solution initialization is preferred (instead of an interval-based initialization), one can leave initial_bounds as None, and override the _fill(...) method in the inheriting subclass.

None
bounds Union[Iterable[Union[float, Iterable[float], torch.Tensor]], evotorch.core.BoundsPair]

Interval in which all the solutions must always reside. Expected as a tuple, each element being either a scalar, or a vector of length n, n being the length of a solution. This argument is optional, and can be left as None if one does not wish to declare hard bounds on the decision values of the problem. If bounds is specified, initial_bounds is missing, and _fill(...) is not overriden, then bounds will also serve as the initial_bounds.

None
solution_length Optional[int]

Length of a solution. Required for all fixed-length numeric optimization problems. For variable-length problems (which might or might not be numeric), one is expected to leave solution_length as None, and declare dtype as object.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

dtype (data type) of the data stored by a solution. Can be given as a string (e.g. "float32"), or as a numpy dtype (e.g. numpy.dtype("float32")), or as a PyTorch dtype (e.g. torch.float32). Alternatively, if the problem is variable-length and/or non-numeric, one is expected to declare dtype as object.

None
eval_dtype Union[str, torch.dtype, numpy.dtype, Type]

dtype to be used for storing the evaluations (or fitnesses, or scores, or costs, or losses) of the solutions. Can be given as a string (e.g. "float32"), or as a numpy dtype (e.g. numpy.dtype("float32")), or as a PyTorch dtype (e.g. torch.float32). eval_dtype must always refer to a "float" data type, therefore, object is not accepted as a valid eval_dtype. If eval_dtype is not specified (i.e. left as None), then the following actions are taken to determine the eval_dtype: if dtype is "float16", eval_dtype becomes "float16"; if dtype is "bfloat16", eval_dtype becomes "bfloat16"; if dtype is "float32", eval_dtype becomes "float32"; if dtype is "float64", eval_dtype becomes "float64"; and for any other dtype, eval_dtype becomes "float32".

None
device Union[str, torch.device]

Default device in which a new population will be generated. For non-numeric problems, this must be "cpu". For numeric problems, this can be any device supported by PyTorch (e.g. "cuda"). Note that, if the number of actors of the problem is configured to be more than 1, device has to be "cpu" (or, equivalently, left as None).

None
eval_data_length Optional[int]

In addition to evaluation results (which are (un)fitnesses, or scores, or costs, or losses), each solution can store extra evaluation data. If storage of such extra evaluation data is required, one can set this argument to an integer bigger than 0.

None
seed Optional[int]

Random seed to be used by the random number generator attached to the problem object. If left as None, no random number generator will be attached, and the global random number generator of PyTorch will be used instead.

None
num_actors Union[int, str]

Number of actors to create for parallelized evaluation of the solutions. Certain string values are also accepted. When given as "max" or as "num_cpus", the number of actors will be equal to the number of all available CPUs in the ray cluster. When given as "num_gpus", the number of actors will be equal to the number of all available GPUs in the ray cluster, and each actor will be assigned a GPU. There is also an option, "num_devices", which means that both the numbers of CPUs and GPUs will be analyzed, and new actors and GPUs for them will be allocated, in a one-to-one mapping manner, if possible. In more details, with num_actors="num_devices", if device is given as a GPU device, then it will be inferred that the user wishes to put everything (including the population) on a single GPU, and therefore there won't be any allocation of actors nor GPUs. With num_actors="num_devices" and with device set as "cpu" (or as left as None), if there are multiple CPUs and multiple GPUs, then n actors will be allocated where n is the minimum among the number of CPUs and the number of GPUs, so that there can be one-to-one mapping between CPUs and GPUs (i.e. such that each actor can be assigned an entire GPU). If num_actors is given as "num_gpus" or "num_devices", the argument num_gpus_per_actor must not be used, and the actor_config dictionary must not contain the key "num_gpus". If num_actors is given as something other than "num_gpus" or "num_devices", and if you wish to assign GPUs to each actor, then please see the argument num_gpus_per_actor.

None
actor_config Optional[dict]

A dictionary, representing the keyword arguments to be passed to the options(...) used when creating the ray actor objects. To be used for explicitly allocating resources per each actor. For example, for declaring that each actor is to use a GPU, one can pass actor_config=dict(num_gpus=1). Can also be given as None (which is the default), if no such options are to be passed.

None
num_gpus_per_actor Union[int, float, str]

Number of GPUs to be allocated by each remote actor. The default behavior is to NOT allocate any GPU at all (which is the default behavior of the ray library as well). When given as a number n, each actor will be given n GPUs (where n can be an integer, or can be a float for fractional allocation). When given as a string "max", then the available GPUs across the entire ray cluster (or within the local computer in the simplest cases) will be equally distributed among the actors. When given as a string "all", then each actor will have access to all the GPUs (this will be achieved by suppressing the environment variable CUDA_VISIBLE_DEVICES for each actor). When the problem is not distributed (i.e. when there are no actors), this argument is expected to be left as None.

None
num_subbatches Optional[int]

If num_subbatches is None (assuming that subbatch_size is also None), then, when evaluating a population, the population will be split into n pieces, n being the number of actors, and each actor will evaluate its assigned piece. If num_subbatches is an integer m, then the population will be split into m pieces, and actors will continually accept the next unevaluated piece as they finish their current tasks. The arguments num_subbatches and subbatch_size cannot be given values other than None at the same time. While using a distributed algorithm, this argument determines how many sub-batches will be generated, and therefore, how many gradients will be computed by the remote actors.

None
subbatch_size Optional[int]

If subbatch_size is None (assuming that num_subbatches is also None), then, when evaluating a population, the population will be split into n pieces, n being the number of actors, and each actor will evaluate its assigned piece. If subbatch_size is an integer m, then the population will be split into pieces of size m, and actors will continually accept the next unevaluated piece as they finish their current tasks. When there can be significant difference across the solutions in terms of computational requirements, specifying a subbatch_size can be beneficial, because, while one actor is busy with a subbatch containing computationally challenging solutions, other actors can accept more tasks and save time. The arguments num_subbatches and subbatch_size cannot be given values other than None at the same time. While using a distributed algorithm, this argument determines the size of a sub-batch (or sub-population) sampled by a remote actor for computing a gradient. In distributed mode, it is expected that the population size is divisible by subbatch_size.

None
store_solution_stats Optional[bool]

Whether or not the problem object should keep track of the best and worst solutions. Can also be left as None (which is the default behavior), in which case, it will store the best and worst solutions only when the first solution batch it encounters is on the cpu. This default behavior is to ensure that there is no transfer between the cpu and a foreign computation device (like the gpu) just for the sake of keeping the best and the worst solutions.

None
vectorized Optional[bool]

Set this to True if the provided fitness function is vectorized but is not decorated via @vectorized.

None
Source code in evotorch/core.py
def __init__(
    self,
    objective_sense: ObjectiveSense,
    objective_func: Optional[Callable] = None,
    *,
    initial_bounds: Optional[BoundsPairLike] = None,
    bounds: Optional[BoundsPairLike] = None,
    solution_length: Optional[int] = None,
    dtype: Optional[DType] = None,
    eval_dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    eval_data_length: Optional[int] = None,
    seed: Optional[int] = None,
    num_actors: Optional[Union[int, str]] = None,
    actor_config: Optional[dict] = None,
    num_gpus_per_actor: Optional[Union[int, float, str]] = None,
    num_subbatches: Optional[int] = None,
    subbatch_size: Optional[int] = None,
    store_solution_stats: Optional[bool] = None,
    vectorized: Optional[bool] = None,
):
    """
    `__init__(...)`: Initialize the Problem object.

    Args:
        objective_sense: A string, or a sequence of strings.
            For a single-objective problem, a single string
            ("min" or "max", for minimization or maximization)
            is enough.
            For a problem with `n` objectives, a sequence
            of strings (e.g. a list of strings) of length `n` is
            required, each string in the sequence being "min" or
            "max". This argument specifies the goal of the
            optimization.
        initial_bounds: In which interval will the values of a
            new solution will be initialized.
            Expected as a tuple, each element being either a
            scalar, or a vector of length `n`, `n` being the
            length of a solution.
            If a manual solution initialization is preferred
            (instead of an interval-based initialization),
            one can leave `initial_bounds` as None, and override
            the `_fill(...)` method in the inheriting subclass.
        bounds: Interval in which all the solutions must always
            reside.
            Expected as a tuple, each element being either a
            scalar, or a vector of length `n`, `n` being the
            length of a solution.
            This argument is optional, and can be left as None
            if one does not wish to declare hard bounds on the
            decision values of the problem.
            If `bounds` is specified, `initial_bounds` is missing,
            and `_fill(...)` is not overriden, then `bounds` will
            also serve as the `initial_bounds`.
        solution_length: Length of a solution.
            Required for all fixed-length numeric optimization
            problems.
            For variable-length problems (which might or might not
            be numeric), one is expected to leave `solution_length`
            as None, and declare `dtype` as `object`.
        dtype: dtype (data type) of the data stored by a solution.
            Can be given as a string (e.g. "float32"),
            or as a numpy dtype (e.g. `numpy.dtype("float32")`),
            or as a PyTorch dtype (e.g. `torch.float32`).
            Alternatively, if the problem is variable-length
            and/or non-numeric, one is expected to declare `dtype`
            as `object`.
        eval_dtype: dtype to be used for storing the evaluations
            (or fitnesses, or scores, or costs, or losses)
            of the solutions.
            Can be given as a string (e.g. "float32"),
            or as a numpy dtype (e.g. `numpy.dtype("float32")`),
            or as a PyTorch dtype (e.g. `torch.float32`).
            `eval_dtype` must always refer to a "float" data type,
            therefore, `object` is not accepted as a valid `eval_dtype`.
            If `eval_dtype` is not specified (i.e. left as None),
            then the following actions are taken to determine the
            `eval_dtype`:
            if `dtype` is "float16", `eval_dtype` becomes "float16";
            if `dtype` is "bfloat16", `eval_dtype` becomes "bfloat16";
            if `dtype` is "float32", `eval_dtype` becomes "float32";
            if `dtype` is "float64", `eval_dtype` becomes "float64";
            and for any other `dtype`, `eval_dtype` becomes "float32".
        device: Default device in which a new population will be
            generated. For non-numeric problems, this must be "cpu".
            For numeric problems, this can be any device supported
            by PyTorch (e.g. "cuda").
            Note that, if the number of actors of the problem is configured
            to be more than 1, `device` has to be "cpu" (or, equivalently,
            left as None).
        eval_data_length: In addition to evaluation results
            (which are (un)fitnesses, or scores, or costs, or losses),
            each solution can store extra evaluation data.
            If storage of such extra evaluation data is required,
            one can set this argument to an integer bigger than 0.
        seed: Random seed to be used by the random number generator
            attached to the problem object.
            If left as None, no random number generator will be
            attached, and the global random number generator of
            PyTorch will be used instead.
        num_actors: Number of actors to create for parallelized
            evaluation of the solutions.
            Certain string values are also accepted.
            When given as "max" or as "num_cpus", the number of actors
            will be equal to the number of all available CPUs in the ray
            cluster.
            When given as "num_gpus", the number of actors will be
            equal to the number of all available GPUs in the ray
            cluster, and each actor will be assigned a GPU.
            There is also an option, "num_devices", which means that
            both the numbers of CPUs and GPUs will be analyzed, and
            new actors and GPUs for them will be allocated,
            in a one-to-one mapping manner, if possible.
            In more details, with `num_actors="num_devices"`, if
            `device` is given as a GPU device, then it will be inferred
            that the user wishes to put everything (including the
            population) on a single GPU, and therefore there won't be
            any allocation of actors nor GPUs.
            With `num_actors="num_devices"` and with `device` set as
            "cpu" (or as left as None), if there are multiple CPUs
            and multiple GPUs, then `n` actors will be allocated
            where `n` is the minimum among the number of CPUs
            and the number of GPUs, so that there can be one-to-one
            mapping between CPUs and GPUs (i.e. such that each actor
            can be assigned an entire GPU).
            If `num_actors` is given as "num_gpus" or "num_devices",
            the argument `num_gpus_per_actor` must not be used,
            and the `actor_config` dictionary must not contain the
            key "num_gpus".
            If `num_actors` is given as something other than "num_gpus"
            or "num_devices", and if you wish to assign GPUs to each
            actor, then please see the argument `num_gpus_per_actor`.
        actor_config: A dictionary, representing the keyword arguments
            to be passed to the options(...) used when creating the
            ray actor objects. To be used for explicitly allocating
            resources per each actor.
            For example, for declaring that each actor is to use a GPU,
            one can pass `actor_config=dict(num_gpus=1)`.
            Can also be given as None (which is the default),
            if no such options are to be passed.
        num_gpus_per_actor: Number of GPUs to be allocated by each
            remote actor.
            The default behavior is to NOT allocate any GPU at all
            (which is the default behavior of the ray library as well).
            When given as a number `n`, each actor will be given
            `n` GPUs (where `n` can be an integer, or can be a `float`
            for fractional allocation).
            When given as a string "max", then the available GPUs
            across the entire ray cluster (or within the local computer
            in the simplest cases) will be equally distributed among
            the actors.
            When given as a string "all", then each actor will have
            access to all the GPUs (this will be achieved by suppressing
            the environment variable `CUDA_VISIBLE_DEVICES` for each
            actor).
            When the problem is not distributed (i.e. when there are
            no actors), this argument is expected to be left as None.
        num_subbatches: If `num_subbatches` is None (assuming that
            `subbatch_size` is also None), then, when evaluating a
            population, the population will be split into n pieces, `n`
            being the number of actors, and each actor will evaluate
            its assigned piece. If `num_subbatches` is an integer `m`,
            then the population will be split into `m` pieces,
            and actors will continually accept the next unevaluated
            piece as they finish their current tasks.
            The arguments `num_subbatches` and `subbatch_size` cannot
            be given values other than None at the same time.
            While using a distributed algorithm, this argument determines
            how many sub-batches will be generated, and therefore,
            how many gradients will be computed by the remote actors.
        subbatch_size: If `subbatch_size` is None (assuming that
            `num_subbatches` is also None), then, when evaluating a
            population, the population will be split into `n` pieces, `n`
            being the number of actors, and each actor will evaluate its
            assigned piece. If `subbatch_size` is an integer `m`,
            then the population will be split into pieces of size `m`,
            and actors will continually accept the next unevaluated
            piece as they finish their current tasks.
            When there can be significant difference across the solutions
            in terms of computational requirements, specifying a
            `subbatch_size` can be beneficial, because, while one
            actor is busy with a subbatch containing computationally
            challenging solutions, other actors can accept more
            tasks and save time.
            The arguments `num_subbatches` and `subbatch_size` cannot
            be given values other than None at the same time.
            While using a distributed algorithm, this argument determines
            the size of a sub-batch (or sub-population) sampled by a
            remote actor for computing a gradient.
            In distributed mode, it is expected that the population size
            is divisible by `subbatch_size`.
        store_solution_stats: Whether or not the problem object should
            keep track of the best and worst solutions.
            Can also be left as None (which is the default behavior),
            in which case, it will store the best and worst solutions
            only when the first solution batch it encounters is on the
            cpu. This default behavior is to ensure that there is no
            transfer between the cpu and a foreign computation device
            (like the gpu) just for the sake of keeping the best and
            the worst solutions.
        vectorized: Set this to True if the provided fitness function
            is vectorized but is not decorated via `@vectorized`.
    """

    # Set the dtype for the decision variables of the Problem
    if dtype is None:
        self._dtype = torch.float32
    elif is_dtype_object(dtype):
        self._dtype = object
    else:
        self._dtype = to_torch_dtype(dtype)

    _evolog.info(message_from(self, f"The `dtype` for the problem's decision variables is set as {self._dtype}"))

    # Set the dtype for the solution evaluations (i.e. fitnesses and evaluation data)
    if eval_dtype is not None:
        # If an `eval_dtype` is explicitly stated, then accept it as the `_eval_dtype` of the Problem
        self._eval_dtype = to_torch_dtype(eval_dtype)
    else:
        # This is the case where an `eval_dtype` is not explicitly stated by the user.
        # We need to choose a default.
        if self._dtype in (torch.float16, torch.bfloat16, torch.float64):
            # If the `dtype` of the problem is a non-32-bit float type (i.e. float16, bfloat16, float64)
            # then we use that as our `_eval_dtype` as well.
            self._eval_dtype = self._dtype
        else:
            # For any other `dtype`, we use float32 as our `_eval_dtype`.
            self._eval_dtype = torch.float32

        _evolog.info(
            message_from(
                self, f"`eval_dtype` (the dtype of the fitnesses and evaluation data) is set as {self._eval_dtype}"
            )
        )

    # Set the main device of the Problem object
    self._device = torch.device("cpu") if device is None else torch.device(device)
    _evolog.info(message_from(self, f"The `device` of the problem is set as {self._device}"))

    # Declare the internal variable that might store the random number generator
    self._generator: Optional[torch.Generator] = None

    # Set the seed of the Problem object, if a seed is provided
    self.manual_seed(seed)

    # Declare the internal variables that will store the bounds and the solution length
    self._initial_lower_bounds: Optional[torch.Tensor] = None
    self._initial_upper_bounds: Optional[torch.Tensor] = None
    self._lower_bounds: Optional[torch.Tensor] = None
    self._upper_bounds: Optional[torch.Tensor] = None
    self._solution_length: Optional[int] = None

    if self._dtype is object:
        # If dtype is given as `object`, then there are some runtime sanity checks to perform
        if bounds is not None or initial_bounds is not None:
            # With dtype as object, if bounds are given then we raise an error.
            # This is because the `object` dtype implies that the decision values are not necessarily numeric,
            # and therefore, we cannot have the guarantee of satisfying numeric bounds.
            raise ValueError(
                f"With dtype as {repr(dtype)}, expected to receive `initial_bounds` and/or `bounds` as None."
                f" However, one or both of them is/are set as value(s) other than None."
            )
        if solution_length is not None:
            # With dtype as object, if `solution_length` is provided, then we raise an error.
            # This is because the `object` dtype implies that the solutions can be expressed via various
            # containers, each with its own length, and therefore, a fixed solution length cannot be guaranteed.
            raise ValueError(
                f"With dtype as {repr(dtype)}, expected to receive `solution_length` as None."
                f" However, received `solution_length` as {repr(solution_length)}."
            )
        if str(self._device) != "cpu":
            # With dtype as object, if `device` is something other than "cpu", then we raise an error.
            # This is because the `object` dtype implies that the decision values are stored by an ObjectArray,
            # whose device is always "cpu".
            raise ValueError(
                f"With dtype as {repr(dtype)}, expected to receive `device` as 'cpu'."
                f" However, received `device` as {repr(device)}."
            )
    else:
        # If dtype is something other than `object`, then we need to make sure that we have a valid length for
        # solutions, and also properly store the given numeric bounds.

        if solution_length is None:
            # With a numeric dtype, if solution length is missing, then we raise an error.
            raise ValueError(
                f"Together with a numeric dtype ({repr(dtype)}),"
                f" expected to receive `solution_length` as an integer."
                f" However, `solution_length` is None."
            )
        else:
            # With a numeric dtype, if a solution length is provided, we make sure that it is integer.
            solution_length = int(solution_length)

        # Store the solution length
        self._solution_length = solution_length

        if (bounds is not None) or (initial_bounds is not None):
            # This is the case where we have a dtype other than `object`, and either `bounds` or `initial_bounds`
            # was provided.
            initbnd_tuple_name = "initial_bounds"
            bnd_tuple_name = "bounds"

            if (bounds is not None) and (initial_bounds is None):
                # With a numeric dtype, if strict bounds are given but initial bounds are not given, then we assume
                # that the strict bounds also serve as the initial bounds.
                # Therefore, we take clones of the strict bounds and use this clones as the initial bounds.
                initial_bounds = clone(bounds)
                initbnd_tuple_name = "bounds"

            # Below is an internal helper function for some common operations for the (strict) bounds
            # and for the initial bounds.
            def process_bounds(bounds_tuple: BoundsPairLike, tuple_name: str) -> BoundsPair:
                # This function receives the bounds_tuple (a tuple containing lower and upper bounds),
                # and the string name of the bounds argument ("bounds" or "initial_bounds").
                # What is returned is the bounds expressed as PyTorch tensors in the correct dtype and device.

                nonlocal solution_length

                # Extract the lower and upper bounds from the received bounds tuple.
                lb, ub = bounds_tuple

                # Make sure that the lower and upper bounds are expressed as tensors of correct dtype and device.
                lb = self.make_tensor(lb)
                ub = self.make_tensor(ub)

                for bound_array in (lb, ub):  # For each boundary tensor (lb and ub)
                    if bound_array.ndim not in (0, 1):
                        # If the boundary tensor is not as scalar and is not a 1-dimensional vector, then raise an
                        # error.
                        raise ValueError(
                            f"Lower and upper bounds are expected as scalars or as 1-dimensional vectors."
                            f" However, these given boundaries have incompatible shape:"
                            f" {bound_array} (of shape {bound_array.shape})."
                        )
                    if bound_array.ndim == 1:
                        if len(bound_array) != solution_length:
                            # In the case where the boundary tensor is a 1-dimensional vector, if this vector's length
                            # is not equal to the solution length, then we raise an error.
                            raise ValueError(
                                f"When boundaries are expressed as 1-dimensional vectors, their length are"
                                f" expected as the solution length of the Problem object."
                                f" However, while the problem's solution length is {solution_length},"
                                f" these given boundaries have incompatible length:"
                                f" {bound_array} (of length {len(bound_array)})."
                            )

                # Return the processed forms of the lower and upper boundary tensors.
                return lb, ub

            # Process the initial bounds with the help of the internal function `process_bounds(...)`
            init_lb, init_ub = process_bounds(initial_bounds, initbnd_tuple_name)

            # Store the processed initial bounds
            self._initial_lower_bounds = init_lb
            self._initial_upper_bounds = init_ub

            if bounds is not None:
                # If there are strict bounds, then process those bounds with the help of `process_bounds(...)`.
                lb, ub = process_bounds(bounds, bnd_tuple_name)
                # Store the processed bounds
                self._lower_bounds = lb
                self._upper_bounds = ub

    # Annotate the variable that will store the objective sense(s) of the problem
    self._objective_sense: ObjectiveSense

    # Below is an internal function which makes sure that a provided objective sense has a valid value
    # (where valid values are "min" or "max")
    def validate_sense(s: str):
        if s not in ("min", "max"):
            raise ValueError(
                f"Invalid objective sense: {repr(s)}."
                f"Instead, please provide the objective sense as 'min' or 'max'."
            )

    if not is_sequence(objective_sense):
        # If the provided objective sense is not a sequence, then convert it to a single-element list
        senses = [objective_sense]
        num_senses = 1
    else:
        # If the provided objective sense is a sequence, then take a list copy of it
        senses = list(objective_sense)
        num_senses = len(objective_sense)

        # Ensure that each provided objective sense is valid
        for sense in senses:
            validate_sense(sense)

        if num_senses == 0:
            # If the given sequence of objective senses is empty, then we raise an error.
            raise ValueError(
                "Encountered an empty sequence via `objective_sense`."
                " For a single-objective problem, please set `objective_sense` as 'min' or 'max'."
                " For a multi-objective problem, please set `objective_sense` as a sequence,"
                " each element being 'min' or 'max'."
            )

    # Store the objective senses
    self._senses: Iterable[str] = senses

    # Store the provided objective function (which can be None)
    self._objective_func: Optional[Callable] = objective_func

    # Declare the instance variable that will store whether or not the external fitness function is
    # vectorized, if such an external fitness function is given.
    self._vectorized: Optional[bool]

    # Store the information which indicates whether or not the given objective function is vectorized
    if self._objective_func is None:
        # This is the case where an external fitness function is not given.
        # In this case, we expect the keyword argument `vectorized` to be left as None.

        if vectorized is not None:
            # If the keyword argument `vectorized` is something other than None, then we raise an error
            # to let the user know.
            raise ValueError(
                f"This problem object received no external fitness function."
                f" When not using an external fitness function, the keyword argument `vectorized`"
                f" is expected to be left as None."
                f" However, the value of the keyword argument `vectorized` is {vectorized}."
            )

        # At this point, we know that we do not have an external fitness function.
        # The variable which is supposed to tell us whether or not the external fitness function is vectorized
        # is therefore irrelevant. We just set it as None.
        self._vectorized = None
    else:
        # This is the case where an external fitness function is given.

        if (
            hasattr(self._objective_func, "__evotorch_vectorized__")
            and self._objective_func.__evotorch_vectorized__
        ):
            # If the external fitness function has an attribute `__evotorch_vectorized__`, and the value of this
            # attribute evaluates to True, then this is an indication that the fitness function was decorated
            # with `@vectorized`.

            if vectorized is not None:
                # At this point, we know (or at least have the assumption) that the fitness function was decorated
                # with `@vectorized`. Any boolean value given via the keyword argument `vectorized` would therefore
                # be either redundant or conflicting.
                # Therefore, in this case, if the keyword argument `vectorized` is anything other than None, we
                # raise an error to inform the user.

                raise ValueError(
                    f"Received a fitness function that was decorated via @vectorized."
                    f" When using such a fitness function, the keyword argument `vectorized`"
                    f" is expected to be left as None."
                    f" However, the value of the keyword argument `vectorized` is {vectorized}."
                )

            # Since we know that our fitness function declares itself as vectorized, we set the instance variable
            # _vectorized as True.
            self._vectorized = True
        else:
            # This is the case in which the fitness function does not appear to be decorated via `@vectorized`.
            # In this case, if the keyword argument `vectorized` has a value that is equivalent to True,
            # then the value of `_vectorized` becomes True. On the other hand, if the keyword argument `vectorized`
            # was left as None or if it has a value that is equivalent to False, `_vectorized` becomes False.
            self._vectorized = bool(vectorized)

    # If the evaluation data length is explicitly stated, then convert it to an integer and store it.
    # Otherwise, store the evaluation data length as 0.
    self._eval_data_length = 0 if eval_data_length is None else int(eval_data_length)

    # Initialize the actor index.
    # If the problem is configured to be parallelized and the parallelization is triggered, then each remote
    # copy will have a different integer value for `_actor_index`.
    self._actor_index: Optional[int] = None

    # Initialize the variable that might store the list of actors as None.
    # If the problem is configured to be parallelized and the parallelization is triggered, then this variable
    # will store references to the remote actors (each remote actor storing its own copy of this Problem
    # instance).
    self._actors: Optional[list] = None

    # Initialize the variable that might store the ray ActorPool.
    # If the problem is configured to be parallelized and the parallelization is triggered, then this variable
    # will store the ray ActorPool that is generated out of the remote actors.
    self._actor_pool: Optional[ActorPool] = None

    # Store the ray actor configuration dictionary provided by the user (if any).
    # When (or if) the parallelization is triggered, each actor will be created with this given configuration.
    self._actor_config: Optional[dict] = None if actor_config is None else deepcopy(dict(actor_config))

    # If given, store the sub-batch size or number of sub-batches.
    # When the problem is parallelized, a sub-batch size determines the maximum size for a SolutionBatch
    # that will be sent to a remote actor for parallel solution evaluation.
    # Alternatively, num_subbatches determines into how many pieces will a SolutionBatch be split
    # for parallelization.
    # If both are None, then the main SolutionBatch will be split among the actors.
    if (num_subbatches is not None) and (subbatch_size is not None):
        raise ValueError(
            f"Encountered both `num_subbatches` and `subbatch_size` as values other than None."
            f" num_subbatches={num_subbatches}, subbatch_size={subbatch_size}."
            f" Having both of them as values other than None cannot be accepted."
        )
    self._num_subbatches: Optional[int] = None if num_subbatches is None else int(num_subbatches)
    self._subbatch_size: Optional[int] = None if subbatch_size is None else int(subbatch_size)

    # Initialize the additional states to be loaded by the remote actor as None.
    # If there are such additional states for remote actors, the inheriting class can fill this as a list
    # of dictionaries.
    self._remote_states: Optional[Iterable[dict]] = None

    # Initialize a temporary internal variable which stores the resources available in the ray cluster.
    # Most probably, we are interested in the resources "CPU" and "GPU".
    ray_resources: Optional[dict] = None

    # The following is an internal helper function which returns the amount of availability for a given
    # resource in the ray cluster.
    # If the requested resource is not available at all, None will be returned.
    def get_ray_resource(resource_name: str) -> Any:
        # Ensure that the ray cluster is initialized
        ensure_ray()
        nonlocal ray_resources
        if ray_resources is None:
            # If the ray resource information was not fetched, then fetch them and store them.
            ray_resources = ray.available_resources()
        # Return the information regarding the requested resource from the fetched resource information.
        # If it turns out that the requested resource is not available at all, the result will be None.
        return ray_resources.get(resource_name, None)

    # Annotate the variable that will store the number of actors (to be created when the parallelization
    # is triggered).
    self._num_actors: int

    if num_actors is None:
        # If the argument `num_actors` is left as None, then we set `_num_actors` as 0, which means that
        # there will be no parallelization.
        self._num_actors = 0
    elif isinstance(num_actors, str):
        # This is the case where `num_actors` has a string value
        if num_actors in ("max", "num_cpus"):
            # If the `num_actors` argument was given as "max" or as "num_cpus", then we first read how many CPUs
            # are available in the ray cluster, then convert it to integer (via computing its ceil value), and
            # finally set `_num_actors` as this integer.
            self._num_actors = math.ceil(get_ray_resource("CPU"))
        elif num_actors == "num_gpus":
            # If the `num_actors` argument was given as "num_gpus", then we first read how many GPUs are
            # available in the ray cluster.
            num_gpus = get_ray_resource("GPU")
            if num_gpus is None:
                # If there are no GPUs at all, then we raise an error
                raise ValueError(
                    "The argument `num_actors` was encountered as 'num_gpus'."
                    " However, there does not seem to be any GPU available."
                )
            if num_gpus < 1e-4:
                # If the number of available GPUs are 0 or close to 0, then we raise an error
                raise ValueError(
                    f"The argument `num_actors` was encountered as 'num_gpus'."
                    f" However, the number of available GPUs are either 0 or close to 0 (= {num_gpus})."
                )
            if (actor_config is not None) and ("num_gpus" in actor_config):
                # With `num_actors` argument given as "num_gpus", we will also allocate each GPU to an actor.
                # If `actor_config` contains an item with key "num_gpus", then that configuration item would
                # conflict with the GPU allocation we are about to do here.
                # So, we raise an error.
                raise ValueError(
                    "The argument `num_actors` was encountered as 'num_gpus'."
                    " With this configuration, the number of GPUs assigned to an actor is automatically determined."
                    " However, at the same time, the `actor_config` argument was received with the key 'num_gpus',"
                    " which causes a conflict."
                )
            if num_gpus_per_actor is not None:
                # With `num_actors` argument given as "num_gpus", we will also allocate each GPU to an actor.
                # If the argument `num_gpus_per_actor` is also stated, then such a configuration item would
                # conflict with the GPU allocation we are about to do here.
                # So, we raise an error.
                raise ValueError(
                    f"The argument `num_actors` was encountered as 'num_gpus'."
                    f" With this configuration, the number of GPUs assigned to an actor is automatically determined."
                    f" However, at the same time, the `num_gpus_per_actor` argument was received with a value other"
                    f" than None ({repr(num_gpus_per_actor)}), which causes a conflict."
                )
            # Set the number of actors as the ceiled integer counterpart of the number of available GPUs
            self._num_actors = math.ceil(num_gpus)
            # We assign a GPU for each actor (by overriding the value for the argument `num_gpus_per_actor`).
            num_gpus_per_actor = num_gpus / self._num_actors
        elif num_actors == "num_devices":
            # This is the case where `num_actors` has the string value "num_devices".

            # With `num_actors` set as "num_devices", if there are any GPUs, the behavior is to assign a GPU
            # to each actor. If there are conflicting configurations regarding how many GPUs are to be assigned
            # to each actor, then we raise an error.
            if (actor_config is not None) and ("num_gpus" in actor_config):
                raise ValueError(
                    "The argument `num_actors` was encountered as 'num_devices'."
                    " With this configuration, the number of GPUs assigned to an actor is automatically determined."
                    " However, at the same time, the `actor_config` argument was received with the key 'num_gpus',"
                    " which causes a conflict."
                )
            if num_gpus_per_actor is not None:
                raise ValueError(
                    f"The argument `num_actors` was encountered as 'num_devices'."
                    f" With this configuration, the number of GPUs assigned to an actor is automatically determined."
                    f" However, at the same time, the `num_gpus_per_actor` argument was received with a value other"
                    f" than None ({repr(num_gpus_per_actor)}), which causes a conflict."
                )

            if self._device != torch.device("cpu"):
                # If the main device is not CPU, then the user most probably wishes to put all the
                # computations (both evaluations and the population) on the GPU, without allocating
                # any actor.
                # So, we set `_num_actors` as None, and overwrite `num_gpus_per_actor` with None.
                self._num_actors = None
                num_gpus_per_actor = None
            else:
                # If the device argument is "cpu" or left as None, then we assume that actor allocations
                # might be desired.

                # Read how many CPUs and GPUs are available in the ray cluster.
                num_cpus = get_ray_resource("CPU")
                num_gpus = get_ray_resource("GPU")

                # If we have multiple CPUs, then we continue with the actor allocation procedures.
                if (num_gpus is None) or (num_gpus < 1e-4):
                    # If there are no GPUs, then we set the number of actors as the number of CPUs, and we
                    # set the number of GPUs per actor as None (which means that there will be no GPU
                    # assignment)
                    self._num_actors = math.ceil(num_cpus)
                    num_gpus_per_actor = None
                else:
                    # If there are GPUs available, then we compute the minimum among the number of CPUs and
                    # GPUs, and this minimum value becomes the number of actors (so that there can be
                    # one-to-one mapping between actors and GPUs).
                    self._num_actors = math.ceil(min(num_cpus, num_gpus))

                    # We assign a GPU for each actor (by overriding the value for the argument
                    # `num_gpus_per_actor`).
                    if self._num_actors <= num_gpus:
                        num_gpus_per_actor = 1
                    else:
                        num_gpus_per_actor = num_gpus / self._num_actors
        else:
            # This is the case where `num_actors` is given as an unexpected string. We raise an error here.
            raise ValueError(
                f"Invalid string value for `num_actors`: {repr(num_actors)}."
                f" The acceptable string values for `num_actors` are 'max', 'num_cpus', 'num_gpus', 'num_devices'."
            )
    else:
        # This is the case where `num_actors` has a value which is not a string.
        # In this case, we make sure that the given value is an integer, and then use this integer as our
        # number of actors.
        self._num_actors = int(num_actors)

    if self._num_actors == 1:
        _evolog.info(
            message_from(
                self,
                (
                    "The number of actors that will be allocated for parallelized evaluation was encountered as 1."
                    " This number is automatically dropped to 0,"
                    " because having only 1 actor does not bring any benefit in terms of parallelization."
                ),
            )
        )
        # Creating a single actor does not bring any benefit of parallelization.
        # Therefore, at the end of all the computations above regarding the number of actors, if it turns out
        # that the target number of actors is 1, we reduce it to 0 (meaning that no actor will be initialized).
        self._num_actors = 0

        # Since we are to allocate no actor, the value of the argument `num_gpus_per_actor` is meaningless.
        # We therefore overwrite the value of that argument with None.
        num_gpus_per_actor = None

    _evolog.info(
        message_from(
            self, f"The number of actors that will be allocated for parallelized evaluation is {self._num_actors}"
        )
    )

    if (self._num_actors >= 2) and (self._device != torch.device("cpu")):
        detailed_error_msg = (
            f"The number of actors that will be allocated for parallelized evaluation is {self._num_actors}."
            " When the number of actors is at least 2,"
            ' the only supported value for the `device` argument is "cpu".'
            f" However, `device` was received as {self._device}."
            "\n\n---- Possible ways to fix the error: ----"
            "\n\n"
            "(1)"
            " If both the population and the fitness evaluation operations can fit into the same device,"
            f" try setting `device={self._device}` and `num_actors=0`."
            "\n\n"
            "(2)"
            " If you would like to use N number of GPUs in parallel for fitness evaluation (where N>1),"
            ' set `device="cpu"` (so that the main process will keep the population on the cpu), set'
            " `num_actors=N` and `num_gpus_per_actor=1` (to allocate an actor for each of the `N` GPUs),"
            " and then, decorate your fitness function using `evotorch.decorators.on_cuda`"
            " so that the fitness evaluation will be performed on the cuda device assigned to the actor."
            " The code for achieving this can look like this:"
            "\n\n"
            "    from evotorch import Problem\n"
            "    from evotorch.decorators import on_cuda, vectorized\n"
            "    import torch\n"
            "\n"
            "    @on_cuda\n"
            "    @vectorized\n"
            "    def f(x: torch.Tensor) -> torch.Tensor:\n"
            "        ...\n"
            "\n"
            '    problem = Problem("min", f, device="cpu", num_actors=N, num_gpus_per_actor=1)\n'
            "\n"
            "Or, it can look like this:\n"
            "\n"
            "    from evotorch import Problem, SolutionBatch\n"
            "    from evotorch.decorators import on_cuda\n"
            "    import torch\n"
            "\n"
            "    class MyProblem(Problem):\n"
            "        def __init__(self, ...):\n"
            "            super().__init__(\n"
            '                objective_sense="min", device="cpu", num_actors=N, num_gpus_per_actor=1, ...\n'
            "            )\n"
            "\n"
            "        @on_cuda\n"
            "        def _evaluate_batch(self, batch: SolutionBatch):\n"
            "            ...\n"
            "\n"
            "    problem = MyProblem(...)\n"
            "\n"
            "\n"
            "(3)"
            " Similarly to option (2), for when you wish to use N number of GPUs for fitness evaluation,"
            ' set `device="cpu"`, set `num_actors=N` and `num_gpus_per_actor=1`, then, within the evaluation'
            ' function, manually use the device `"cuda"` to accelerate the computation.'
            "\n\n"
            "--------------\n"
            "Note for cases (2) and (3): if you are on a computer or on a ray cluster with multiple GPUs, you"
            ' might prefer to set `num_actors` as the string "num_gpus" instead of an integer N,'
            " which will cause the number of available GPUs to be counted, and the number of actors to be"
            " configured as that count."
        )

        raise ValueError(detailed_error_msg)

    # Annotate the variable which will determine how many GPUs are to be assigned to each actor.
    self._num_gpus_per_actor: Optional[Union[str, int, float]]

    if (actor_config is not None) and ("num_gpus" in actor_config) and (num_gpus_per_actor is not None):
        # If `actor_config` dictionary has the item "num_gpus" and also `num_gpus_per_actor` is not None,
        # then there is a conflicting (or redundant) configuration. We raise an error here.
        raise ValueError(
            'The `actor_config` dictionary contains the key "num_gpus".'
            " At the same time, `num_gpus_per_actor` has a value other than None."
            " These two configurations are conflicting."
            " Please specify the number of GPUs per actor either via the `actor_config` dictionary,"
            " or via the `num_gpus_per_actor` argument, but not via both."
        )

    if num_gpus_per_actor is None:
        # If the argument `num_gpus_per_actor` is not specified, then we set the attribute
        # `_num_gpus_per_actor` as None, which means that no GPUs will be assigned to the actors.
        self._num_gpus_per_actor = None
    elif isinstance(num_gpus_per_actor, str):
        # This is the case where `num_gpus_per_actor` is given as a string.
        if num_gpus_per_actor == "max":
            # This is the case where `num_gpus_per_actor` is given as "max".
            num_gpus = get_ray_resource("GPU")
            if num_gpus is None:
                # With `num_gpus_per_actor` as "max", if there is no GPU available, then we set the attribute
                # `_num_gpus_per_actor` as None, which means there will be no GPU assignment to the actors.
                self._num_gpus_per_actor = None
            else:
                # With `num_gpus_per_actor` as "max", if there are GPUs available, then the available GPUs will
                # be shared among the actors.
                self._num_gpus_per_actor = num_gpus / self._num_actors
        elif num_gpus_per_actor == "all":
            # When `num_gpus_per_actor` is "all", we also set the attribute `_num_gpus_per_actor` as "all".
            # When a remote actor is initialized, the remote actor will see that the Problem instance has its
            # `_num_gpus_per_actor` set as "all", and it will remove the environment variable named
            # "CUDA_VISIBLE_DEVICES" in its own environment.
            # With "CUDA_VISIBLE_DEVICES" removed, an actor will see all the GPUs available in its own
            # environment.
            self._num_gpus_per_actor = "all"
        else:
            # This is the case where `num_gpus_per_actor` argument has an unexpected string value.
            # We raise an error.
            raise ValueError(
                f"Invalid string value for `num_gpus_per_actor`: {repr(num_gpus_per_actor)}."
                f' Acceptable string values for `num_gpus_per_actor` are: "max", "all".'
            )
    elif isinstance(num_gpus_per_actor, int):
        # When the argument `num_gpus_per_actor` is set as an integer we just set the attribute
        # `_num_gpus_per_actor` as this integer.
        self._num_gpus_per_actor = num_gpus_per_actor
    else:
        # For anything else, we assume that `num_gpus_per_actor` is an object that is convertible to float.
        # Therefore, we convert it to float and store it in the attribute `_num_gpus_per_actor`.
        # Also, remember that, when `num_actors` is given as "num_gpus" or as "num_devices",
        # the code above overrides the value for the argument `num_gpus_per_actor`, which means,
        # this is the case that is activated when `num_actors` is "num_gpus" or "num_devices".
        self._num_gpus_per_actor = float(num_gpus_per_actor)

    if self._num_actors > 0:
        _evolog.info(
            message_from(self, f"Number of GPUs that will be allocated per actor is {self._num_gpus_per_actor}")
        )

    # Initialize the Hook instances (and the related status dictionary for the `_after_eval_hook`)
    self._before_eval_hook: Hook = Hook()
    self._after_eval_hook: Hook = Hook()
    self._after_eval_status: dict = {}
    self._remote_hook: Hook = Hook()
    self._before_grad_hook: Hook = Hook()
    self._after_grad_hook: Hook = Hook()

    # Initialize various stats regarding the solutions encountered by this Problem instance.
    self._store_solution_stats = None if store_solution_stats is None else bool(store_solution_stats)
    self._best: Optional[list] = None
    self._worst: Optional[list] = None
    self._best_evals: Optional[torch.Tensor] = None
    self._worst_evals: Optional[torch.Tensor] = None

    # Initialize the boolean attribute which indicates whether or not this Problem instance (which can be
    # the main instance or a remote instance on an actor) is "prepared" via the `_prepare` method.
    self._prepared: bool = False

all_remote_envs(self)

Get an accessor which is used for running a method on all remote reinforcement learning environments.

This method can only be used on parallelized Problem objects which have their get_env() methods defined. For example, one can use this feature on a parallelized GymProblem.

As an example, let us consider a parallelized GymProblem object named my_problem. Given that my_problem has n remote actors, a method f() can be executed on all remote reinforcement learning environments as follows:

results = my_problem.all_remote_envs().f()

The variable results is a list of length n, the i-th item of the list belonging to the method f's result from the i-th actor.

Returns:

Type Description
AllRemoteEnvs

A method accessor for all the remote reinforcement learning environments.

Source code in evotorch/core.py
def all_remote_envs(self) -> AllRemoteEnvs:
    """
    Get an accessor which is used for running a method
    on all remote reinforcement learning environments.

    This method can only be used on parallelized Problem
    objects which have their `get_env()` methods defined.
    For example, one can use this feature on a parallelized
    GymProblem.

    As an example, let us consider a parallelized GymProblem
    object named `my_problem`. Given that `my_problem` has
    `n` remote actors, a method `f()` can be executed
    on all remote reinforcement learning environments as
    follows:

        results = my_problem.all_remote_envs().f()

    The variable `results` is a list of length `n`, the i-th
    item of the list belonging to the method f's result
    from the i-th actor.

    Returns:
        A method accessor for all the remote reinforcement
        learning environments.
    """
    self._parallelize()
    if self.is_remote:
        raise RuntimeError(
            "The method `all_remote_envs()` can only be used on the main (i.e. non-remote)"
            " Problem instance."
            " However, this Problem instance is on a remote actor."
        )
    return AllRemoteEnvs(self._actors)

all_remote_problems(self)

Get an accessor which is used for running a method on all remote clones of this Problem object.

For example, given a Problem object named my_problem, also assuming that this Problem object is parallelized, and therefore has n remote actors, a method f() can be executed on all the remote instances as follows:

results = my_problem.all_remote_problems().f()

The variable results is a list of length n, the i-th item of the list belonging to the method f's result from the i-th actor.

Returns:

Type Description
AllRemoteProblems

A method accessor for all the remote Problem objects.

Source code in evotorch/core.py
def all_remote_problems(self) -> AllRemoteProblems:
    """
    Get an accessor which is used for running a method
    on all remote clones of this Problem object.

    For example, given a Problem object named `my_problem`,
    also assuming that this Problem object is parallelized,
    and therefore has `n` remote actors, a method `f()`
    can be executed on all the remote instances as follows:

        results = my_problem.all_remote_problems().f()

    The variable `results` is a list of length `n`, the i-th
    item of the list belonging to the method f's result
    from the i-th actor.

    Returns:
        A method accessor for all the remote Problem objects.
    """
    self._parallelize()
    if self.is_remote:
        raise RuntimeError(
            "The method `all_remote_problems()` can only be used on the main (i.e. non-remote)"
            " Problem instance."
            " However, this Problem instance is on a remote actor."
        )
    return AllRemoteProblems(self._actors)

compare_solutions(self, a, b, obj_index=None)

Compare two solutions. It is assumed that both solutions are already evaluated.

Parameters:

Name Type Description Default
a Solution

The first solution.

required
b Solution

The second solution.

required
obj_index Optional[int]

The objective index according to which the comparison will be made. Can be left as None if the problem is single-objective.

None

Returns:

Type Description
float

A positive number if a is better; a negative number if b is better; 0 if there is a tie.

Source code in evotorch/core.py
def compare_solutions(self, a: "Solution", b: "Solution", obj_index: Optional[int] = None) -> float:
    """
    Compare two solutions.
    It is assumed that both solutions are already evaluated.

    Args:
        a: The first solution.
        b: The second solution.
        obj_index: The objective index according to which the comparison
            will be made.
            Can be left as None if the problem is single-objective.
    Returns:
        A positive number if `a` is better;
        a negative number if `b` is better;
        0 if there is a tie.
    """
    senses = self.senses
    obj_index = self.normalize_obj_index(obj_index)
    sense = senses[obj_index]

    def score(s: Solution):
        return s.evals[obj_index]

    if sense == "max":
        return score(a) - score(b)
    elif sense == "min":
        return score(b) - score(a)
    else:
        raise ValueError("Unrecognized sense: " + repr(sense))

ensure_numeric(self)

Ensure that the problem has a numeric dtype.

Exceptions:

Type Description
ValueError

if the problem has a non-numeric dtype.

Source code in evotorch/core.py
def ensure_numeric(self):
    """
    Ensure that the problem has a numeric dtype.

    Raises:
        ValueError: if the problem has a non-numeric dtype.
    """
    if is_dtype_object(self.dtype):
        raise ValueError("Expected a problem with numeric dtype, but the dtype is object.")

ensure_single_objective(self)

Ensure that the problem has only one objective.

Exceptions:

Type Description
ValueError

if the problem is multi-objective.

Source code in evotorch/core.py
def ensure_single_objective(self):
    """
    Ensure that the problem has only one objective.

    Raises:
        ValueError: if the problem is multi-objective.
    """
    n = len(self.senses)
    if n > 1:
        raise ValueError(f"Expected a single-objective problem, but this problem has {n} objectives.")

ensure_unbounded(self)

Ensure that the problem has no strict lower and upper bounds.

Exceptions:

Type Description
ValueError

if the problem has strict lower and upper bounds.

Source code in evotorch/core.py
def ensure_unbounded(self):
    """
    Ensure that the problem has no strict lower and upper bounds.

    Raises:
        ValueError: if the problem has strict lower and upper bounds.
    """
    if not (self.lower_bounds is None and self.upper_bounds is None):
        raise ValueError("Expected an unbounded problem, but this problem has lower and/or upper bounds.")

evaluate(self, x)

Evaluate the given Solution or SolutionBatch.

Parameters:

Name Type Description Default
x Union[SolutionBatch, Solution]

The SolutionBatch to be evaluated.

required
Source code in evotorch/core.py
def evaluate(self, x: Union["SolutionBatch", "Solution"]):
    """
    Evaluate the given Solution or SolutionBatch.

    Args:
        x: The SolutionBatch to be evaluated.
    """

    if isinstance(x, Solution):
        batch = x.to_batch()
    elif isinstance(x, SolutionBatch):
        batch = x
    else:
        raise TypeError(
            f"The method `evaluate(...)` expected a Solution or a SolutionBatch as its argument."
            f" However, the received object is {repr(x)}, which is of type {repr(type(x))}."
        )

    self._parallelize()

    if self.is_main:
        self.before_eval_hook(batch)

    must_sync_after = self._sync_before()

    self._start_preparations()

    self._evaluate_all(batch)

    if must_sync_after:
        self._sync_after()

    if self.is_main:
        self._after_eval_status = {}

        best_and_worst = self._get_best_and_worst(batch)
        if best_and_worst is not None:
            self._after_eval_status.update(best_and_worst)

        self._after_eval_status.update(self.after_eval_hook.accumulate_dict(batch))

generate_batch(self, popsize=None, *, empty=False, center=None, stdev=None, symmetric=False)

Generate a new SolutionBatch.

Parameters:

Name Type Description Default
popsize Optional[int]

Number of solutions that will be contained in the new batch.

None
empty bool

Set this as True if you would like to receive the solutions un-initialized.

False
center Union[float, Iterable[float], torch.Tensor]

Center point of the Gaussian distribution from which the decision values will be sampled, as a scalar or as a 1-dimensional vector. Can also be left as None. If center is None and stdev is None, all the decision values will be sampled from the interval specified by initial_bounds (or by bounds if initial_bounds was not specified). If center is None and stdev is not None, a center point will be sampled from within the interval specified by initial_bounds or bounds, and the decision values will be sampled from a Gaussian distribution around this center point.

None
stdev Union[float, Iterable[float], torch.Tensor]

Can be None (default) if the SolutionBatch is to contain decision values sampled from the interval specified by initial_bounds (or by bounds if initial_bounds was not provided during the initialization phase). Alternatively, a scalar or a 1-dimensional vector specifying the standard deviation of the Gaussian distribution from which the decision values will be sampled.

None
symmetric bool

To be used only when stdev is not None. If symmetric is True, decision values will be sampled from the Gaussian distribution in a symmetric (i.e. antithetic) manner. Otherwise, the decision values will be sampled in the non-antithetic manner.

False
Source code in evotorch/core.py
def generate_batch(
    self,
    popsize: Optional[int] = None,
    *,
    empty: bool = False,
    center: Optional[RealOrVector] = None,
    stdev: Optional[RealOrVector] = None,
    symmetric: bool = False,
) -> "SolutionBatch":
    """
    Generate a new SolutionBatch.

    Args:
        popsize: Number of solutions that will be contained in the new
            batch.
        empty: Set this as True if you would like to receive the solutions
            un-initialized.
        center: Center point of the Gaussian distribution from which
            the decision values will be sampled, as a scalar or as a
            1-dimensional vector.
            Can also be left as None.
            If `center` is None and `stdev` is None, all the decision
            values will be sampled from the interval specified by
            `initial_bounds` (or by `bounds` if `initial_bounds` was not
            specified).
            If `center` is None and `stdev` is not None, a center point
            will be sampled from within the interval specified by
            `initial_bounds` or `bounds`, and the decision values will be
            sampled from a Gaussian distribution around this center point.
        stdev: Can be None (default) if the SolutionBatch is to contain
            decision values sampled from the interval specified by
            `initial_bounds` (or by `bounds` if `initial_bounds` was not
            provided during the initialization phase).
            Alternatively, a scalar or a 1-dimensional vector specifying
            the standard deviation of the Gaussian distribution from which
            the decision values will be sampled.
        symmetric: To be used only when `stdev` is not None.
            If `symmetric` is True, decision values will be sampled from
            the Gaussian distribution in a symmetric (i.e. antithetic)
            manner.
            Otherwise, the decision values will be sampled in the
            non-antithetic manner.
    """
    if (center is None) and (stdev is None):
        if symmetric:
            raise ValueError(
                f"The argument `symmetric` can be set as True only when `center` and `stdev` are provided."
                f" Although `center` and `stdev` are None, `symmetric` was received as {symmetric}."
            )
        return SolutionBatch(self, popsize, empty=empty, device=self.device)
    elif (center is not None) and (stdev is not None):
        if empty:
            raise ValueError(
                f"When `center` and `stdev` are provided, the argument `empty` must be False."
                f" However, the received value for `empty` is {empty}."
            )
        result = SolutionBatch(self, popsize, device=self.device, empty=True)
        self.make_gaussian(out=result.access_values(), center=center, stdev=stdev, symmetric=symmetric)
        return result
    else:
        raise ValueError(
            f"The arguments `center` and `stdev` were expected to be None or non-None at the same time."
            f" Received `center`: {center}."
            f" Received `stdev`: {stdev}."
        )

generate_values(self, num_solutions)

Generate decision values.

This function returns a tensor containing the decision values for n new solutions, n being the integer passed as the num_rows argument.

For numeric problems, this function generates the decision values which respect initial_bounds (or bounds, if initial_bounds was not provided). If this type of initialization is not desired, one can override this function and define a manual initialization scheme in the inheriting subclass.

For non-numeric problems, it is expected that the inheriting subclass will override the method _fill(...).

Parameters:

Name Type Description Default
num_solutions int

For how many solutions will new decision values be generated.

required

Returns:

Type Description
Union[torch.Tensor, evotorch.tools.objectarray.ObjectArray]

A PyTorch tensor for numeric problems, an ObjectArray for non-numeric problems.

Source code in evotorch/core.py
def generate_values(self, num_solutions: int) -> Union[torch.Tensor, ObjectArray]:
    """
    Generate decision values.

    This function returns a tensor containing the decision values
    for `n` new solutions, `n` being the integer passed as the `num_rows`
    argument.

    For numeric problems, this function generates the decision values
    which respect `initial_bounds` (or `bounds`, if `initial_bounds`
    was not provided).
    If this type of initialization is not desired, one can override
    this function and define a manual initialization scheme in the
    inheriting subclass.

    For non-numeric problems, it is expected that the inheriting subclass
    will override the method `_fill(...)`.

    Args:
        num_solutions: For how many solutions will new decision values be
            generated.
    Returns:
        A PyTorch tensor for numeric problems, an ObjectArray for
        non-numeric problems.
    """
    result = self.make_empty(num_solutions=num_solutions)
    self._fill(result)
    return result

get_obj_order_descending(self)

When sorting the solutions from best to worst according to each objective i, is the ordering descending?

Source code in evotorch/core.py
def get_obj_order_descending(self) -> Iterable[bool]:
    """When sorting the solutions from best to worst according to each objective i, is the ordering descending?"""
    result = []
    for s in self.senses:
        if s == "min":
            result.append(False)
        elif s == "max":
            result.append(True)
        else:
            raise ValueError(f"Invalid sense: {repr(s)}")
    return result

is_better(self, a, b, obj_index=None)

Check whether or not the first solution is better. It is assumed that both solutions are already evaluated.

Parameters:

Name Type Description Default
a Solution

The first solution.

required
b Solution

The second solution.

required
obj_index Optional[int]

The objective index according to which the comparison will be made. Can be left as None if the problem is single-objective.

None

Returns:

Type Description
bool

True if a is better; False otherwise.

Source code in evotorch/core.py
def is_better(self, a: "Solution", b: "Solution", obj_index: Optional[int] = None) -> bool:
    """
    Check whether or not the first solution is better.
    It is assumed that both solutions are already evaluated.

    Args:
        a: The first solution.
        b: The second solution.
        obj_index: The objective index according to which the comparison
            will be made.
            Can be left as None if the problem is single-objective.
    Returns:
        True if `a` is better; False otherwise.
    """
    return self.compare_solutions(a, b, obj_index) > 0

is_on_cpu(self)

Whether or not the Problem object has its device set as "cpu".

Source code in evotorch/core.py
def is_on_cpu(self) -> bool:
    """
    Whether or not the Problem object has its device set as "cpu".
    """
    return str(self.device) == "cpu"

is_worse(self, a, b, obj_index=None)

Check whether or not the first solution is worse. It is assumed that both solutions are already evaluated.

Parameters:

Name Type Description Default
a Solution

The first solution.

required
b Solution

The second solution.

required
obj_index Optional[int]

The objective index according to which the comparison will be made. Can be left as None if the problem is single-objective.

None

Returns:

Type Description
bool

True if a is worse; False otherwise.

Source code in evotorch/core.py
def is_worse(self, a: "Solution", b: "Solution", obj_index: Optional[int] = None) -> bool:
    """
    Check whether or not the first solution is worse.
    It is assumed that both solutions are already evaluated.

    Args:
        a: The first solution.
        b: The second solution.
        obj_index: The objective index according to which the comparison
            will be made.
            Can be left as None if the problem is single-objective.
    Returns:
        True if `a` is worse; False otherwise.
    """
    return self.compare_solutions(a, b, obj_index) < 0

kill_actors(self)

Kill all the remote actors used by the Problem instance.

One might use this method to release the resources used by the remote actors.

Source code in evotorch/core.py
def kill_actors(self):
    """
    Kill all the remote actors used by the Problem instance.

    One might use this method to release the resources used by the
    remote actors.
    """
    if not self.is_main:
        raise RuntimeError(
            "The method `kill_actors()` can only be used on the main (i.e. non-remote)"
            " Problem instance."
            " However, this Problem instance is on a remote actor."
        )
    for actor in self._actors:
        ray.kill(actor)
    self._actors = None
    self._actor_pool = None

manual_seed(self, seed=None)

Provide a manual seed for the Problem object.

If the given seed is None, then the Problem object will remove its own stored generator, and start using the global generator of PyTorch instead. If the given seed is an integer, then the Problem object will instantiate its own generator with the given seed.

Parameters:

Name Type Description Default
seed Optional[int]

None for using the global PyTorch generator; an integer for instantiating a new PyTorch generator with this given integer seed, specific to this Problem object.

None
Source code in evotorch/core.py
def manual_seed(self, seed: Optional[int] = None):
    """
    Provide a manual seed for the Problem object.

    If the given seed is None, then the Problem object will remove
    its own stored generator, and start using the global generator
    of PyTorch instead.
    If the given seed is an integer, then the Problem object will
    instantiate its own generator with the given seed.

    Args:
        seed: None for using the global PyTorch generator; an integer
            for instantiating a new PyTorch generator with this given
            integer seed, specific to this Problem object.
    """
    if seed is None:
        self._generator = None
    else:
        if self._generator is None:
            self._generator = torch.Generator(device=self.device)
        self._generator.manual_seed(seed)

normalize_obj_index(self, obj_index=None)

Normalize the objective index.

If the provided index is non-negative, it is ensured that the index is valid.

If the provided index is negative, the objectives are counted in the reverse order, and the corresponding non-negative index is returned. For example, -1 is converted to a non-negative integer corresponding to the last objective.

If the provided index is None and if the problem is single-objective, the returned value is 0, which represents the only objective.

If the provided index is None and if the problem is multi-objective, an error is raised.

Parameters:

Name Type Description Default
obj_index Optional[int]

The non-normalized objective index.

None

Returns:

Type Description
int

The normalized objective index, as a non-negative integer.

Source code in evotorch/core.py
def normalize_obj_index(self, obj_index: Optional[int] = None) -> int:
    """
    Normalize the objective index.

    If the provided index is non-negative, it is ensured that the index
    is valid.

    If the provided index is negative, the objectives are counted in the
    reverse order, and the corresponding non-negative index is returned.
    For example, -1 is converted to a non-negative integer corresponding to
    the last objective.

    If the provided index is None and if the problem is single-objective,
    the returned value is 0, which represents the only objective.

    If the provided index is None and if the problem is multi-objective,
    an error is raised.

    Args:
        obj_index: The non-normalized objective index.
    Returns:
        The normalized objective index, as a non-negative integer.
    """
    if obj_index is None:
        if len(self.senses) == 1:
            return 0
        else:
            raise ValueError(
                "This problem is multi-objective, therefore, an explicit objective index was expected."
                " However, `obj_index` was found to be None."
            )
    else:
        obj_index = int(obj_index)
        if obj_index < 0:
            obj_index = len(self.senses) + obj_index
        if obj_index < 0 or obj_index >= len(self.senses):
            raise IndexError("Objective index out of range.")
        return obj_index

sample_and_compute_gradients(self, distribution, popsize, *, num_interactions=None, popsize_max=None, obj_index=None, ranking_method=None, with_stats=True, ensure_even_popsize=False)

Sample new solutions from the distribution and compute gradients.

The distribution can then be updated according to the computed gradients.

If the problem is not parallelized, and with_stats is False, then the result will be a single dictionary of gradients. For example, in the case of a Gaussian distribution, the returned gradients dictionary would look like this:

{
    "mu": ...,     # the gradient for the mean
    "sigma": ...,  # the gradient for the standard deviation
}

If the problem is not parallelized, and with_stats is True, then the result will be a dictionary which contains in itself the gradients dictionary, and additional elements for providing further information. In the case of a Gaussian distribution, the returned dictionary with additional stats would look like this:

{
    "gradients": {
        "mu": ...,     # the gradient for the mean
        "sigma": ...,  # the gradient for the standard deviation
    },
    "num_solutions": ...,  # how many solutions were sampled
    "mean_eval": ...,      # Mean of all evaluations
}

If the problem is parallelized, then the gradient computation will be distributed among the remote actors. In more details, each actor will sample its own solutions (such that the total population size across all remote actors will be near the provided popsize) and will compute its own gradients, and will produce its own additional stats (if with_stats is given as True). These remote results will then be collected by the main process, and the final result of this method will be a list of dictionaries, each dictionary being the result of a remote gradient computation.

The sampled solutions are temporary, and will not be kept (and will not be returned).

To customize how solutions are sampled and how gradients are computed, one is encouraged to override _sample_and_compute_gradients(...) (instead of overriding this method directly.

Parameters:

Name Type Description Default
distribution

The search distribution from which the solutions will be sampled, and according to which the gradients will be computed.

required
popsize int

The number of solutions which will be sampled.

required
num_interactions Optional[int]

Number of simulator interactions that must be completed (more solutions will be sampled until this threshold is reached). This argument is to be used when the problem has characteristics similar to reinforcement learning, and an adaptive population size, depending on the interactions made, is desired. Otherwise, one can leave this argument as None, in which case, there will not be any threshold based on number of interactions.

None
popsize_max Optional[int]

To be used when num_interactions is provided, as an additional criterion for ending the solution sampling phase. This argument can be used to prevent the population size from growing too much while trying to satisfy the num_interactions. If not needed, popsize_max can be left as None.

None
obj_index Optional[int]

Index of the objective according to which the gradients will be computed. Can be left as None if the problem has only one objective.

None
ranking_method Optional[str]

The solution ranking method to be used when computing the gradients. If not specified, the raw fitnesses will be used.

None
with_stats bool

If given as False, then the results dictionary will only contain the gradients information. If given as True, then the results dictionary will contain within itself the gradients dictionary, and also additional elements for providing further information. The default is True.

True
ensure_even_popsize bool

If ensure_even_popsize is True and the problem is not parallelized, then a popsize given as an odd number will cause an error. If ensure_even_popsize is True and the problem is parallelized, then the remote actors will sample their own sub-populations in such a way that their sizes are even. If ensure_even_popsize is False, whether or not the popsize is even will not be checked. When the provided distribution is a symmetric (or "mirrored", or "antithetic"), then this argument must be given as True.

False

Returns:

Type Description
Union[list, dict]

A results dictionary when the problem is not parallelized, or list of results dictionaries when the problem is parallelized.

Source code in evotorch/core.py
def sample_and_compute_gradients(
    self,
    distribution,
    popsize: int,
    *,
    num_interactions: Optional[int] = None,
    popsize_max: Optional[int] = None,
    obj_index: Optional[int] = None,
    ranking_method: Optional[str] = None,
    with_stats: bool = True,
    ensure_even_popsize: bool = False,
) -> Union[list, dict]:
    """
    Sample new solutions from the distribution and compute gradients.

    The distribution can then be updated according to the computed
    gradients.

    If the problem is not parallelized, and `with_stats` is False,
    then the result will be a single dictionary of gradients.
    For example, in the case of a Gaussian distribution, the returned
    gradients dictionary would look like this:

        {
            "mu": ...,     # the gradient for the mean
            "sigma": ...,  # the gradient for the standard deviation
        }

    If the problem is not parallelized, and `with_stats` is True,
    then the result will be a dictionary which contains in itself
    the gradients dictionary, and additional elements for providing
    further information. In the case of a Gaussian distribution,
    the returned dictionary with additional stats would look like
    this:

        {
            "gradients": {
                "mu": ...,     # the gradient for the mean
                "sigma": ...,  # the gradient for the standard deviation
            },
            "num_solutions": ...,  # how many solutions were sampled
            "mean_eval": ...,      # Mean of all evaluations
        }

    If the problem is parallelized, then the gradient computation will
    be distributed among the remote actors. In more details, each actor
    will sample its own solutions (such that the total population size
    across all remote actors will be near the provided `popsize`)
    and will compute its own gradients, and will produce its own
    additional stats (if `with_stats` is given as True).
    These remote results will then be collected by the main process,
    and the final result of this method will be a list of dictionaries,
    each dictionary being the result of a remote gradient computation.

    The sampled solutions are temporary, and will not be kept
    (and will not be returned).

    To customize how solutions are sampled and how gradients are
    computed, one is encouraged to override
    `_sample_and_compute_gradients(...)` (instead of overriding this
    method directly.

    Args:
        distribution: The search distribution from which the solutions
            will be sampled, and according to which the gradients will
            be computed.
        popsize: The number of solutions which will be sampled.
        num_interactions: Number of simulator interactions that must
            be completed (more solutions will be sampled until this
            threshold is reached). This argument is to be used when
            the problem has characteristics similar to reinforcement
            learning, and an adaptive population size, depending on
            the interactions made, is desired.
            Otherwise, one can leave this argument as None, in which
            case, there will not be any threshold based on number
            of interactions.
        popsize_max: To be used when `num_interactions` is provided,
            as an additional criterion for ending the solution sampling
            phase. This argument can be used to prevent the population
            size from growing too much while trying to satisfy the
            `num_interactions`. If not needed, `popsize_max` can be left
            as None.
        obj_index: Index of the objective according to which the gradients
            will be computed. Can be left as None if the problem has only
            one objective.
        ranking_method: The solution ranking method to be used when
            computing the gradients.
            If not specified, the raw fitnesses will be used.
        with_stats: If given as False, then the results dictionary will
            only contain the gradients information. If given as True,
            then the results dictionary will contain within itself
            the gradients dictionary, and also additional elements for
            providing further information.
            The default is True.
        ensure_even_popsize: If `ensure_even_popsize` is True and the
            problem is not parallelized, then a `popsize` given as an odd
            number will cause an error. If `ensure_even_popsize` is True
            and the problem is parallelized, then the remote actors will
            sample their own sub-populations in such a way that their
            sizes are even.
            If `ensure_even_popsize` is False, whether or not the
            `popsize` is even will not be checked.
            When the provided `distribution` is a symmetric (or
            "mirrored", or "antithetic"), then this argument must be
            given as True.
    Returns:
        A results dictionary when the problem is not parallelized,
        or list of results dictionaries when the problem is parallelized.
    """

    # For problems which are configured for parallelization, make sure that the actors are created.
    self._parallelize()

    # Below we check if there is an inconsistency in arguments.
    if (num_interactions is None) and (popsize_max is not None):
        # If `num_interactions` is None, then we assume that the user does not wish an adaptive population size.
        # However, at the same time, if `popsize_max` is not None, then there is an inconsistency,
        # because, `popsize_max` without `num_interactions` (therefore without adaptive population size)
        # does not make sense.
        # This is probably a configuration error, so, we inform the user by raising an error.
        raise ValueError(
            f"`popsize_max` was expected as None, because `num_interactions` is None."
            f" However, `popsize_max` was found as {popsize_max}."
        )

    # The problem instance in the main process should trigger the `before_grad_hook`.
    if self.is_main:
        self._before_grad_hook()

    if self.is_main and (self._actors is not None) and (len(self._actors) > 0):
        # If this is the main process and the problem is parallelized, then we need to split the request
        # into multiple tasks, and then execute those tasks in parallel using the problem's actor pool.

        if self._subbatch_size is not None:
            # If `subbatch_size` is provided, then we first make sure that `popsize` is divisible by
            # `subbatch_size`
            if (popsize % self._subbatch_size) != 0:
                raise ValueError(
                    f"This Problem was created with `subbatch_size` as {self._subbatch_size}."
                    f" When doing remote gradient computation, the requested population size must be divisible by"
                    f" the `subbatch_size`."
                    f" However, the requested population size is {popsize}, and the remainder after dividing it"
                    f" by `subbatch_size` is not 0 (it is {popsize % self._subbatch_size})."
                )
            # After making sure that `popsize` and `subbatch_size` configurations are compatible, we declare that
            # we are going to have n tasks, each task imposing a sample size of `subbatch_size`.
            n = int(popsize // self._subbatch_size)
            popsize_per_task = [self._subbatch_size for _ in range(n)]
        elif self._num_subbatches is not None:
            # If `num_subbatches` is provided, then we are going to have n tasks where n is equal to the given
            # `num_subbatches`.
            popsize_per_task = split_workload(popsize, self._num_subbatches)
        else:
            # If neither `subbatch_size` nor `num_subbatches` is given, then we will split the workload in such
            # a way that each actor will have its share.
            popsize_per_task = split_workload(popsize, len(self._actors))

        if ensure_even_popsize:
            # If `ensure_even_popsize` argument is True, then we need to make sure that each tasks's popsize is
            # an even number.
            for i in range(len(popsize_per_task)):
                if (popsize_per_task[i] % 2) != 0:
                    # If the i-th actor's assigned popsize is not even, increase its assigned popsize by 1.
                    popsize_per_task[i] += 1

        # The number of tasks is finally determined by the length of `popsize_per_task` list we created above.
        num_tasks = len(popsize_per_task)

        if num_interactions is None:
            # If the argument `num_interactions` is not given, then, for each task, we declare that
            # `num_interactions` is None.
            num_inter_per_task = [None for _ in range(num_tasks)]
        else:
            # If the argument `num_interactions` is given, then we compute each task's target number of
            # interactions from its sample size.
            num_inter_per_task = [
                math.ceil((popsize_per_task[i] / popsize) * num_interactions) for i in range(num_tasks)
            ]

        if popsize_max is None:
            # If the argument `popsize_max` is not given, then, for each task, we declare that
            # `popsize_max` is None.
            popsize_max_per_task = [None for _ in range(num_tasks)]
        else:
            # If the argument `popsize_max` is given, then we compute each task's target maximum population size
            # from its sample size.
            popsize_max_per_task = [
                math.ceil((popsize_per_task[i] / popsize) * popsize_max) for i in range(num_tasks)
            ]

        # We trigger the synchronization between the main process and the remote actors.
        # If this problem instance has nothing to synchronize, then `must_sync_after` will be False.
        must_sync_after = self._sync_before()

        # Because we want to send the distribution to remote actors, we first copy the distribution to cpu
        # (unless it is already on cpu)
        dist_on_cpu = distribution.to("cpu")

        # Here, we use our actor pool to execute our tasks in parallel.
        result = list(
            self._actor_pool.map_unordered(
                (
                    lambda a, v: a.call.remote(
                        "_sample_and_compute_gradients",
                        [dist_on_cpu, v[0]],
                        {
                            "obj_index": obj_index,
                            "num_interactions": v[1],
                            "popsize_max": v[2],
                            "ranking_method": ranking_method,
                        },
                    )
                ),
                list(zip(popsize_per_task, num_inter_per_task, popsize_max_per_task)),
            )
        )

        # At this point, all the tensors within our collected results are on the CPU.

        if torch.device(self.device) != torch.device("cpu"):
            # If the main device of this problem instance is not CPU, then we move the tensors to the main device.
            result = cast_tensors_in_container(result, device=self.device)

        if must_sync_after:
            # If a post-gradient synchronization is required, we trigger the synchronization operations.
            self._sync_after()

        # ####################################################
        # # If this is the main process and the problem is parallelized, then we need to split the workload among
        # # the remote actors, and then request each of them to compute their gradients.
        #
        # # We begin by getting the number of actors, and computing the `popsize` for each actor.
        # num_actors = len(self._actors)
        # popsize_per_actor = split_workload(popsize, num_actors)
        #
        # if ensure_even_popsize:
        #     # If `ensure_even_popsize` argument is True, then we need to make sure that each actor's popsize is
        #     # an even number.
        #     for i in range(len(popsize_per_actor)):
        #         if (popsize_per_actor[i] % 2) != 0:
        #             # If the i-th actor's assigned popsize is not even, increase its assigned popsize by 1.
        #             popsize_per_actor[i] += 1
        #
        # if num_interactions is None:
        #     # If `num_interactions` is None, then the `num_interactions` argument for each actor must also be
        #     # passed as None.
        #     num_int_per_actor = [None] * num_actors
        # else:
        #     # If `num_interactions` is not None, then we split the `num_interactions` workload among the actors.
        #     num_int_per_actor = split_workload(num_interactions, num_actors)
        #
        # if popsize_max is None:
        #     # If `popsize_max` is None, then the `popsize_max` argument for each actor must also be None.
        #     popsize_max_per_actor = [None] * num_actors
        # else:
        #     # If `popsize_max` is not None, then we split the `popsize_max` workload among the actors.
        #     popsize_max_per_actor = split_workload(popsize_max, num_actors)
        #
        # # We trigger the synchronization between the main process and the remote actors.
        # # If this problem instance has nothing to synchronize, then `must_sync_after` will be False.
        # must_sync_after = self._sync_before()
        #
        # # Because we want to send the distribution to remote actors, we first copy the distribution to cpu
        # # (unless it is already on cpu)
        # dist_on_cpu = distribution.to("cpu")
        #
        # # To each actor, we send the request of computing the gradients, and then collect the results
        # result = ray.get(
        #     [
        #         self._actors[i].call.remote(
        #             "_gradient_computation_helper",
        #             [dist_on_cpu, popsize_per_actor[i]],
        #             dict(
        #                 num_interactions=num_int_per_actor[i],
        #                 popsize_max=popsize_max_per_actor[i],
        #                 obj_index=obj_index,
        #                 ranking_method=ranking_method,
        #                 with_stats=with_stats,
        #                 move_results_to_device="cpu",
        #             ),
        #         )
        #         for i in range(num_actors)
        #     ]
        # )
        #
        # # At this point, all the tensors within our collected results are on the CPU.
        #
        # if torch.device(self.device) != torch.device("cpu"):
        #     # If the main device of this problem instance is not CPU, then we move the tensors to the main device.
        #     result = cast_tensors_in_container(result, device=device)
        #
        # if must_sync_after:
        #     # If a post-gradient synchronization is required, we trigger the synchronization operations.
        #     self._sync_after()
    else:
        # If the problem is not parallelized, then we request this instance itself to compute the gradients.
        result = self._gradient_computation_helper(
            distribution,
            popsize,
            popsize_max=popsize_max,
            obj_index=obj_index,
            ranking_method=ranking_method,
            num_interactions=num_interactions,
            with_stats=with_stats,
        )

    # The problem instance in the main process should trigger the `after_grad_hook`.
    if self.is_main:
        self._after_eval_status = self._after_grad_hook.accumulate_dict(result)

    # We finally return the results
    return result

RemoteMethod

Representation of a method on a remote actor's contained Problem or reinforcement learning environment

Source code in evotorch/core.py
class RemoteMethod:
    """
    Representation of a method on a remote actor's contained Problem
    or reinforcement learning environment
    """

    def __init__(self, method_name: str, actors: list, on_env: bool = False):
        self._method_name = str(method_name)
        self._actors = actors
        self._on_env = bool(on_env)

    def __call__(self, *args, **kwargs) -> Any:
        def invoke(actor):
            if self._on_env:
                return actor.call_on_env.remote(self._method_name, args, kwargs)
            else:
                return actor.call.remote(self._method_name, args, kwargs)

        return ray.get([invoke(actor) for actor in self._actors])

    def __repr__(self) -> str:
        if self._on_env:
            further = ", on_env=True"
        else:
            further = ""
        return f"<{type(self).__name__} {repr(self._method_name)}{further}>"

Solution (Serializable)

Representation of a single Solution.

A Solution can be a reference to a row of a SolutionBatch (in which case it shares its storage with the SolutionBatch), or can be an independent solution. When the Solution shares its storage with a SolutionBatch, any modifications to its decision values and/or evaluation results will affect its parent SolutionBatch as well.

When a Solution object is cloned (via its clone() method, or via the functions copy.copy(...) and copy.deepcopy(...), a new independent Solution object will be created. This new independent copy will NOT share its storage with its original SolutionBatch anymore.

Source code in evotorch/core.py
class Solution(Serializable):
    """
    Representation of a single Solution.

    A Solution can be a reference to a row of a SolutionBatch
    (in which case it shares its storage with the SolutionBatch),
    or can be an independent solution.
    When the Solution shares its storage with a SolutionBatch,
    any modifications to its decision values and/or evaluation
    results will affect its parent SolutionBatch as well.

    When a Solution object is cloned (via its `clone()` method,
    or via the functions `copy.copy(...)` and `copy.deepcopy(...)`,
    a new independent Solution object will be created.
    This new independent copy will NOT share its storage with
    its original SolutionBatch anymore.
    """

    def __init__(self, parent: SolutionBatch, index: int):
        """
        `__init__(...)`: Initialize the Solution object.

        Args:
            parent: The parent SolutionBatch which stores the Solution.
            index: Index of the solution in SolutionBatch.
        """
        if not isinstance(parent, SolutionBatch):
            raise TypeError(
                f"Expected a SolutionBatch as a parent, but encountered {repr(parent)},"
                f" which is of type {repr(type(parent))}."
            )
        index = int(index)
        if index < 0:
            index = len(parent) + index
        if not ((index >= 0) and (index <= len(parent))):
            raise IndexError(f"Invalid index: {index}")
        self._batch: SolutionBatch = parent[index : index + 1]

    def access_values(self, *, keep_evals: bool = False) -> torch.Tensor:
        """
        Access the decision values tensor of the solution.
        The received tensor will be mutable.

        By default, it will be assumed that the user wishes to
        obtain this tensor to change the decision values, and therefore,
        the evaluation results associated with this solution will be
        cleared (i.e. will be NaN).

        Args:
            keep_evals: When this is set to True, the evaluation results
                associated with this solution will be kept (i.e. will NOT
                be cleared).
        Returns:
            The decision values tensor of the solution.
        """
        return self._batch.access_values(keep_evals=keep_evals)[0]

    def access_evals(self) -> torch.Tensor:
        """
        Access the evaluation results of the solution.
        The received tensor will be mutable.

        Returns:
            The evaluation results tensor of the solution.
        """
        return self._batch.access_evals()[0]

    @property
    def values(self) -> Any:
        """
        Decision values of the solution
        """
        return self._batch.values[0]

    @property
    def evals(self) -> torch.Tensor:
        """
        Evaluation results of the solution in a 1-dimensional tensor.
        """
        return self._batch.evals[0]

    @property
    def evaluation(self) -> torch.Tensor:
        """
        Get the evaluation result.

        If the problem is single-objective and the problem does not
        allocate any space for extra evaluation data, then a scalar
        is returned.
        Otherwise, this property becomes equivalent to the `evals`
        property, and a 1-dimensional tensor is returned.
        """
        result = self.evals
        if len(result) == 1:
            result = result[0]
        return result

    def set_values(self, values: Any):
        """
        Set the decision values of the Solution.

        Note that modifying the decision values will result in the
        evaluation results being getting cleared (in more details,
        the evaluation results tensor will be filled with NaN values).

        Args:
            values: New decision values for this Solution.
        """
        if is_dtype_object(self.dtype):
            value_tensor = ObjectArray(1)
            value_tensor[0] = values
        else:
            value_tensor = torch.as_tensor(values, dtype=self.dtype).reshape(1, -1)
        self._batch.set_values(value_tensor)

    def set_evals(self, evals: torch.Tensor, eval_data: Optional[Iterable] = None):
        """
        Set the evaluation results of the Solution.

        Args:
            evals: New evaluation result(s) for the Solution.
                For single-objective problems, this argument can be given
                as a scalar.
                When this argument is given as a scalar (for single-objective
                cases) or as a tensor which is long enough to cover for
                all the objectives but not for the extra evaluation data,
                then the extra evaluation data will be cleared
                (in more details, extra evaluation data will be filled with
                NaN values).
            eval_data: Optionally, the argument `eval_data` can be used to
                specify extra evaluation data separately.
                `eval_data` is expected as a 1-dimensional sequence.
        """
        evals = torch.as_tensor(evals, dtype=self.eval_dtype, device=self.device)
        if evals.ndim in (0, 1):
            evals = evals.reshape(1, -1)
        else:
            raise ValueError(
                f"`set_evals(...)` method of a Solution expects a 1-dimensional or a 2-dimensional"
                f" evaluation tensor. However, the received evaluation tensor has {evals.ndim} dimensions"
                f" (having a shape of {evals.shape})."
            )

        if eval_data is not None:
            eval_data = torch.as_tensor(eval_data, dtype=self.eval_dtype, device=self.device)
            if eval_data.ndim != 1:
                raise ValueError(
                    f"The argument `eval_data` was expected as a 1-dimensional sequence."
                    f" However, the shape of `eval_data` is {eval_data.shape}."
                )
            eval_data = eval_data.reshape(1, -1)

        self._batch.set_evals(evals, eval_data)

    def set_evaluation(self, evaluation: RealOrVector, eval_data: Optional[Iterable] = None):
        """
        Set the evaluation results of the Solution.

        This method is an alias for `set_evals(...)`, added for having
        a setter counterpart for the `evaluation` property of the Solution
        class.

        Args:
            evaluation: New evaluation result(s) for the Solution.
                For single-objective problems, this argument can be given
                as a scalar.
                When this argument is given as a scalar (for single-objective
                cases) or as a tensor which is long enough to cover for
                all the objectives but not for the extra evaluation data,
                then the extra evaluation data will be cleared
                (in more details, extra evaluation data will be filled with
                NaN values).
            eval_data: Optionally, the argument `eval_data` can be used to
                specify extra evaluation data separately.
                `eval_data` is expected as a 1-dimensional sequence.
        """
        self.set_evals(evaluation, eval_data)

    def objective_sense(self) -> ObjectiveSense:
        """
        Get the objective sense(s) of this Solution's associated Problem.

        If the problem is single-objective, then a single string is returned.
        If the problem is multi-objective, then the objective senses will be
        returned in a list.

        The returned string in the single-objective case, or each returned
        string in the multi-objective case, is "min" or "max".
        """
        return self._batch.objective_sense

    @property
    def senses(self) -> Iterable[str]:
        """
        Objective sense(s) of this Solution's associated Problem.

        This is a list of strings, each string being "min" or "max".
        """
        return self._batch.senses

    @property
    def is_evaluated(self) -> bool:
        """
        Whether or not the Solution is fully evaluated.

        This property returns True only when all of the evaluation results
        for all objectives have numeric values other than NaN.

        This property assumes that the extra evaluation data is optional,
        and therefore does not take into consideration whether or not the
        extra evaluation data contains NaN values.
        In other words, while determining whether or not a solution is fully
        evaluated, only the evaluation results corresponding to the
        objectives are taken into account.
        """
        num_objs = len(self.senses)
        with torch.no_grad():
            return not bool(torch.any(torch.isnan(self._batch.evals[0, :num_objs])))

    @property
    def dtype(self) -> DType:
        """
        dtype of the decision values
        """
        return self._batch.dtype

    @property
    def device(self) -> Device:
        """
        The device storing the Solution
        """
        return self._batch.device

    @property
    def eval_dtype(self) -> DType:
        """
        dtype of the evaluation results
        """
        return self._batch.eval_dtype

    @staticmethod
    def _rightmost_shape(shape: Iterable) -> torch.Size:
        if len(shape) >= 2:
            return torch.Size([int(shape[-1])])
        else:
            return torch.Size([])

    @property
    def shape(self) -> torch.Size:
        """
        Shape of the decision values of the Solution
        """
        return self._rightmost_shape(self._batch.values_shape)

    def size(self) -> torch.Size:
        """
        Shape of the decision values of the Solution
        """
        return self.shape

    @property
    def eval_shape(self) -> torch.Size:
        """
        Shape of the evaluation results
        """
        return self._rightmost_shape(self._batch.eval_shape)

    @property
    def ndim(self) -> int:
        """
        Number of dimensions of the decision values.

        For numeric solutions (e.g. of dtype `torch.float32`), this returns
        1, since such numeric solutions are kepts as 1-dimensional vectors.

        When dtype is `object`, `ndim` is reported as whatever the contained
        object reports as its `ndim`, or 0 if the contained object does not
        have an `ndim` attribute.
        """
        values = self.values
        if hasattr(values, "ndim"):
            return values.ndim
        else:
            return 0

    def dim(self) -> int:
        """
        This method returns the `ndim` attribute of this Solution.
        """
        return self.ndim

    def __len__(self) -> int:
        return len(self.values)

    def __iter__(self):
        return self.values.__iter__()

    def __reversed__(self):
        return self.values.__reversed__()

    def __getitem__(self, i):
        return self.values.__getitem__(i)

    def _to_string(self) -> str:
        clsname = type(self).__name__
        result = []

        values = self._batch.access_values(keep_evals=True)[0]
        evals = self._batch.access_evals()[0]

        def write(*args):
            for arg in args:
                result.append(str(arg))

        write("<", clsname, " values=", values)
        if not torch.all(torch.isnan(evals)):
            write(", evals=", evals)
        write(">")
        return "".join(result)

    def __repr__(self) -> str:
        return self._to_string()

    def __str__(self) -> str:
        return self._to_string()

    def _get_cloned_state(self, *, memo: dict) -> dict:
        with _no_grad_if_basic_dtype(self.dtype):
            return deep_clone(
                self.__dict__,
                otherwise_deepcopy=True,
                memo=memo,
            )

    def to(self, device: Device) -> "Solution":
        """
        Get the counterpart of this Solution on the new device.

        If the specified device is the device of this Solution,
        then this Solution itself is returned.
        If the specified device is a different device, then a clone
        of this Solution on this different device is first
        created, and then this new clone is returned.

        Please note that the `to(...)` method is not supported when
        the dtype is `object`.

        Args:
            device: The device on which the resulting Solution
                will be stored.
        Returns:
            The Solution on the specified device.
        """
        return Solution(self._batch.to(device), 0)

    def to_batch(self) -> SolutionBatch:
        """
        Get the single-row SolutionBatch counterpart of the Solution.
        The returned SolutionBatch and the Solution have shared
        storage, meaning that modifying one of them affects the other.

        Returns:
            The SolutionBatch counterpart of the Solution.
        """
        return self._batch

device: Union[str, torch.device] property readonly

The device storing the Solution

dtype: Union[str, torch.dtype, numpy.dtype, Type] property readonly

dtype of the decision values

eval_dtype: Union[str, torch.dtype, numpy.dtype, Type] property readonly

dtype of the evaluation results

eval_shape: Size property readonly

Shape of the evaluation results

evals: Tensor property readonly

Evaluation results of the solution in a 1-dimensional tensor.

evaluation: Tensor property readonly

Get the evaluation result.

If the problem is single-objective and the problem does not allocate any space for extra evaluation data, then a scalar is returned. Otherwise, this property becomes equivalent to the evals property, and a 1-dimensional tensor is returned.

is_evaluated: bool property readonly

Whether or not the Solution is fully evaluated.

This property returns True only when all of the evaluation results for all objectives have numeric values other than NaN.

This property assumes that the extra evaluation data is optional, and therefore does not take into consideration whether or not the extra evaluation data contains NaN values. In other words, while determining whether or not a solution is fully evaluated, only the evaluation results corresponding to the objectives are taken into account.

ndim: int property readonly

Number of dimensions of the decision values.

For numeric solutions (e.g. of dtype torch.float32), this returns 1, since such numeric solutions are kepts as 1-dimensional vectors.

When dtype is object, ndim is reported as whatever the contained object reports as its ndim, or 0 if the contained object does not have an ndim attribute.

senses: Iterable[str] property readonly

Objective sense(s) of this Solution's associated Problem.

This is a list of strings, each string being "min" or "max".

shape: Size property readonly

Shape of the decision values of the Solution

values: Any property readonly

Decision values of the solution

__init__(self, parent, index) special

__init__(...): Initialize the Solution object.

Parameters:

Name Type Description Default
parent SolutionBatch

The parent SolutionBatch which stores the Solution.

required
index int

Index of the solution in SolutionBatch.

required
Source code in evotorch/core.py
def __init__(self, parent: SolutionBatch, index: int):
    """
    `__init__(...)`: Initialize the Solution object.

    Args:
        parent: The parent SolutionBatch which stores the Solution.
        index: Index of the solution in SolutionBatch.
    """
    if not isinstance(parent, SolutionBatch):
        raise TypeError(
            f"Expected a SolutionBatch as a parent, but encountered {repr(parent)},"
            f" which is of type {repr(type(parent))}."
        )
    index = int(index)
    if index < 0:
        index = len(parent) + index
    if not ((index >= 0) and (index <= len(parent))):
        raise IndexError(f"Invalid index: {index}")
    self._batch: SolutionBatch = parent[index : index + 1]

access_evals(self)

Access the evaluation results of the solution. The received tensor will be mutable.

Returns:

Type Description
Tensor

The evaluation results tensor of the solution.

Source code in evotorch/core.py
def access_evals(self) -> torch.Tensor:
    """
    Access the evaluation results of the solution.
    The received tensor will be mutable.

    Returns:
        The evaluation results tensor of the solution.
    """
    return self._batch.access_evals()[0]

access_values(self, *, keep_evals=False)

Access the decision values tensor of the solution. The received tensor will be mutable.

By default, it will be assumed that the user wishes to obtain this tensor to change the decision values, and therefore, the evaluation results associated with this solution will be cleared (i.e. will be NaN).

Parameters:

Name Type Description Default
keep_evals bool

When this is set to True, the evaluation results associated with this solution will be kept (i.e. will NOT be cleared).

False

Returns:

Type Description
Tensor

The decision values tensor of the solution.

Source code in evotorch/core.py
def access_values(self, *, keep_evals: bool = False) -> torch.Tensor:
    """
    Access the decision values tensor of the solution.
    The received tensor will be mutable.

    By default, it will be assumed that the user wishes to
    obtain this tensor to change the decision values, and therefore,
    the evaluation results associated with this solution will be
    cleared (i.e. will be NaN).

    Args:
        keep_evals: When this is set to True, the evaluation results
            associated with this solution will be kept (i.e. will NOT
            be cleared).
    Returns:
        The decision values tensor of the solution.
    """
    return self._batch.access_values(keep_evals=keep_evals)[0]

dim(self)

This method returns the ndim attribute of this Solution.

Source code in evotorch/core.py
def dim(self) -> int:
    """
    This method returns the `ndim` attribute of this Solution.
    """
    return self.ndim

objective_sense(self)

Get the objective sense(s) of this Solution's associated Problem.

If the problem is single-objective, then a single string is returned. If the problem is multi-objective, then the objective senses will be returned in a list.

The returned string in the single-objective case, or each returned string in the multi-objective case, is "min" or "max".

Source code in evotorch/core.py
def objective_sense(self) -> ObjectiveSense:
    """
    Get the objective sense(s) of this Solution's associated Problem.

    If the problem is single-objective, then a single string is returned.
    If the problem is multi-objective, then the objective senses will be
    returned in a list.

    The returned string in the single-objective case, or each returned
    string in the multi-objective case, is "min" or "max".
    """
    return self._batch.objective_sense

set_evals(self, evals, eval_data=None)

Set the evaluation results of the Solution.

Parameters:

Name Type Description Default
evals Tensor

New evaluation result(s) for the Solution. For single-objective problems, this argument can be given as a scalar. When this argument is given as a scalar (for single-objective cases) or as a tensor which is long enough to cover for all the objectives but not for the extra evaluation data, then the extra evaluation data will be cleared (in more details, extra evaluation data will be filled with NaN values).

required
eval_data Optional[Iterable]

Optionally, the argument eval_data can be used to specify extra evaluation data separately. eval_data is expected as a 1-dimensional sequence.

None
Source code in evotorch/core.py
def set_evals(self, evals: torch.Tensor, eval_data: Optional[Iterable] = None):
    """
    Set the evaluation results of the Solution.

    Args:
        evals: New evaluation result(s) for the Solution.
            For single-objective problems, this argument can be given
            as a scalar.
            When this argument is given as a scalar (for single-objective
            cases) or as a tensor which is long enough to cover for
            all the objectives but not for the extra evaluation data,
            then the extra evaluation data will be cleared
            (in more details, extra evaluation data will be filled with
            NaN values).
        eval_data: Optionally, the argument `eval_data` can be used to
            specify extra evaluation data separately.
            `eval_data` is expected as a 1-dimensional sequence.
    """
    evals = torch.as_tensor(evals, dtype=self.eval_dtype, device=self.device)
    if evals.ndim in (0, 1):
        evals = evals.reshape(1, -1)
    else:
        raise ValueError(
            f"`set_evals(...)` method of a Solution expects a 1-dimensional or a 2-dimensional"
            f" evaluation tensor. However, the received evaluation tensor has {evals.ndim} dimensions"
            f" (having a shape of {evals.shape})."
        )

    if eval_data is not None:
        eval_data = torch.as_tensor(eval_data, dtype=self.eval_dtype, device=self.device)
        if eval_data.ndim != 1:
            raise ValueError(
                f"The argument `eval_data` was expected as a 1-dimensional sequence."
                f" However, the shape of `eval_data` is {eval_data.shape}."
            )
        eval_data = eval_data.reshape(1, -1)

    self._batch.set_evals(evals, eval_data)

set_evaluation(self, evaluation, eval_data=None)

Set the evaluation results of the Solution.

This method is an alias for set_evals(...), added for having a setter counterpart for the evaluation property of the Solution class.

Parameters:

Name Type Description Default
evaluation Union[float, Iterable[float], torch.Tensor]

New evaluation result(s) for the Solution. For single-objective problems, this argument can be given as a scalar. When this argument is given as a scalar (for single-objective cases) or as a tensor which is long enough to cover for all the objectives but not for the extra evaluation data, then the extra evaluation data will be cleared (in more details, extra evaluation data will be filled with NaN values).

required
eval_data Optional[Iterable]

Optionally, the argument eval_data can be used to specify extra evaluation data separately. eval_data is expected as a 1-dimensional sequence.

None
Source code in evotorch/core.py
def set_evaluation(self, evaluation: RealOrVector, eval_data: Optional[Iterable] = None):
    """
    Set the evaluation results of the Solution.

    This method is an alias for `set_evals(...)`, added for having
    a setter counterpart for the `evaluation` property of the Solution
    class.

    Args:
        evaluation: New evaluation result(s) for the Solution.
            For single-objective problems, this argument can be given
            as a scalar.
            When this argument is given as a scalar (for single-objective
            cases) or as a tensor which is long enough to cover for
            all the objectives but not for the extra evaluation data,
            then the extra evaluation data will be cleared
            (in more details, extra evaluation data will be filled with
            NaN values).
        eval_data: Optionally, the argument `eval_data` can be used to
            specify extra evaluation data separately.
            `eval_data` is expected as a 1-dimensional sequence.
    """
    self.set_evals(evaluation, eval_data)

set_values(self, values)

Set the decision values of the Solution.

Note that modifying the decision values will result in the evaluation results being getting cleared (in more details, the evaluation results tensor will be filled with NaN values).

Parameters:

Name Type Description Default
values Any

New decision values for this Solution.

required
Source code in evotorch/core.py
def set_values(self, values: Any):
    """
    Set the decision values of the Solution.

    Note that modifying the decision values will result in the
    evaluation results being getting cleared (in more details,
    the evaluation results tensor will be filled with NaN values).

    Args:
        values: New decision values for this Solution.
    """
    if is_dtype_object(self.dtype):
        value_tensor = ObjectArray(1)
        value_tensor[0] = values
    else:
        value_tensor = torch.as_tensor(values, dtype=self.dtype).reshape(1, -1)
    self._batch.set_values(value_tensor)

size(self)

Shape of the decision values of the Solution

Source code in evotorch/core.py
def size(self) -> torch.Size:
    """
    Shape of the decision values of the Solution
    """
    return self.shape

to(self, device)

Get the counterpart of this Solution on the new device.

If the specified device is the device of this Solution, then this Solution itself is returned. If the specified device is a different device, then a clone of this Solution on this different device is first created, and then this new clone is returned.

Please note that the to(...) method is not supported when the dtype is object.

Parameters:

Name Type Description Default
device Union[str, torch.device]

The device on which the resulting Solution will be stored.

required

Returns:

Type Description
Solution

The Solution on the specified device.

Source code in evotorch/core.py
def to(self, device: Device) -> "Solution":
    """
    Get the counterpart of this Solution on the new device.

    If the specified device is the device of this Solution,
    then this Solution itself is returned.
    If the specified device is a different device, then a clone
    of this Solution on this different device is first
    created, and then this new clone is returned.

    Please note that the `to(...)` method is not supported when
    the dtype is `object`.

    Args:
        device: The device on which the resulting Solution
            will be stored.
    Returns:
        The Solution on the specified device.
    """
    return Solution(self._batch.to(device), 0)

to_batch(self)

Get the single-row SolutionBatch counterpart of the Solution. The returned SolutionBatch and the Solution have shared storage, meaning that modifying one of them affects the other.

Returns:

Type Description
SolutionBatch

The SolutionBatch counterpart of the Solution.

Source code in evotorch/core.py
def to_batch(self) -> SolutionBatch:
    """
    Get the single-row SolutionBatch counterpart of the Solution.
    The returned SolutionBatch and the Solution have shared
    storage, meaning that modifying one of them affects the other.

    Returns:
        The SolutionBatch counterpart of the Solution.
    """
    return self._batch

SolutionBatch (Serializable)

Representation of a batch of solutions.

A SolutionBatch stores the decision values of multiple solutions in a single contiguous tensor. For numeric and fixed-length problems, this contiguous tensor is a PyTorch tensor. For not-necessarily-numeric and not-necessarily-fixed-length problems, this contiguous tensor is an ObjectArray.

The evalution results and extra evaluation data of the solutions are also stored in an additional contiguous tensor.

Interface-wise, a SolutionBatch behaves like a sequence of Solution objects. One can get single Solution from a SolutionBatch via the indexing operator ([]). Additionally, one can iterate over each solution using the for ... in ... statement.

One can also get a slice of a SolutionBatch. The slicing of a SolutionBatch results in a new SolutionBatch. With simple slicing, the obtained SolutionBatch shares its memory with the original SolutionBatch. With advanced slicing (i.e. the kind of slicing where the solution indices are specified one by one, like: mybatch[[0, 4, 2, 5]]), the obtained SolutionBatch is a copy, and does not share any memory with its original.

The decision values of all the stored solutions in the batch can be obtained in a read-only tensor via:

values = batch.values

If one has modified decision values and wishes to put them into the batch, the set_values(...) method can be used as follows:

batch.set_values(modified_values)

The evaluation results of the solutions can be obtained in a read-only tensor via:

evals = batch.evals

If one has newly computed evaluation results, and wishes to put them into the batch, the set_evals(...) method can be used as follows:

batch.set_evals(newly_computed_evals)
Source code in evotorch/core.py
class SolutionBatch(Serializable):
    """
    Representation of a batch of solutions.

    A SolutionBatch stores the decision values of multiple solutions
    in a single contiguous tensor. For numeric and fixed-length
    problems, this contiguous tensor is a PyTorch tensor.
    For not-necessarily-numeric and not-necessarily-fixed-length
    problems, this contiguous tensor is an ObjectArray.

    The evalution results and extra evaluation data of the solutions
    are also stored in an additional contiguous tensor.

    Interface-wise, a SolutionBatch behaves like a sequence of
    Solution objects. One can get single Solution from a SolutionBatch
    via the indexing operator (`[]`).
    Additionally, one can iterate over each solution using
    the `for ... in ...` statement.

    One can also get a slice of a SolutionBatch.
    The slicing of a SolutionBatch results in a new SolutionBatch.
    With simple slicing, the obtained SolutionBatch shares its
    memory with the original SolutionBatch.
    With advanced slicing (i.e. the kind of slicing where the
    solution indices are specified one by one, like:
    `mybatch[[0, 4, 2, 5]]`), the obtained SolutionBatch is a copy,
    and does not share any memory with its original.

    The decision values of all the stored solutions in the batch
    can be obtained in a read-only tensor via:

        values = batch.values

    If one has modified decision values and wishes to put them
    into the batch, the `set_values(...)` method can be used
    as follows:

        batch.set_values(modified_values)

    The evaluation results of the solutions can be obtained
    in a read-only tensor via:

        evals = batch.evals

    If one has newly computed evaluation results, and wishes
    to put them into the batch, the `set_evals(...)` method
    can be used as follows:

        batch.set_evals(newly_computed_evals)
    """

    def __init__(
        self,
        problem: Optional[Problem] = None,
        popsize: Optional[int] = None,
        *,
        device: Optional[Device] = None,
        slice_of: Optional[Union[tuple, SolutionBatchSliceInfo]] = None,
        like: Optional["SolutionBatch"] = None,
        merging_of: Iterable["SolutionBatch"] = None,
        empty: Optional[bool] = None,
    ):
        self._num_objs: int
        self._data: Union[torch.Tensor, ObjectArray]
        self._descending: Iterable[bool]
        self._slice: Optional[IndicesOrSlice] = None

        if slice_of is not None:
            expect_none(
                "While making a new SolutionBatch via slicing",
                problem=problem,
                popsize=popsize,
                device=device,
                merging_of=merging_of,
                like=like,
                empty=empty,
            )

            source: "SolutionBatch"
            slice_info: IndicesOrSlice
            source, slice_info = slice_of

            def safe_slice(t: torch.Tensor, slice_info):
                d0 = t.ndim
                t = t[slice_info]
                d1 = t.ndim
                if d0 != d1:
                    raise ValueError(
                        "Encountered an illegal slicing operation which would"
                        " change the shape of the stored tensor(s) of the"
                        " SolutionBatch."
                    )
                return t

            with torch.no_grad():
                # self._data = source._data[slice_info]
                # self._evdata = source._evdata[slice_info]
                self._data = safe_slice(source._data, slice_info)
                self._evdata = safe_slice(source._evdata, slice_info)
            self._slice = slice_info
            self._descending = source._descending

            shares_storage = storage_ptr(self._data) == storage_ptr(source._data)

            if not shares_storage:
                self._descending = deepcopy(self._descending)

            self._num_objs = source._num_objs
        elif like is not None:
            expect_none(
                "While making a new SolutionBatch via the like=... argument",
                merging_of=merging_of,
                slice_of=slice_of,
            )
            self._data = empty_tensor_like(like._data, length=popsize, device=device)
            self._evdata = empty_tensor_like(like._evdata, length=popsize, device=device)
            self._evdata[:] = float("nan")

            self._descending = like._descending
            self._num_objs = like._num_objs

            if not _opt_bool(empty, default=False):
                self._fill_via_problem(problem)
        elif merging_of is not None:
            expect_none(
                "While making a new SolutionBatch via merging",
                problem=problem,
                popsize=popsize,
                device=device,
                slice_of=slice_of,
                like=like,
                empty=empty,
            )

            # Convert `merging_of` into a list.
            # While doing that, also count the total number of rows
            batches = []
            total_rows = 0
            for batch in merging_of:
                total_rows += len(batch)
                batches.append(batch)

            # Get essential attributes from the first batch
            self._descending = deepcopy(batches[0]._descending)
            self._num_objs = batches[0]._num_objs

            if isinstance(batches[0]._data, ObjectArray):

                def process_data(x):
                    return deepcopy(x)

                self._data = ObjectArray(total_rows)
            else:

                def process_data(x):
                    return x

                self._data = empty_tensor_like(batches[0]._data, length=total_rows)

            self._evdata = empty_tensor_like(batches[0]._evdata, length=total_rows)

            row_begin = 0
            for batch in batches:
                row_end = row_begin + len(batch)
                self._data[row_begin:row_end] = process_data(batch._data)
                self._evdata[row_begin:row_end] = batch._evdata
                row_begin = row_end

        elif problem is not None:
            expect_none(
                "While making a new SolutionBatch with a given problem",
                slice_of=slice_of,
                like=like,
                merging_of=merging_of,
            )

            if device is None:
                device = problem.device

            self._num_objs = len(problem.senses)

            if problem.dtype is object:
                if str(device) != "cpu":
                    raise ValueError("Cannot create a batch containing arbitrary objects on a device other than cpu")
                self._data = ObjectArray(popsize)
            else:
                self._data = torch.empty((popsize, problem.solution_length), device=device, dtype=problem.dtype)

            if not _opt_bool(empty, default=False):
                self._data[:] = problem.generate_values(len(self._data))

            self._evdata = problem.make_nan(
                popsize, self._num_objs + problem.eval_data_length, device=device, use_eval_dtype=True
            )
            self._descending = problem.get_obj_order_descending()
        else:
            raise ValueError("Invalid call to the __init__(...) of SolutionBatch")

    def _normalize_row_index(self, i: int) -> int:
        i = int(i)
        org_i = i

        if i < 0:
            i = int(self._data.shape[0]) + i

        if (i < 0) or (i > (self._data.shape[0] - 1)):
            raise IndexError(f"Invalid row: {org_i}")

        return i

    def _normalize_obj_index(self, i: int) -> int:
        i = int(i)
        org_i = i

        if i < 0:
            i = self._num_objs + i

        if (i < 0) or (i > (self._num_objs)):
            raise IndexError(f"Invalid objective index: {org_i}")

        return i

    def _optionally_get_obj_index(self, i: Optional[int]) -> int:
        if i is None:
            if self._num_objs != 1:
                raise ValueError(
                    f"The objective index was given as None."
                    f" However, the number of objectives is not 1,"
                    f" it is {self._num_objs}."
                    f" Therefore, the objective index is not optional,"
                    f" and must be provided as an integer, not as None."
                )
            return 0
        else:
            return self._normalize_obj_index(i)

    @torch.no_grad()
    def argsort(self, obj_index: Optional[int] = None) -> torch.Tensor:
        """Return the indices of solutions, sorted from best to worst.

        Args:
            obj_index: The objective index. Can be passed as None
                if the problem is single-objective. Otherwise,
                expected as an int.
        Returns:
            A PyTorch tensor, containing the solution indices,
            sorted from the best solution to the worst.
        """
        obj_index = self._optionally_get_obj_index(obj_index)

        descending = self._descending[obj_index]
        ev_col = self._evdata[:, obj_index]

        return torch.argsort(ev_col, descending=descending)

    @torch.no_grad()
    def compute_pareto_ranks(self, crowdsort: bool = True) -> Tuple[torch.Tensor, torch.Tensor]:
        """
        Compute the pareto-ranks of the solutions in the batch.
        Args:
            crowdsort: If given as True, each front in itself
                will be sorted from the least crowding solution
                to the most crowding solution.
                If given as False, there will be no crowd-sorting.
        Returns:
            ranks (torch.Tensor): The computed pareto ranks, of shape [num_samples,]
                                Ranks are encoded in the form 'lowest is best'. So solutions in the pareto front will be assigned rank 0,
                                solutions in the next non-dominated set (excluding the pareto front) will be assigned rank 1, and so on.
            crowdsort_ranks (Optional[torch.Tensor]): The computed crowd sort ranks, only returned if crowdsort=True. Otherwise, None is returned.
                                Solutions within a front are assigned a crowding score, as described in the above paper.
                                Then, the solution with the best crowding score within a front of size K is assigned rank 0, and the solution with the
                                worst crowding score within a front is assigned rank K-1.
        """
        utils = self.utils()

        ranks, crowdsort_ranks = _compute_pareto_ranks(utils, crowdsort)

        return ranks, crowdsort_ranks

    @torch.no_grad()
    def arg_pareto_sort(self, crowdsort: bool = True) -> ParetoInfo:
        """
        Pareto-sort the solutions in the batch.

        The result is a namedtuple consisting of two elements:
        `fronts` and `ranks`.
        Let us assume that we have 5 solutions, and after a
        pareto-sorting they ended up in this order:

            front 0 (best front) : solution 1, solution 2
            front 1              : solution 0, solution 4
            front 2 (worst front): solution 3

        Considering the example ordering above, the returned
        ParetoInfo instance looks like this:

            ParetoInfo(
                fronts=[[1, 2], [0, 4], [3]],
                ranks=tensor([1, 0, 0, 2, 1])
            )

        where `fronts` stores the solution indices grouped by
        pareto fronts; and `ranks` stores, as a tensor of int64,
        the pareto rank for each solution (where 0 means best
        rank).

        Args:
            crowdsort: If given as True, each front in itself
                will be sorted from the least crowding solution
                to the most crowding solution.
                If given as False, there will be no crowd-sorting.
        Returns:
            A ParetoInfo instance
        """

        utils = self.utils()

        fronts, ranks = _pareto_sort(utils, crowdsort)

        return ParetoInfo(fronts=fronts, ranks=ranks)

    @torch.no_grad()
    def argbest(self, obj_index: Optional[int] = None) -> torch.Tensor:
        """Return the best solution's index

        Args:
            obj_index: The objective index. Can be passed as None
                if the problem is single-objective. Otherwise,
                expected as an int.
        Returns:
            The index of the best solution.
        """
        obj_index = self._optionally_get_obj_index(obj_index)
        descending = self._descending[obj_index]
        argf = torch.argmax if descending else torch.argmin
        return argf(self._evdata[:, obj_index])

    @torch.no_grad()
    def argworst(self, obj_index: Optional[int] = None) -> torch.Tensor:
        """Return the worst solution's index

        Args:
            obj_index: The objective index. Can be passed as None
                if the problem is single-objective. Otherwise,
                expected as an int.
        Returns:
            The index of the worst solution.
        """
        obj_index = self._optionally_get_obj_index(obj_index)
        descending = self._descending[obj_index]
        argf = torch.argmin if descending else torch.argmax
        return argf(self._evdata[:, obj_index])

    def _get_objective_sign(self, i_obj: int) -> float:
        if self._descending[i_obj]:
            return 1.0
        else:
            return -1.0

    @torch.no_grad()
    def set_values(self, values: Any, *, solutions: MaybeIndicesOrSlice = None):
        """
        Set the decision values of the solutions.

        Args:
            values: New decision values.
            solutions: Optionally a list of integer indices or an instance
                of `slice(...)`, to be used if one wishes to set the
                decision values of only some of the solutions.
        """
        if solutions is None:
            solutions = slice(None, None, None)
        self._data[solutions] = values
        self._evdata[solutions] = float("nan")

    @torch.no_grad()
    def set_evals(
        self,
        evals: torch.Tensor,
        eval_data: Optional[torch.Tensor] = None,
        *,
        solutions: MaybeIndicesOrSlice = None,
    ):
        """
        Set the evaluations of the solutions.

        Args:
            evals: A numeric tensor which contains the evaluation results.
                Acceptable shapes are as follows:
                `(n,)` only to be used for single-objective problems, sets
                the evaluation results of the target `n` solutions, and clears
                (where clearing means to fill with NaN values)
                extra evaluation data (if the problem has allocations for such
                extra evaluation data);
                `(n,m)` where `m` is the number of objectives, sets the
                evaluation results of the target `n` solutions, and clears
                their extra evaluation data;
                `(n,m+q)` where `m` is the number of objectives and `q` is the
                length of extra evaluation data, sets the evaluation result
                and extra data of the target `n` solutions.
            eval_data: To be used only when the problem has extra evaluation
                data. Optionally, one can pass the extra evaluation data
                separately via this argument (instead of jointly through
                a single tensor via `evals`).
                The expected shape of this tensor is `(n,q)` where `n`
                is the number of solutions and `q` is the length of the
                extra evaluation data.
            solutions: Optionally a list of integer indices or an instance
                of `slice(...)`, to be used if one wishes to set the
                evaluations of only some of the solutions.
        Raises:
            ValueError: if the given tensor has an incompatible shape.
        """
        if solutions is None:
            solutions = slice(None, None, None)
            num_solutions = self._evdata.shape[0]
        elif isinstance(solutions, slice):
            num_solutions = self._evdata[solutions].shape[0]
        elif is_sequence(solutions):
            num_solutions = len(solutions)
        total_eval_width = self._evdata.shape[1]

        num_objs = self._num_objs
        num_data = total_eval_width - num_objs

        if evals.ndim == 1:
            if num_objs != 1:
                raise ValueError(
                    f"The method `set_evals(...)` was given a 1-dimensional tensor."
                    f" However, the number of objectives of the problem at hand is {num_objs}, not 1."
                    f" 1-dimensional evaluation tensors can only be accepted if the problem"
                    f" has one objective."
                )
            evals = evals.reshape(-1, 1)
        elif evals.ndim == 2:
            pass  # nothing to do here
        else:
            if num_objs == 1:
                raise ValueError(
                    f"The method `set_evals(...)` received a tensor with {evals.ndim} dimensions."
                    f" Since the problem at hand has only one objective,"
                    f" 1-dimensional or 2-dimensional tensors are acceptable, but not {evals.ndim}-dimensional ones."
                )
            else:
                raise ValueError(
                    f"The method `set_evals(...)` received a tensor with {evals.ndim} dimensions."
                    f" Since the problem at hand has more than one objective (there are {num_objs} objectives),"
                    f" only 2-dimensional tensors are acceptable, not {evals.ndim}-dimensional ones."
                )

        [nrows, ncols] = evals.shape

        if nrows != num_solutions:
            raise ValueError(
                f"Trying to set the evaluations of {num_solutions} solutions, but the given tensor has {nrows} rows."
            )

        if eval_data is not None:
            if eval_data.ndim != 2:
                raise ValueError(
                    f"The `eval_data` argument was expected as a 2-dimensional tensor."
                    f" However, the shape of the given `eval_data` is {eval_data.shape}."
                )
            if eval_data.shape[1] != num_data:
                raise ValueError(
                    f"The `eval_data` argument was expected to have {num_data} columns."
                    f" However, the received `eval_data` has the shape: {eval_data.shape}."
                )
            if ncols != num_objs:
                raise ValueError(
                    f"The method `set_evals(...)` was used with `evals` and `eval_data` arguments."
                    f" When both of these arguments are provided, `evals` is expected either as a 1-dimensional tensor"
                    f" (for single-objective cases only), or as a tensor of shape (n, m) where n is the number of"
                    f" solutions, and m is the number of objectives."
                    f" However, while the problem at hand has {num_objs} objectives,"
                    f" the `evals` tensor has {ncols} columns."
                )
            if evals.shape[0] != eval_data.shape[0]:
                raise ValueError(
                    f"The provided `evals` and `eval_data` tensors have incompatible shapes."
                    f" Shape of `evals`: {evals.shape},"
                    f" shape of `eval_data`: {eval_data.shape}."
                )
            self._evdata[solutions, :] = torch.hstack([evals, eval_data])
        else:
            if ncols == num_objs:
                self._evdata[solutions, :num_objs] = evals
                self._evdata[solutions, num_objs:] = float("nan")
            elif ncols == total_eval_width:
                self._evdata[solutions, :] = evals
            else:
                raise ValueError(
                    f"The method `set_evals(...)` received a tensor with {ncols} columns, which is incompatible."
                    f" Acceptable number of columns are: {num_objs}"
                    f" (for setting only the objective-associated evaluations and leave extra evaluation data as NaN), or"
                    f" {total_eval_width} (for setting both objective-associated evaluations and extra evaluation data)."
                )

    @property
    def evals(self) -> torch.Tensor:
        """
        Evaluation results of the solutions, in a ReadOnlyTensor
        """
        from .tools.readonlytensor import as_read_only_tensor

        with torch.no_grad():
            return as_read_only_tensor(self._evdata)

    @property
    def values(self) -> Union[torch.Tensor, Iterable]:
        """
        Decision values of the solutions, in a read-only tensor-like object
        """
        from .tools.readonlytensor import as_read_only_tensor

        with torch.no_grad():
            return as_read_only_tensor(self._data)

    # @property
    # def unsafe_evals(self) -> torch.Tensor:
    #    """
    #    It is not recommended to use this property.
    #
    #    Grants mutable access to the evaluations of the solutions.
    #    """
    #    return self._evdata
    #
    # @property
    # def unsafe_values(self) -> Union[torch.Tensor, Iterable]:
    #    """
    #    It is not recommended to use this property.
    #
    #    Grants mutable access to the decision values of the solutions.
    #    """
    #    return self._data

    @torch.no_grad()
    def access_evals(self, obj_index: Optional[int] = None) -> torch.Tensor:
        """
        Get the internal mutable tensor storing the evaluations.

        IMPORTANT: This method exposes the evaluation tensor of the batch
        as it is, in mutable mode. It is therefore considered unsafe to rely
        on this method. Before using this method, please consider using the
        `evals` property for reading the evaluation results, and using the
        `set_evals(...)` method which allows one to update the evaluations
        without exposing any internal tensor.

        When this method is used without any argument, the returned tensor
        will be of shape `(n, m)`, where `n` is the number of solutions,
        and `m` is the number of objectives plus the length of extra
        evaluation data.

        When this method is used with an integer argument specifying an
        objective index, the returned tensor will be 1-dimensional
        having a length of `n`, where `n` is the number of solutions.
        In this case, the returned 1-dimensional tensor will be a view
        upon the evaluation results of the specified objective.

        The value `nan` (not-a-number) means not evaluated yet.

        Args:
            obj_index: None for getting the entire 2-dimensional evaluation
                tensor; an objective index (as integer) for getting a
                1-dimensional mutable slice of the evaluation tensor,
                the slice being a view upon the evaluation results
                regarding the specified objective.
        Returns:
            The mutable tensor storing the evaluation information.
        """
        if obj_index is None:
            return self._evdata
        else:
            return self._evdata[:, self._normalize_obj_index(obj_index)]

    @torch.no_grad()
    def access_values(self, *, keep_evals: bool = False) -> Union[torch.Tensor, ObjectArray]:
        """
        Get the internal mutable tensor storing the decision values.

        IMPORTANT: This method exposes the internal decision values tensor of
        the batch as it is, in mutable mode. It is therefore considered unsafe
        to rely on this method. Before using this method, please consider
        using the `values` property for reading the decision values, and using
        the `set_values(...)` method which allows one to update the decision
        values without exposing any internal tensor.

        IMPORTANT: The default assumption of this method is that the tensor
        is requested for modification purposes. Therefore, by default, as soon
        as this method is called, the evaluation results of the solutions will
        be cleared (where clearing means that the evaluation results will be
        filled with `NaN`s).
        The reasoning behind this default behavior is to prevent the modified
        solutions from having outdated evaluation results.

        Args:
            keep_evals: If set as False, the evaluation data of the solutions
                will be cleared (i.e. will be filled with `NaN`s).
                If set as True, the existing evaluation data will be kept.
        Returns:
            The mutable tensor storing the decision values.
        """
        if not keep_evals:
            self.forget_evals()
        return self._data

    @torch.no_grad()
    def forget_evals(self, *, solutions: MaybeIndicesOrSlice = None):
        """
        Forget the evaluations of the solutions.
        The evaluation results will be cleared, which means that they will
        be filled with `NaN`s.
        """
        if solutions is None:
            solutions = slice(None, None, None)
        self._evdata[solutions, :] = float("nan")

    @torch.no_grad()
    def utility(
        self,
        obj_index: Optional[int] = None,
        *,
        ranking_method: Optional[str] = None,
        check_nans: bool = True,
        using_values_dtype: bool = False,
    ) -> torch.Tensor:
        """
        Return numeric scores for each solution.

        Utility scores are different from evaluation results,
        in the sense that utilities monotonically increase from
        bad solutions to good solutions, regardless of the
        objective sense.

        **If ranking method is passed as None:**
        if the objective sense is 'max', the evaluation results are returned
        as the utility scores; otherwise, if the objective sense is 'min',
        the evaluation results multiplied by -1 are returned as the
        utility scores.

        **If the name of a ranking method is given** (e.g. 'centered'):
        then the solutions are ranked (best solutions having the
        highest rank), and those ranks are returned as the utility
        scores.

        **If an objective index is not provided:** (i.e. passed as None)
        if the problem is multi-objective, the utility scores
        for each objective is given, in a tensor shaped (n, m),
        n being the number of solutions and m being the number
        of objectives; otherwise, if the problem is single-objective,
        the utility scores for each objective is given in a 1-dimensional
        tensor of length n, n being the number of solutions.

        **If an objective index is provided as an int:**
        the utility scores are returned in a 1-dimensional tensor
        of length n, n being the number of solutions.

        Args:
            obj_index: Expected as None, or as an integer.
                In the single-objective case, None is equivalent to 0.
                In the multi-objective case, None means "for each
                objective".
            ranking_method: If the utility scores are to be generated
                according to a certain ranking method, pass here the name
                of that ranking method as a str (e.g. 'centered').
            check_nans: Check for nan (not-a-number) values in the
                evaluation results, which is an indication of
                unevaluated solutions.
            using_values_dtype: If True, the utility values will be returned
                using the dtype of the decision values.
                If False, the utility values will be returned using the dtype
                of the evaluation data.
                The default is False.
        Returns:
            Utility scores, in a PyTorch tensor.
        """
        if obj_index is not None:
            obj_index = self._normalize_obj_index(obj_index)

            evdata = self._evdata[:, obj_index]
            if check_nans:
                if torch.any(torch.isnan(evdata)):
                    raise ValueError(
                        "Cannot compute the utility values, because there are solutions which are not evaluated yet."
                    )

            if ranking_method is None:
                result = evdata * self._get_objective_sign(obj_index)
            else:
                result = rank(evdata, ranking_method=ranking_method, higher_is_better=self._descending[obj_index])

            if using_values_dtype:
                result = torch.as_tensor(result, dtype=self._data.dtype, device=self._data.device)

            return result
        else:
            if self._num_objs == 1:
                return self.utility(
                    0, ranking_method=ranking_method, check_nans=check_nans, using_values_dtype=using_values_dtype
                )
            else:
                return torch.stack(
                    [
                        self.utility(
                            j,
                            ranking_method=ranking_method,
                            check_nans=check_nans,
                            using_values_dtype=using_values_dtype,
                        )
                        for j in range(self._num_objs)
                    ],
                ).T

    @torch.no_grad()
    def utils(
        self,
        *,
        ranking_method: Optional[str] = None,
        check_nans: bool = True,
        using_values_dtype: bool = False,
    ) -> torch.Tensor:
        """
        Return numeric scores for each solution, and for each objective.
        Utility scores are different from evaluation results,
        in the sense that utilities monotonically increase from
        bad solutions to good solutions, regardless of the
        objective sense.

        Unlike the method called `utility(...)`, this function returns
        a 2-dimensional tensor even when the problem is single-objective.

        The result of this method is always a 2-dimensional tensor of
        shape `(n, m)`, `n` being the number of solutions, `m` being the
        number of objectives.

        Args:
            ranking_method: If the utility scores are to be generated
                according to a certain ranking method, pass here the name
                of that ranking method as a str (e.g. 'centered').
            check_nans: Check for nan (not-a-number) values in the
                evaluation results, which is an indication of
                unevaluated solutions.
            using_values_dtype: If True, the utility values will be returned
                using the dtype of the decision values.
                If False, the utility values will be returned using the dtype
                of the evaluation data.
                The default is False.
        Returns:
            Utility scores, in a 2-dimensional PyTorch tensor.

        """
        result = self.utility(
            ranking_method=ranking_method, check_nans=check_nans, using_values_dtype=using_values_dtype
        )
        if result.ndim == 1:
            result = result.view(len(result), 1)
        return result

    def split(self, num_pieces: Optional[int] = None, *, max_size: Optional[int] = None) -> "SolutionBatchPieces":
        """Split this SolutionBatch into a specified number of pieces,
        or into an unspecified number of pieces where the maximum
        size of each piece is specified.

        Args:
            num_pieces: Can be provided as an integer n, which means
                that the this SolutionBatch will be split to n pieces.
                Alternatively, can be left as None if the user intends
                to set max_size as an integer instead.
            max_size: Can be provided as an integer n, which means
                that this SolutionBatch will be split to multiple
                pieces, each piece containing n solutions at most.
                Alternatively, can be left as None if the user intends
                to set num_pieces as an integer instead.
        Returns:
            A SolutionBatchPieces object, which behaves like a list of
            SolutionBatch objects, each object in the list being a
            slice view of this SolutionBatch object.
        """
        return SolutionBatchPieces(self, num_pieces=num_pieces, max_size=max_size)

    @torch.no_grad()
    def concat(self, other: Union["SolutionBatch", Iterable]) -> "SolutionBatch":
        """Concatenate this SolutionBatch with the other(s).

        In this context, concatenation means that the solutions of
        this SolutionBatch and of the others are collected in one big
        SolutionBatch object.

        Args:
            other: A SolutionBatch, or a sequence of SolutionBatch objects.
        Returns:
            A new SolutionBatch object which is the result of the
            concatenation.
        """
        if isinstance(other, SolutionBatch):
            lst = [self, other]
        else:
            lst = [self]
            lst.extend(list(other))
        return SolutionBatch(merging_of=lst)

    def take(self, indices: Iterable) -> "SolutionBatch":
        """Make a new SolutionBatch containing the specified solutions.

        Args:
            indices: A sequence of solution indices. These specified
                solutions will make it to the newly made SolutionBatch.
        Returns:
            The new SolutionBatch.
        """
        if is_sequence(indices):
            return type(self)(slice_of=(self, indices))
        else:
            raise TypeError("Expected a sequence of solution indices, but got a `{type(indices)}`")

    def take_best(self, n: int, *, obj_index: Optional[int] = None) -> "SolutionBatch":
        """Make a new SolutionBatch containing the best `n` solutions.

        Args:
            n: Number of solutions which will be taken.
            obj_index: Objective index according to which the best ones
                will be taken.
                If `obj_index` is left as None and the problem is multi-
                objective, then the solutions will be ranked according to
                their fronts, and according how crowding they are, and then
                the topmost `n` solutions will be taken.
                If `obj_index` is left as None and the problem is single-
                objective, then that single objective will be taken as the
                ranking criterion.
        Returns:
            The new SolutionBatch.
        """
        if obj_index is None and self._num_objs >= 2:
            ranks, crowdsort_ranks = self.compute_pareto_ranks(crowdsort=True)
            # Combine the ranks, such that solutions with a better crowdsort rank are weighted above solutions with the same pareto rank **only**
            combined_ranks = ranks.to(torch.float) + 0.1 * crowdsort_ranks.to(torch.float) / len(self)
            indices = torch.argsort(combined_ranks)[:n]
        else:
            indices = self.argsort(obj_index)[:n]
        return type(self)(slice_of=(self, indices))

    def __getitem__(self, i):
        if isinstance(i, slice) or is_sequence(i) or isinstance(i, type(...)):
            return type(self)(slice_of=(self, i))
        else:
            return Solution(parent=self, index=i)

    def __len__(self):
        return int(self._data.shape[0])

    def __iter__(self):
        for i in range(len(self)):
            yield self[i]

    def _get_cloned_state(self, *, memo: dict) -> dict:
        with _no_grad_if_basic_dtype(self.dtype):
            return deep_clone(
                self.__dict__,
                otherwise_deepcopy=True,
                memo=memo,
            )

    def to(self, device: Device) -> "SolutionBatch":
        """
        Get the counterpart of this SolutionBatch on the new device.

        If the specified device is the device of this SolutionBatch,
        then this SolutionBatch itself is returned.
        If the specified device is a different device, then a clone
        of this SolutionBatch on this different device is first
        created, and then this new clone is returned.

        Please note that the `to(...)` method is not supported when
        the dtype is `object`.

        Args:
            device: The device on which the resulting SolutionBatch
                will be stored.
        Returns:
            The SolutionBatch on the specified device.
        """
        if isinstance(self._data, ObjectArray):
            raise ValueError("The `to(...)` method is not supported when the dtype is `object`.")

        device = torch.device(device)
        if device == self.device:
            return self
        else:
            new_batch = SolutionBatch(like=self, device=device, empty=True)
            with torch.no_grad():
                new_batch._data[:] = self._data.to(device)
                new_batch._evdata[:] = self._evdata.to(device)
            return new_batch

    @property
    def device(self) -> Device:
        """
        The device in which the solutions are stored.
        """
        return self._data.device

    @property
    def dtype(self) -> DType:
        """
        The dtype of the decision values of the solutions.

        This property exists as an alias for the property
        `.values_dtype`.
        """
        return self._data.dtype

    @property
    def values_dtype(self) -> DType:
        """
        The dtype of the decision values of the solutions.
        """
        return self._data.dtype

    @property
    def eval_dtype(self) -> DType:
        """
        The dtype of the evaluation results and extra evaluation data
        of the solutions.
        """
        return self._evdata.dtype

    @property
    def values_shape(self) -> torch.Size:
        """
        The shape of the batch's decision values tensor, as a tuple (n, l),
        where `n` is the number of solutions, and `l` is the length
        of a single solution.

        If `dtype=None`, then there is no fixed length.
        Therefore, the shape is returned as (n,).
        """
        return self._data.shape

    @property
    def eval_shape(self) -> torch.Size:
        """
        The shape of the batch's evaluation tensor, as a tuple (n, l),
        where `n` is the number of solutions, and `l` is an integer
        which is equal to number of objectives plus the length of the
        extra evaluation data, if any.
        """
        return self._evdata.shape

    @property
    def solution_length(self) -> Optional[int]:
        """
        Get the length of a solution, if this batch is numeric.
        For non-numeric batches (i.e. batches with dtype=object),
        `solution_length` is given as None.
        """
        if self._data.ndim == 2:
            return self._data.shape[1]
        else:
            return None

    @property
    def objective_sense(self) -> ObjectiveSense:
        """
        Get the objective sense(s) of this batch's associated Problem.

        If the problem is single-objective, then a single string is returned.
        If the problem is multi-objective, then the objective senses will be
        returned in a list.

        The returned string in the single-objective case, or each returned
        string in the multi-objective case, is "min" or "max".
        """
        if len(self.senses) == 1:
            return self.senses[0]
        else:
            return self.senses

    @property
    def senses(self) -> Iterable[str]:
        """
        Objective sense(s) of this batch's associated Problem.

        This is a list of strings, each string being "min" or "max".
        """

        def desc_to_sense(desc: bool) -> str:
            return "max" if desc else "min"

        return [desc_to_sense(desc) for desc in self._descending]

    @staticmethod
    def cat(solution_batches: Iterable) -> "SolutionBatch":
        """
        Concatenate multiple SolutionBatch instances into one.

        Args:
            solution_batches: An Iterable of SolutionBatch objects to
                concatenate.
        Returns:
            The result of the concatenation, as a new SolutionBatch.
        """
        first = None
        rest = []
        for i, batch in enumerate(solution_batches):
            if not isinstance(batch, SolutionBatch):
                raise TypeError(f"Expected a SolutionBatch but got {repr(batch)}")
            if i == 0:
                first = batch
            else:
                rest.append(batch)
        return first.concat(rest)

device: Union[str, torch.device] property readonly

The device in which the solutions are stored.

dtype: Union[str, torch.dtype, numpy.dtype, Type] property readonly

The dtype of the decision values of the solutions.

This property exists as an alias for the property .values_dtype.

eval_dtype: Union[str, torch.dtype, numpy.dtype, Type] property readonly

The dtype of the evaluation results and extra evaluation data of the solutions.

eval_shape: Size property readonly

The shape of the batch's evaluation tensor, as a tuple (n, l), where n is the number of solutions, and l is an integer which is equal to number of objectives plus the length of the extra evaluation data, if any.

evals: Tensor property readonly

Evaluation results of the solutions, in a ReadOnlyTensor

objective_sense: Union[str, Iterable[str]] property readonly

Get the objective sense(s) of this batch's associated Problem.

If the problem is single-objective, then a single string is returned. If the problem is multi-objective, then the objective senses will be returned in a list.

The returned string in the single-objective case, or each returned string in the multi-objective case, is "min" or "max".

senses: Iterable[str] property readonly

Objective sense(s) of this batch's associated Problem.

This is a list of strings, each string being "min" or "max".

solution_length: Optional[int] property readonly

Get the length of a solution, if this batch is numeric. For non-numeric batches (i.e. batches with dtype=object), solution_length is given as None.

values: Union[torch.Tensor, Iterable] property readonly

Decision values of the solutions, in a read-only tensor-like object

values_dtype: Union[str, torch.dtype, numpy.dtype, Type] property readonly

The dtype of the decision values of the solutions.

values_shape: Size property readonly

The shape of the batch's decision values tensor, as a tuple (n, l), where n is the number of solutions, and l is the length of a single solution.

If dtype=None, then there is no fixed length. Therefore, the shape is returned as (n,).

access_evals(self, obj_index=None)

Get the internal mutable tensor storing the evaluations.

IMPORTANT: This method exposes the evaluation tensor of the batch as it is, in mutable mode. It is therefore considered unsafe to rely on this method. Before using this method, please consider using the evals property for reading the evaluation results, and using the set_evals(...) method which allows one to update the evaluations without exposing any internal tensor.

When this method is used without any argument, the returned tensor will be of shape (n, m), where n is the number of solutions, and m is the number of objectives plus the length of extra evaluation data.

When this method is used with an integer argument specifying an objective index, the returned tensor will be 1-dimensional having a length of n, where n is the number of solutions. In this case, the returned 1-dimensional tensor will be a view upon the evaluation results of the specified objective.

The value nan (not-a-number) means not evaluated yet.

Parameters:

Name Type Description Default
obj_index Optional[int]

None for getting the entire 2-dimensional evaluation tensor; an objective index (as integer) for getting a 1-dimensional mutable slice of the evaluation tensor, the slice being a view upon the evaluation results regarding the specified objective.

None

Returns:

Type Description
Tensor

The mutable tensor storing the evaluation information.

Source code in evotorch/core.py
@torch.no_grad()
def access_evals(self, obj_index: Optional[int] = None) -> torch.Tensor:
    """
    Get the internal mutable tensor storing the evaluations.

    IMPORTANT: This method exposes the evaluation tensor of the batch
    as it is, in mutable mode. It is therefore considered unsafe to rely
    on this method. Before using this method, please consider using the
    `evals` property for reading the evaluation results, and using the
    `set_evals(...)` method which allows one to update the evaluations
    without exposing any internal tensor.

    When this method is used without any argument, the returned tensor
    will be of shape `(n, m)`, where `n` is the number of solutions,
    and `m` is the number of objectives plus the length of extra
    evaluation data.

    When this method is used with an integer argument specifying an
    objective index, the returned tensor will be 1-dimensional
    having a length of `n`, where `n` is the number of solutions.
    In this case, the returned 1-dimensional tensor will be a view
    upon the evaluation results of the specified objective.

    The value `nan` (not-a-number) means not evaluated yet.

    Args:
        obj_index: None for getting the entire 2-dimensional evaluation
            tensor; an objective index (as integer) for getting a
            1-dimensional mutable slice of the evaluation tensor,
            the slice being a view upon the evaluation results
            regarding the specified objective.
    Returns:
        The mutable tensor storing the evaluation information.
    """
    if obj_index is None:
        return self._evdata
    else:
        return self._evdata[:, self._normalize_obj_index(obj_index)]

access_values(self, *, keep_evals=False)

Get the internal mutable tensor storing the decision values.

IMPORTANT: This method exposes the internal decision values tensor of the batch as it is, in mutable mode. It is therefore considered unsafe to rely on this method. Before using this method, please consider using the values property for reading the decision values, and using the set_values(...) method which allows one to update the decision values without exposing any internal tensor.

IMPORTANT: The default assumption of this method is that the tensor is requested for modification purposes. Therefore, by default, as soon as this method is called, the evaluation results of the solutions will be cleared (where clearing means that the evaluation results will be filled with NaNs). The reasoning behind this default behavior is to prevent the modified solutions from having outdated evaluation results.

Parameters:

Name Type Description Default
keep_evals bool

If set as False, the evaluation data of the solutions will be cleared (i.e. will be filled with NaNs). If set as True, the existing evaluation data will be kept.

False

Returns:

Type Description
Union[torch.Tensor, evotorch.tools.objectarray.ObjectArray]

The mutable tensor storing the decision values.

Source code in evotorch/core.py
@torch.no_grad()
def access_values(self, *, keep_evals: bool = False) -> Union[torch.Tensor, ObjectArray]:
    """
    Get the internal mutable tensor storing the decision values.

    IMPORTANT: This method exposes the internal decision values tensor of
    the batch as it is, in mutable mode. It is therefore considered unsafe
    to rely on this method. Before using this method, please consider
    using the `values` property for reading the decision values, and using
    the `set_values(...)` method which allows one to update the decision
    values without exposing any internal tensor.

    IMPORTANT: The default assumption of this method is that the tensor
    is requested for modification purposes. Therefore, by default, as soon
    as this method is called, the evaluation results of the solutions will
    be cleared (where clearing means that the evaluation results will be
    filled with `NaN`s).
    The reasoning behind this default behavior is to prevent the modified
    solutions from having outdated evaluation results.

    Args:
        keep_evals: If set as False, the evaluation data of the solutions
            will be cleared (i.e. will be filled with `NaN`s).
            If set as True, the existing evaluation data will be kept.
    Returns:
        The mutable tensor storing the decision values.
    """
    if not keep_evals:
        self.forget_evals()
    return self._data

arg_pareto_sort(self, crowdsort=True)

Pareto-sort the solutions in the batch.

The result is a namedtuple consisting of two elements: fronts and ranks. Let us assume that we have 5 solutions, and after a pareto-sorting they ended up in this order:

front 0 (best front) : solution 1, solution 2
front 1              : solution 0, solution 4
front 2 (worst front): solution 3

Considering the example ordering above, the returned ParetoInfo instance looks like this:

ParetoInfo(
    fronts=[[1, 2], [0, 4], [3]],
    ranks=tensor([1, 0, 0, 2, 1])
)

where fronts stores the solution indices grouped by pareto fronts; and ranks stores, as a tensor of int64, the pareto rank for each solution (where 0 means best rank).

Parameters:

Name Type Description Default
crowdsort bool

If given as True, each front in itself will be sorted from the least crowding solution to the most crowding solution. If given as False, there will be no crowd-sorting.

True

Returns:

Type Description
ParetoInfo

A ParetoInfo instance

Source code in evotorch/core.py
@torch.no_grad()
def arg_pareto_sort(self, crowdsort: bool = True) -> ParetoInfo:
    """
    Pareto-sort the solutions in the batch.

    The result is a namedtuple consisting of two elements:
    `fronts` and `ranks`.
    Let us assume that we have 5 solutions, and after a
    pareto-sorting they ended up in this order:

        front 0 (best front) : solution 1, solution 2
        front 1              : solution 0, solution 4
        front 2 (worst front): solution 3

    Considering the example ordering above, the returned
    ParetoInfo instance looks like this:

        ParetoInfo(
            fronts=[[1, 2], [0, 4], [3]],
            ranks=tensor([1, 0, 0, 2, 1])
        )

    where `fronts` stores the solution indices grouped by
    pareto fronts; and `ranks` stores, as a tensor of int64,
    the pareto rank for each solution (where 0 means best
    rank).

    Args:
        crowdsort: If given as True, each front in itself
            will be sorted from the least crowding solution
            to the most crowding solution.
            If given as False, there will be no crowd-sorting.
    Returns:
        A ParetoInfo instance
    """

    utils = self.utils()

    fronts, ranks = _pareto_sort(utils, crowdsort)

    return ParetoInfo(fronts=fronts, ranks=ranks)

argbest(self, obj_index=None)

Return the best solution's index

Parameters:

Name Type Description Default
obj_index Optional[int]

The objective index. Can be passed as None if the problem is single-objective. Otherwise, expected as an int.

None

Returns:

Type Description
Tensor

The index of the best solution.

Source code in evotorch/core.py
@torch.no_grad()
def argbest(self, obj_index: Optional[int] = None) -> torch.Tensor:
    """Return the best solution's index

    Args:
        obj_index: The objective index. Can be passed as None
            if the problem is single-objective. Otherwise,
            expected as an int.
    Returns:
        The index of the best solution.
    """
    obj_index = self._optionally_get_obj_index(obj_index)
    descending = self._descending[obj_index]
    argf = torch.argmax if descending else torch.argmin
    return argf(self._evdata[:, obj_index])

argsort(self, obj_index=None)

Return the indices of solutions, sorted from best to worst.

Parameters:

Name Type Description Default
obj_index Optional[int]

The objective index. Can be passed as None if the problem is single-objective. Otherwise, expected as an int.

None

Returns:

Type Description
Tensor

A PyTorch tensor, containing the solution indices, sorted from the best solution to the worst.

Source code in evotorch/core.py
@torch.no_grad()
def argsort(self, obj_index: Optional[int] = None) -> torch.Tensor:
    """Return the indices of solutions, sorted from best to worst.

    Args:
        obj_index: The objective index. Can be passed as None
            if the problem is single-objective. Otherwise,
            expected as an int.
    Returns:
        A PyTorch tensor, containing the solution indices,
        sorted from the best solution to the worst.
    """
    obj_index = self._optionally_get_obj_index(obj_index)

    descending = self._descending[obj_index]
    ev_col = self._evdata[:, obj_index]

    return torch.argsort(ev_col, descending=descending)

argworst(self, obj_index=None)

Return the worst solution's index

Parameters:

Name Type Description Default
obj_index Optional[int]

The objective index. Can be passed as None if the problem is single-objective. Otherwise, expected as an int.

None

Returns:

Type Description
Tensor

The index of the worst solution.

Source code in evotorch/core.py
@torch.no_grad()
def argworst(self, obj_index: Optional[int] = None) -> torch.Tensor:
    """Return the worst solution's index

    Args:
        obj_index: The objective index. Can be passed as None
            if the problem is single-objective. Otherwise,
            expected as an int.
    Returns:
        The index of the worst solution.
    """
    obj_index = self._optionally_get_obj_index(obj_index)
    descending = self._descending[obj_index]
    argf = torch.argmin if descending else torch.argmax
    return argf(self._evdata[:, obj_index])

cat(solution_batches) staticmethod

Concatenate multiple SolutionBatch instances into one.

Parameters:

Name Type Description Default
solution_batches Iterable

An Iterable of SolutionBatch objects to concatenate.

required

Returns:

Type Description
SolutionBatch

The result of the concatenation, as a new SolutionBatch.

Source code in evotorch/core.py
@staticmethod
def cat(solution_batches: Iterable) -> "SolutionBatch":
    """
    Concatenate multiple SolutionBatch instances into one.

    Args:
        solution_batches: An Iterable of SolutionBatch objects to
            concatenate.
    Returns:
        The result of the concatenation, as a new SolutionBatch.
    """
    first = None
    rest = []
    for i, batch in enumerate(solution_batches):
        if not isinstance(batch, SolutionBatch):
            raise TypeError(f"Expected a SolutionBatch but got {repr(batch)}")
        if i == 0:
            first = batch
        else:
            rest.append(batch)
    return first.concat(rest)

compute_pareto_ranks(self, crowdsort=True)

Compute the pareto-ranks of the solutions in the batch.

Parameters:

Name Type Description Default
crowdsort bool

If given as True, each front in itself will be sorted from the least crowding solution to the most crowding solution. If given as False, there will be no crowd-sorting.

True

Returns:

Type Description
ranks (torch.Tensor)

The computed pareto ranks, of shape [num_samples,] Ranks are encoded in the form 'lowest is best'. So solutions in the pareto front will be assigned rank 0, solutions in the next non-dominated set (excluding the pareto front) will be assigned rank 1, and so on. crowdsort_ranks (Optional[torch.Tensor]): The computed crowd sort ranks, only returned if crowdsort=True. Otherwise, None is returned. Solutions within a front are assigned a crowding score, as described in the above paper. Then, the solution with the best crowding score within a front of size K is assigned rank 0, and the solution with the worst crowding score within a front is assigned rank K-1.

Source code in evotorch/core.py
@torch.no_grad()
def compute_pareto_ranks(self, crowdsort: bool = True) -> Tuple[torch.Tensor, torch.Tensor]:
    """
    Compute the pareto-ranks of the solutions in the batch.
    Args:
        crowdsort: If given as True, each front in itself
            will be sorted from the least crowding solution
            to the most crowding solution.
            If given as False, there will be no crowd-sorting.
    Returns:
        ranks (torch.Tensor): The computed pareto ranks, of shape [num_samples,]
                            Ranks are encoded in the form 'lowest is best'. So solutions in the pareto front will be assigned rank 0,
                            solutions in the next non-dominated set (excluding the pareto front) will be assigned rank 1, and so on.
        crowdsort_ranks (Optional[torch.Tensor]): The computed crowd sort ranks, only returned if crowdsort=True. Otherwise, None is returned.
                            Solutions within a front are assigned a crowding score, as described in the above paper.
                            Then, the solution with the best crowding score within a front of size K is assigned rank 0, and the solution with the
                            worst crowding score within a front is assigned rank K-1.
    """
    utils = self.utils()

    ranks, crowdsort_ranks = _compute_pareto_ranks(utils, crowdsort)

    return ranks, crowdsort_ranks

concat(self, other)

Concatenate this SolutionBatch with the other(s).

In this context, concatenation means that the solutions of this SolutionBatch and of the others are collected in one big SolutionBatch object.

Parameters:

Name Type Description Default
other Union[SolutionBatch, Iterable]

A SolutionBatch, or a sequence of SolutionBatch objects.

required

Returns:

Type Description
SolutionBatch

A new SolutionBatch object which is the result of the concatenation.

Source code in evotorch/core.py
@torch.no_grad()
def concat(self, other: Union["SolutionBatch", Iterable]) -> "SolutionBatch":
    """Concatenate this SolutionBatch with the other(s).

    In this context, concatenation means that the solutions of
    this SolutionBatch and of the others are collected in one big
    SolutionBatch object.

    Args:
        other: A SolutionBatch, or a sequence of SolutionBatch objects.
    Returns:
        A new SolutionBatch object which is the result of the
        concatenation.
    """
    if isinstance(other, SolutionBatch):
        lst = [self, other]
    else:
        lst = [self]
        lst.extend(list(other))
    return SolutionBatch(merging_of=lst)

forget_evals(self, *, solutions=None)

Forget the evaluations of the solutions. The evaluation results will be cleared, which means that they will be filled with NaNs.

Source code in evotorch/core.py
@torch.no_grad()
def forget_evals(self, *, solutions: MaybeIndicesOrSlice = None):
    """
    Forget the evaluations of the solutions.
    The evaluation results will be cleared, which means that they will
    be filled with `NaN`s.
    """
    if solutions is None:
        solutions = slice(None, None, None)
    self._evdata[solutions, :] = float("nan")

set_evals(self, evals, eval_data=None, *, solutions=None)

Set the evaluations of the solutions.

Parameters:

Name Type Description Default
evals Tensor

A numeric tensor which contains the evaluation results. Acceptable shapes are as follows: (n,) only to be used for single-objective problems, sets the evaluation results of the target n solutions, and clears (where clearing means to fill with NaN values) extra evaluation data (if the problem has allocations for such extra evaluation data); (n,m) where m is the number of objectives, sets the evaluation results of the target n solutions, and clears their extra evaluation data; (n,m+q) where m is the number of objectives and q is the length of extra evaluation data, sets the evaluation result and extra data of the target n solutions.

required
eval_data Optional[torch.Tensor]

To be used only when the problem has extra evaluation data. Optionally, one can pass the extra evaluation data separately via this argument (instead of jointly through a single tensor via evals). The expected shape of this tensor is (n,q) where n is the number of solutions and q is the length of the extra evaluation data.

None
solutions Union[int, Iterable[int], slice]

Optionally a list of integer indices or an instance of slice(...), to be used if one wishes to set the evaluations of only some of the solutions.

None

Exceptions:

Type Description
ValueError

if the given tensor has an incompatible shape.

Source code in evotorch/core.py
@torch.no_grad()
def set_evals(
    self,
    evals: torch.Tensor,
    eval_data: Optional[torch.Tensor] = None,
    *,
    solutions: MaybeIndicesOrSlice = None,
):
    """
    Set the evaluations of the solutions.

    Args:
        evals: A numeric tensor which contains the evaluation results.
            Acceptable shapes are as follows:
            `(n,)` only to be used for single-objective problems, sets
            the evaluation results of the target `n` solutions, and clears
            (where clearing means to fill with NaN values)
            extra evaluation data (if the problem has allocations for such
            extra evaluation data);
            `(n,m)` where `m` is the number of objectives, sets the
            evaluation results of the target `n` solutions, and clears
            their extra evaluation data;
            `(n,m+q)` where `m` is the number of objectives and `q` is the
            length of extra evaluation data, sets the evaluation result
            and extra data of the target `n` solutions.
        eval_data: To be used only when the problem has extra evaluation
            data. Optionally, one can pass the extra evaluation data
            separately via this argument (instead of jointly through
            a single tensor via `evals`).
            The expected shape of this tensor is `(n,q)` where `n`
            is the number of solutions and `q` is the length of the
            extra evaluation data.
        solutions: Optionally a list of integer indices or an instance
            of `slice(...)`, to be used if one wishes to set the
            evaluations of only some of the solutions.
    Raises:
        ValueError: if the given tensor has an incompatible shape.
    """
    if solutions is None:
        solutions = slice(None, None, None)
        num_solutions = self._evdata.shape[0]
    elif isinstance(solutions, slice):
        num_solutions = self._evdata[solutions].shape[0]
    elif is_sequence(solutions):
        num_solutions = len(solutions)
    total_eval_width = self._evdata.shape[1]

    num_objs = self._num_objs
    num_data = total_eval_width - num_objs

    if evals.ndim == 1:
        if num_objs != 1:
            raise ValueError(
                f"The method `set_evals(...)` was given a 1-dimensional tensor."
                f" However, the number of objectives of the problem at hand is {num_objs}, not 1."
                f" 1-dimensional evaluation tensors can only be accepted if the problem"
                f" has one objective."
            )
        evals = evals.reshape(-1, 1)
    elif evals.ndim == 2:
        pass  # nothing to do here
    else:
        if num_objs == 1:
            raise ValueError(
                f"The method `set_evals(...)` received a tensor with {evals.ndim} dimensions."
                f" Since the problem at hand has only one objective,"
                f" 1-dimensional or 2-dimensional tensors are acceptable, but not {evals.ndim}-dimensional ones."
            )
        else:
            raise ValueError(
                f"The method `set_evals(...)` received a tensor with {evals.ndim} dimensions."
                f" Since the problem at hand has more than one objective (there are {num_objs} objectives),"
                f" only 2-dimensional tensors are acceptable, not {evals.ndim}-dimensional ones."
            )

    [nrows, ncols] = evals.shape

    if nrows != num_solutions:
        raise ValueError(
            f"Trying to set the evaluations of {num_solutions} solutions, but the given tensor has {nrows} rows."
        )

    if eval_data is not None:
        if eval_data.ndim != 2:
            raise ValueError(
                f"The `eval_data` argument was expected as a 2-dimensional tensor."
                f" However, the shape of the given `eval_data` is {eval_data.shape}."
            )
        if eval_data.shape[1] != num_data:
            raise ValueError(
                f"The `eval_data` argument was expected to have {num_data} columns."
                f" However, the received `eval_data` has the shape: {eval_data.shape}."
            )
        if ncols != num_objs:
            raise ValueError(
                f"The method `set_evals(...)` was used with `evals` and `eval_data` arguments."
                f" When both of these arguments are provided, `evals` is expected either as a 1-dimensional tensor"
                f" (for single-objective cases only), or as a tensor of shape (n, m) where n is the number of"
                f" solutions, and m is the number of objectives."
                f" However, while the problem at hand has {num_objs} objectives,"
                f" the `evals` tensor has {ncols} columns."
            )
        if evals.shape[0] != eval_data.shape[0]:
            raise ValueError(
                f"The provided `evals` and `eval_data` tensors have incompatible shapes."
                f" Shape of `evals`: {evals.shape},"
                f" shape of `eval_data`: {eval_data.shape}."
            )
        self._evdata[solutions, :] = torch.hstack([evals, eval_data])
    else:
        if ncols == num_objs:
            self._evdata[solutions, :num_objs] = evals
            self._evdata[solutions, num_objs:] = float("nan")
        elif ncols == total_eval_width:
            self._evdata[solutions, :] = evals
        else:
            raise ValueError(
                f"The method `set_evals(...)` received a tensor with {ncols} columns, which is incompatible."
                f" Acceptable number of columns are: {num_objs}"
                f" (for setting only the objective-associated evaluations and leave extra evaluation data as NaN), or"
                f" {total_eval_width} (for setting both objective-associated evaluations and extra evaluation data)."
            )

set_values(self, values, *, solutions=None)

Set the decision values of the solutions.

Parameters:

Name Type Description Default
values Any

New decision values.

required
solutions Union[int, Iterable[int], slice]

Optionally a list of integer indices or an instance of slice(...), to be used if one wishes to set the decision values of only some of the solutions.

None
Source code in evotorch/core.py
@torch.no_grad()
def set_values(self, values: Any, *, solutions: MaybeIndicesOrSlice = None):
    """
    Set the decision values of the solutions.

    Args:
        values: New decision values.
        solutions: Optionally a list of integer indices or an instance
            of `slice(...)`, to be used if one wishes to set the
            decision values of only some of the solutions.
    """
    if solutions is None:
        solutions = slice(None, None, None)
    self._data[solutions] = values
    self._evdata[solutions] = float("nan")

split(self, num_pieces=None, *, max_size=None)

Split this SolutionBatch into a specified number of pieces, or into an unspecified number of pieces where the maximum size of each piece is specified.

Parameters:

Name Type Description Default
num_pieces Optional[int]

Can be provided as an integer n, which means that the this SolutionBatch will be split to n pieces. Alternatively, can be left as None if the user intends to set max_size as an integer instead.

None
max_size Optional[int]

Can be provided as an integer n, which means that this SolutionBatch will be split to multiple pieces, each piece containing n solutions at most. Alternatively, can be left as None if the user intends to set num_pieces as an integer instead.

None

Returns:

Type Description
SolutionBatchPieces

A SolutionBatchPieces object, which behaves like a list of SolutionBatch objects, each object in the list being a slice view of this SolutionBatch object.

Source code in evotorch/core.py
def split(self, num_pieces: Optional[int] = None, *, max_size: Optional[int] = None) -> "SolutionBatchPieces":
    """Split this SolutionBatch into a specified number of pieces,
    or into an unspecified number of pieces where the maximum
    size of each piece is specified.

    Args:
        num_pieces: Can be provided as an integer n, which means
            that the this SolutionBatch will be split to n pieces.
            Alternatively, can be left as None if the user intends
            to set max_size as an integer instead.
        max_size: Can be provided as an integer n, which means
            that this SolutionBatch will be split to multiple
            pieces, each piece containing n solutions at most.
            Alternatively, can be left as None if the user intends
            to set num_pieces as an integer instead.
    Returns:
        A SolutionBatchPieces object, which behaves like a list of
        SolutionBatch objects, each object in the list being a
        slice view of this SolutionBatch object.
    """
    return SolutionBatchPieces(self, num_pieces=num_pieces, max_size=max_size)

take(self, indices)

Make a new SolutionBatch containing the specified solutions.

Parameters:

Name Type Description Default
indices Iterable

A sequence of solution indices. These specified solutions will make it to the newly made SolutionBatch.

required

Returns:

Type Description
SolutionBatch

The new SolutionBatch.

Source code in evotorch/core.py
def take(self, indices: Iterable) -> "SolutionBatch":
    """Make a new SolutionBatch containing the specified solutions.

    Args:
        indices: A sequence of solution indices. These specified
            solutions will make it to the newly made SolutionBatch.
    Returns:
        The new SolutionBatch.
    """
    if is_sequence(indices):
        return type(self)(slice_of=(self, indices))
    else:
        raise TypeError("Expected a sequence of solution indices, but got a `{type(indices)}`")

take_best(self, n, *, obj_index=None)

Make a new SolutionBatch containing the best n solutions.

Parameters:

Name Type Description Default
n int

Number of solutions which will be taken.

required
obj_index Optional[int]

Objective index according to which the best ones will be taken. If obj_index is left as None and the problem is multi- objective, then the solutions will be ranked according to their fronts, and according how crowding they are, and then the topmost n solutions will be taken. If obj_index is left as None and the problem is single- objective, then that single objective will be taken as the ranking criterion.

None

Returns:

Type Description
SolutionBatch

The new SolutionBatch.

Source code in evotorch/core.py
def take_best(self, n: int, *, obj_index: Optional[int] = None) -> "SolutionBatch":
    """Make a new SolutionBatch containing the best `n` solutions.

    Args:
        n: Number of solutions which will be taken.
        obj_index: Objective index according to which the best ones
            will be taken.
            If `obj_index` is left as None and the problem is multi-
            objective, then the solutions will be ranked according to
            their fronts, and according how crowding they are, and then
            the topmost `n` solutions will be taken.
            If `obj_index` is left as None and the problem is single-
            objective, then that single objective will be taken as the
            ranking criterion.
    Returns:
        The new SolutionBatch.
    """
    if obj_index is None and self._num_objs >= 2:
        ranks, crowdsort_ranks = self.compute_pareto_ranks(crowdsort=True)
        # Combine the ranks, such that solutions with a better crowdsort rank are weighted above solutions with the same pareto rank **only**
        combined_ranks = ranks.to(torch.float) + 0.1 * crowdsort_ranks.to(torch.float) / len(self)
        indices = torch.argsort(combined_ranks)[:n]
    else:
        indices = self.argsort(obj_index)[:n]
    return type(self)(slice_of=(self, indices))

to(self, device)

Get the counterpart of this SolutionBatch on the new device.

If the specified device is the device of this SolutionBatch, then this SolutionBatch itself is returned. If the specified device is a different device, then a clone of this SolutionBatch on this different device is first created, and then this new clone is returned.

Please note that the to(...) method is not supported when the dtype is object.

Parameters:

Name Type Description Default
device Union[str, torch.device]

The device on which the resulting SolutionBatch will be stored.

required

Returns:

Type Description
SolutionBatch

The SolutionBatch on the specified device.

Source code in evotorch/core.py
def to(self, device: Device) -> "SolutionBatch":
    """
    Get the counterpart of this SolutionBatch on the new device.

    If the specified device is the device of this SolutionBatch,
    then this SolutionBatch itself is returned.
    If the specified device is a different device, then a clone
    of this SolutionBatch on this different device is first
    created, and then this new clone is returned.

    Please note that the `to(...)` method is not supported when
    the dtype is `object`.

    Args:
        device: The device on which the resulting SolutionBatch
            will be stored.
    Returns:
        The SolutionBatch on the specified device.
    """
    if isinstance(self._data, ObjectArray):
        raise ValueError("The `to(...)` method is not supported when the dtype is `object`.")

    device = torch.device(device)
    if device == self.device:
        return self
    else:
        new_batch = SolutionBatch(like=self, device=device, empty=True)
        with torch.no_grad():
            new_batch._data[:] = self._data.to(device)
            new_batch._evdata[:] = self._evdata.to(device)
        return new_batch

utility(self, obj_index=None, *, ranking_method=None, check_nans=True, using_values_dtype=False)

Return numeric scores for each solution.

Utility scores are different from evaluation results, in the sense that utilities monotonically increase from bad solutions to good solutions, regardless of the objective sense.

If ranking method is passed as None: if the objective sense is 'max', the evaluation results are returned as the utility scores; otherwise, if the objective sense is 'min', the evaluation results multiplied by -1 are returned as the utility scores.

If the name of a ranking method is given (e.g. 'centered'): then the solutions are ranked (best solutions having the highest rank), and those ranks are returned as the utility scores.

If an objective index is not provided: (i.e. passed as None) if the problem is multi-objective, the utility scores for each objective is given, in a tensor shaped (n, m), n being the number of solutions and m being the number of objectives; otherwise, if the problem is single-objective, the utility scores for each objective is given in a 1-dimensional tensor of length n, n being the number of solutions.

If an objective index is provided as an int: the utility scores are returned in a 1-dimensional tensor of length n, n being the number of solutions.

Parameters:

Name Type Description Default
obj_index Optional[int]

Expected as None, or as an integer. In the single-objective case, None is equivalent to 0. In the multi-objective case, None means "for each objective".

None
ranking_method Optional[str]

If the utility scores are to be generated according to a certain ranking method, pass here the name of that ranking method as a str (e.g. 'centered').

None
check_nans bool

Check for nan (not-a-number) values in the evaluation results, which is an indication of unevaluated solutions.

True
using_values_dtype bool

If True, the utility values will be returned using the dtype of the decision values. If False, the utility values will be returned using the dtype of the evaluation data. The default is False.

False

Returns:

Type Description
Tensor

Utility scores, in a PyTorch tensor.

Source code in evotorch/core.py
@torch.no_grad()
def utility(
    self,
    obj_index: Optional[int] = None,
    *,
    ranking_method: Optional[str] = None,
    check_nans: bool = True,
    using_values_dtype: bool = False,
) -> torch.Tensor:
    """
    Return numeric scores for each solution.

    Utility scores are different from evaluation results,
    in the sense that utilities monotonically increase from
    bad solutions to good solutions, regardless of the
    objective sense.

    **If ranking method is passed as None:**
    if the objective sense is 'max', the evaluation results are returned
    as the utility scores; otherwise, if the objective sense is 'min',
    the evaluation results multiplied by -1 are returned as the
    utility scores.

    **If the name of a ranking method is given** (e.g. 'centered'):
    then the solutions are ranked (best solutions having the
    highest rank), and those ranks are returned as the utility
    scores.

    **If an objective index is not provided:** (i.e. passed as None)
    if the problem is multi-objective, the utility scores
    for each objective is given, in a tensor shaped (n, m),
    n being the number of solutions and m being the number
    of objectives; otherwise, if the problem is single-objective,
    the utility scores for each objective is given in a 1-dimensional
    tensor of length n, n being the number of solutions.

    **If an objective index is provided as an int:**
    the utility scores are returned in a 1-dimensional tensor
    of length n, n being the number of solutions.

    Args:
        obj_index: Expected as None, or as an integer.
            In the single-objective case, None is equivalent to 0.
            In the multi-objective case, None means "for each
            objective".
        ranking_method: If the utility scores are to be generated
            according to a certain ranking method, pass here the name
            of that ranking method as a str (e.g. 'centered').
        check_nans: Check for nan (not-a-number) values in the
            evaluation results, which is an indication of
            unevaluated solutions.
        using_values_dtype: If True, the utility values will be returned
            using the dtype of the decision values.
            If False, the utility values will be returned using the dtype
            of the evaluation data.
            The default is False.
    Returns:
        Utility scores, in a PyTorch tensor.
    """
    if obj_index is not None:
        obj_index = self._normalize_obj_index(obj_index)

        evdata = self._evdata[:, obj_index]
        if check_nans:
            if torch.any(torch.isnan(evdata)):
                raise ValueError(
                    "Cannot compute the utility values, because there are solutions which are not evaluated yet."
                )

        if ranking_method is None:
            result = evdata * self._get_objective_sign(obj_index)
        else:
            result = rank(evdata, ranking_method=ranking_method, higher_is_better=self._descending[obj_index])

        if using_values_dtype:
            result = torch.as_tensor(result, dtype=self._data.dtype, device=self._data.device)

        return result
    else:
        if self._num_objs == 1:
            return self.utility(
                0, ranking_method=ranking_method, check_nans=check_nans, using_values_dtype=using_values_dtype
            )
        else:
            return torch.stack(
                [
                    self.utility(
                        j,
                        ranking_method=ranking_method,
                        check_nans=check_nans,
                        using_values_dtype=using_values_dtype,
                    )
                    for j in range(self._num_objs)
                ],
            ).T

utils(self, *, ranking_method=None, check_nans=True, using_values_dtype=False)

Return numeric scores for each solution, and for each objective. Utility scores are different from evaluation results, in the sense that utilities monotonically increase from bad solutions to good solutions, regardless of the objective sense.

Unlike the method called utility(...), this function returns a 2-dimensional tensor even when the problem is single-objective.

The result of this method is always a 2-dimensional tensor of shape (n, m), n being the number of solutions, m being the number of objectives.

Parameters:

Name Type Description Default
ranking_method Optional[str]

If the utility scores are to be generated according to a certain ranking method, pass here the name of that ranking method as a str (e.g. 'centered').

None
check_nans bool

Check for nan (not-a-number) values in the evaluation results, which is an indication of unevaluated solutions.

True
using_values_dtype bool

If True, the utility values will be returned using the dtype of the decision values. If False, the utility values will be returned using the dtype of the evaluation data. The default is False.

False

Returns:

Type Description
Tensor

Utility scores, in a 2-dimensional PyTorch tensor.

Source code in evotorch/core.py
@torch.no_grad()
def utils(
    self,
    *,
    ranking_method: Optional[str] = None,
    check_nans: bool = True,
    using_values_dtype: bool = False,
) -> torch.Tensor:
    """
    Return numeric scores for each solution, and for each objective.
    Utility scores are different from evaluation results,
    in the sense that utilities monotonically increase from
    bad solutions to good solutions, regardless of the
    objective sense.

    Unlike the method called `utility(...)`, this function returns
    a 2-dimensional tensor even when the problem is single-objective.

    The result of this method is always a 2-dimensional tensor of
    shape `(n, m)`, `n` being the number of solutions, `m` being the
    number of objectives.

    Args:
        ranking_method: If the utility scores are to be generated
            according to a certain ranking method, pass here the name
            of that ranking method as a str (e.g. 'centered').
        check_nans: Check for nan (not-a-number) values in the
            evaluation results, which is an indication of
            unevaluated solutions.
        using_values_dtype: If True, the utility values will be returned
            using the dtype of the decision values.
            If False, the utility values will be returned using the dtype
            of the evaluation data.
            The default is False.
    Returns:
        Utility scores, in a 2-dimensional PyTorch tensor.

    """
    result = self.utility(
        ranking_method=ranking_method, check_nans=check_nans, using_values_dtype=using_values_dtype
    )
    if result.ndim == 1:
        result = result.view(len(result), 1)
    return result

SolutionBatchPieces (Sequence)

A collection of SolutionBatch slice views.

An instance of this class behaves like a read-only collection of SolutionBatch objects (each being a sliced view of a bigger SolutionBatch).

Source code in evotorch/core.py
class SolutionBatchPieces(Sequence):
    """A collection of SolutionBatch slice views.

    An instance of this class behaves like a read-only collection of
    SolutionBatch objects (each being a sliced view of a bigger
    SolutionBatch).
    """

    @torch.no_grad()
    def __init__(self, batch: SolutionBatch, *, num_pieces: Optional[int] = None, max_size: Optional[int] = None):
        """
        `__init__(...)`: Initialize the SolutionBatchPieces.

        Args:
            batch: The SolutionBatch which will be split into
                multiple SolutionBatch views.
                Each view itself is a SolutionBatch object,
                but not independent, meaning that any modification
                done to a SolutionBatch view will reflect on this
                main batch.
            num_pieces: Can be provided as an integer n, which means
                that the main SolutionBatch will be split to n pieces.
                Alternatively, can be left as None if the user intends
                to set max_size as an integer instead.
            max_size: Can be provided as an integer n, which means
                that the main SolutionBatch will be split to multiple
                pieces, each piece containing n solutions at most.
                Alternatively, can be left as None if the user intends
                to set num_pieces as an integer instead.
        """

        self._batch = batch

        self._pieces: List[SolutionBatch] = []
        self._piece_sizes: List[int] = []
        self._piece_slices: List[Tuple[int, int]] = []

        total_size = len(self._batch)

        if max_size is None and num_pieces is not None:
            num_pieces = int(num_pieces)
            # divide to pieces
            base_size = total_size // num_pieces
            rest = total_size - (base_size * num_pieces)
            self._piece_sizes = [base_size] * num_pieces
            for i in range(rest):
                self._piece_sizes[i] += 1
        elif max_size is not None and num_pieces is None:
            max_size = int(max_size)
            # divide to pieces
            num_pieces = math.ceil(total_size / max_size)
            current_total = 0
            for i in range(num_pieces):
                if current_total + max_size > total_size:
                    self._piece_sizes.append(total_size - current_total)
                else:
                    self._piece_sizes.append(max_size)
                current_total += max_size
        elif max_size is not None and num_pieces is not None:
            raise ValueError("Expected either max_size or num_pieces, received both.")
        elif max_size is None and num_pieces is None:
            raise ValueError("Expected either max_size or num_pieces, received none.")

        current_begin = 0
        for size in self._piece_sizes:
            current_end = current_begin + size
            self._piece_slices.append((current_begin, current_end))
            current_begin = current_end

        for slice_begin, slice_end in self._piece_slices:
            self._pieces.append(self._batch[slice_begin:slice_end])

    def __len__(self) -> int:
        return len(self._pieces)

    def __getitem__(self, i: Union[int, slice]) -> SolutionBatch:
        return self._pieces[i]

    def iter_with_indices(self):
        """Iterate over each `(piece, (i_begin, i_end))`
        where `piece` is a SolutionBatch view, `i_begin` is the beginning
        index of the SolutionBatch view in the main batch, `j_begin` is the
        ending index (exclusive) of the SolutionBatch view in the main batch.
        """
        for i in range(len(self._pieces)):
            yield self._pieces[i], self._piece_slices[i]

    def indices_of(self, n) -> tuple:
        """Get `(i_begin, i_end)` for the n-th piece
        (i.e. the n-th sliced view of the main SolutionBatch)
        where `i_begin` is the beginning index of the n-th piece,
        `i_end` is the (exclusive) ending index of the n-th piece.

        Args:
            n: Specifies the index of the queried SolutionBatch view.
        Returns:
            Beginning and ending indices of the SolutionBatch view,
            in a tuple.
        """
        return self._piece_slices[n]

    @property
    def batch(self) -> SolutionBatch:
        """Get the main SolutionBatch object, in its non-split form"""
        return self._batch

    def _to_string(self) -> str:
        f = io.StringIO()
        print(f"<{type(self).__name__}", file=f)
        n = len(self._pieces)
        for i, piece in enumerate(self._pieces):
            print(f"    {piece}", end="", file=f)
            if (i + 1) == n:
                print(file=f)
            else:
                print(",", file=f)
        print(">", file=f)
        f.seek(0)
        return f.read()

    def __str__(self) -> str:
        return self._to_string()

    def __repr__(self) -> str:
        return self._to_string()

batch: SolutionBatch property readonly

Get the main SolutionBatch object, in its non-split form

__init__(self, batch, *, num_pieces=None, max_size=None) special

__init__(...): Initialize the SolutionBatchPieces.

Parameters:

Name Type Description Default
batch SolutionBatch

The SolutionBatch which will be split into multiple SolutionBatch views. Each view itself is a SolutionBatch object, but not independent, meaning that any modification done to a SolutionBatch view will reflect on this main batch.

required
num_pieces Optional[int]

Can be provided as an integer n, which means that the main SolutionBatch will be split to n pieces. Alternatively, can be left as None if the user intends to set max_size as an integer instead.

None
max_size Optional[int]

Can be provided as an integer n, which means that the main SolutionBatch will be split to multiple pieces, each piece containing n solutions at most. Alternatively, can be left as None if the user intends to set num_pieces as an integer instead.

None
Source code in evotorch/core.py
@torch.no_grad()
def __init__(self, batch: SolutionBatch, *, num_pieces: Optional[int] = None, max_size: Optional[int] = None):
    """
    `__init__(...)`: Initialize the SolutionBatchPieces.

    Args:
        batch: The SolutionBatch which will be split into
            multiple SolutionBatch views.
            Each view itself is a SolutionBatch object,
            but not independent, meaning that any modification
            done to a SolutionBatch view will reflect on this
            main batch.
        num_pieces: Can be provided as an integer n, which means
            that the main SolutionBatch will be split to n pieces.
            Alternatively, can be left as None if the user intends
            to set max_size as an integer instead.
        max_size: Can be provided as an integer n, which means
            that the main SolutionBatch will be split to multiple
            pieces, each piece containing n solutions at most.
            Alternatively, can be left as None if the user intends
            to set num_pieces as an integer instead.
    """

    self._batch = batch

    self._pieces: List[SolutionBatch] = []
    self._piece_sizes: List[int] = []
    self._piece_slices: List[Tuple[int, int]] = []

    total_size = len(self._batch)

    if max_size is None and num_pieces is not None:
        num_pieces = int(num_pieces)
        # divide to pieces
        base_size = total_size // num_pieces
        rest = total_size - (base_size * num_pieces)
        self._piece_sizes = [base_size] * num_pieces
        for i in range(rest):
            self._piece_sizes[i] += 1
    elif max_size is not None and num_pieces is None:
        max_size = int(max_size)
        # divide to pieces
        num_pieces = math.ceil(total_size / max_size)
        current_total = 0
        for i in range(num_pieces):
            if current_total + max_size > total_size:
                self._piece_sizes.append(total_size - current_total)
            else:
                self._piece_sizes.append(max_size)
            current_total += max_size
    elif max_size is not None and num_pieces is not None:
        raise ValueError("Expected either max_size or num_pieces, received both.")
    elif max_size is None and num_pieces is None:
        raise ValueError("Expected either max_size or num_pieces, received none.")

    current_begin = 0
    for size in self._piece_sizes:
        current_end = current_begin + size
        self._piece_slices.append((current_begin, current_end))
        current_begin = current_end

    for slice_begin, slice_end in self._piece_slices:
        self._pieces.append(self._batch[slice_begin:slice_end])

indices_of(self, n)

Get (i_begin, i_end) for the n-th piece (i.e. the n-th sliced view of the main SolutionBatch) where i_begin is the beginning index of the n-th piece, i_end is the (exclusive) ending index of the n-th piece.

Parameters:

Name Type Description Default
n

Specifies the index of the queried SolutionBatch view.

required

Returns:

Type Description
tuple

Beginning and ending indices of the SolutionBatch view, in a tuple.

Source code in evotorch/core.py
def indices_of(self, n) -> tuple:
    """Get `(i_begin, i_end)` for the n-th piece
    (i.e. the n-th sliced view of the main SolutionBatch)
    where `i_begin` is the beginning index of the n-th piece,
    `i_end` is the (exclusive) ending index of the n-th piece.

    Args:
        n: Specifies the index of the queried SolutionBatch view.
    Returns:
        Beginning and ending indices of the SolutionBatch view,
        in a tuple.
    """
    return self._piece_slices[n]

iter_with_indices(self)

Iterate over each (piece, (i_begin, i_end)) where piece is a SolutionBatch view, i_begin is the beginning index of the SolutionBatch view in the main batch, j_begin is the ending index (exclusive) of the SolutionBatch view in the main batch.

Source code in evotorch/core.py
def iter_with_indices(self):
    """Iterate over each `(piece, (i_begin, i_end))`
    where `piece` is a SolutionBatch view, `i_begin` is the beginning
    index of the SolutionBatch view in the main batch, `j_begin` is the
    ending index (exclusive) of the SolutionBatch view in the main batch.
    """
    for i in range(len(self._pieces)):
        yield self._pieces[i], self._piece_slices[i]

SolutionBatchSliceInfo (tuple)

SolutionBatchSliceInfo(source, slice)

__getnewargs__(self) special

Return self as a plain tuple. Used by copy and pickle.

Source code in evotorch/core.py
def __getnewargs__(self):
    'Return self as a plain tuple.  Used by copy and pickle.'
    return _tuple(self)

__new__(_cls, source, slice) special staticmethod

Create new instance of SolutionBatchSliceInfo(source, slice)

__repr__(self) special

Return a nicely formatted representation string

Source code in evotorch/core.py
def __repr__(self):
    'Return a nicely formatted representation string'
    return self.__class__.__name__ + repr_fmt % self

decorators

Module defining decorators for evotorch.

on_aux_device(*args)

Decorator that informs a problem object that this function wants to receive its solutions on the auxiliary device of the problem.

According to its default (non-overriden) implementation, a problem object returns torch.device("cuda") as its auxiliary device if PyTorch's cuda backend is available and if there is a visible cuda device. Otherwise, the auxiliary device is returned as torch.device("cpu"). The auxiliary device is meant as a secondary device (in addition to the main device reported by the problem object's device attribute) used mainly for boosting the performance of fitness evaluations. This decorator, therefore, tells a problem object that the fitness function requests to receive its solutions on this secondary device.

What this decorator does is that it injects a new attribute named __evotorch_on_aux_device__ onto the decorated callable object, then sets that new attribute to True, and then return the decorated callable object itself. Upon seeing this new attribute with the value True, a Problem object will attempt to move the solutions to its auxiliary device before calling the decorated fitness function.

Let us imagine a fitness function f whose definition looks like:

import torch


def f(x: torch.Tensor) -> torch.Tensor:
    return torch.sum(x, dim=-1)

In its not-yet-decorated form, the function f would be given x on the main device of the associated problem object. However, if one decorates f as follows:

from evotorch.decorators import on_aux_device


@on_aux_device
def f(x: torch.Tensor) -> torch.Tensor:
    return torch.sum(x, dim=-1)

then the Problem object will first move x onto its auxiliary device, then will call f.

This decorator is useful on multi-GPU settings. For details, please see the following example:

from evotorch import Problem
from evotorch.decorators import on_aux_device


@on_aux_device
def f(x: torch.Tensor) -> torch.Tensor:
    ...


problem = Problem(
    "min",
    f,
    num_actors=4,
    num_gpus_per_actor=1,
    device="cpu",
)

In the example code above, we assume that there are 4 GPUs available. The main device of the problem is "cpu", which means the populations will be generated on the cpu. When evaluating a population, the population will be split into 4 subbatches (because we have 4 actors), and each subbatch will be sent to an actor. Thanks to the decorator @on_aux_device, the Problem instance on each actor will first move its SolutionBatch to its auxiliary device visible to the actor, and then the fitness function will perform its fitness evaluations on that device. In summary, the actors will use their associated auxiliary devices (most commonly "cuda") to evaluate the fitnesses of the solutions in parallel.

This decorator can also be used to decorate the method _evaluate or _evaluate_batch belonging to a custom subclass of Problem. Please see the example below:

from evotorch import Problem


class MyCustomProblem(Problem):
    def __init__(self):
        super().__init__(
            ...,
            device="cpu",  # populations will be created on the cpu
            ...,
        )

    @on_aux_device("cuda")  # evaluations will be on the auxiliary device
    def _evaluate_batch(self, solutions: SolutionBatch):
        fitnesses = ...
        solutions.set_evals(fitnesses)
Source code in evotorch/decorators.py
def on_aux_device(*args) -> Callable:
    """
    Decorator that informs a problem object that this function wants to
    receive its solutions on the auxiliary device of the problem.

    According to its default (non-overriden) implementation, a problem
    object returns `torch.device("cuda")` as its auxiliary device if
    PyTorch's cuda backend is available and if there is a visible cuda
    device. Otherwise, the auxiliary device is returned as
    `torch.device("cpu")`.
    The auxiliary device is meant as a secondary device (in addition
    to the main device reported by the problem object's `device`
    attribute) used mainly for boosting the performance of fitness
    evaluations.
    This decorator, therefore, tells a problem object that the fitness
    function requests to receive its solutions on this secondary device.

    What this decorator does is that it injects a new attribute named
    `__evotorch_on_aux_device__` onto the decorated callable object,
    then sets that new attribute to `True`, and then return the decorated
    callable object itself. Upon seeing this new attribute with the
    value `True`, a [Problem][evotorch.core.Problem] object will attempt
    to move the solutions to its auxiliary device before calling the
    decorated fitness function.

    Let us imagine a fitness function `f` whose definition looks like:

    ```python
    import torch


    def f(x: torch.Tensor) -> torch.Tensor:
        return torch.sum(x, dim=-1)
    ```

    In its not-yet-decorated form, the function `f` would be given `x` on the
    main device of the associated problem object. However, if one decorates
    `f` as follows:

    ```python
    from evotorch.decorators import on_aux_device


    @on_aux_device
    def f(x: torch.Tensor) -> torch.Tensor:
        return torch.sum(x, dim=-1)
    ```

    then the Problem object will first move `x` onto its auxiliary device,
    then will call `f`.

    This decorator is useful on multi-GPU settings. For details, please see
    the following example:

    ```python
    from evotorch import Problem
    from evotorch.decorators import on_aux_device


    @on_aux_device
    def f(x: torch.Tensor) -> torch.Tensor:
        ...


    problem = Problem(
        "min",
        f,
        num_actors=4,
        num_gpus_per_actor=1,
        device="cpu",
    )
    ```

    In the example code above, we assume that there are 4 GPUs available.
    The main device of the problem is "cpu", which means the populations
    will be generated on the cpu. When evaluating a population, the population
    will be split into 4 subbatches (because we have 4 actors), and each
    subbatch will be sent to an actor. Thanks to the decorator `@on_aux_device`,
    the [Problem][evotorch.core.Problem] instance on each actor will first move
    its [SolutionBatch][evotorch.core.SolutionBatch] to its auxiliary device
    visible to the actor, and then the fitness function will perform its
    fitness evaluations on that device. In summary, the actors will use their
    associated auxiliary devices (most commonly "cuda") to evaluate the
    fitnesses of the solutions in parallel.

    This decorator can also be used to decorate the method `_evaluate` or
    `_evaluate_batch` belonging to a custom subclass of
    [Problem][evotorch.core.Problem]. Please see the example below:

    ```python
    from evotorch import Problem


    class MyCustomProblem(Problem):
        def __init__(self):
            super().__init__(
                ...,
                device="cpu",  # populations will be created on the cpu
                ...,
            )

        @on_aux_device("cuda")  # evaluations will be on the auxiliary device
        def _evaluate_batch(self, solutions: SolutionBatch):
            fitnesses = ...
            solutions.set_evals(fitnesses)
    ```
    """
    return _simple_decorator("__evotorch_on_aux_device__", args, decorator_name="on_aux_device")

on_cuda(*args)

Decorator that informs a problem object that this function wants to receive its solutions on a cuda device (optionally of the specified cuda index).

Decorating a fitness function like this:

@on_cuda
def f(...):
    ...

is equivalent to:

@on_device("cuda")
def f(...):
    ...

Decorating a fitness function like this:

@on_cuda(0)
def f(...):
    ...

is equivalent to:

@on_device("cuda:0")
def f(...):
    ...

Please see the documentation of on_device for further details.

Parameters:

Name Type Description Default
args

An optional positional arguments using which one can specify the index of the cuda device to use.

()
Source code in evotorch/decorators.py
def on_cuda(*args) -> Callable:
    """
    Decorator that informs a problem object that this function wants to
    receive its solutions on a cuda device (optionally of the specified
    cuda index).

    Decorating a fitness function like this:

    ```
    @on_cuda
    def f(...):
        ...
    ```

    is equivalent to:

    ```
    @on_device("cuda")
    def f(...):
        ...
    ```

    Decorating a fitness function like this:

    ```
    @on_cuda(0)
    def f(...):
        ...
    ```

    is equivalent to:

    ```
    @on_device("cuda:0")
    def f(...):
        ...
    ```

    Please see the documentation of [on_device][evotorch.decorators.on_device]
    for further details.

    Args:
        args: An optional positional arguments using which one can specify
            the index of the cuda device to use.
    """

    # Get the number of arguments
    nargs = len(args)

    if nargs == 0:
        # If the number of arguments is 0, then we assume that we are in this situation:
        #
        #     @on_cuda()
        #     def f(...):
        #         ...
        #
        # There is no specified index, and we are not yet given which object to decorate.
        # Therefore, we set both of them as None.
        index = None
        fn = None
    elif nargs == 1:
        # The number of arguments is 1. We begin by storing that single argument using a variable named `arg`.
        arg = args[0]

        if isinstance(arg, Callable):
            # If the argument is a callable object, we assume that we are in this situation:
            #
            #     @on_cuda
            #     def f(...):
            #         ...

            # We are not given a cuda index
            index = None

            # We are given our function to decorate. We store that function using a variable named `fn`.
            fn = arg
        else:
            # If the argument is not a callable object, we assume that it is a cuda index, and that we are in the
            # following situation:
            #
            #     @on_cuda(index)
            #     def f(...):
            #         ...

            # We are given a cuda index. After making sure that it is an integer, we store it by a variable named
            # `index`.
            index = int(arg)

            # At this moment, we do not know the function that is being decorated. So, we set `fn` as None.
            fn = None
    else:
        # If the number of arguments is neither 0 nor 1, then this is an unexpected case.
        # We raise an error to inform the user.
        raise TypeError("`on_cuda(...)` received invalid number of arguments")

    # Prepare the device as "cuda"
    device_str = "cuda"

    if index is not None:
        # If a cuda index is given, then we add ":N" (where N is the index) to the end of `device_str`.
        device_str += ":" + str(index)

    # Prepare the decorator function which, upon being called with a function argument, wraps that function.
    decorator = on_device(device_str)

    # If the function that is being decorated is not known yet (i.e. if `fn` is None), then we return the
    # decorator function. If the function is known, then we decorate and return it.
    return decorator if fn is None else decorator(fn)

on_device(device)

Decorator that informs a problem object that this function wants to receive its solutions on the specified device.

What this decorator does is that it injects a device attribute onto the decorated callable object. Then, this callable object itself is returned. Upon seeing the device attribute, the evaluate(...) method of the Problem object will attempt to move the solutions to that device.

Let us imagine a fitness function f whose definition looks like:

import torch


def f(x: torch.Tensor) -> torch.Tensor:
    return torch.sum(x, dim=-1)

In its not-yet-decorated form, the function f would be given x on the default device of the associated problem object. However, if one decorates f as follows:

from evotorch.decorators import on_device


@on_device("cuda:0")
def f(x: torch.Tensor) -> torch.Tensor:
    return torch.sum(x, dim=-1)

then the Problem object will first move x onto the device cuda:0, and then will call f.

This decorator is useful on multi-GPU settings. For details, please see the following example:

from evotorch import Problem
from evotorch.decorators import on_device


@on_device("cuda")
def f(x: torch.Tensor) -> torch.Tensor:
    ...


problem = Problem(
    "min",
    f,
    num_actors=4,
    num_gpus_per_actor=1,
    device="cpu",
)

In the example code above, we assume that there are 4 GPUs available. The main device of the problem is "cpu", which means the populations will be generated on the cpu. When evaluating a population, the population will be split into 4 subbatches (because we have 4 actors), and each subbatch will be sent to an actor. Thanks to the decorator @on_device, the Problem instance on each actor will first move its SolutionBatch to the cuda device visible to its actor, and then the fitness function f will perform its evaluation operations on that SolutionBatch on the the visible cuda. In summary, the actors will use their associated cuda devices to evaluate the fitnesses of the solutions in parallel.

This decorator can also be used to decorate the method _evaluate or _evaluate_batch belonging to a custom subclass of Problem. Please see the example below:

from evotorch import Problem


class MyCustomProblem(Problem):
    def __init__(self):
        super().__init__(
            ...,
            device="cpu",  # populations will be created on the cpu
            ...,
        )

    @on_device("cuda")  # fitness evaluations will happen on cuda
    def _evaluate_batch(self, solutions: SolutionBatch):
        fitnesses = ...
        solutions.set_evals(fitnesses)

The attribute device that is added by this decorator can be used to query the fitness device, and also to modify/update it:

@on_device("cpu")
def f(x: torch.Tensor) -> torch.Tensor:
    ...


print(f.device)  # Prints: torch.device("cpu")
f.device = "cuda:0"  # Evaluations will be done on cuda:0 from now on

Parameters:

Name Type Description Default
device Union[str, torch.device]

The device on which the decorated fitness function will work.

required
Source code in evotorch/decorators.py
def on_device(device: Device) -> Callable:
    """
    Decorator that informs a problem object that this function wants to
    receive its solutions on the specified device.

    What this decorator does is that it injects a `device` attribute onto
    the decorated callable object. Then, this callable object itself is
    returned. Upon seeing the `device` attribute, the `evaluate(...)` method
    of the [Problem][evotorch.core.Problem] object will attempt to move the
    solutions to that device.

    Let us imagine a fitness function `f` whose definition looks like:

    ```python
    import torch


    def f(x: torch.Tensor) -> torch.Tensor:
        return torch.sum(x, dim=-1)
    ```

    In its not-yet-decorated form, the function `f` would be given `x` on the
    default device of the associated problem object. However, if one decorates
    `f` as follows:

    ```python
    from evotorch.decorators import on_device


    @on_device("cuda:0")
    def f(x: torch.Tensor) -> torch.Tensor:
        return torch.sum(x, dim=-1)
    ```

    then the Problem object will first move `x` onto the device cuda:0, and
    then will call `f`.

    This decorator is useful on multi-GPU settings. For details, please see
    the following example:

    ```python
    from evotorch import Problem
    from evotorch.decorators import on_device


    @on_device("cuda")
    def f(x: torch.Tensor) -> torch.Tensor:
        ...


    problem = Problem(
        "min",
        f,
        num_actors=4,
        num_gpus_per_actor=1,
        device="cpu",
    )
    ```

    In the example code above, we assume that there are 4 GPUs available.
    The main device of the problem is "cpu", which means the populations
    will be generated on the cpu. When evaluating a population, the population
    will be split into 4 subbatches (because we have 4 actors), and each
    subbatch will be sent to an actor. Thanks to the decorator `@on_device`,
    the [Problem][evotorch.core.Problem] instance on each actor will first move
    its [SolutionBatch][evotorch.core.SolutionBatch] to the cuda device visible
    to its actor, and then the fitness function `f` will perform its evaluation
    operations on that [SolutionBatch][evotorch.core.SolutionBatch] on the
    the visible cuda. In summary, the actors will use their associated cuda
    devices to evaluate the fitnesses of the solutions in parallel.

    This decorator can also be used to decorate the method `_evaluate` or
    `_evaluate_batch` belonging to a custom subclass of
    [Problem][evotorch.core.Problem]. Please see the example below:

    ```python
    from evotorch import Problem


    class MyCustomProblem(Problem):
        def __init__(self):
            super().__init__(
                ...,
                device="cpu",  # populations will be created on the cpu
                ...,
            )

        @on_device("cuda")  # fitness evaluations will happen on cuda
        def _evaluate_batch(self, solutions: SolutionBatch):
            fitnesses = ...
            solutions.set_evals(fitnesses)
    ```

    The attribute `device` that is added by this decorator can be used to
    query the fitness device, and also to modify/update it:

    ```python
    @on_device("cpu")
    def f(x: torch.Tensor) -> torch.Tensor:
        ...


    print(f.device)  # Prints: torch.device("cpu")
    f.device = "cuda:0"  # Evaluations will be done on cuda:0 from now on
    ```

    Args:
        device: The device on which the decorated fitness function will work.
    """

    # Take the `torch.device` counterpart of `device`
    device = torch.device(device)

    def decorator(fn: Callable) -> Callable:
        setattr(fn, "__evotorch_on_device__", True)
        setattr(fn, "device", device)
        return fn

    return decorator

pass_info(*args)

Decorates a callable so that the neuroevolution problem class (e.g. GymNE) will pass information regarding the task at hand, in the form of keyword arguments.

This decorator adds a new attribute named __evotorch_pass_info__ to the decorated callable object, sets this new attribute to True, and then returns the callable object itself. Upon seeing this attribute with the value True, a neuroevolution problem class sends extra information as keyword arguments.

For example, in the case of GymNE or VecGymNE, the passed information would include dimensions of the observation and action spaces.

Examples:

@pass_info
class MyModule(nn.Module):
    def __init__(self, obs_length: int, act_length: int, **kwargs):
        # Because MyModule is decorated with @pass_info, it receives
        # keyword arguments related to the environment "CartPole-v0",
        # including obs_length and act_length.
        ...


problem = GymNE(
    "CartPole-v0",
    network=MyModule,
    ...,
)

Parameters:

Name Type Description Default
fn_or_class Callable

Function or class to decorate

required

Returns:

Type Description
Callable

Decorated function or class

Source code in evotorch/decorators.py
def pass_info(*args) -> Callable:
    """
    Decorates a callable so that the neuroevolution problem class (e.g. GymNE) will
    pass information regarding the task at hand, in the form of keyword arguments.

    This decorator adds a new attribute named `__evotorch_pass_info__` to the
    decorated callable object, sets this new attribute to True, and then returns
    the callable object itself. Upon seeing this attribute with the value `True`,
    a neuroevolution problem class sends extra information as keyword arguments.

    For example, in the case of [GymNE][evotorch.neuroevolution.GymNE] or
    [VecGymNE][evotorch.neuroevolution.VecGymNE], the passed information would
    include dimensions of the observation and action spaces.

    Example:
        ```python
        @pass_info
        class MyModule(nn.Module):
            def __init__(self, obs_length: int, act_length: int, **kwargs):
                # Because MyModule is decorated with @pass_info, it receives
                # keyword arguments related to the environment "CartPole-v0",
                # including obs_length and act_length.
                ...


        problem = GymNE(
            "CartPole-v0",
            network=MyModule,
            ...,
        )
        ```

    Args:
        fn_or_class (Callable): Function or class to decorate

    Returns:
        Callable: Decorated function or class
    """
    return _simple_decorator("__evotorch_pass_info__", args, decorator_name="pass_info")

vectorized(*args)

Decorates a fitness function so that the problem object (which can be an instance of evotorch.Problem) will send the fitness function a 2D tensor containing all the solutions, instead of a 1D tensor containing a single solution.

What this decorator does is that it adds the decorated fitness function a new attribute named __evotorch_vectorized__, the value of this new attribute being True. Upon seeing this new attribute, the problem object will send this function multiple solutions so that vectorized operations on multiple solutions can be performed by this fitness function.

Let us imagine that we have the following fitness function which works on a single solution x, and returns a single fitness value:

import torch


def f(x: torch.Tensor) -> torch.Tensor:
    return torch.sum(x**2)

...and let us now define the optimization problem associated with this fitness function:

p1 = Problem("min", f, initial_bounds=(-10.0, 10.0), solution_length=5)

While the fitness function f and the definition p1 form a valid problem description, it does not use PyTorch to its full potential in terms of performance. If we were to request the evaluation results on a population of solutions via p1.evaluate(population), p1 would use a classic for loop to evaluate every single solution within population one by one. We could greatly increase our performance by: (i) re-defining our fitness function in a vectorized manner, i.e. in such a way that it will operate on many solutions and compute all of their fitnesses at once; (ii) label our fitness function via @vectorized, so that the problem object will be aware that this new fitness function expects n solutions and returns n fitnesses. The re-designed and labeled fitness function looks like this:

from evotorch.decorators import vectorized


@vectorized
def f2(x: torch.Tensor) -> torch.Tensor:
    return torch.sum(x**2, dim=-1)

The problem description for f2 is:

p2 = Problem("min", f2, initial_bounds=(-10.0, 10.0), solution_length=5)

In this last example, p2 will realize that f2 is decorated via @vectorized, and will send it n solutions, and will receive and process n fitnesses.

Source code in evotorch/decorators.py
def vectorized(*args) -> Callable:
    """
    Decorates a fitness function so that the problem object (which can be an instance
    of [evotorch.Problem][evotorch.core.Problem]) will send the fitness function a 2D
    tensor containing all the solutions, instead of a 1D tensor containing a single
    solution.

    What this decorator does is that it adds the decorated fitness function a new
    attribute named `__evotorch_vectorized__`, the value of this new attribute being
    True. Upon seeing this new attribute, the problem object will send this function
    multiple solutions so that vectorized operations on multiple solutions can be
    performed by this fitness function.

    Let us imagine that we have the following fitness function which works on a
    single solution `x`, and returns a single fitness value:

    ```python
    import torch


    def f(x: torch.Tensor) -> torch.Tensor:
        return torch.sum(x**2)
    ```

    ...and let us now define the optimization problem associated with this fitness
    function:

    ```python
    p1 = Problem("min", f, initial_bounds=(-10.0, 10.0), solution_length=5)
    ```

    While the fitness function `f` and the definition `p1` form a valid problem
    description, it does not use PyTorch to its full potential in terms of performance.
    If we were to request the evaluation results on a population of solutions via
    `p1.evaluate(population)`, `p1` would use a classic `for` loop to evaluate every
    single solution within `population` one by one.
    We could greatly increase our performance by:
    (i) re-defining our fitness function in a vectorized manner, i.e. in such a way
    that it will operate on many solutions and compute all of their fitnesses at once;
    (ii) label our fitness function via `@vectorized`, so that the problem object
    will be aware that this new fitness function expects `n` solutions and returns
    `n` fitnesses. The re-designed and labeled fitness function looks like this:

    ```python
    from evotorch.decorators import vectorized


    @vectorized
    def f2(x: torch.Tensor) -> torch.Tensor:
        return torch.sum(x**2, dim=-1)
    ```

    The problem description for `f2` is:

    ```python
    p2 = Problem("min", f2, initial_bounds=(-10.0, 10.0), solution_length=5)
    ```

    In this last example, `p2` will realize that `f2` is decorated via `@vectorized`,
    and will send it `n` solutions, and will receive and process `n` fitnesses.
    """
    return _simple_decorator("__evotorch_vectorized__", args, decorator_name="vectorized")

distributions

Distribution (TensorMakerMixin, Serializable)

Base class for any search distribution.

Source code in evotorch/distributions.py
class Distribution(TensorMakerMixin, Serializable):
    """
    Base class for any search distribution.
    """

    MANDATORY_PARAMETERS = set()
    OPTIONAL_PARAMETERS = set()

    def __init__(
        self, *, solution_length: int, parameters: dict, dtype: Optional[DType] = None, device: Optional[Device] = None
    ):
        """
        `__init__(...)`: Initialize the Distribution.

        It is expected that one of these two conditions is met:
        (i) the inheriting search distribution class does not implement its
        own `__init__(...)` method; or
        (ii) the inheriting search distribution class has its own
        `__init__(...)` method, and calls `Distribution.__init__(...)`
        from there, during its initialization phase.

        Args:
            solution_length: Expected as an integer, this argument represents
                the solution length.
            parameters: Expected as a dictionary, this argument stores
                the parameters of the search distribution.
                For example, for a Gaussian distribution where `mu`
                represents the mean, and `sigma` represents the coverage
                area, this dictionary would have the keys "mu" and "sigma",
                and each of these keys would map to a PyTorch tensor.
            dtype: The dtype of the search distribution (e.g. torch.float32).
            device: The device of the search distribution (e.g. "cpu").
        """
        self.__solution_length: int = int(solution_length)

        self.__parameters: dict
        self.__dtype: torch.dtype
        self.__device: torch.device

        self.__check_correctness(parameters)

        cast_kwargs = {}
        if dtype is not None:
            cast_kwargs["dtype"] = to_torch_dtype(dtype)
        if device is not None:
            cast_kwargs["device"] = torch.device(device)

        if len(cast_kwargs) == 0:
            self.__parameters = copy(parameters)
        else:
            self.__parameters = cast_tensors_in_container(parameters, **cast_kwargs)

        self.__dtype = cast_kwargs.get("dtype", dtype_of_container(parameters))
        self.__device = cast_kwargs.get("device", device_of_container(parameters))

    def __check_correctness(self, parameters: dict):
        found_mandatory = 0
        for param_name in parameters.keys():
            if param_name in self.MANDATORY_PARAMETERS:
                found_mandatory += 1
            elif param_name in self.OPTIONAL_PARAMETERS:
                pass  # nothing to do
            else:
                raise ValueError(f"Unrecognized parameter: {repr(param_name)}")
        if found_mandatory < len(self.MANDATORY_PARAMETERS):
            raise ValueError(
                f"Not all mandatory parameters of this Distribution were specified."
                f" Mandatory parameters of this distribution: {self.MANDATORY_PARAMETERS};"
                f" optional parameters of this distribution: {self.OPTIONAL_PARAMETERS};"
                f" encountered parameters: {set(parameters.keys())}."
            )

    def to(self, device: Device) -> "Distribution":
        """
        Bring the Distribution onto a computational device.

        If the given device is already the device of this Distribution,
        then the Distribution itself will be returned.
        If the given device is different than the device of this
        Distribution, a copy of this Distribution on the given device
        will be created and returned.

        Args:
            device: The computation device onto which the Distribution
                will be brought.
        Returns:
            The Distribution on the target device.
        """
        if torch.device(self.device) == torch.device(device):
            return self
        else:
            cls = self.__class__
            return cls(solution_length=self.solution_length, parameters=self.parameters, device=device)

    def _fill(self, out: torch.Tensor, *, generator: Optional[torch.Generator] = None):
        """
        Fill the given tensor with samples from this search distribution.

        It is expected that the inheriting search distribution class
        has its own implementation for this method.

        Args:
            out: The PyTorch tensor that will be filled with the samples.
                This tensor is expected as 2-dimensional with its number
                of columns equal to the solution length declared by this
                distribution.
            generator: Optionally a PyTorch generator, to be used for
                sampling. None means that the global generator of PyTorch
                is to be used.
        """
        raise NotImplementedError

    def sample(
        self,
        num_solutions: Optional[int] = None,
        *,
        out: Optional[torch.Tensor] = None,
        generator: Any = None,
    ) -> torch.Tensor:
        """
        Sample solutions from this search distribution.

        Args:
            num_solutions: How many solutions will be sampled.
                If this argument is given as an integer and the argument
                `out` is left as None, then a new PyTorch tensor, filled
                with the samples from this distribution, will be generated
                and returned. The number of rows of this new tensor will
                be equal to the given `num_solutions`.
                If the argument `num_solutions` is provided as an integer,
                then the argument `out` is expected as None.
            out: The PyTorch tensor that will be filled with the samples
                of this distribution. This tensor is expected as a
                2-dimensional tensor with its number of columns equal to
                the solution length declared by this distribution.
                If the argument `out` is provided as a tensor, then the
                argument `num_solutions` is expected as None.
            generator: Optionally a PyTorch generator or any object which
                has a `generator` attribute (e.g. a Problem instance).
                If left as None, the global generator of PyTorch will be
                used.
        Returns:
            A 2-dimensional PyTorch tensor which stores the sampled solutions.
        """
        if (num_solutions is not None) and (out is not None):
            raise ValueError(
                "Received both `num_solutions` and `out` with values other than None."
                "Please provide only one of these arguments with a value other than None, not both of them."
            )
        elif (num_solutions is not None) and (out is None):
            num_solutions = int(num_solutions)
            out = self.make_empty(num_solutions=num_solutions)
        elif (num_solutions is None) and (out is not None):
            if out.ndim != 2:
                raise ValueError(
                    f"The `sample(...)` method can fill only 2-dimensional tensors."
                    f" However, the provided `out` tensor has {out.ndim} dimensions, its shape being {out.shape}."
                )
            _, num_cols = out.shape
            if num_cols != self.solution_length:
                raise ValueError(
                    f"The solution length declared by this distribution is {self.solution_length}."
                    f" However, the provided `out` tensor has {num_cols} columns."
                    f" The `sample(...)` method can only work with tensors whose number of columns are equal"
                    f" to the declared solution length."
                )
        else:
            raise ValueError(
                "Received both `num_solutions` and `out` as None."
                "Please provide one of these arguments with a value other than None."
            )
        self._fill(out, generator=generator)
        return out

    def _compute_gradients(self, samples: torch.Tensor, weights: torch.Tensor, ranking_used: Optional[str]) -> dict:
        """
        Compute the gradients out of the samples (sampled solutions)
        and weights (i.e. weights or ranks of the solutions, better
        solutions having numerically higher weights).

        It is expected that the inheriting class implements this method.

        Args:
            samples: The sampled solutions, as a 2-dimensional tensor.
            weights: Solution weights, as a 1-dimensional tensor of length
                `n`, `n` being the number of sampled solutions.
            ranking_used: Ranking that was used to obtain the weights.
        Returns:
            The gradient(s) in a dictionary.
        """
        raise NotImplementedError

    def compute_gradients(
        self,
        samples: torch.Tensor,
        fitnesses: torch.Tensor,
        *,
        objective_sense: str,
        ranking_method: Optional[str] = None,
    ) -> dict:
        """
        Compute and return gradients.

        Args:
            samples: The solutions that were sampled from this Distribution.
                The tensor passed via this argument is expected to have
                the same dtype and device with this Distribution.
            fitnesses: The evaluation results of the sampled solutions.
                If fitnesses are given with a different dtype (maybe because
                the eval_dtype of the Problem object is different than its
                decision variable dtype), then this method will first
                create an internal copy of the fitnesses with the correct
                dtype, and then will use those copied fitnesses for
                computing the gradients.
            objective_sense: The objective sense, expected as "min" or "max".
                In the case of "min", lower fitness values will be regarded
                as better (therefore, in this case, one can alternatively
                refer to fitnesses as 'unfitnesses' or 'solution costs').
                In the case of "max", higher fitness values will be regarded
                as better.
            ranking_method: The ranking method to be used.
                Can be: "linear" (where ranks linearly go from 0 to 1);
                "centered" (where ranks linearly go from -0.5 to +0.5);
                "normalized" (where the standard-normalized fitnesses
                serve as ranks); or "raw" (where the fitnesses themselves
                serve as ranks).
                The default is "raw".
        Returns:
            A dictionary which contains the gradient for each parameter of the
            distribution.
        """
        if objective_sense == "max":
            higher_is_better = True
        elif objective_sense == "min":
            higher_is_better = False
        else:
            raise ValueError(
                f'`objective_sense` was expected as "min" or as "max".'
                f" However, it was encountered as {repr(objective_sense)}."
            )

        if ranking_method is None:
            ranking_method = "raw"

        # Make sure that the fitnesses are in the correct dtype
        fitnesses = torch.as_tensor(fitnesses, dtype=self.dtype)

        [num_samples, _] = samples.shape
        [num_fitnesses] = fitnesses.shape
        if num_samples != num_fitnesses:
            raise ValueError(
                f"The number of samples and the number of fitnesses do not match:" f" {num_samples} != {num_fitnesses}."
            )

        weights = rank(fitnesses, ranking_method=ranking_method, higher_is_better=higher_is_better)
        return self._compute_gradients(samples, weights, ranking_method)

    def update_parameters(
        self,
        gradients: dict,
        *,
        learning_rates: Optional[dict] = None,
        optimizers: Optional[dict] = None,
    ) -> "Distribution":
        """
        Do an update on the distribution by following the given gradients.

        It is expected that the inheriting class has its own implementation
        for this method.

        Args:
            gradients: Gradients, as a dictionary, which will be used for
                computing the necessary updates.
            learning_rates: A dictionary which contains learning rates
                for parameters that will be updated using a learning rate
                coefficient.
            optimizers: A dictionary which contains optimizer objects
                for parameters that will be updated using an adaptive
                optimizer.
        Returns:
            The updated copy of the distribution.
        """
        raise NotImplementedError

    def modified_copy(
        self, *, dtype: Optional[DType] = None, device: Optional[Device] = None, **parameters
    ) -> "Distribution":
        """
        Return a modified copy of this distribution.

        Args:
            dtype: The new dtype of the distribution.
            device: The new device of the distribution.
            parameters: Expected in the form of extra keyword arguments.
                Each of these keyword arguments will cause the new distribution
                to have a modified value for the specified parameter.
        Returns:
            The modified copy of the distribution.
        """
        cls = self.__class__
        if device is None:
            device = self.device
        if dtype is None:
            dtype = self.dtype

        new_parameters = copy(self.parameters)
        new_parameters.update(parameters)
        return cls(parameters=new_parameters, dtype=dtype, device=device)

    def relative_entropy(dist_0: "Distribution", dist_1: "Distribution") -> float:
        raise NotImplementedError

    @property
    def solution_length(self) -> int:
        return self.__solution_length

    @property
    def device(self) -> torch.device:
        return self.__device

    @property
    def dtype(self) -> torch.dtype:
        return self.__dtype

    @property
    def parameters(self) -> dict:
        return self.__parameters

    def _follow_gradient(
        self,
        param_name: str,
        x: torch.Tensor,
        *,
        learning_rates: Optional[dict] = None,
        optimizers: Optional[dict] = None,
    ) -> torch.Tensor:
        x = torch.as_tensor(x, dtype=self.dtype, device=self.device)
        learning_rate, optimizer = self._get_learning_rate_and_optimizer(param_name, learning_rates, optimizers)
        if (learning_rate is None) and (optimizer is None):
            return x
        elif (learning_rate is not None) and (optimizer is None):
            return learning_rate * x
        elif (learning_rate is None) and (optimizer is not None):
            return optimizer.ascent(x)
        else:
            raise ValueError(
                "Encountered both `learning_rate` and `optimizer` as values other than None."
                " This method can only work if both of them are None or only one of them is not None."
            )

    @staticmethod
    def _get_learning_rate_and_optimizer(
        param_name: str, learning_rates: Optional[dict], optimizers: Optional[dict]
    ) -> tuple:
        if learning_rates is None:
            learning_rates = {}
        if optimizers is None:
            optimizers = {}
        return learning_rates.get(param_name, None), optimizers.get(param_name, None)

    @torch.no_grad()
    def _get_cloned_state(self, *, memo: dict) -> dict:
        return deep_clone(
            self.__dict__,
            otherwise_deepcopy=True,
            memo=memo,
        )

__init__(self, *, solution_length, parameters, dtype=None, device=None) special

__init__(...): Initialize the Distribution.

It is expected that one of these two conditions is met: (i) the inheriting search distribution class does not implement its own __init__(...) method; or (ii) the inheriting search distribution class has its own __init__(...) method, and calls Distribution.__init__(...) from there, during its initialization phase.

Parameters:

Name Type Description Default
solution_length int

Expected as an integer, this argument represents the solution length.

required
parameters dict

Expected as a dictionary, this argument stores the parameters of the search distribution. For example, for a Gaussian distribution where mu represents the mean, and sigma represents the coverage area, this dictionary would have the keys "mu" and "sigma", and each of these keys would map to a PyTorch tensor.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

The dtype of the search distribution (e.g. torch.float32).

None
device Union[str, torch.device]

The device of the search distribution (e.g. "cpu").

None
Source code in evotorch/distributions.py
def __init__(
    self, *, solution_length: int, parameters: dict, dtype: Optional[DType] = None, device: Optional[Device] = None
):
    """
    `__init__(...)`: Initialize the Distribution.

    It is expected that one of these two conditions is met:
    (i) the inheriting search distribution class does not implement its
    own `__init__(...)` method; or
    (ii) the inheriting search distribution class has its own
    `__init__(...)` method, and calls `Distribution.__init__(...)`
    from there, during its initialization phase.

    Args:
        solution_length: Expected as an integer, this argument represents
            the solution length.
        parameters: Expected as a dictionary, this argument stores
            the parameters of the search distribution.
            For example, for a Gaussian distribution where `mu`
            represents the mean, and `sigma` represents the coverage
            area, this dictionary would have the keys "mu" and "sigma",
            and each of these keys would map to a PyTorch tensor.
        dtype: The dtype of the search distribution (e.g. torch.float32).
        device: The device of the search distribution (e.g. "cpu").
    """
    self.__solution_length: int = int(solution_length)

    self.__parameters: dict
    self.__dtype: torch.dtype
    self.__device: torch.device

    self.__check_correctness(parameters)

    cast_kwargs = {}
    if dtype is not None:
        cast_kwargs["dtype"] = to_torch_dtype(dtype)
    if device is not None:
        cast_kwargs["device"] = torch.device(device)

    if len(cast_kwargs) == 0:
        self.__parameters = copy(parameters)
    else:
        self.__parameters = cast_tensors_in_container(parameters, **cast_kwargs)

    self.__dtype = cast_kwargs.get("dtype", dtype_of_container(parameters))
    self.__device = cast_kwargs.get("device", device_of_container(parameters))

compute_gradients(self, samples, fitnesses, *, objective_sense, ranking_method=None)

Compute and return gradients.

Parameters:

Name Type Description Default
samples Tensor

The solutions that were sampled from this Distribution. The tensor passed via this argument is expected to have the same dtype and device with this Distribution.

required
fitnesses Tensor

The evaluation results of the sampled solutions. If fitnesses are given with a different dtype (maybe because the eval_dtype of the Problem object is different than its decision variable dtype), then this method will first create an internal copy of the fitnesses with the correct dtype, and then will use those copied fitnesses for computing the gradients.

required
objective_sense str

The objective sense, expected as "min" or "max". In the case of "min", lower fitness values will be regarded as better (therefore, in this case, one can alternatively refer to fitnesses as 'unfitnesses' or 'solution costs'). In the case of "max", higher fitness values will be regarded as better.

required
ranking_method Optional[str]

The ranking method to be used. Can be: "linear" (where ranks linearly go from 0 to 1); "centered" (where ranks linearly go from -0.5 to +0.5); "normalized" (where the standard-normalized fitnesses serve as ranks); or "raw" (where the fitnesses themselves serve as ranks). The default is "raw".

None

Returns:

Type Description
dict

A dictionary which contains the gradient for each parameter of the distribution.

Source code in evotorch/distributions.py
def compute_gradients(
    self,
    samples: torch.Tensor,
    fitnesses: torch.Tensor,
    *,
    objective_sense: str,
    ranking_method: Optional[str] = None,
) -> dict:
    """
    Compute and return gradients.

    Args:
        samples: The solutions that were sampled from this Distribution.
            The tensor passed via this argument is expected to have
            the same dtype and device with this Distribution.
        fitnesses: The evaluation results of the sampled solutions.
            If fitnesses are given with a different dtype (maybe because
            the eval_dtype of the Problem object is different than its
            decision variable dtype), then this method will first
            create an internal copy of the fitnesses with the correct
            dtype, and then will use those copied fitnesses for
            computing the gradients.
        objective_sense: The objective sense, expected as "min" or "max".
            In the case of "min", lower fitness values will be regarded
            as better (therefore, in this case, one can alternatively
            refer to fitnesses as 'unfitnesses' or 'solution costs').
            In the case of "max", higher fitness values will be regarded
            as better.
        ranking_method: The ranking method to be used.
            Can be: "linear" (where ranks linearly go from 0 to 1);
            "centered" (where ranks linearly go from -0.5 to +0.5);
            "normalized" (where the standard-normalized fitnesses
            serve as ranks); or "raw" (where the fitnesses themselves
            serve as ranks).
            The default is "raw".
    Returns:
        A dictionary which contains the gradient for each parameter of the
        distribution.
    """
    if objective_sense == "max":
        higher_is_better = True
    elif objective_sense == "min":
        higher_is_better = False
    else:
        raise ValueError(
            f'`objective_sense` was expected as "min" or as "max".'
            f" However, it was encountered as {repr(objective_sense)}."
        )

    if ranking_method is None:
        ranking_method = "raw"

    # Make sure that the fitnesses are in the correct dtype
    fitnesses = torch.as_tensor(fitnesses, dtype=self.dtype)

    [num_samples, _] = samples.shape
    [num_fitnesses] = fitnesses.shape
    if num_samples != num_fitnesses:
        raise ValueError(
            f"The number of samples and the number of fitnesses do not match:" f" {num_samples} != {num_fitnesses}."
        )

    weights = rank(fitnesses, ranking_method=ranking_method, higher_is_better=higher_is_better)
    return self._compute_gradients(samples, weights, ranking_method)

modified_copy(self, *, dtype=None, device=None, **parameters)

Return a modified copy of this distribution.

Parameters:

Name Type Description Default
dtype Union[str, torch.dtype, numpy.dtype, Type]

The new dtype of the distribution.

None
device Union[str, torch.device]

The new device of the distribution.

None
parameters

Expected in the form of extra keyword arguments. Each of these keyword arguments will cause the new distribution to have a modified value for the specified parameter.

{}

Returns:

Type Description
Distribution

The modified copy of the distribution.

Source code in evotorch/distributions.py
def modified_copy(
    self, *, dtype: Optional[DType] = None, device: Optional[Device] = None, **parameters
) -> "Distribution":
    """
    Return a modified copy of this distribution.

    Args:
        dtype: The new dtype of the distribution.
        device: The new device of the distribution.
        parameters: Expected in the form of extra keyword arguments.
            Each of these keyword arguments will cause the new distribution
            to have a modified value for the specified parameter.
    Returns:
        The modified copy of the distribution.
    """
    cls = self.__class__
    if device is None:
        device = self.device
    if dtype is None:
        dtype = self.dtype

    new_parameters = copy(self.parameters)
    new_parameters.update(parameters)
    return cls(parameters=new_parameters, dtype=dtype, device=device)

sample(self, num_solutions=None, *, out=None, generator=None)

Sample solutions from this search distribution.

Parameters:

Name Type Description Default
num_solutions Optional[int]

How many solutions will be sampled. If this argument is given as an integer and the argument out is left as None, then a new PyTorch tensor, filled with the samples from this distribution, will be generated and returned. The number of rows of this new tensor will be equal to the given num_solutions. If the argument num_solutions is provided as an integer, then the argument out is expected as None.

None
out Optional[torch.Tensor]

The PyTorch tensor that will be filled with the samples of this distribution. This tensor is expected as a 2-dimensional tensor with its number of columns equal to the solution length declared by this distribution. If the argument out is provided as a tensor, then the argument num_solutions is expected as None.

None
generator Any

Optionally a PyTorch generator or any object which has a generator attribute (e.g. a Problem instance). If left as None, the global generator of PyTorch will be used.

None

Returns:

Type Description
Tensor

A 2-dimensional PyTorch tensor which stores the sampled solutions.

Source code in evotorch/distributions.py
def sample(
    self,
    num_solutions: Optional[int] = None,
    *,
    out: Optional[torch.Tensor] = None,
    generator: Any = None,
) -> torch.Tensor:
    """
    Sample solutions from this search distribution.

    Args:
        num_solutions: How many solutions will be sampled.
            If this argument is given as an integer and the argument
            `out` is left as None, then a new PyTorch tensor, filled
            with the samples from this distribution, will be generated
            and returned. The number of rows of this new tensor will
            be equal to the given `num_solutions`.
            If the argument `num_solutions` is provided as an integer,
            then the argument `out` is expected as None.
        out: The PyTorch tensor that will be filled with the samples
            of this distribution. This tensor is expected as a
            2-dimensional tensor with its number of columns equal to
            the solution length declared by this distribution.
            If the argument `out` is provided as a tensor, then the
            argument `num_solutions` is expected as None.
        generator: Optionally a PyTorch generator or any object which
            has a `generator` attribute (e.g. a Problem instance).
            If left as None, the global generator of PyTorch will be
            used.
    Returns:
        A 2-dimensional PyTorch tensor which stores the sampled solutions.
    """
    if (num_solutions is not None) and (out is not None):
        raise ValueError(
            "Received both `num_solutions` and `out` with values other than None."
            "Please provide only one of these arguments with a value other than None, not both of them."
        )
    elif (num_solutions is not None) and (out is None):
        num_solutions = int(num_solutions)
        out = self.make_empty(num_solutions=num_solutions)
    elif (num_solutions is None) and (out is not None):
        if out.ndim != 2:
            raise ValueError(
                f"The `sample(...)` method can fill only 2-dimensional tensors."
                f" However, the provided `out` tensor has {out.ndim} dimensions, its shape being {out.shape}."
            )
        _, num_cols = out.shape
        if num_cols != self.solution_length:
            raise ValueError(
                f"The solution length declared by this distribution is {self.solution_length}."
                f" However, the provided `out` tensor has {num_cols} columns."
                f" The `sample(...)` method can only work with tensors whose number of columns are equal"
                f" to the declared solution length."
            )
    else:
        raise ValueError(
            "Received both `num_solutions` and `out` as None."
            "Please provide one of these arguments with a value other than None."
        )
    self._fill(out, generator=generator)
    return out

to(self, device)

Bring the Distribution onto a computational device.

If the given device is already the device of this Distribution, then the Distribution itself will be returned. If the given device is different than the device of this Distribution, a copy of this Distribution on the given device will be created and returned.

Parameters:

Name Type Description Default
device Union[str, torch.device]

The computation device onto which the Distribution will be brought.

required

Returns:

Type Description
Distribution

The Distribution on the target device.

Source code in evotorch/distributions.py
def to(self, device: Device) -> "Distribution":
    """
    Bring the Distribution onto a computational device.

    If the given device is already the device of this Distribution,
    then the Distribution itself will be returned.
    If the given device is different than the device of this
    Distribution, a copy of this Distribution on the given device
    will be created and returned.

    Args:
        device: The computation device onto which the Distribution
            will be brought.
    Returns:
        The Distribution on the target device.
    """
    if torch.device(self.device) == torch.device(device):
        return self
    else:
        cls = self.__class__
        return cls(solution_length=self.solution_length, parameters=self.parameters, device=device)

update_parameters(self, gradients, *, learning_rates=None, optimizers=None)

Do an update on the distribution by following the given gradients.

It is expected that the inheriting class has its own implementation for this method.

Parameters:

Name Type Description Default
gradients dict

Gradients, as a dictionary, which will be used for computing the necessary updates.

required
learning_rates Optional[dict]

A dictionary which contains learning rates for parameters that will be updated using a learning rate coefficient.

None
optimizers Optional[dict]

A dictionary which contains optimizer objects for parameters that will be updated using an adaptive optimizer.

None

Returns:

Type Description
Distribution

The updated copy of the distribution.

Source code in evotorch/distributions.py
def update_parameters(
    self,
    gradients: dict,
    *,
    learning_rates: Optional[dict] = None,
    optimizers: Optional[dict] = None,
) -> "Distribution":
    """
    Do an update on the distribution by following the given gradients.

    It is expected that the inheriting class has its own implementation
    for this method.

    Args:
        gradients: Gradients, as a dictionary, which will be used for
            computing the necessary updates.
        learning_rates: A dictionary which contains learning rates
            for parameters that will be updated using a learning rate
            coefficient.
        optimizers: A dictionary which contains optimizer objects
            for parameters that will be updated using an adaptive
            optimizer.
    Returns:
        The updated copy of the distribution.
    """
    raise NotImplementedError

ExpGaussian (Distribution)

exponential Multivariate Gaussian, as used by XNES

Source code in evotorch/distributions.py
class ExpGaussian(Distribution):
    """exponential Multivariate Gaussian, as used by XNES"""

    # Corresponding to mu and A in symbols used in xNES paper
    MANDATORY_PARAMETERS = {"mu", "sigma"}

    # Inverse of sigma, numerically more stable to track this independently to sigma
    OPTIONAL_PARAMETERS = {"sigma_inv"}

    def __init__(
        self,
        parameters: dict,
        *,
        solution_length: Optional[int] = None,
        device: Optional[Device] = None,
        dtype: Optional[DType] = None,
    ):
        [mu_length] = parameters["mu"].shape

        # Make sigma 2D
        if len(parameters["sigma"].shape) == 1:
            parameters["sigma"] = torch.diag(parameters["sigma"])

        # Automatically generate sigma_inv if not provided
        if "sigma_inv" not in parameters:
            parameters["sigma_inv"] = torch.inverse(parameters["sigma"])

        [sigma_length, _] = parameters["sigma"].shape

        if solution_length is None:
            solution_length = mu_length
        else:
            if solution_length != mu_length:
                raise ValueError(
                    f"The argument `solution_length` does not match the length of `mu` provided in `parameters`."
                    f" solution_length={solution_length},"
                    f' parameters["mu"]={mu_length}.'
                )

        if mu_length != sigma_length:
            raise ValueError(
                f"The tensors `mu` and `sigma` provided within `parameters` have mismatching lengths."
                f' parameters["mu"]={mu_length},'
                f' parameters["sigma"]={sigma_length}.'
            )

        super().__init__(
            solution_length=solution_length,
            parameters=parameters,
            device=device,
            dtype=dtype,
        )
        # Make identity matrix as this is used throughout in gradient computation
        self.eye = self.make_I(solution_length)

    @property
    def mu(self) -> torch.Tensor:
        """Getter for mu
        Returns:
            mu (torch.Tensor): The center of the search distribution
        """
        return self.parameters["mu"]

    @mu.setter
    def mu(self, new_mu: Iterable):
        """Setter for mu
        Args:
            new_mu (torch.Tensor): The new value of mu
        """
        self.parameters["mu"] = torch.as_tensor(new_mu, dtype=self.dtype, device=self.device)

    @property
    def cov(self) -> torch.Tensor:
        """The covariance matrix A^T A"""
        return self.sigma.transpose(0, 1) @ self.sigma

    @property
    def sigma(self) -> torch.Tensor:
        """Getter for sigma
        Returns:
            sigma (torch.Tensor): The square root of the covariance matrix
        """
        return self.parameters["sigma"]

    @property
    def sigma_inv(self) -> torch.Tensor:
        """Getter for sigma_inv
        Returns:
            sigma_inv (torch.Tensor): The inverse square root of the covariance matrix
        """
        if "sigma_inv" in self.parameters:
            return self.parameters["sigma_inv"]
        else:
            return torch.inverse(self.parameters["sigma"])

    @property
    def A(self) -> torch.Tensor:
        """Alias for self.sigma, for notational consistency with paper"""
        return self.sigma

    @property
    def A_inv(self) -> torch.Tensor:
        """Alias for self.sigma_inv, for notational consistency with paper"""
        return self.sigma_inv

    @sigma.setter
    def sigma(self, new_sigma: Iterable):
        """Setter for sigma
        Args:
            new_sigma (torch.Tensor): The new value of sigma, the square root of the covariance matrix
        """
        self.parameters["sigma"] = torch.as_tensor(new_sigma, dtype=self.dtype, device=self.device)

    def to_global_coordinates(self, local_coordinates: torch.Tensor) -> torch.Tensor:
        """Map samples from local coordinate space N(0, I_d) to global coordinate space N(mu, A^T A)
        This function is the inverse of to_local_coordinates
        Args:
            local_coordinates (torch.Tensor): The local coordinates sampled from N(0, I_d)
        Returns:
            global_coordinates (torch.Tensor): The global coordinates sampled from N(mu, A^T A)
        """
        # Global samples are constructed as x = mu + A z where z is local coordinate
        # We use transpose here to simplify the batched application of A
        return self.mu.unsqueeze(0) + (self.A @ local_coordinates.T).T

    def to_local_coordinates(self, global_coordinates: torch.Tensor) -> torch.Tensor:
        """Map samples from global coordinate space N(mu, A^T A) to local coordinate space N(0, I_d)
        This function is the inverse of to_global_coordinates
        Args:
            global_coordinates (torch.Tensor): The global coordinates sampled from N(mu, A^T A)
        Returns:
            local_coordinates (torch.Tensor): The local coordinates sampled from N(0, I_d)
        """
        # Global samples are constructed as x = mu + A z where z is local coordinate
        # Therefore, we can recover z according to z = A_inv (x - mu)
        return (self.A_inv @ (global_coordinates - self.mu.unsqueeze(0)).T).T

    def _fill(self, out: torch.Tensor, *, generator: Optional[torch.Generator] = None):
        """Fill a tensor with samples from N(mu, A^T A)
        Args:
            out (torch.Tensor): The tensor to fill
            generator (Optional[torch.Generator]): A generator to use to generate random values
        """
        # Fill with local coordinates from N(0, I_d)
        self.make_gaussian(out=out, generator=generator)
        # Map local coordinates to global coordinate system
        out[:] = self.to_global_coordinates(out)

    def _compute_gradients(self, samples: torch.Tensor, weights: torch.Tensor, ranking_used: Optional[str]) -> dict:
        """Compute the gradients with respect to a given set of samples and weights
        Args:
            samples (torch.Tensor): Samples drawn from N(mu, A^T A), ideally using self._fill
            weights (torch.Tensor): Weights e.g. fitnesses or utilities assigned to samples
            ranking_used (optional[str]): The ranking method used to compute weights
        Returns:
            grads (dict): A dictionary containing the approximated natural gradient on d and M
        """
        # Compute the local coordinates
        local_coordinates = self.to_local_coordinates(samples)

        # Make sure that the weights (utilities) are 0-centered
        # (Otherwise the formulations would have to consider a bias term)
        if ranking_used not in ("centered", "normalized"):
            weights = weights - torch.mean(weights)

        d_grad = total(dot(weights, local_coordinates))
        local_coordinates_outer = local_coordinates.unsqueeze(1) * local_coordinates.unsqueeze(2)
        M_grad = torch.sum(
            weights.unsqueeze(-1).unsqueeze(-1) * (local_coordinates_outer - self.eye.unsqueeze(0)), dim=0
        )

        return {
            "d": d_grad,
            "M": M_grad,
        }

    def update_parameters(
        self,
        gradients: dict,
        *,
        learning_rates: Optional[dict] = None,
        optimizers: Optional[dict] = None,
    ) -> "ExpGaussian":
        d_grad = gradients["d"]
        M_grad = gradients["M"]

        if "d" not in learning_rates:
            learning_rates["d"] = learning_rates["mu"]
        if "M" not in learning_rates:
            learning_rates["M"] = learning_rates["sigma"]

        # Follow gradients for d, and M
        update_d = self._follow_gradient("d", d_grad, learning_rates=learning_rates, optimizers=optimizers)
        update_M = self._follow_gradient("M", M_grad, learning_rates=learning_rates, optimizers=optimizers)

        # Fold into parameters mu, A and A inv
        new_mu = self.mu + torch.mv(self.A, update_d)
        new_A = self.A @ torch.matrix_exp(0.5 * update_M)
        new_A_inv = torch.matrix_exp(-0.5 * update_M) @ self.A_inv

        # Return modified distribution
        return self.modified_copy(mu=new_mu, sigma=new_A, sigma_inv=new_A_inv)

A: Tensor property readonly

Alias for self.sigma, for notational consistency with paper

A_inv: Tensor property readonly

Alias for self.sigma_inv, for notational consistency with paper

cov: Tensor property readonly

The covariance matrix A^T A

mu: Tensor property writable

Getter for mu

Returns:

Type Description
mu (torch.Tensor)

The center of the search distribution

sigma: Tensor property writable

Getter for sigma

Returns:

Type Description
sigma (torch.Tensor)

The square root of the covariance matrix

sigma_inv: Tensor property readonly

Getter for sigma_inv

Returns:

Type Description
sigma_inv (torch.Tensor)

The inverse square root of the covariance matrix

to_global_coordinates(self, local_coordinates)

Map samples from local coordinate space N(0, I_d) to global coordinate space N(mu, A^T A) This function is the inverse of to_local_coordinates

Parameters:

Name Type Description Default
local_coordinates torch.Tensor

The local coordinates sampled from N(0, I_d)

required

Returns:

Type Description
global_coordinates (torch.Tensor)

The global coordinates sampled from N(mu, A^T A)

Source code in evotorch/distributions.py
def to_global_coordinates(self, local_coordinates: torch.Tensor) -> torch.Tensor:
    """Map samples from local coordinate space N(0, I_d) to global coordinate space N(mu, A^T A)
    This function is the inverse of to_local_coordinates
    Args:
        local_coordinates (torch.Tensor): The local coordinates sampled from N(0, I_d)
    Returns:
        global_coordinates (torch.Tensor): The global coordinates sampled from N(mu, A^T A)
    """
    # Global samples are constructed as x = mu + A z where z is local coordinate
    # We use transpose here to simplify the batched application of A
    return self.mu.unsqueeze(0) + (self.A @ local_coordinates.T).T

to_local_coordinates(self, global_coordinates)

Map samples from global coordinate space N(mu, A^T A) to local coordinate space N(0, I_d) This function is the inverse of to_global_coordinates

Parameters:

Name Type Description Default
global_coordinates torch.Tensor

The global coordinates sampled from N(mu, A^T A)

required

Returns:

Type Description
local_coordinates (torch.Tensor)

The local coordinates sampled from N(0, I_d)

Source code in evotorch/distributions.py
def to_local_coordinates(self, global_coordinates: torch.Tensor) -> torch.Tensor:
    """Map samples from global coordinate space N(mu, A^T A) to local coordinate space N(0, I_d)
    This function is the inverse of to_global_coordinates
    Args:
        global_coordinates (torch.Tensor): The global coordinates sampled from N(mu, A^T A)
    Returns:
        local_coordinates (torch.Tensor): The local coordinates sampled from N(0, I_d)
    """
    # Global samples are constructed as x = mu + A z where z is local coordinate
    # Therefore, we can recover z according to z = A_inv (x - mu)
    return (self.A_inv @ (global_coordinates - self.mu.unsqueeze(0)).T).T

update_parameters(self, gradients, *, learning_rates=None, optimizers=None)

Do an update on the distribution by following the given gradients.

It is expected that the inheriting class has its own implementation for this method.

Parameters:

Name Type Description Default
gradients dict

Gradients, as a dictionary, which will be used for computing the necessary updates.

required
learning_rates Optional[dict]

A dictionary which contains learning rates for parameters that will be updated using a learning rate coefficient.

None
optimizers Optional[dict]

A dictionary which contains optimizer objects for parameters that will be updated using an adaptive optimizer.

None

Returns:

Type Description
ExpGaussian

The updated copy of the distribution.

Source code in evotorch/distributions.py
def update_parameters(
    self,
    gradients: dict,
    *,
    learning_rates: Optional[dict] = None,
    optimizers: Optional[dict] = None,
) -> "ExpGaussian":
    d_grad = gradients["d"]
    M_grad = gradients["M"]

    if "d" not in learning_rates:
        learning_rates["d"] = learning_rates["mu"]
    if "M" not in learning_rates:
        learning_rates["M"] = learning_rates["sigma"]

    # Follow gradients for d, and M
    update_d = self._follow_gradient("d", d_grad, learning_rates=learning_rates, optimizers=optimizers)
    update_M = self._follow_gradient("M", M_grad, learning_rates=learning_rates, optimizers=optimizers)

    # Fold into parameters mu, A and A inv
    new_mu = self.mu + torch.mv(self.A, update_d)
    new_A = self.A @ torch.matrix_exp(0.5 * update_M)
    new_A_inv = torch.matrix_exp(-0.5 * update_M) @ self.A_inv

    # Return modified distribution
    return self.modified_copy(mu=new_mu, sigma=new_A, sigma_inv=new_A_inv)

ExpSeparableGaussian (SeparableGaussian)

exponentialseparable Multivariate Gaussian, as used by SNES

Source code in evotorch/distributions.py
class ExpSeparableGaussian(SeparableGaussian):
    """exponentialseparable Multivariate Gaussian, as used by SNES"""

    MANDATORY_PARAMETERS = {"mu", "sigma"}
    OPTIONAL_PARAMETERS = set()

    def _compute_gradients(self, samples: torch.Tensor, weights: torch.Tensor, ranking_used: Optional[str]) -> dict:
        if ranking_used != "nes":
            weights = weights / torch.sum(torch.abs(weights))

        scaled_noises = samples - self.mu
        raw_noises = scaled_noises / self.sigma

        mu_grad = total(dot(weights, scaled_noises))
        sigma_grad = total(dot(weights, (raw_noises**2) - 1))

        return {"mu": mu_grad, "sigma": sigma_grad}

    def update_parameters(
        self,
        gradients: dict,
        *,
        learning_rates: Optional[dict] = None,
        optimizers: Optional[dict] = None,
    ) -> "ExpSeparableGaussian":
        mu_grad = gradients["mu"]
        sigma_grad = gradients["sigma"]

        new_mu = self.mu + self._follow_gradient("mu", mu_grad, learning_rates=learning_rates, optimizers=optimizers)
        new_sigma = self.sigma * torch.exp(
            0.5 * self._follow_gradient("sigma", sigma_grad, learning_rates=learning_rates, optimizers=optimizers)
        )

        return self.modified_copy(mu=new_mu, sigma=new_sigma)

update_parameters(self, gradients, *, learning_rates=None, optimizers=None)

Do an update on the distribution by following the given gradients.

It is expected that the inheriting class has its own implementation for this method.

Parameters:

Name Type Description Default
gradients dict

Gradients, as a dictionary, which will be used for computing the necessary updates.

required
learning_rates Optional[dict]

A dictionary which contains learning rates for parameters that will be updated using a learning rate coefficient.

None
optimizers Optional[dict]

A dictionary which contains optimizer objects for parameters that will be updated using an adaptive optimizer.

None

Returns:

Type Description
ExpSeparableGaussian

The updated copy of the distribution.

Source code in evotorch/distributions.py
def update_parameters(
    self,
    gradients: dict,
    *,
    learning_rates: Optional[dict] = None,
    optimizers: Optional[dict] = None,
) -> "ExpSeparableGaussian":
    mu_grad = gradients["mu"]
    sigma_grad = gradients["sigma"]

    new_mu = self.mu + self._follow_gradient("mu", mu_grad, learning_rates=learning_rates, optimizers=optimizers)
    new_sigma = self.sigma * torch.exp(
        0.5 * self._follow_gradient("sigma", sigma_grad, learning_rates=learning_rates, optimizers=optimizers)
    )

    return self.modified_copy(mu=new_mu, sigma=new_sigma)

SeparableGaussian (Distribution)

Separable Multivariate Gaussian, as used by PGPE

Source code in evotorch/distributions.py
class SeparableGaussian(Distribution):
    """Separable Multivariate Gaussian, as used by PGPE"""

    MANDATORY_PARAMETERS = {"mu", "sigma"}
    OPTIONAL_PARAMETERS = {"divide_mu_grad_by", "divide_sigma_grad_by", "parenthood_ratio"}

    def __init__(
        self,
        parameters: dict,
        *,
        solution_length: Optional[int] = None,
        device: Optional[Device] = None,
        dtype: Optional[DType] = None,
    ):
        [mu_length] = parameters["mu"].shape
        [sigma_length] = parameters["sigma"].shape

        if solution_length is None:
            solution_length = mu_length
        else:
            if solution_length != mu_length:
                raise ValueError(
                    f"The argument `solution_length` does not match the length of `mu` provided in `parameters`."
                    f" solution_length={solution_length},"
                    f' parameters["mu"]={mu_length}.'
                )

        if mu_length != sigma_length:
            raise ValueError(
                f"The tensors `mu` and `sigma` provided within `parameters` have mismatching lengths."
                f' parameters["mu"]={mu_length},'
                f' parameters["sigma"]={sigma_length}.'
            )

        super().__init__(
            solution_length=solution_length,
            parameters=parameters,
            device=device,
            dtype=dtype,
        )

    @property
    def mu(self) -> torch.Tensor:
        return self.parameters["mu"]

    @mu.setter
    def mu(self, new_mu: Iterable):
        self.parameters["mu"] = torch.as_tensor(new_mu, dtype=self.dtype, device=self.device)

    @property
    def sigma(self) -> torch.Tensor:
        return self.parameters["sigma"]

    @sigma.setter
    def sigma(self, new_sigma: Iterable):
        self.parameters["sigma"] = torch.as_tensor(new_sigma, dtype=self.dtype, device=self.device)

    def _fill(self, out: torch.Tensor, *, generator: Optional[torch.Generator] = None):
        self.make_gaussian(out=out, center=self.mu, stdev=self.sigma, generator=generator)

    def _divide_grad(self, param_name: str, grad: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
        option = f"divide_{param_name}_grad_by"
        if option in self.parameters:
            div_by_what = self.parameters[option]
            if div_by_what == "num_solutions":
                [num_solutions] = weights.shape
                grad = grad / num_solutions
            elif div_by_what == "num_directions":
                [num_solutions] = weights.shape
                num_directions = num_solutions // 2
                grad = grad / num_directions
            elif div_by_what == "total_weight":
                total_weight = torch.sum(torch.abs(weights))
                grad = grad / total_weight
            elif div_by_what == "weight_stdev":
                weight_stdev = torch.std(weights)
                grad = grad / weight_stdev
            else:
                raise ValueError(f"The parameter {option} has an unrecognized value: {div_by_what}")
        return grad

    def _compute_gradients_via_parenthood_ratio(self, samples: torch.Tensor, weights: torch.Tensor) -> dict:
        [num_samples, _] = samples.shape
        num_elites = math.floor(num_samples * self.parameters["parenthood_ratio"])
        elite_indices = weights.argsort(descending=True)[:num_elites]
        elites = samples[elite_indices, :]
        return {
            "mu": torch.mean(elites, dim=0) - self.parameters["mu"],
            "sigma": torch.std(elites, dim=0) - self.parameters["sigma"],
        }

    def _compute_gradients(self, samples: torch.Tensor, weights: torch.Tensor, ranking_used: Optional[str]) -> dict:
        if "parenthood_ratio" in self.parameters:
            return self._compute_gradients_via_parenthood_ratio(samples, weights)
        else:
            mu = self.mu
            sigma = self.sigma

            # Compute the scaled noises, that is, the noise vectors which
            # were used for generating the solutions
            # (solution = scaled_noise + center)
            scaled_noises = samples - mu

            # Make sure that the weights (utilities) are 0-centered
            # (Otherwise the formulations would have to consider a bias term)
            if ranking_used not in ("centered", "normalized"):
                weights = weights - torch.mean(weights)

            mu_grad = self._divide_grad(
                "mu",
                total(dot(weights, scaled_noises)),
                weights,
            )
            sigma_grad = self._divide_grad(
                "sigma",
                total(dot(weights, ((scaled_noises**2) - (sigma**2)) / sigma)),
                weights,
            )

            return {
                "mu": mu_grad,
                "sigma": sigma_grad,
            }

    def update_parameters(
        self,
        gradients: dict,
        *,
        learning_rates: Optional[dict] = None,
        optimizers: Optional[dict] = None,
    ) -> "SeparableGaussian":
        mu_grad = gradients["mu"]
        sigma_grad = gradients["sigma"]

        new_mu = self.mu + self._follow_gradient("mu", mu_grad, learning_rates=learning_rates, optimizers=optimizers)
        new_sigma = self.sigma + self._follow_gradient(
            "sigma", sigma_grad, learning_rates=learning_rates, optimizers=optimizers
        )

        return self.modified_copy(mu=new_mu, sigma=new_sigma)

    def relative_entropy(dist_0: "SeparableGaussian", dist_1: "SeparableGaussian") -> float:
        mu_0 = dist_0.parameters["mu"]
        mu_1 = dist_1.parameters["mu"]
        sigma_0 = dist_0.parameters["sigma"]
        sigma_1 = dist_1.parameters["sigma"]
        cov_0 = sigma_0.pow(2.0)
        cov_1 = sigma_1.pow(2.0)

        mu_delta = mu_1 - mu_0

        trace_cov = torch.sum(cov_0 / cov_1)
        k = dist_0.solution_length
        scaled_mu = torch.sum(mu_delta.pow(2.0) / cov_1)
        log_det = torch.sum(torch.log(cov_1)) - torch.sum(torch.log(cov_0))

        return 0.5 * (trace_cov - k + scaled_mu + log_det)

update_parameters(self, gradients, *, learning_rates=None, optimizers=None)

Do an update on the distribution by following the given gradients.

It is expected that the inheriting class has its own implementation for this method.

Parameters:

Name Type Description Default
gradients dict

Gradients, as a dictionary, which will be used for computing the necessary updates.

required
learning_rates Optional[dict]

A dictionary which contains learning rates for parameters that will be updated using a learning rate coefficient.

None
optimizers Optional[dict]

A dictionary which contains optimizer objects for parameters that will be updated using an adaptive optimizer.

None

Returns:

Type Description
SeparableGaussian

The updated copy of the distribution.

Source code in evotorch/distributions.py
def update_parameters(
    self,
    gradients: dict,
    *,
    learning_rates: Optional[dict] = None,
    optimizers: Optional[dict] = None,
) -> "SeparableGaussian":
    mu_grad = gradients["mu"]
    sigma_grad = gradients["sigma"]

    new_mu = self.mu + self._follow_gradient("mu", mu_grad, learning_rates=learning_rates, optimizers=optimizers)
    new_sigma = self.sigma + self._follow_gradient(
        "sigma", sigma_grad, learning_rates=learning_rates, optimizers=optimizers
    )

    return self.modified_copy(mu=new_mu, sigma=new_sigma)

SymmetricSeparableGaussian (SeparableGaussian)

Symmetric (antithetic) separable Gaussian distribution as used by PGPE.

Source code in evotorch/distributions.py
class SymmetricSeparableGaussian(SeparableGaussian):
    """
    Symmetric (antithetic) separable Gaussian distribution
    as used by PGPE.
    """

    MANDATORY_PARAMETERS = {"mu", "sigma"}
    OPTIONAL_PARAMETERS = {"divide_mu_grad_by", "divide_sigma_grad_by", "parenthood_ratio"}

    def _fill(self, out: torch.Tensor, *, generator: Optional[torch.Generator] = None):
        self.make_gaussian(out=out, center=self.mu, stdev=self.sigma, symmetric=True, generator=generator)

    def _compute_gradients(
        self,
        samples: torch.Tensor,
        weights: torch.Tensor,
        ranking_used: Optional[str],
    ) -> dict:
        if "parenthood_ratio" in self.parameters:
            return self._compute_gradients_via_parenthood_ratio(samples, weights)
        else:
            mu = self.mu
            sigma = self.sigma

            # Make sure that the weights (utilities) are 0-centered
            # (Otherwise the formulations would have to consider a bias term)
            if ranking_used not in ("centered", "normalized"):
                weights = weights - torch.mean(weights)

            [nslns] = weights.shape
            # ndirs = nslns // 2

            # Compute the scaled noises, that is, the noise vectors which
            # were used for generating the solutions
            # (solution = scaled_noise + center)
            scaled_noises = samples[0::2] - mu

            # Separate the plus and the minus ends of the directions
            fdplus = weights[0::2]
            fdminus = weights[1::2]

            # Considering that the population is stored like this:
            #                                     _
            #   solution0: center + scaled_noise0  \
            #                                       > direction0
            #   solution1: center - scaled_noise0 _/
            #                                     _
            #   solution2: center + scaled_noise1  \
            #                                       > direction1
            #   solution3: center - scaled_noise1 _/
            #
            #   ...

            # fdplus[0] becomes the utility of the plus end of direction0
            #                   (i.e. utility of solution0)

            # fdminus[0] becomes the utility of the minus end of direction0
            #                   (i.e. utility of solution1)

            # fdplus[1] becomes the utility of the plus end of direction1
            #                   (i.e. utility of solution2)

            # fdminus[1] becomes the utility of the minus end of direction1
            #                   (i.e. utility of solution3)

            # ... and so on...

            grad_mu = self._divide_grad("mu", total(dot((fdplus - fdminus) / 2, scaled_noises)), weights)
            grad_sigma = self._divide_grad(
                "sigma",
                total(dot(((fdplus + fdminus) / 2), ((scaled_noises**2) - (sigma**2)) / sigma)),
                weights,
            )

            return {
                "mu": grad_mu,
                "sigma": grad_sigma,
            }

logging

This module contains logging utilities.

Logger

Base class for all logging classes.

Source code in evotorch/logging.py
class Logger:
    """Base class for all logging classes."""

    def __init__(self, searcher: SearchAlgorithm, *, interval: int = 1, after_first_step: bool = False):
        """`__init__(...)`: Initialize the Logger.

        Args:
            searcher: The evolutionary algorithm instance whose progress
                is to be logged.
            interval: Expected as an integer n.
                Logging is to be done at every n iterations.
            after_first_step: Expected as a boolean.
                Meaningful only if interval is set as an integer greater
                than 1. Let us suppose that interval is set as 10.
                If after_first_step is False (which is the default),
                then the logging will be done at steps 10, 20, 30, and so on.
                On the other hand, if after_first_step is True,
                then the logging will be done at steps 1, 11, 21, 31, and so
                on.
        """
        searcher.log_hook.append(self)
        self._interval = int(interval)
        self._after_first_step = bool(after_first_step)
        self._steps_count = 0

    def __call__(self, status: dict):
        if self._after_first_step:
            n = self._steps_count
            self._steps_count += 1
        else:
            self._steps_count += 1
            n = self._steps_count

        if (n % self._interval) == 0:
            self._log(self._filter(status))

    def _filter(self, status: dict) -> dict:
        return status

    def _log(self, status: dict):
        raise NotImplementedError

__init__(self, searcher, *, interval=1, after_first_step=False) special

__init__(...): Initialize the Logger.

Parameters:

Name Type Description Default
searcher SearchAlgorithm

The evolutionary algorithm instance whose progress is to be logged.

required
interval int

Expected as an integer n. Logging is to be done at every n iterations.

1
after_first_step bool

Expected as a boolean. Meaningful only if interval is set as an integer greater than 1. Let us suppose that interval is set as 10. If after_first_step is False (which is the default), then the logging will be done at steps 10, 20, 30, and so on. On the other hand, if after_first_step is True, then the logging will be done at steps 1, 11, 21, 31, and so on.

False
Source code in evotorch/logging.py
def __init__(self, searcher: SearchAlgorithm, *, interval: int = 1, after_first_step: bool = False):
    """`__init__(...)`: Initialize the Logger.

    Args:
        searcher: The evolutionary algorithm instance whose progress
            is to be logged.
        interval: Expected as an integer n.
            Logging is to be done at every n iterations.
        after_first_step: Expected as a boolean.
            Meaningful only if interval is set as an integer greater
            than 1. Let us suppose that interval is set as 10.
            If after_first_step is False (which is the default),
            then the logging will be done at steps 10, 20, 30, and so on.
            On the other hand, if after_first_step is True,
            then the logging will be done at steps 1, 11, 21, 31, and so
            on.
    """
    searcher.log_hook.append(self)
    self._interval = int(interval)
    self._after_first_step = bool(after_first_step)
    self._steps_count = 0

MlflowLogger (ScalarLogger)

A logger which stores the status via Mlflow.

Source code in evotorch/logging.py
class MlflowLogger(ScalarLogger):
    """A logger which stores the status via Mlflow."""

    def __init__(
        self,
        searcher: SearchAlgorithm,
        client: Optional[mlflow.tracking.MlflowClient] = None,
        run: Union[mlflow.entities.Run, Optional[MlflowID]] = None,
        *,
        interval: int = 1,
        after_first_step: bool = False,
    ):
        """`__init__(...)`: Initialize the MlflowLogger.

        Args:
            searcher: The evolutionary algorithm instance whose progress
                is to be logged.
            client: The MlflowClient object whose log_metric() method
                will be used for logging. This can be passed as None,
                in which case mlflow.log_metrics() will be used instead.
                Please note that, if a client is provided, the `run`
                argument is required as well.
            run: Expected only if a client is provided.
                This is the mlflow Run object (an instance of
                mlflow.entities.Run), or the ID of the mlflow run.
            interval: Expected as an integer n.
                Logging is to be done at every n iterations.
            after_first_step: Expected as a boolean.
                Meaningful only if interval is set as an integer greater
                than 1. Let us suppose that interval is set as 10.
                If after_first_step is False (which is the default),
                then the logging will be done at steps 10, 20, 30, and
                so on. On the other hand, if after_first_step is True,
                then the logging will be done at steps 1, 11, 21, 31,
                and so on.
        """

        super().__init__(searcher, interval=interval, after_first_step=after_first_step)

        self._client = client
        self._run_id: Optional[MlflowID] = None

        if self._client is None:
            if run is not None:
                raise ValueError("Received `run`, but `client` is missing")
        else:
            if run is None:
                raise ValueError("Received `client`, but `run` is missing")
            if isinstance(run, mlflow.entities.Run):
                self._run_id = run.info.run_id
            else:
                self._run_id = run

    def _log(self, status: dict):
        if self._client is None:
            mlflow.log_metrics(status, step=self._steps_count)
        else:
            for k, v in status.items():
                self._client.log_metric(self._run_id, k, v, step=self._steps_count)

__init__(self, searcher, client=None, run=None, *, interval=1, after_first_step=False) special

__init__(...): Initialize the MlflowLogger.

Parameters:

Name Type Description Default
searcher SearchAlgorithm

The evolutionary algorithm instance whose progress is to be logged.

required
client Optional[mlflow.tracking.client.MlflowClient]

The MlflowClient object whose log_metric() method will be used for logging. This can be passed as None, in which case mlflow.log_metrics() will be used instead. Please note that, if a client is provided, the run argument is required as well.

None
run Union[mlflow.entities.run.Run, str, bytes, int]

Expected only if a client is provided. This is the mlflow Run object (an instance of mlflow.entities.Run), or the ID of the mlflow run.

None
interval int

Expected as an integer n. Logging is to be done at every n iterations.

1
after_first_step bool

Expected as a boolean. Meaningful only if interval is set as an integer greater than 1. Let us suppose that interval is set as 10. If after_first_step is False (which is the default), then the logging will be done at steps 10, 20, 30, and so on. On the other hand, if after_first_step is True, then the logging will be done at steps 1, 11, 21, 31, and so on.

False
Source code in evotorch/logging.py
def __init__(
    self,
    searcher: SearchAlgorithm,
    client: Optional[mlflow.tracking.MlflowClient] = None,
    run: Union[mlflow.entities.Run, Optional[MlflowID]] = None,
    *,
    interval: int = 1,
    after_first_step: bool = False,
):
    """`__init__(...)`: Initialize the MlflowLogger.

    Args:
        searcher: The evolutionary algorithm instance whose progress
            is to be logged.
        client: The MlflowClient object whose log_metric() method
            will be used for logging. This can be passed as None,
            in which case mlflow.log_metrics() will be used instead.
            Please note that, if a client is provided, the `run`
            argument is required as well.
        run: Expected only if a client is provided.
            This is the mlflow Run object (an instance of
            mlflow.entities.Run), or the ID of the mlflow run.
        interval: Expected as an integer n.
            Logging is to be done at every n iterations.
        after_first_step: Expected as a boolean.
            Meaningful only if interval is set as an integer greater
            than 1. Let us suppose that interval is set as 10.
            If after_first_step is False (which is the default),
            then the logging will be done at steps 10, 20, 30, and
            so on. On the other hand, if after_first_step is True,
            then the logging will be done at steps 1, 11, 21, 31,
            and so on.
    """

    super().__init__(searcher, interval=interval, after_first_step=after_first_step)

    self._client = client
    self._run_id: Optional[MlflowID] = None

    if self._client is None:
        if run is not None:
            raise ValueError("Received `run`, but `client` is missing")
    else:
        if run is None:
            raise ValueError("Received `client`, but `run` is missing")
        if isinstance(run, mlflow.entities.Run):
            self._run_id = run.info.run_id
        else:
            self._run_id = run

NeptuneLogger (ScalarLogger)

A logger which stores the status via neptune.

Source code in evotorch/logging.py
class NeptuneLogger(ScalarLogger):
    """A logger which stores the status via neptune."""

    def __init__(
        self,
        searcher: SearchAlgorithm,
        run: Optional[neptune.Run] = None,
        *,
        interval: int = 1,
        after_first_step: bool = False,
        group: Optional[str] = None,
        **neptune_kwargs,
    ):
        """`__init__(...)`: Initialize the NeptuneLogger.

        Args:
            searcher: The evolutionary algorithm instance whose progress
                is to be logged.
            run: A `neptune.new.run.Run` instance using which the status
                will be logged. If None, then a new run will be created.
            interval: Expected as an integer n.
                Logging is to be done at every n iterations.
            after_first_step: Expected as a boolean.
                Meaningful only if interval is set as an integer greater
                than 1. Let us suppose that interval is set as 10.
                If after_first_step is False (which is the default),
                then the logging will be done at steps 10, 20, 30, and so on.
                On the other hand, if after_first_step is True,
                then the logging will be done at steps 1, 11, 21, 31, and so
                on.
            group: Into which group will the metrics be stored.
                For example, if the status keys to be logged are "score" and
                "elapsed", and `group` is set as "training", then the metrics
                will be sent to neptune with the keys "training/score" and
                "training/elapsed". `group` can also be left as None,
                in which case the status will be sent to neptune with the
                key names unchanged.
            **neptune_kwargs: Any additional keyword arguments to be passed
                to `neptune.init_run()` when creating a new run.
                For example, `project="my-project"` or `tags=["my-tag"]`.
        """
        super().__init__(searcher, interval=interval, after_first_step=after_first_step)
        self._group = group
        if run is None:
            self._run = neptune.init_run(**neptune_kwargs)
        else:
            self._run = run

    @property
    def run(self) -> neptune.Run:
        return self._run

    def _log(self, status: dict):
        for k, v in status.items():
            target_key = k if self._group is None else self._group + "/" + k
            self._run[target_key].log(v)

__init__(self, searcher, run=None, *, interval=1, after_first_step=False, group=None, **neptune_kwargs) special

__init__(...): Initialize the NeptuneLogger.

Parameters:

Name Type Description Default
searcher SearchAlgorithm

The evolutionary algorithm instance whose progress is to be logged.

required
run Optional[neptune.metadata_containers.run.Run]

A neptune.new.run.Run instance using which the status will be logged. If None, then a new run will be created.

None
interval int

Expected as an integer n. Logging is to be done at every n iterations.

1
after_first_step bool

Expected as a boolean. Meaningful only if interval is set as an integer greater than 1. Let us suppose that interval is set as 10. If after_first_step is False (which is the default), then the logging will be done at steps 10, 20, 30, and so on. On the other hand, if after_first_step is True, then the logging will be done at steps 1, 11, 21, 31, and so on.

False
group Optional[str]

Into which group will the metrics be stored. For example, if the status keys to be logged are "score" and "elapsed", and group is set as "training", then the metrics will be sent to neptune with the keys "training/score" and "training/elapsed". group can also be left as None, in which case the status will be sent to neptune with the key names unchanged.

None
**neptune_kwargs

Any additional keyword arguments to be passed to neptune.init_run() when creating a new run. For example, project="my-project" or tags=["my-tag"].

{}
Source code in evotorch/logging.py
def __init__(
    self,
    searcher: SearchAlgorithm,
    run: Optional[neptune.Run] = None,
    *,
    interval: int = 1,
    after_first_step: bool = False,
    group: Optional[str] = None,
    **neptune_kwargs,
):
    """`__init__(...)`: Initialize the NeptuneLogger.

    Args:
        searcher: The evolutionary algorithm instance whose progress
            is to be logged.
        run: A `neptune.new.run.Run` instance using which the status
            will be logged. If None, then a new run will be created.
        interval: Expected as an integer n.
            Logging is to be done at every n iterations.
        after_first_step: Expected as a boolean.
            Meaningful only if interval is set as an integer greater
            than 1. Let us suppose that interval is set as 10.
            If after_first_step is False (which is the default),
            then the logging will be done at steps 10, 20, 30, and so on.
            On the other hand, if after_first_step is True,
            then the logging will be done at steps 1, 11, 21, 31, and so
            on.
        group: Into which group will the metrics be stored.
            For example, if the status keys to be logged are "score" and
            "elapsed", and `group` is set as "training", then the metrics
            will be sent to neptune with the keys "training/score" and
            "training/elapsed". `group` can also be left as None,
            in which case the status will be sent to neptune with the
            key names unchanged.
        **neptune_kwargs: Any additional keyword arguments to be passed
            to `neptune.init_run()` when creating a new run.
            For example, `project="my-project"` or `tags=["my-tag"]`.
    """
    super().__init__(searcher, interval=interval, after_first_step=after_first_step)
    self._group = group
    if run is None:
        self._run = neptune.init_run(**neptune_kwargs)
    else:
        self._run = run

PandasLogger (ScalarLogger)

A logger which collects status information and generates a pandas.DataFrame at the end.

Source code in evotorch/logging.py
class PandasLogger(ScalarLogger):
    """A logger which collects status information and
    generates a pandas.DataFrame at the end.
    """

    def __init__(self, searcher: SearchAlgorithm, *, interval: int = 1, after_first_step: bool = False):
        """`__init__(...)`: Initialize the PandasLogger.

        Args:
            searcher: The evolutionary algorithm instance whose progress
                is to be logged.
            interval: Expected as an integer n.
                Logging is to be done at every n iterations.
            after_first_step: Expected as a boolean.
                Meaningful only if interval is set as an integer greater
                than 1. Let us suppose that interval is set as 10.
                If after_first_step is False (which is the default),
                then the logging will be done at steps 10, 20, 30, and
                so on. On the other hand, if after_first_step is True,
                then the logging will be done at steps 1, 11, 21, 31, and
                so on.
        """
        super().__init__(searcher, interval=interval, after_first_step=after_first_step)
        self._data = []

    def _log(self, status: dict):
        self._data.append(deepcopy(status))

    def to_dataframe(self, *, index: Optional[str] = "iter") -> pandas.DataFrame:
        """Generate a pandas.DataFrame from the collected
        status information.

        Args:
            index: The column to be set as the index.
                If passed as None, then no index will be set.
                The default is "iter".
        """
        result = pandas.DataFrame(self._data)
        if index is not None:
            result.set_index(index, inplace=True)
        return result

__init__(self, searcher, *, interval=1, after_first_step=False) special

__init__(...): Initialize the PandasLogger.

Parameters:

Name Type Description Default
searcher SearchAlgorithm

The evolutionary algorithm instance whose progress is to be logged.

required
interval int

Expected as an integer n. Logging is to be done at every n iterations.

1
after_first_step bool

Expected as a boolean. Meaningful only if interval is set as an integer greater than 1. Let us suppose that interval is set as 10. If after_first_step is False (which is the default), then the logging will be done at steps 10, 20, 30, and so on. On the other hand, if after_first_step is True, then the logging will be done at steps 1, 11, 21, 31, and so on.

False
Source code in evotorch/logging.py
def __init__(self, searcher: SearchAlgorithm, *, interval: int = 1, after_first_step: bool = False):
    """`__init__(...)`: Initialize the PandasLogger.

    Args:
        searcher: The evolutionary algorithm instance whose progress
            is to be logged.
        interval: Expected as an integer n.
            Logging is to be done at every n iterations.
        after_first_step: Expected as a boolean.
            Meaningful only if interval is set as an integer greater
            than 1. Let us suppose that interval is set as 10.
            If after_first_step is False (which is the default),
            then the logging will be done at steps 10, 20, 30, and
            so on. On the other hand, if after_first_step is True,
            then the logging will be done at steps 1, 11, 21, 31, and
            so on.
    """
    super().__init__(searcher, interval=interval, after_first_step=after_first_step)
    self._data = []

to_dataframe(self, *, index='iter')

Generate a pandas.DataFrame from the collected status information.

Parameters:

Name Type Description Default
index Optional[str]

The column to be set as the index. If passed as None, then no index will be set. The default is "iter".

'iter'
Source code in evotorch/logging.py
def to_dataframe(self, *, index: Optional[str] = "iter") -> pandas.DataFrame:
    """Generate a pandas.DataFrame from the collected
    status information.

    Args:
        index: The column to be set as the index.
            If passed as None, then no index will be set.
            The default is "iter".
    """
    result = pandas.DataFrame(self._data)
    if index is not None:
        result.set_index(index, inplace=True)
    return result

PicklingLogger (Logger)

A logger which periodically pickles the current result of the search.

The pickled data includes the current center solution and the best solution (if available). If the problem being solved is a reinforcement learning task, then the pickled data also includes the observation normalization data and the policy.

Source code in evotorch/logging.py
class PicklingLogger(Logger):
    """
    A logger which periodically pickles the current result of the search.

    The pickled data includes the current center solution and the best solution
    (if available). If the problem being solved is a reinforcement learning
    task, then the pickled data also includes the observation normalization
    data and the policy.
    """

    def __init__(
        self,
        searcher: SearchAlgorithm,
        *,
        interval: int,
        directory: Optional[Union[str, pathlib.Path]] = None,
        prefix: Optional[str] = None,
        zfill: int = 6,
        items_to_save: Union[str, Iterable] = (
            "center",
            "best",
            "pop_best",
            "median_eval",
            "mean_eval",
            "pop_best_eval",
        ),
        make_policy_from: Optional[str] = None,
        after_first_step: bool = False,
        verbose: bool = True,
    ):
        """
        `__init__(...)`: Initialize the PicklingLogger.

        Args:
            searcher: The evolutionary algorithm instance whose progress
                is to be pickled.
            interval: Expected as an integer n.
                Pickling is to be done at every n iterations.
            directory: The directory into which the pickle files will be
                placed. If given as None, the current directory will be
                used.
            prefix: The prefix to be used when naming the pickle file.
                If left as None, then the prefix will contain the date,
                time, name of the class of the problem object, and the PID
                of the current process.
            zfill: When naming the pickle file, the generation number will
                also be used. This `zfill` argument is used to determine
                the length of the number of generations string.
                For example, if the current number of generations is
                150, and zfill is 6 (which is the default), then the
                number of generations part of the file name will look
                like this: 'generation000150', the numeric part of the
                string taking 6 characters.
            items_to_save: Items to save from the status dictionary.
                This can be a string, or a sequence of strings.
            make_policy_from: If given as a string, then the solution
                represented by this key in the status dictionary will be
                taken, and converted a PyTorch module with the help of the
                problem's `to_policy(...)` method, and then will be added
                into the pickle file. For example, if this argument is
                given as "center", then the center solution will be converted
                to a policy and saved.
                If this argument is left as None, then, this logger will
                first try to obtain the center solution. If there is no
                center solution, then the logger will try to obtain the
                'pop_best' solution. If that one does not exist either, then
                an error will be raised, and the user will be requested to
                explicitly state a value for the argument `make_policy_from`.
                If the problem object does not provide a `to_policy(...)`,
                method, then this configuration will be ignored, and no
                policy will be saved.
            after_first_step: Expected as a boolean.
                Meaningful only if interval is set as an integer greater
                than 1. Let us suppose that interval is set as 10.
                If after_first_step is False (which is the default),
                then the logging will be done at steps 10, 20, 30, and so on.
                On the other hand, if after_first_step is True,
                then the logging will be done at steps 1, 11, 21, 31, and so
                on.
            verbose: If set as True, then a message will be printed out to
                the standard output every time the pickling happens.
        """
        # Call the initializer of the superclass.
        super().__init__(searcher, interval=interval, after_first_step=after_first_step)

        # Each Logger register itself to the search algorithm's log hook. Additionally, in the case of PicklingLogger,
        # we add this object's final saving method to the search algorithm's end-of-run hook.
        # This is to make sure that the latest generation's result is saved, even when the last generation number
        # does not coincide with the saving period.
        searcher.end_of_run_hook.append(self._final_save)

        # Store a weak reference to the search algorithm.
        self._searcher_ref = weakref.ref(searcher)

        # Store the item keys as a tuple of strings.
        if isinstance(items_to_save, str):
            self._items_to_save = (items_to_save,)
        else:
            self._items_to_save = tuple(items_to_save)

        # Store the status key that will be used to get the current solution for making the policy.
        self._make_policy_from = None if make_policy_from is None else str(make_policy_from)

        if prefix is None:
            # If a file name prefix is not given by the user, then we prepare one using the current date and time,
            # name of the problem type, and the PID of the current process.
            strnow = datetime.now().strftime("%Y-%m-%d-%H.%M.%S")
            probname = type(searcher.problem).__name__
            strpid = str(os.getpid())
            self._prefix = f"{probname}_{strnow}_{strpid}"
        else:
            # If there is a file name prefix given by the user, then we use that name.
            self._prefix = str(prefix)

        if directory is None:
            # If a directory name is not given by the user, then we store the directory name as None.
            self._directory = None
        else:
            # If a directory name is given by the user, then we store it as a string, and make sure that it exists.
            self._directory = str(directory)
            os.makedirs(self._directory, exist_ok=True)

        self._verbose = bool(verbose)
        self._zfill = int(zfill)

        self._last_generation: Optional[int] = None
        self._last_file_name: Optional[str] = None

    def _within_dir(self, fname: Union[str, pathlib.Path]) -> str:
        fname = str(fname)
        if self._directory is not None:
            fname = os.path.join(self._directory, fname)
        return fname

    @classmethod
    def _as_cpu_tensor(cls, x: Any) -> Any:
        if isinstance(x, Solution):
            x = cls._as_cpu_tensor(x.values)
        elif isinstance(x, torch.Tensor):
            with torch.no_grad():
                x = x.cpu().clone()
            if isinstance(x, ReadOnlyTensor):
                x = x.as_subclass(torch.Tensor)
        return x

    def _log(self, status: dict):
        self.save()

    def _final_save(self, status: dict):
        # Get the stored search algorithm
        searcher: Optional[SearchAlgorithm] = self._searcher_ref()

        if searcher is not None:
            # If the search algorithm object is still alive, then we check its generation number
            current_gen = searcher.step_count

            if (self._last_generation is None) or (current_gen > self._last_generation):
                # If there was not a save at all, or the latest save was for a previous generation,
                # then save the current status of the search.
                self.save()

    def save(self, fname: Optional[Union[str, pathlib.Path]] = None) -> str:
        """
        Pickle the current status of the evolutionary search.

        If this PicklingLogger was initialized with a `directory` argument,
        then the pickle file will be put into that directory.

        Args:
            fname: The name of the pickle file to be created.
                This can be left as None if the user wishes this PicklingLogger
                to determine the name of the file.
                Alternatively, an explicit name for this file can be specified
                via this argument.
        Returns:
            The name of the pickle file that was generated, as a string.
        """
        # Get the stored search algorithm
        searcher: Optional[SearchAlgorithm] = self._searcher_ref()

        if searcher is not None:
            # If the search algorithm object is still alive, then take the problem object from it.
            problem: Problem = searcher.problem

            # Initialize the data dictionary that will contain the objects to be put into the pickle file.
            data = {}

            for item_to_save in self._items_to_save:
                # For each item key, get the object with that key from the algorithm's status dictionary, and then put
                # that object into our data dictionary.
                if item_to_save in searcher.status:
                    data[item_to_save] = self._as_cpu_tensor(searcher.status[item_to_save])

            if (
                hasattr(problem, "observation_normalization")
                and hasattr(problem, "get_observation_stats")
                and problem.observation_normalization
            ):
                # If the problem object has observation normalization, then put the normalizer object to the data
                # dictionary.
                data["obs_stats"] = problem.get_observation_stats().to("cpu")

            if hasattr(problem, "to_policy"):
                # If the problem object has the method `to_policy(...)`, then we will generate a policy from the
                # current solution, and put that policy into the data dictionary.

                if self._make_policy_from is None:
                    # If the user did not specify the status key of the solution that will be converted to a policy,
                    # then, we first check if the search algorithm has a "center" item, and if not, we check if it
                    # has a "pop_best" item.

                    if "center" in searcher.status:
                        # The status key of the search algorithm has "center". We declare "center" as the key of the
                        # current solution.
                        policy_key = "center"
                    elif "pop_best" in searcher.status:
                        # The status key of the search algorithm has "pop_best". We declare "pop_best" as the key of
                        # the current solution.
                        policy_key = "pop_best"
                    else:
                        # The status key of the search algorithm contains neither a "center" solution and nor a
                        # "pop_best" solution. So, we raise an error.
                        raise ValueError(
                            "PicklingLogger did not receive an explicit value for its `make_policy_from` argument."
                            " The status dictionary of the search algorithm has neither 'center' nor 'pop_best'."
                            " Therefore, it is not clear which status item is to be used for making a policy."
                            " Please try instantiating a PicklingLogger with an explicit `make_policy_from` value."
                        )
                else:
                    # This is the case where the user explicitly specified (via a status key) which solution will be
                    # used for making the current policy. We declare that key as the key to use.
                    policy_key = self._make_policy_from

                # Get the solution.
                policy_solution = searcher.status[policy_key]

                # Make a policy from the solution
                policy = problem.to_policy(policy_solution)

                if isinstance(policy, nn.Module) and (device_of_module(policy) != torch.device("cpu")):
                    # If the created policy is a PyTorch module, and this module is not on the cpu, then we make
                    # a copy of this module on the cpu.
                    policy = clone(policy).to("cpu")

                # We put this policy into our data dictionary.
                data["policy"] = policy

                # We put the datetime-related information
                first_step_datetime = searcher.first_step_datetime
                if first_step_datetime is not None:
                    now = datetime.now()
                    elapsed = now - first_step_datetime
                    data["beginning_time"] = first_step_datetime
                    data["now"] = now
                    data["elapsed"] = elapsed

            if fname is None:
                # If a file name was not given, then we generate one.
                num_gens = str(searcher.step_count).zfill(self._zfill)
                fname = f"{self._prefix}_generation{num_gens}.pickle"

            # We prepend the specified directory name, if given.
            fname = self._within_dir(fname)

            # Here, the pickle file is created and the data is saved.
            with open(fname, "wb") as f:
                pickle.dump(data, f)

            # Store the most recently saved generation number
            self._last_generation = searcher.step_count

            # Store the name of the last pickle file
            self._last_file_name = fname

            # Report to the user that the save was successful
            if self._verbose:
                print("Saved to", fname)

            # Return the file name
            return fname
        else:
            return None

    @property
    def last_generation(self) -> Optional[int]:
        """
        Get the last generation for which a pickle file was created.
        If no pickle file is created yet, the result will be None.
        """
        return self._last_generation

    @property
    def last_file_name(self) -> Optional[str]:
        """
        Get the name of the last pickle file.
        If no pickle file is created yet, the result will be None.
        """
        return self._last_file_name

    def unpickle_last_file(self) -> dict:
        """
        Unpickle the most recently made pickle file.
        The file itself will not be modified.
        Its contents will be returned.
        """
        with open(self._last_file_name, "rb") as f:
            return pickle.load(f)

last_file_name: Optional[str] property readonly

Get the name of the last pickle file. If no pickle file is created yet, the result will be None.

last_generation: Optional[int] property readonly

Get the last generation for which a pickle file was created. If no pickle file is created yet, the result will be None.

__init__(self, searcher, *, interval, directory=None, prefix=None, zfill=6, items_to_save=('center', 'best', 'pop_best', 'median_eval', 'mean_eval', 'pop_best_eval'), make_policy_from=None, after_first_step=False, verbose=True) special

__init__(...): Initialize the PicklingLogger.

Parameters:

Name Type Description Default
searcher SearchAlgorithm

The evolutionary algorithm instance whose progress is to be pickled.

required
interval int

Expected as an integer n. Pickling is to be done at every n iterations.

required
directory Union[str, pathlib.Path]

The directory into which the pickle files will be placed. If given as None, the current directory will be used.

None
prefix Optional[str]

The prefix to be used when naming the pickle file. If left as None, then the prefix will contain the date, time, name of the class of the problem object, and the PID of the current process.

None
zfill int

When naming the pickle file, the generation number will also be used. This zfill argument is used to determine the length of the number of generations string. For example, if the current number of generations is 150, and zfill is 6 (which is the default), then the number of generations part of the file name will look like this: 'generation000150', the numeric part of the string taking 6 characters.

6
items_to_save Union[str, Iterable]

Items to save from the status dictionary. This can be a string, or a sequence of strings.

('center', 'best', 'pop_best', 'median_eval', 'mean_eval', 'pop_best_eval')
make_policy_from Optional[str]

If given as a string, then the solution represented by this key in the status dictionary will be taken, and converted a PyTorch module with the help of the problem's to_policy(...) method, and then will be added into the pickle file. For example, if this argument is given as "center", then the center solution will be converted to a policy and saved. If this argument is left as None, then, this logger will first try to obtain the center solution. If there is no center solution, then the logger will try to obtain the 'pop_best' solution. If that one does not exist either, then an error will be raised, and the user will be requested to explicitly state a value for the argument make_policy_from. If the problem object does not provide a to_policy(...), method, then this configuration will be ignored, and no policy will be saved.

None
after_first_step bool

Expected as a boolean. Meaningful only if interval is set as an integer greater than 1. Let us suppose that interval is set as 10. If after_first_step is False (which is the default), then the logging will be done at steps 10, 20, 30, and so on. On the other hand, if after_first_step is True, then the logging will be done at steps 1, 11, 21, 31, and so on.

False
verbose bool

If set as True, then a message will be printed out to the standard output every time the pickling happens.

True
Source code in evotorch/logging.py
def __init__(
    self,
    searcher: SearchAlgorithm,
    *,
    interval: int,
    directory: Optional[Union[str, pathlib.Path]] = None,
    prefix: Optional[str] = None,
    zfill: int = 6,
    items_to_save: Union[str, Iterable] = (
        "center",
        "best",
        "pop_best",
        "median_eval",
        "mean_eval",
        "pop_best_eval",
    ),
    make_policy_from: Optional[str] = None,
    after_first_step: bool = False,
    verbose: bool = True,
):
    """
    `__init__(...)`: Initialize the PicklingLogger.

    Args:
        searcher: The evolutionary algorithm instance whose progress
            is to be pickled.
        interval: Expected as an integer n.
            Pickling is to be done at every n iterations.
        directory: The directory into which the pickle files will be
            placed. If given as None, the current directory will be
            used.
        prefix: The prefix to be used when naming the pickle file.
            If left as None, then the prefix will contain the date,
            time, name of the class of the problem object, and the PID
            of the current process.
        zfill: When naming the pickle file, the generation number will
            also be used. This `zfill` argument is used to determine
            the length of the number of generations string.
            For example, if the current number of generations is
            150, and zfill is 6 (which is the default), then the
            number of generations part of the file name will look
            like this: 'generation000150', the numeric part of the
            string taking 6 characters.
        items_to_save: Items to save from the status dictionary.
            This can be a string, or a sequence of strings.
        make_policy_from: If given as a string, then the solution
            represented by this key in the status dictionary will be
            taken, and converted a PyTorch module with the help of the
            problem's `to_policy(...)` method, and then will be added
            into the pickle file. For example, if this argument is
            given as "center", then the center solution will be converted
            to a policy and saved.
            If this argument is left as None, then, this logger will
            first try to obtain the center solution. If there is no
            center solution, then the logger will try to obtain the
            'pop_best' solution. If that one does not exist either, then
            an error will be raised, and the user will be requested to
            explicitly state a value for the argument `make_policy_from`.
            If the problem object does not provide a `to_policy(...)`,
            method, then this configuration will be ignored, and no
            policy will be saved.
        after_first_step: Expected as a boolean.
            Meaningful only if interval is set as an integer greater
            than 1. Let us suppose that interval is set as 10.
            If after_first_step is False (which is the default),
            then the logging will be done at steps 10, 20, 30, and so on.
            On the other hand, if after_first_step is True,
            then the logging will be done at steps 1, 11, 21, 31, and so
            on.
        verbose: If set as True, then a message will be printed out to
            the standard output every time the pickling happens.
    """
    # Call the initializer of the superclass.
    super().__init__(searcher, interval=interval, after_first_step=after_first_step)

    # Each Logger register itself to the search algorithm's log hook. Additionally, in the case of PicklingLogger,
    # we add this object's final saving method to the search algorithm's end-of-run hook.
    # This is to make sure that the latest generation's result is saved, even when the last generation number
    # does not coincide with the saving period.
    searcher.end_of_run_hook.append(self._final_save)

    # Store a weak reference to the search algorithm.
    self._searcher_ref = weakref.ref(searcher)

    # Store the item keys as a tuple of strings.
    if isinstance(items_to_save, str):
        self._items_to_save = (items_to_save,)
    else:
        self._items_to_save = tuple(items_to_save)

    # Store the status key that will be used to get the current solution for making the policy.
    self._make_policy_from = None if make_policy_from is None else str(make_policy_from)

    if prefix is None:
        # If a file name prefix is not given by the user, then we prepare one using the current date and time,
        # name of the problem type, and the PID of the current process.
        strnow = datetime.now().strftime("%Y-%m-%d-%H.%M.%S")
        probname = type(searcher.problem).__name__
        strpid = str(os.getpid())
        self._prefix = f"{probname}_{strnow}_{strpid}"
    else:
        # If there is a file name prefix given by the user, then we use that name.
        self._prefix = str(prefix)

    if directory is None:
        # If a directory name is not given by the user, then we store the directory name as None.
        self._directory = None
    else:
        # If a directory name is given by the user, then we store it as a string, and make sure that it exists.
        self._directory = str(directory)
        os.makedirs(self._directory, exist_ok=True)

    self._verbose = bool(verbose)
    self._zfill = int(zfill)

    self._last_generation: Optional[int] = None
    self._last_file_name: Optional[str] = None

save(self, fname=None)

Pickle the current status of the evolutionary search.

If this PicklingLogger was initialized with a directory argument, then the pickle file will be put into that directory.

Parameters:

Name Type Description Default
fname Union[str, pathlib.Path]

The name of the pickle file to be created. This can be left as None if the user wishes this PicklingLogger to determine the name of the file. Alternatively, an explicit name for this file can be specified via this argument.

None

Returns:

Type Description
str

The name of the pickle file that was generated, as a string.

Source code in evotorch/logging.py
def save(self, fname: Optional[Union[str, pathlib.Path]] = None) -> str:
    """
    Pickle the current status of the evolutionary search.

    If this PicklingLogger was initialized with a `directory` argument,
    then the pickle file will be put into that directory.

    Args:
        fname: The name of the pickle file to be created.
            This can be left as None if the user wishes this PicklingLogger
            to determine the name of the file.
            Alternatively, an explicit name for this file can be specified
            via this argument.
    Returns:
        The name of the pickle file that was generated, as a string.
    """
    # Get the stored search algorithm
    searcher: Optional[SearchAlgorithm] = self._searcher_ref()

    if searcher is not None:
        # If the search algorithm object is still alive, then take the problem object from it.
        problem: Problem = searcher.problem

        # Initialize the data dictionary that will contain the objects to be put into the pickle file.
        data = {}

        for item_to_save in self._items_to_save:
            # For each item key, get the object with that key from the algorithm's status dictionary, and then put
            # that object into our data dictionary.
            if item_to_save in searcher.status:
                data[item_to_save] = self._as_cpu_tensor(searcher.status[item_to_save])

        if (
            hasattr(problem, "observation_normalization")
            and hasattr(problem, "get_observation_stats")
            and problem.observation_normalization
        ):
            # If the problem object has observation normalization, then put the normalizer object to the data
            # dictionary.
            data["obs_stats"] = problem.get_observation_stats().to("cpu")

        if hasattr(problem, "to_policy"):
            # If the problem object has the method `to_policy(...)`, then we will generate a policy from the
            # current solution, and put that policy into the data dictionary.

            if self._make_policy_from is None:
                # If the user did not specify the status key of the solution that will be converted to a policy,
                # then, we first check if the search algorithm has a "center" item, and if not, we check if it
                # has a "pop_best" item.

                if "center" in searcher.status:
                    # The status key of the search algorithm has "center". We declare "center" as the key of the
                    # current solution.
                    policy_key = "center"
                elif "pop_best" in searcher.status:
                    # The status key of the search algorithm has "pop_best". We declare "pop_best" as the key of
                    # the current solution.
                    policy_key = "pop_best"
                else:
                    # The status key of the search algorithm contains neither a "center" solution and nor a
                    # "pop_best" solution. So, we raise an error.
                    raise ValueError(
                        "PicklingLogger did not receive an explicit value for its `make_policy_from` argument."
                        " The status dictionary of the search algorithm has neither 'center' nor 'pop_best'."
                        " Therefore, it is not clear which status item is to be used for making a policy."
                        " Please try instantiating a PicklingLogger with an explicit `make_policy_from` value."
                    )
            else:
                # This is the case where the user explicitly specified (via a status key) which solution will be
                # used for making the current policy. We declare that key as the key to use.
                policy_key = self._make_policy_from

            # Get the solution.
            policy_solution = searcher.status[policy_key]

            # Make a policy from the solution
            policy = problem.to_policy(policy_solution)

            if isinstance(policy, nn.Module) and (device_of_module(policy) != torch.device("cpu")):
                # If the created policy is a PyTorch module, and this module is not on the cpu, then we make
                # a copy of this module on the cpu.
                policy = clone(policy).to("cpu")

            # We put this policy into our data dictionary.
            data["policy"] = policy

            # We put the datetime-related information
            first_step_datetime = searcher.first_step_datetime
            if first_step_datetime is not None:
                now = datetime.now()
                elapsed = now - first_step_datetime
                data["beginning_time"] = first_step_datetime
                data["now"] = now
                data["elapsed"] = elapsed

        if fname is None:
            # If a file name was not given, then we generate one.
            num_gens = str(searcher.step_count).zfill(self._zfill)
            fname = f"{self._prefix}_generation{num_gens}.pickle"

        # We prepend the specified directory name, if given.
        fname = self._within_dir(fname)

        # Here, the pickle file is created and the data is saved.
        with open(fname, "wb") as f:
            pickle.dump(data, f)

        # Store the most recently saved generation number
        self._last_generation = searcher.step_count

        # Store the name of the last pickle file
        self._last_file_name = fname

        # Report to the user that the save was successful
        if self._verbose:
            print("Saved to", fname)

        # Return the file name
        return fname
    else:
        return None

unpickle_last_file(self)

Unpickle the most recently made pickle file. The file itself will not be modified. Its contents will be returned.

Source code in evotorch/logging.py
def unpickle_last_file(self) -> dict:
    """
    Unpickle the most recently made pickle file.
    The file itself will not be modified.
    Its contents will be returned.
    """
    with open(self._last_file_name, "rb") as f:
        return pickle.load(f)

SacredLogger (ScalarLogger)

A logger which stores the status via the Run object of sacred.

Source code in evotorch/logging.py
class SacredLogger(ScalarLogger):
    """A logger which stores the status via the Run object of sacred."""

    def __init__(
        self,
        searcher: SearchAlgorithm,
        run: ExpOrRun,
        result: Optional[str] = None,
        *,
        interval: int = 1,
        after_first_step: bool = False,
    ):
        """`__init__(...)`: Initialize the SacredLogger.

        Args:
            searcher: The evolutionary algorithm instance whose progress
                is to be logged.
            run: An instance of `sacred.run.Run` or `sacred.Experiment`,
                using which the progress will be logged.
            result: The key in the status dictionary whose associated
                value will be registered as the current result
                of the experiment.
                If left as None, no result will be registered.
            interval: Expected as an integer n.
                Logging is to be done at every n iterations.
            after_first_step: Expected as a boolean.
                Meaningful only if interval is set as an integer greater
                than 1. Let us suppose that interval is set as 10.
                If after_first_step is False (which is the default),
                then the logging will be done at steps 10, 20, 30, and
                so on. On the other hand, if after_first_step is True,
                then the logging will be done at steps 1, 11, 21, 31,
                and so on.
        """
        super().__init__(searcher, interval=interval, after_first_step=after_first_step)
        self._result = result
        self._run = run

    def _log(self, status: dict):
        for k, v in status.items():
            self._run.log_scalar(k, v)
        if self._result is not None:
            self._run.result = status[self._result]

__init__(self, searcher, run, result=None, *, interval=1, after_first_step=False) special

__init__(...): Initialize the SacredLogger.

Parameters:

Name Type Description Default
searcher SearchAlgorithm

The evolutionary algorithm instance whose progress is to be logged.

required
run Union[sacred.experiment.Experiment, sacred.run.Run]

An instance of sacred.run.Run or sacred.Experiment, using which the progress will be logged.

required
result Optional[str]

The key in the status dictionary whose associated value will be registered as the current result of the experiment. If left as None, no result will be registered.

None
interval int

Expected as an integer n. Logging is to be done at every n iterations.

1
after_first_step bool

Expected as a boolean. Meaningful only if interval is set as an integer greater than 1. Let us suppose that interval is set as 10. If after_first_step is False (which is the default), then the logging will be done at steps 10, 20, 30, and so on. On the other hand, if after_first_step is True, then the logging will be done at steps 1, 11, 21, 31, and so on.

False
Source code in evotorch/logging.py
def __init__(
    self,
    searcher: SearchAlgorithm,
    run: ExpOrRun,
    result: Optional[str] = None,
    *,
    interval: int = 1,
    after_first_step: bool = False,
):
    """`__init__(...)`: Initialize the SacredLogger.

    Args:
        searcher: The evolutionary algorithm instance whose progress
            is to be logged.
        run: An instance of `sacred.run.Run` or `sacred.Experiment`,
            using which the progress will be logged.
        result: The key in the status dictionary whose associated
            value will be registered as the current result
            of the experiment.
            If left as None, no result will be registered.
        interval: Expected as an integer n.
            Logging is to be done at every n iterations.
        after_first_step: Expected as a boolean.
            Meaningful only if interval is set as an integer greater
            than 1. Let us suppose that interval is set as 10.
            If after_first_step is False (which is the default),
            then the logging will be done at steps 10, 20, 30, and
            so on. On the other hand, if after_first_step is True,
            then the logging will be done at steps 1, 11, 21, 31,
            and so on.
    """
    super().__init__(searcher, interval=interval, after_first_step=after_first_step)
    self._result = result
    self._run = run

StdOutLogger (ScalarLogger)

A logger which prints the status into the screen.

Source code in evotorch/logging.py
class StdOutLogger(ScalarLogger):
    """A logger which prints the status into the screen."""

    def __init__(
        self,
        searcher: SearchAlgorithm,
        *,
        interval: int = 1,
        after_first_step: bool = False,
        leading_keys: Iterable[str] = ("iter",),
    ):
        """`__init__(...)`: Initialize the StdOutLogger.

        Args:
            searcher: The evolutionary algorithm instance whose progress
                is to be logged.
            interval: Expected as an integer n.
                Logging is to be done at every n iterations.
            after_first_step: Expected as a boolean.
                Meaningful only if interval is set as an integer greater
                than 1. Let us suppose that interval is set as 10.
                If after_first_step is False (which is the default),
                then the logging will be done at steps 10, 20, 30, and so on.
                On the other hand, if after_first_step is True,
                then the logging will be done at steps 1, 11, 21, 31, and so
                on.
            leading_keys: A sequence of strings where each string is a status
                key. When printing the status, these keys will be shown first.
        """
        super().__init__(searcher, interval=interval, after_first_step=after_first_step)
        self._leading_keys = list(leading_keys)
        self._leading_keys_set = set(self._leading_keys)

    def _log(self, status: dict):
        max_key_length = max([len(str(k)) for k in status.keys()])

        def report(k, v):
            nonlocal max_key_length
            print(str(k).rjust(max_key_length), ":", v)

        for k in self._leading_keys:
            if k in status:
                v = status[k]
                report(k, v)
        for k, v in status.items():
            if k not in self._leading_keys_set:
                report(k, v)
        print()

__init__(self, searcher, *, interval=1, after_first_step=False, leading_keys=('iter',)) special

__init__(...): Initialize the StdOutLogger.

Parameters:

Name Type Description Default
searcher SearchAlgorithm

The evolutionary algorithm instance whose progress is to be logged.

required
interval int

Expected as an integer n. Logging is to be done at every n iterations.

1
after_first_step bool

Expected as a boolean. Meaningful only if interval is set as an integer greater than 1. Let us suppose that interval is set as 10. If after_first_step is False (which is the default), then the logging will be done at steps 10, 20, 30, and so on. On the other hand, if after_first_step is True, then the logging will be done at steps 1, 11, 21, 31, and so on.

False
leading_keys Iterable[str]

A sequence of strings where each string is a status key. When printing the status, these keys will be shown first.

('iter',)
Source code in evotorch/logging.py
def __init__(
    self,
    searcher: SearchAlgorithm,
    *,
    interval: int = 1,
    after_first_step: bool = False,
    leading_keys: Iterable[str] = ("iter",),
):
    """`__init__(...)`: Initialize the StdOutLogger.

    Args:
        searcher: The evolutionary algorithm instance whose progress
            is to be logged.
        interval: Expected as an integer n.
            Logging is to be done at every n iterations.
        after_first_step: Expected as a boolean.
            Meaningful only if interval is set as an integer greater
            than 1. Let us suppose that interval is set as 10.
            If after_first_step is False (which is the default),
            then the logging will be done at steps 10, 20, 30, and so on.
            On the other hand, if after_first_step is True,
            then the logging will be done at steps 1, 11, 21, 31, and so
            on.
        leading_keys: A sequence of strings where each string is a status
            key. When printing the status, these keys will be shown first.
    """
    super().__init__(searcher, interval=interval, after_first_step=after_first_step)
    self._leading_keys = list(leading_keys)
    self._leading_keys_set = set(self._leading_keys)

WandbLogger (ScalarLogger)

A logger which stores the status to Weights & Biases.

Source code in evotorch/logging.py
class WandbLogger(ScalarLogger):
    """A logger which stores the status to Weights & Biases."""

    def __init__(
        self,
        searcher: SearchAlgorithm,
        init: Optional[bool] = True,
        *,
        interval: int = 1,
        after_first_step: bool = False,
        group: Optional[str] = None,
        **wandb_kwargs,
    ):
        """`__init__(...)`: Initialize the WandbLogger.

        Args:
            searcher: The evolutionary algorithm instance whose progress
                is to be logged.
            init: Run `wandb.init()` in the logger initialization
            interval: Expected as an integer n.
                Logging is to be done at every n iterations.
            after_first_step: Expected as a boolean.
                Meaningful only if interval is set as an integer greater
                than 1. Let us suppose that interval is set as 10.
                If after_first_step is False (which is the default),
                then the logging will be done at steps 10, 20, 30, and so on.
                On the other hand, if after_first_step is True,
                then the logging will be done at steps 1, 11, 21, 31, and so
                on.
            group: Into which group will the metrics be stored.
                For example, if the status keys to be logged are "score" and
                "elapsed", and `group` is set as "training", then the metrics
                will be sent to W&B with the keys "training/score" and
                "training/elapsed". `group` can also be left as None,
                in which case the status will be sent to W&B with the
                key names unchanged.
            **wandb_kwargs: If `init` is `True` any additional keyword argument
                will be passed to `wandb.init()`.
                For example, WandbLogger(searcher, project=my-project, entity=my-organization)
                will result in calling `wandb.init(project=my-project, entity=my-organization)`
        """
        super().__init__(searcher, interval=interval, after_first_step=after_first_step)
        self._group = group
        if init:
            wandb.init(**wandb_kwargs)

    def _log(self, status: dict):
        log_status = dict()
        for k, v in status.items():
            target_key = k if self._group is None else self._group + "/" + k
            log_status[target_key] = v

        wandb.log(log_status)

__init__(self, searcher, init=True, *, interval=1, after_first_step=False, group=None, **wandb_kwargs) special

__init__(...): Initialize the WandbLogger.

Parameters:

Name Type Description Default
searcher SearchAlgorithm

The evolutionary algorithm instance whose progress is to be logged.

required
init Optional[bool]

Run wandb.init() in the logger initialization

True
interval int

Expected as an integer n. Logging is to be done at every n iterations.

1
after_first_step bool

Expected as a boolean. Meaningful only if interval is set as an integer greater than 1. Let us suppose that interval is set as 10. If after_first_step is False (which is the default), then the logging will be done at steps 10, 20, 30, and so on. On the other hand, if after_first_step is True, then the logging will be done at steps 1, 11, 21, 31, and so on.

False
group Optional[str]

Into which group will the metrics be stored. For example, if the status keys to be logged are "score" and "elapsed", and group is set as "training", then the metrics will be sent to W&B with the keys "training/score" and "training/elapsed". group can also be left as None, in which case the status will be sent to W&B with the key names unchanged.

None
**wandb_kwargs

If init is True any additional keyword argument will be passed to wandb.init(). For example, WandbLogger(searcher, project=my-project, entity=my-organization) will result in calling wandb.init(project=my-project, entity=my-organization)

{}
Source code in evotorch/logging.py
def __init__(
    self,
    searcher: SearchAlgorithm,
    init: Optional[bool] = True,
    *,
    interval: int = 1,
    after_first_step: bool = False,
    group: Optional[str] = None,
    **wandb_kwargs,
):
    """`__init__(...)`: Initialize the WandbLogger.

    Args:
        searcher: The evolutionary algorithm instance whose progress
            is to be logged.
        init: Run `wandb.init()` in the logger initialization
        interval: Expected as an integer n.
            Logging is to be done at every n iterations.
        after_first_step: Expected as a boolean.
            Meaningful only if interval is set as an integer greater
            than 1. Let us suppose that interval is set as 10.
            If after_first_step is False (which is the default),
            then the logging will be done at steps 10, 20, 30, and so on.
            On the other hand, if after_first_step is True,
            then the logging will be done at steps 1, 11, 21, 31, and so
            on.
        group: Into which group will the metrics be stored.
            For example, if the status keys to be logged are "score" and
            "elapsed", and `group` is set as "training", then the metrics
            will be sent to W&B with the keys "training/score" and
            "training/elapsed". `group` can also be left as None,
            in which case the status will be sent to W&B with the
            key names unchanged.
        **wandb_kwargs: If `init` is `True` any additional keyword argument
            will be passed to `wandb.init()`.
            For example, WandbLogger(searcher, project=my-project, entity=my-organization)
            will result in calling `wandb.init(project=my-project, entity=my-organization)`
    """
    super().__init__(searcher, interval=interval, after_first_step=after_first_step)
    self._group = group
    if init:
        wandb.init(**wandb_kwargs)

neuroevolution special

Problem types for neuroevolution

baseneproblem

BaseNEProblem (Problem)

This is the base class for all neuro-evolution problems.

Currently, this class does not offer any additional functionality. Its purpose is to collect all neuro-evolution problems under the same branch of inheritance.

Source code in evotorch/neuroevolution/baseneproblem.py
class BaseNEProblem(Problem):
    """
    This is the base class for all neuro-evolution problems.

    Currently, this class does not offer any additional functionality.
    Its purpose is to collect all neuro-evolution problems under the same
    branch of inheritance.
    """

    pass

gymne

This namespace contains the GymNE class.

GymNE (NEProblem)

Representation of a NEProblem where the goal is to maximize the total reward obtained in a gym environment.

Source code in evotorch/neuroevolution/gymne.py
class GymNE(NEProblem):
    """
    Representation of a NEProblem where the goal is to maximize
    the total reward obtained in a `gym` environment.
    """

    def __init__(
        self,
        env: Optional[Union[str, Callable]] = None,
        network: Optional[Union[str, nn.Module, Callable[[], nn.Module]]] = None,
        *,
        env_name: Optional[Union[str, Callable]] = None,
        network_args: Optional[dict] = None,
        env_config: Optional[Mapping] = None,
        observation_normalization: bool = False,
        num_episodes: int = 1,
        episode_length: Optional[int] = None,
        decrease_rewards_by: Optional[float] = None,
        alive_bonus_schedule: Optional[tuple] = None,
        action_noise_stdev: Optional[float] = None,
        num_actors: Optional[Union[int, str]] = None,
        actor_config: Optional[dict] = None,
        num_subbatches: Optional[int] = None,
        subbatch_size: Optional[int] = None,
        initial_bounds: Optional[BoundsPairLike] = (-0.00001, 0.00001),
    ):
        """
        `__init__(...)`: Initialize the GymNE.

        Args:
            env: The gym environment to solve. Expected as a Callable
                (maybe a function returning a gym.Env, or maybe a gym.Env
                subclass), or as a string referring to a gym environment
                ID (e.g. "Ant-v4", "Humanoid-v4", etc.).
            network: A network structure string, or a Callable (which can be
                a class inheriting from `torch.nn.Module`, or a function
                which returns a `torch.nn.Module` instance), or an instance
                of `torch.nn.Module`.
                The object provided here determines the structure of the
                neural network policy whose parameters will be evolved.
                A network structure string is a string which can be processed
                by `evotorch.neuroevolution.net.str_to_net(...)`.
                Please see the documentation of the function
                `evotorch.neuroevolution.net.str_to_net(...)` to see how such
                a neural network structure string looks like.
                Note that this network can be a recurrent network.
                When the network's `forward(...)` method can optionally accept
                an additional positional argument for the hidden state of the
                network and returns an additional value for its next state,
                then the policy is treated as a recurrent one.
                When the network is given as a callable object (e.g.
                a subclass of `nn.Module` or a function) and this callable
                object is decorated via `evotorch.decorators.pass_info`,
                the following keyword arguments will be passed:
                (i) `obs_length` (the length of the observation vector),
                (ii) `act_length` (the length of the action vector),
                (iii) `obs_shape` (the shape tuple of the observation space),
                (iv) `act_shape` (the shape tuple of the action space),
                (v) `obs_space` (the Box object specifying the observation
                space, and
                (vi) `act_space` (the Box object specifying the action
                space). Note that `act_space` will always be given as a
                `gym.spaces.Box` instance, even when the actual gym
                environment has a discrete action space. This because `GymNE`
                always expects the neural network to return a tensor of
                floating-point numbers.
            env_name: Deprecated alias for the keyword argument `env`.
                It is recommended to use the argument `env` instead.
            network_args: Optionally a dict-like object, storing keyword
                arguments to be passed to the network while instantiating it.
            env_config: Keyword arguments to pass to `gym.make(...)` while
                creating the `gym` environment.
            observation_normalization: Whether or not to do online observation
                normalization.
            num_episodes: Number of episodes over which a single solution will
                be evaluated.
            episode_length: Maximum amount of simulator interactions allowed
                in a single episode. If left as None, whether or not an episode
                is terminated is determined only by the `gym` environment
                itself.
            decrease_rewards_by: Some gym env.s are defined in such a way that
                the agent gets a constant reward for each timestep
                it survives. This constant reward can also be called
                "survival bonus". Such a rewarding scheme can lead the
                evolution to local optima where the agent does nothing
                but does not die either, just to collect the survival
                bonuses. To prevent this, it can be desired to
                remove the survival bonuses from each reward obtained.
                If this is the case with the problem at hand,
                the user can set the argument `decrease_rewards_by`
                to a positive float number, and that number will
                be subtracted from each reward.
            alive_bonus_schedule: Use this to add a customized amount of
                alive bonus.
                If left as None (which is the default), additional alive
                bonus will not be added.
                If given as a tuple `(t, b)`, an alive bonus `b` will be
                added onto all the rewards beyond the timestep `t`.
                If given as a tuple `(t0, t1, b)`, a partial (linearly
                increasing towards `b`) alive bonus will be added onto
                all the rewards between the timesteps `t0` and `t1`,
                and a full alive bonus (which equals to `b`) will be added
                onto all the rewards beyond the timestep `t1`.
            action_noise_stdev: If given as a real number `s`, then, for
                each generated action, Gaussian noise with standard
                deviation `s` will be sampled, and then this sampled noise
                will be added onto the action.
                If action noise is not desired, then this argument can be
                left as None.
            num_actors: Number of actors to create for parallelized
                evaluation of the solutions.
                One can also set this as "max", which means that
                an actor will be created on each available CPU.
                When the parallelization is enabled each actor will have its
                own instance of the `gym` environment.
            actor_config: A dictionary, representing the keyword arguments
                to be passed to the options(...) used when creating the
                ray actor objects. To be used for explicitly allocating
                resources per each actor.
                For example, for declaring that each actor is to use a GPU,
                one can pass `actor_config=dict(num_gpus=1)`.
                Can also be given as None (which is the default),
                if no such options are to be passed.
            num_subbatches: If `num_subbatches` is None (assuming that
                `subbatch_size` is also None), then, when evaluating a
                population, the population will be split into n pieces, `n`
                being the number of actors, and each actor will evaluate
                its assigned piece. If `num_subbatches` is an integer `m`,
                then the population will be split into `m` pieces,
                and actors will continually accept the next unevaluated
                piece as they finish their current tasks.
                The arguments `num_subbatches` and `subbatch_size` cannot
                be given values other than None at the same time.
            subbatch_size: If `subbatch_size` is None (assuming that
                `num_subbatches` is also None), then, when evaluating a
                population, the population will be split into `n` pieces, `n`
                being the number of actors, and each actor will evaluate its
                assigned piece. If `subbatch_size` is an integer `m`,
                then the population will be split into pieces of size `m`,
                and actors will continually accept the next unevaluated
                piece as they finish their current tasks.
                When there can be significant difference across the solutions
                in terms of computational requirements, specifying a
                `subbatch_size` can be beneficial, because, while one
                actor is busy with a subbatch containing computationally
                challenging solutions, other actors can accept more
                tasks and save time.
                The arguments `num_subbatches` and `subbatch_size` cannot
                be given values other than None at the same time.
            initial_bounds: Specifies an interval from which the values of the
                initial policy parameters will be drawn.
        """
        # Store various environment information
        if (env is not None) and (env_name is None):
            self._env_maker = env
        elif (env is None) and (env_name is not None):
            self._env_maker = env_name
        elif (env is not None) and (env_name is not None):
            raise ValueError(
                f"Received values for both `env` ({repr(env)}) and `env_name` ({repr(env_name)})."
                f" Please specify the environment to solve via only one of these arguments, not both."
            )
        else:
            raise ValueError("Environment name is missing. Please specify it via the argument `env`.")

        # Make sure that the network argument is not missing.
        if network is None:
            raise ValueError(
                "Received None via the argument `network`."
                "Please provide the network as a string, or as a `Callable`, or as a `torch.nn.Module` instance."
            )

        # Store various environment information
        self._env_config = {} if env_config is None else deepcopy(dict(env_config))
        self._decrease_rewards_by = 0.0 if decrease_rewards_by is None else float(decrease_rewards_by)
        self._alive_bonus_schedule = alive_bonus_schedule
        self._action_noise_stdev = None if action_noise_stdev is None else float(action_noise_stdev)
        self._observation_normalization = bool(observation_normalization)
        self._num_episodes = int(num_episodes)
        self._episode_length = None if episode_length is None else int(episode_length)

        self._info_keys = dict(cumulative_reward="avg", interaction_count="sum")

        self._env: Optional[gym.Env] = None

        self._obs_stats: Optional[RunningStat] = None
        self._collected_stats: Optional[RunningStat] = None

        # Create a temporary environment to read its dimensions
        tmp_env = _make_env(self._env_maker, **(self._env_config))

        # Store the temporary environment's dimensions
        self._obs_length = len(tmp_env.observation_space.low)

        if isinstance(tmp_env.action_space, gym.spaces.Discrete):
            self._act_length = tmp_env.action_space.n
            self._box_act_space = gym.spaces.Box(low=float("-inf"), high=float("inf"), shape=(self._act_length,))
        else:
            self._act_length = len(tmp_env.action_space.low)
            self._box_act_space = tmp_env.action_space

        self._act_space = tmp_env.action_space
        self._obs_space = tmp_env.observation_space
        self._obs_shape = tmp_env.observation_space.low.shape

        # Validate the space types of the environment
        ensure_space_types(tmp_env)

        if self._observation_normalization:
            self._obs_stats = RunningStat()
            self._collected_stats = RunningStat()
        else:
            self._obs_stats = None
            self._collected_stats = None
        self._interaction_count: int = 0
        self._episode_count: int = 0

        super().__init__(
            objective_sense="max",  # RL is maximization
            network=network,  # Using the policy as the network
            network_args=network_args,
            initial_bounds=initial_bounds,
            num_actors=num_actors,
            actor_config=actor_config,
            subbatch_size=subbatch_size,
            device="cpu",
        )

        self.after_eval_hook.append(self._extra_status)

    @property
    def _network_constants(self) -> dict:
        return {
            "obs_length": self._obs_length,
            "act_length": self._act_length,
            "obs_space": self._obs_space,
            "act_space": self._box_act_space,
            "obs_shape": self._obs_space.shape,
            "act_shape": self._box_act_space.shape,
        }

    @property
    def _str_network_constants(self) -> dict:
        return {
            "obs_space": self._obs_space.shape,
            "act_space": self._box_act_space.shape,
        }

    def _instantiate_new_env(self, **kwargs) -> gym.Env:
        env_config = {**kwargs, **(self._env_config)}
        env = _make_env(self._env_maker, **env_config)
        if self._alive_bonus_schedule is not None:
            env = AliveBonusScheduleWrapper(env, self._alive_bonus_schedule)
        return env

    def _get_env(self) -> gym.Env:
        if self._env is None:
            self._env = self._instantiate_new_env()
        return self._env

    def _normalize_observation(self, observation: Iterable, *, update_stats: bool = True) -> Iterable:
        observation = np.asarray(observation, dtype="float32")
        if self.observation_normalization:
            if update_stats:
                self._obs_stats.update(observation)
                self._collected_stats.update(observation)
            return self._obs_stats.normalize(observation)
        else:
            return observation

    def _use_policy(self, observation: Iterable, policy: nn.Module) -> Iterable:
        with torch.no_grad():
            result = policy(torch.as_tensor(observation, dtype=torch.float32, device="cpu")).numpy()
        if self._action_noise_stdev is not None:
            result = (
                result
                + self.make_gaussian(len(result), center=0.0, stdev=self._action_noise_stdev, device="cpu").numpy()
            )
        env = self._get_env()
        if isinstance(env.action_space, gym.spaces.Discrete):
            result = np.argmax(result)
        elif isinstance(env.action_space, gym.spaces.Box):
            result = np.clip(result, env.action_space.low, env.action_space.high)
        return result

    def _prepare(self) -> None:
        super()._prepare()
        self._get_env()

    @property
    def network_device(self) -> Device:
        """The device on which the problem should place data e.g. the network
        In the case of GymNE, supported Gym environments return numpy arrays on CPU which are converted to Tensors
        Therefore, it is almost always optimal to place the network on CPU
        """
        return torch.device("cpu")

    def _rollout(
        self,
        *,
        policy: nn.Module,
        update_stats: bool = True,
        visualize: bool = False,
        decrease_rewards_by: Optional[float] = None,
    ) -> dict:
        """Peform a rollout of a network"""
        if decrease_rewards_by is None:
            decrease_rewards_by = self._decrease_rewards_by
        else:
            decrease_rewards_by = float(decrease_rewards_by)

        policy = ensure_stateful(policy)
        policy.reset()

        if visualize:
            env = self._instantiate_new_env(render_mode="human")
        else:
            env = self._get_env()

        observation = self._normalize_observation(reset_env(env), update_stats=update_stats)
        if visualize:
            env.render()
        t = 0

        cumulative_reward = 0.0

        while True:
            observation, raw_reward, done, info = take_step_in_env(env, self._use_policy(observation, policy))
            reward = raw_reward - decrease_rewards_by
            t += 1
            if update_stats:
                self._interaction_count += 1

            if visualize:
                env.render()

            observation = self._normalize_observation(observation, update_stats=update_stats)

            cumulative_reward += reward

            if done or ((self._episode_length is not None) and (t >= self._episode_length)):
                if update_stats:
                    self._episode_count += 1

                final_info = dict(cumulative_reward=cumulative_reward, interaction_count=t)

                for k in self._info_keys:
                    if k not in final_info:
                        final_info[k] = info[k]

                return final_info

    @property
    def _nonserialized_attribs(self) -> List[str]:
        return super()._nonserialized_attribs + ["_env"]

    def run(
        self,
        policy: Union[nn.Module, Iterable],
        *,
        update_stats: bool = False,
        visualize: bool = False,
        num_episodes: Optional[int] = None,
        decrease_rewards_by: Optional[float] = None,
    ) -> dict:
        """
        Evaluate the policy on the gym environment.

        Args:
            policy: The policy to be evaluated. This can be a torch module
                or a sequence of real numbers representing the parameters
                of a policy network.
            update_stats: Whether or not to update the observation
                normalization data while running the policy. If observation
                normalization is not enabled, then this argument will be
                ignored.
            visualize: Whether or not to render the environment while running
                the policy.
            num_episodes: Over how many episodes will the policy be evaluated.
                Expected as None (which is the default), or as an integer.
                If given as None, then the `num_episodes` value that was given
                while initializing this GymNE will be used.
            decrease_rewards_by: How much each reward value should be
                decreased. If left as None, the `decrease_rewards_by` value
                value that was given while initializing this GymNE will be
                used.
        Returns:
            A dictionary containing the score and the timestep count.
        """
        if not isinstance(policy, nn.Module):
            policy = self.make_net(policy)

        if num_episodes is None:
            num_episodes = self._num_episodes

        try:
            policy.eval()

            episode_results = [
                self._rollout(
                    policy=policy,
                    update_stats=update_stats,
                    visualize=visualize,
                    decrease_rewards_by=decrease_rewards_by,
                )
                for _ in range(num_episodes)
            ]

            results = _accumulate_all_across_dicts(episode_results, self._info_keys)
            return results
        finally:
            policy.train()

    def visualize(
        self,
        policy: Union[nn.Module, Iterable],
        *,
        update_stats: bool = False,
        num_episodes: Optional[int] = 1,
        decrease_rewards_by: Optional[float] = None,
    ) -> dict:
        """
        Evaluate the policy and render its actions in the environment.

        Args:
            policy: The policy to be evaluated. This can be a torch module
                or a sequence of real numbers representing the parameters
                of a policy network.
            update_stats: Whether or not to update the observation
                normalization data while running the policy. If observation
                normalization is not enabled, then this argument will be
                ignored.
            num_episodes: Over how many episodes will the policy be evaluated.
                Expected as None (which is the default), or as an integer.
                If given as None, then the `num_episodes` value that was given
                while initializing this GymNE will be used.
            decrease_rewards_by: How much each reward value should be
                decreased. If left as None, the `decrease_rewards_by` value
                value that was given while initializing this GymNE will be
                used.
        Returns:
            A dictionary containing the score and the timestep count.
        """
        return self.run(
            policy=policy,
            update_stats=update_stats,
            visualize=True,
            num_episodes=num_episodes,
            decrease_rewards_by=decrease_rewards_by,
        )

    def _ensure_obsnorm(self):
        if not self.observation_normalization:
            raise ValueError("This feature can only be used when observation_normalization=True.")

    def get_observation_stats(self) -> RunningStat:
        """Get the observation stats"""
        self._ensure_obsnorm()
        return self._obs_stats

    def _make_sync_data_for_actors(self) -> Any:
        if self.observation_normalization:
            return dict(obs_stats=self.get_observation_stats())
        else:
            return None

    def set_observation_stats(self, rs: RunningStat):
        """Set the observation stats"""
        self._ensure_obsnorm()
        self._obs_stats.reset()
        self._obs_stats.update(rs)

    def _use_sync_data_from_main(self, received: dict):
        for k, v in received.items():
            if k == "obs_stats":
                self.set_observation_stats(v)

    def pop_observation_stats(self) -> RunningStat:
        """Get and clear the collected observation stats"""
        self._ensure_obsnorm()
        result = self._collected_stats
        self._collected_stats = RunningStat()
        return result

    def _make_sync_data_for_main(self) -> Any:
        result = dict(episode_count=self.episode_count, interaction_count=self.interaction_count)

        if self.observation_normalization:
            result["obs_stats_delta"] = self.pop_observation_stats()

        return result

    def update_observation_stats(self, rs: RunningStat):
        """Update the observation stats via another RunningStat instance"""
        self._ensure_obsnorm()
        self._obs_stats.update(rs)

    def _use_sync_data_from_actors(self, received: list):
        total_episode_count = 0
        total_interaction_count = 0

        for data in received:
            data: dict
            total_episode_count += data["episode_count"]
            total_interaction_count += data["interaction_count"]
            if self.observation_normalization:
                self.update_observation_stats(data["obs_stats_delta"])

        self.set_episode_count(total_episode_count)
        self.set_interaction_count(total_interaction_count)

    def _make_pickle_data_for_main(self) -> dict:
        # For when the main Problem object (the non-remote one) gets pickled,
        # this function returns the counters of this remote Problem instance,
        # to be sent to the main one.
        return dict(interaction_count=self.interaction_count, episode_count=self.episode_count)

    def _use_pickle_data_from_main(self, state: dict):
        # For when a newly unpickled Problem object gets (re)parallelized,
        # this function restores the inner states specific to this remote
        # worker. In the case of GymNE, those inner states are episode
        # and interaction counters.
        for k, v in state.items():
            if k == "episode_count":
                self.set_episode_count(v)
            elif k == "interaction_count":
                self.set_interaction_count(v)
            else:
                raise ValueError(f"When restoring the inner state of a remote worker, unrecognized state key: {k}")

    def _extra_status(self, batch: SolutionBatch):
        return dict(total_interaction_count=self.interaction_count, total_episode_count=self.episode_count)

    @property
    def observation_normalization(self) -> bool:
        """
        Get whether or not observation normalization is enabled.
        """
        return self._observation_normalization

    def set_episode_count(self, n: int):
        """
        Set the episode count manually.
        """
        self._episode_count = int(n)

    def set_interaction_count(self, n: int):
        """
        Set the interaction count manually.
        """
        self._interaction_count = int(n)

    @property
    def interaction_count(self) -> int:
        """
        Get the total number of simulator interactions made.
        """
        return self._interaction_count

    @property
    def episode_count(self) -> int:
        """
        Get the total number of episodes completed.
        """
        return self._episode_count

    def _get_local_episode_count(self) -> int:
        return self.episode_count

    def _get_local_interaction_count(self) -> int:
        return self.interaction_count

    def _evaluate_network(self, policy: nn.Module) -> Union[float, torch.Tensor]:
        result = self.run(
            policy,
            update_stats=True,
            visualize=False,
            num_episodes=self._num_episodes,
            decrease_rewards_by=self._decrease_rewards_by,
        )
        return result["cumulative_reward"]

    def to_policy(self, x: Iterable, *, clip_actions: bool = True) -> nn.Module:
        """
        Convert the given parameter vector to a policy as a PyTorch module.

        If the problem is configured to have observation normalization,
        the PyTorch module also contains an additional normalization layer.

        Args:
            x: An sequence of real numbers, containing the parameters
                of a policy. Can be a PyTorch tensor, a numpy array,
                or a Solution.
            clip_actions: Whether or not to add an action clipping layer so
                that the generated actions will always be within an
                acceptable range for the environment.
        Returns:
            The policy expressed by the parameters.
        """

        policy = self.make_net(x)

        if self.observation_normalization and (self._obs_stats.count > 0):
            policy = ObsNormWrapperModule(policy, self._obs_stats)

        if clip_actions and isinstance(self._get_env().action_space, gym.spaces.Box):
            policy = ActClipWrapperModule(policy, self._get_env().action_space)

        return policy

    def save_solution(self, solution: Iterable, fname: Union[str, Path]):
        """
        Save the solution into a pickle file.
        Among the saved data within the pickle file are the solution
        (as a PyTorch tensor), the policy (as a `torch.nn.Module` instance),
        and observation stats (if any).

        Args:
            solution: The solution to be saved. This can be a PyTorch tensor,
                a `Solution` instance, or any `Iterable`.
            fname: The file name of the pickle file to be created.
        """

        # Convert the solution to a PyTorch tensor on the cpu.
        if isinstance(solution, torch.Tensor):
            solution = solution.to("cpu")
        elif isinstance(solution, Solution):
            solution = solution.values.clone().to("cpu")
        else:
            solution = torch.as_tensor(solution, dtype=torch.float32, device="cpu")

        if isinstance(solution, ReadOnlyTensor):
            solution = solution.as_subclass(torch.Tensor)

        policy = self.to_policy(solution).to("cpu")

        # Store the solution and the policy.
        result = {
            "solution": solution,
            "policy": policy,
        }

        # If available, store the observation stats.
        if self.observation_normalization and (self._obs_stats is not None):
            result["obs_mean"] = torch.as_tensor(self._obs_stats.mean)
            result["obs_stdev"] = torch.as_tensor(self._obs_stats.stdev)
            result["obs_sum"] = torch.as_tensor(self._obs_stats.sum)
            result["obs_sum_of_squares"] = torch.as_tensor(self._obs_stats.sum_of_squares)

        # Some additional data.
        result["interaction_count"] = self.interaction_count
        result["episode_count"] = self.episode_count
        result["time"] = datetime.now()

        # If the environment is specified via a string ID, then store that ID.
        if isinstance(self._env_maker, str):
            result["env"] = self._env_maker

        # Save the dictionary which stores the data.
        with open(fname, "wb") as f:
            pickle.dump(result, f)

    def get_env(self) -> gym.Env:
        """
        Get the gym environment stored by this GymNE instance
        """
        return self._get_env()
episode_count: int property readonly

Get the total number of episodes completed.

interaction_count: int property readonly

Get the total number of simulator interactions made.

network_device: Union[str, torch.device] property readonly

The device on which the problem should place data e.g. the network In the case of GymNE, supported Gym environments return numpy arrays on CPU which are converted to Tensors Therefore, it is almost always optimal to place the network on CPU

observation_normalization: bool property readonly

Get whether or not observation normalization is enabled.

__init__(self, env=None, network=None, *, env_name=None, network_args=None, env_config=None, observation_normalization=False, num_episodes=1, episode_length=None, decrease_rewards_by=None, alive_bonus_schedule=None, action_noise_stdev=None, num_actors=None, actor_config=None, num_subbatches=None, subbatch_size=None, initial_bounds=(-1e-05, 1e-05)) special

__init__(...): Initialize the GymNE.

Parameters:

Name Type Description Default
env Union[str, Callable]

The gym environment to solve. Expected as a Callable (maybe a function returning a gym.Env, or maybe a gym.Env subclass), or as a string referring to a gym environment ID (e.g. "Ant-v4", "Humanoid-v4", etc.).

None
network Union[str, torch.nn.modules.module.Module, Callable[[], torch.nn.modules.module.Module]]

A network structure string, or a Callable (which can be a class inheriting from torch.nn.Module, or a function which returns a torch.nn.Module instance), or an instance of torch.nn.Module. The object provided here determines the structure of the neural network policy whose parameters will be evolved. A network structure string is a string which can be processed by evotorch.neuroevolution.net.str_to_net(...). Please see the documentation of the function evotorch.neuroevolution.net.str_to_net(...) to see how such a neural network structure string looks like. Note that this network can be a recurrent network. When the network's forward(...) method can optionally accept an additional positional argument for the hidden state of the network and returns an additional value for its next state, then the policy is treated as a recurrent one. When the network is given as a callable object (e.g. a subclass of nn.Module or a function) and this callable object is decorated via evotorch.decorators.pass_info, the following keyword arguments will be passed: (i) obs_length (the length of the observation vector), (ii) act_length (the length of the action vector), (iii) obs_shape (the shape tuple of the observation space), (iv) act_shape (the shape tuple of the action space), (v) obs_space (the Box object specifying the observation space, and (vi) act_space (the Box object specifying the action space). Note that act_space will always be given as a gym.spaces.Box instance, even when the actual gym environment has a discrete action space. This because GymNE always expects the neural network to return a tensor of floating-point numbers.

None
env_name Union[str, Callable]

Deprecated alias for the keyword argument env. It is recommended to use the argument env instead.

None
network_args Optional[dict]

Optionally a dict-like object, storing keyword arguments to be passed to the network while instantiating it.

None
env_config Optional[collections.abc.Mapping]

Keyword arguments to pass to gym.make(...) while creating the gym environment.

None
observation_normalization bool

Whether or not to do online observation normalization.

False
num_episodes int

Number of episodes over which a single solution will be evaluated.

1
episode_length Optional[int]

Maximum amount of simulator interactions allowed in a single episode. If left as None, whether or not an episode is terminated is determined only by the gym environment itself.

None
decrease_rewards_by Optional[float]

Some gym env.s are defined in such a way that the agent gets a constant reward for each timestep it survives. This constant reward can also be called "survival bonus". Such a rewarding scheme can lead the evolution to local optima where the agent does nothing but does not die either, just to collect the survival bonuses. To prevent this, it can be desired to remove the survival bonuses from each reward obtained. If this is the case with the problem at hand, the user can set the argument decrease_rewards_by to a positive float number, and that number will be subtracted from each reward.

None
alive_bonus_schedule Optional[tuple]

Use this to add a customized amount of alive bonus. If left as None (which is the default), additional alive bonus will not be added. If given as a tuple (t, b), an alive bonus b will be added onto all the rewards beyond the timestep t. If given as a tuple (t0, t1, b), a partial (linearly increasing towards b) alive bonus will be added onto all the rewards between the timesteps t0 and t1, and a full alive bonus (which equals to b) will be added onto all the rewards beyond the timestep t1.

None
action_noise_stdev Optional[float]

If given as a real number s, then, for each generated action, Gaussian noise with standard deviation s will be sampled, and then this sampled noise will be added onto the action. If action noise is not desired, then this argument can be left as None.

None
num_actors Union[int, str]

Number of actors to create for parallelized evaluation of the solutions. One can also set this as "max", which means that an actor will be created on each available CPU. When the parallelization is enabled each actor will have its own instance of the gym environment.

None
actor_config Optional[dict]

A dictionary, representing the keyword arguments to be passed to the options(...) used when creating the ray actor objects. To be used for explicitly allocating resources per each actor. For example, for declaring that each actor is to use a GPU, one can pass actor_config=dict(num_gpus=1). Can also be given as None (which is the default), if no such options are to be passed.

None
num_subbatches Optional[int]

If num_subbatches is None (assuming that subbatch_size is also None), then, when evaluating a population, the population will be split into n pieces, n being the number of actors, and each actor will evaluate its assigned piece. If num_subbatches is an integer m, then the population will be split into m pieces, and actors will continually accept the next unevaluated piece as they finish their current tasks. The arguments num_subbatches and subbatch_size cannot be given values other than None at the same time.

None
subbatch_size Optional[int]

If subbatch_size is None (assuming that num_subbatches is also None), then, when evaluating a population, the population will be split into n pieces, n being the number of actors, and each actor will evaluate its assigned piece. If subbatch_size is an integer m, then the population will be split into pieces of size m, and actors will continually accept the next unevaluated piece as they finish their current tasks. When there can be significant difference across the solutions in terms of computational requirements, specifying a subbatch_size can be beneficial, because, while one actor is busy with a subbatch containing computationally challenging solutions, other actors can accept more tasks and save time. The arguments num_subbatches and subbatch_size cannot be given values other than None at the same time.

None
initial_bounds Union[Iterable[Union[float, Iterable[float], torch.Tensor]], evotorch.core.BoundsPair]

Specifies an interval from which the values of the initial policy parameters will be drawn.

(-1e-05, 1e-05)
Source code in evotorch/neuroevolution/gymne.py
def __init__(
    self,
    env: Optional[Union[str, Callable]] = None,
    network: Optional[Union[str, nn.Module, Callable[[], nn.Module]]] = None,
    *,
    env_name: Optional[Union[str, Callable]] = None,
    network_args: Optional[dict] = None,
    env_config: Optional[Mapping] = None,
    observation_normalization: bool = False,
    num_episodes: int = 1,
    episode_length: Optional[int] = None,
    decrease_rewards_by: Optional[float] = None,
    alive_bonus_schedule: Optional[tuple] = None,
    action_noise_stdev: Optional[float] = None,
    num_actors: Optional[Union[int, str]] = None,
    actor_config: Optional[dict] = None,
    num_subbatches: Optional[int] = None,
    subbatch_size: Optional[int] = None,
    initial_bounds: Optional[BoundsPairLike] = (-0.00001, 0.00001),
):
    """
    `__init__(...)`: Initialize the GymNE.

    Args:
        env: The gym environment to solve. Expected as a Callable
            (maybe a function returning a gym.Env, or maybe a gym.Env
            subclass), or as a string referring to a gym environment
            ID (e.g. "Ant-v4", "Humanoid-v4", etc.).
        network: A network structure string, or a Callable (which can be
            a class inheriting from `torch.nn.Module`, or a function
            which returns a `torch.nn.Module` instance), or an instance
            of `torch.nn.Module`.
            The object provided here determines the structure of the
            neural network policy whose parameters will be evolved.
            A network structure string is a string which can be processed
            by `evotorch.neuroevolution.net.str_to_net(...)`.
            Please see the documentation of the function
            `evotorch.neuroevolution.net.str_to_net(...)` to see how such
            a neural network structure string looks like.
            Note that this network can be a recurrent network.
            When the network's `forward(...)` method can optionally accept
            an additional positional argument for the hidden state of the
            network and returns an additional value for its next state,
            then the policy is treated as a recurrent one.
            When the network is given as a callable object (e.g.
            a subclass of `nn.Module` or a function) and this callable
            object is decorated via `evotorch.decorators.pass_info`,
            the following keyword arguments will be passed:
            (i) `obs_length` (the length of the observation vector),
            (ii) `act_length` (the length of the action vector),
            (iii) `obs_shape` (the shape tuple of the observation space),
            (iv) `act_shape` (the shape tuple of the action space),
            (v) `obs_space` (the Box object specifying the observation
            space, and
            (vi) `act_space` (the Box object specifying the action
            space). Note that `act_space` will always be given as a
            `gym.spaces.Box` instance, even when the actual gym
            environment has a discrete action space. This because `GymNE`
            always expects the neural network to return a tensor of
            floating-point numbers.
        env_name: Deprecated alias for the keyword argument `env`.
            It is recommended to use the argument `env` instead.
        network_args: Optionally a dict-like object, storing keyword
            arguments to be passed to the network while instantiating it.
        env_config: Keyword arguments to pass to `gym.make(...)` while
            creating the `gym` environment.
        observation_normalization: Whether or not to do online observation
            normalization.
        num_episodes: Number of episodes over which a single solution will
            be evaluated.
        episode_length: Maximum amount of simulator interactions allowed
            in a single episode. If left as None, whether or not an episode
            is terminated is determined only by the `gym` environment
            itself.
        decrease_rewards_by: Some gym env.s are defined in such a way that
            the agent gets a constant reward for each timestep
            it survives. This constant reward can also be called
            "survival bonus". Such a rewarding scheme can lead the
            evolution to local optima where the agent does nothing
            but does not die either, just to collect the survival
            bonuses. To prevent this, it can be desired to
            remove the survival bonuses from each reward obtained.
            If this is the case with the problem at hand,
            the user can set the argument `decrease_rewards_by`
            to a positive float number, and that number will
            be subtracted from each reward.
        alive_bonus_schedule: Use this to add a customized amount of
            alive bonus.
            If left as None (which is the default), additional alive
            bonus will not be added.
            If given as a tuple `(t, b)`, an alive bonus `b` will be
            added onto all the rewards beyond the timestep `t`.
            If given as a tuple `(t0, t1, b)`, a partial (linearly
            increasing towards `b`) alive bonus will be added onto
            all the rewards between the timesteps `t0` and `t1`,
            and a full alive bonus (which equals to `b`) will be added
            onto all the rewards beyond the timestep `t1`.
        action_noise_stdev: If given as a real number `s`, then, for
            each generated action, Gaussian noise with standard
            deviation `s` will be sampled, and then this sampled noise
            will be added onto the action.
            If action noise is not desired, then this argument can be
            left as None.
        num_actors: Number of actors to create for parallelized
            evaluation of the solutions.
            One can also set this as "max", which means that
            an actor will be created on each available CPU.
            When the parallelization is enabled each actor will have its
            own instance of the `gym` environment.
        actor_config: A dictionary, representing the keyword arguments
            to be passed to the options(...) used when creating the
            ray actor objects. To be used for explicitly allocating
            resources per each actor.
            For example, for declaring that each actor is to use a GPU,
            one can pass `actor_config=dict(num_gpus=1)`.
            Can also be given as None (which is the default),
            if no such options are to be passed.
        num_subbatches: If `num_subbatches` is None (assuming that
            `subbatch_size` is also None), then, when evaluating a
            population, the population will be split into n pieces, `n`
            being the number of actors, and each actor will evaluate
            its assigned piece. If `num_subbatches` is an integer `m`,
            then the population will be split into `m` pieces,
            and actors will continually accept the next unevaluated
            piece as they finish their current tasks.
            The arguments `num_subbatches` and `subbatch_size` cannot
            be given values other than None at the same time.
        subbatch_size: If `subbatch_size` is None (assuming that
            `num_subbatches` is also None), then, when evaluating a
            population, the population will be split into `n` pieces, `n`
            being the number of actors, and each actor will evaluate its
            assigned piece. If `subbatch_size` is an integer `m`,
            then the population will be split into pieces of size `m`,
            and actors will continually accept the next unevaluated
            piece as they finish their current tasks.
            When there can be significant difference across the solutions
            in terms of computational requirements, specifying a
            `subbatch_size` can be beneficial, because, while one
            actor is busy with a subbatch containing computationally
            challenging solutions, other actors can accept more
            tasks and save time.
            The arguments `num_subbatches` and `subbatch_size` cannot
            be given values other than None at the same time.
        initial_bounds: Specifies an interval from which the values of the
            initial policy parameters will be drawn.
    """
    # Store various environment information
    if (env is not None) and (env_name is None):
        self._env_maker = env
    elif (env is None) and (env_name is not None):
        self._env_maker = env_name
    elif (env is not None) and (env_name is not None):
        raise ValueError(
            f"Received values for both `env` ({repr(env)}) and `env_name` ({repr(env_name)})."
            f" Please specify the environment to solve via only one of these arguments, not both."
        )
    else:
        raise ValueError("Environment name is missing. Please specify it via the argument `env`.")

    # Make sure that the network argument is not missing.
    if network is None:
        raise ValueError(
            "Received None via the argument `network`."
            "Please provide the network as a string, or as a `Callable`, or as a `torch.nn.Module` instance."
        )

    # Store various environment information
    self._env_config = {} if env_config is None else deepcopy(dict(env_config))
    self._decrease_rewards_by = 0.0 if decrease_rewards_by is None else float(decrease_rewards_by)
    self._alive_bonus_schedule = alive_bonus_schedule
    self._action_noise_stdev = None if action_noise_stdev is None else float(action_noise_stdev)
    self._observation_normalization = bool(observation_normalization)
    self._num_episodes = int(num_episodes)
    self._episode_length = None if episode_length is None else int(episode_length)

    self._info_keys = dict(cumulative_reward="avg", interaction_count="sum")

    self._env: Optional[gym.Env] = None

    self._obs_stats: Optional[RunningStat] = None
    self._collected_stats: Optional[RunningStat] = None

    # Create a temporary environment to read its dimensions
    tmp_env = _make_env(self._env_maker, **(self._env_config))

    # Store the temporary environment's dimensions
    self._obs_length = len(tmp_env.observation_space.low)

    if isinstance(tmp_env.action_space, gym.spaces.Discrete):
        self._act_length = tmp_env.action_space.n
        self._box_act_space = gym.spaces.Box(low=float("-inf"), high=float("inf"), shape=(self._act_length,))
    else:
        self._act_length = len(tmp_env.action_space.low)
        self._box_act_space = tmp_env.action_space

    self._act_space = tmp_env.action_space
    self._obs_space = tmp_env.observation_space
    self._obs_shape = tmp_env.observation_space.low.shape

    # Validate the space types of the environment
    ensure_space_types(tmp_env)

    if self._observation_normalization:
        self._obs_stats = RunningStat()
        self._collected_stats = RunningStat()
    else:
        self._obs_stats = None
        self._collected_stats = None
    self._interaction_count: int = 0
    self._episode_count: int = 0

    super().__init__(
        objective_sense="max",  # RL is maximization
        network=network,  # Using the policy as the network
        network_args=network_args,
        initial_bounds=initial_bounds,
        num_actors=num_actors,
        actor_config=actor_config,
        subbatch_size=subbatch_size,
        device="cpu",
    )

    self.after_eval_hook.append(self._extra_status)
get_env(self)

Get the gym environment stored by this GymNE instance

Source code in evotorch/neuroevolution/gymne.py
def get_env(self) -> gym.Env:
    """
    Get the gym environment stored by this GymNE instance
    """
    return self._get_env()
get_observation_stats(self)

Get the observation stats

Source code in evotorch/neuroevolution/gymne.py
def get_observation_stats(self) -> RunningStat:
    """Get the observation stats"""
    self._ensure_obsnorm()
    return self._obs_stats
pop_observation_stats(self)

Get and clear the collected observation stats

Source code in evotorch/neuroevolution/gymne.py
def pop_observation_stats(self) -> RunningStat:
    """Get and clear the collected observation stats"""
    self._ensure_obsnorm()
    result = self._collected_stats
    self._collected_stats = RunningStat()
    return result
run(self, policy, *, update_stats=False, visualize=False, num_episodes=None, decrease_rewards_by=None)

Evaluate the policy on the gym environment.

Parameters:

Name Type Description Default
policy Union[torch.nn.modules.module.Module, Iterable]

The policy to be evaluated. This can be a torch module or a sequence of real numbers representing the parameters of a policy network.

required
update_stats bool

Whether or not to update the observation normalization data while running the policy. If observation normalization is not enabled, then this argument will be ignored.

False
visualize bool

Whether or not to render the environment while running the policy.

False
num_episodes Optional[int]

Over how many episodes will the policy be evaluated. Expected as None (which is the default), or as an integer. If given as None, then the num_episodes value that was given while initializing this GymNE will be used.

None
decrease_rewards_by Optional[float]

How much each reward value should be decreased. If left as None, the decrease_rewards_by value value that was given while initializing this GymNE will be used.

None

Returns:

Type Description
dict

A dictionary containing the score and the timestep count.

Source code in evotorch/neuroevolution/gymne.py
def run(
    self,
    policy: Union[nn.Module, Iterable],
    *,
    update_stats: bool = False,
    visualize: bool = False,
    num_episodes: Optional[int] = None,
    decrease_rewards_by: Optional[float] = None,
) -> dict:
    """
    Evaluate the policy on the gym environment.

    Args:
        policy: The policy to be evaluated. This can be a torch module
            or a sequence of real numbers representing the parameters
            of a policy network.
        update_stats: Whether or not to update the observation
            normalization data while running the policy. If observation
            normalization is not enabled, then this argument will be
            ignored.
        visualize: Whether or not to render the environment while running
            the policy.
        num_episodes: Over how many episodes will the policy be evaluated.
            Expected as None (which is the default), or as an integer.
            If given as None, then the `num_episodes` value that was given
            while initializing this GymNE will be used.
        decrease_rewards_by: How much each reward value should be
            decreased. If left as None, the `decrease_rewards_by` value
            value that was given while initializing this GymNE will be
            used.
    Returns:
        A dictionary containing the score and the timestep count.
    """
    if not isinstance(policy, nn.Module):
        policy = self.make_net(policy)

    if num_episodes is None:
        num_episodes = self._num_episodes

    try:
        policy.eval()

        episode_results = [
            self._rollout(
                policy=policy,
                update_stats=update_stats,
                visualize=visualize,
                decrease_rewards_by=decrease_rewards_by,
            )
            for _ in range(num_episodes)
        ]

        results = _accumulate_all_across_dicts(episode_results, self._info_keys)
        return results
    finally:
        policy.train()
save_solution(self, solution, fname)

Save the solution into a pickle file. Among the saved data within the pickle file are the solution (as a PyTorch tensor), the policy (as a torch.nn.Module instance), and observation stats (if any).

Parameters:

Name Type Description Default
solution Iterable

The solution to be saved. This can be a PyTorch tensor, a Solution instance, or any Iterable.

required
fname Union[str, pathlib.Path]

The file name of the pickle file to be created.

required
Source code in evotorch/neuroevolution/gymne.py
def save_solution(self, solution: Iterable, fname: Union[str, Path]):
    """
    Save the solution into a pickle file.
    Among the saved data within the pickle file are the solution
    (as a PyTorch tensor), the policy (as a `torch.nn.Module` instance),
    and observation stats (if any).

    Args:
        solution: The solution to be saved. This can be a PyTorch tensor,
            a `Solution` instance, or any `Iterable`.
        fname: The file name of the pickle file to be created.
    """

    # Convert the solution to a PyTorch tensor on the cpu.
    if isinstance(solution, torch.Tensor):
        solution = solution.to("cpu")
    elif isinstance(solution, Solution):
        solution = solution.values.clone().to("cpu")
    else:
        solution = torch.as_tensor(solution, dtype=torch.float32, device="cpu")

    if isinstance(solution, ReadOnlyTensor):
        solution = solution.as_subclass(torch.Tensor)

    policy = self.to_policy(solution).to("cpu")

    # Store the solution and the policy.
    result = {
        "solution": solution,
        "policy": policy,
    }

    # If available, store the observation stats.
    if self.observation_normalization and (self._obs_stats is not None):
        result["obs_mean"] = torch.as_tensor(self._obs_stats.mean)
        result["obs_stdev"] = torch.as_tensor(self._obs_stats.stdev)
        result["obs_sum"] = torch.as_tensor(self._obs_stats.sum)
        result["obs_sum_of_squares"] = torch.as_tensor(self._obs_stats.sum_of_squares)

    # Some additional data.
    result["interaction_count"] = self.interaction_count
    result["episode_count"] = self.episode_count
    result["time"] = datetime.now()

    # If the environment is specified via a string ID, then store that ID.
    if isinstance(self._env_maker, str):
        result["env"] = self._env_maker

    # Save the dictionary which stores the data.
    with open(fname, "wb") as f:
        pickle.dump(result, f)
set_episode_count(self, n)

Set the episode count manually.

Source code in evotorch/neuroevolution/gymne.py
def set_episode_count(self, n: int):
    """
    Set the episode count manually.
    """
    self._episode_count = int(n)
set_interaction_count(self, n)

Set the interaction count manually.

Source code in evotorch/neuroevolution/gymne.py
def set_interaction_count(self, n: int):
    """
    Set the interaction count manually.
    """
    self._interaction_count = int(n)
set_observation_stats(self, rs)

Set the observation stats

Source code in evotorch/neuroevolution/gymne.py
def set_observation_stats(self, rs: RunningStat):
    """Set the observation stats"""
    self._ensure_obsnorm()
    self._obs_stats.reset()
    self._obs_stats.update(rs)
to_policy(self, x, *, clip_actions=True)

Convert the given parameter vector to a policy as a PyTorch module.

If the problem is configured to have observation normalization, the PyTorch module also contains an additional normalization layer.

Parameters:

Name Type Description Default
x Iterable

An sequence of real numbers, containing the parameters of a policy. Can be a PyTorch tensor, a numpy array, or a Solution.

required
clip_actions bool

Whether or not to add an action clipping layer so that the generated actions will always be within an acceptable range for the environment.

True

Returns:

Type Description
Module

The policy expressed by the parameters.

Source code in evotorch/neuroevolution/gymne.py
def to_policy(self, x: Iterable, *, clip_actions: bool = True) -> nn.Module:
    """
    Convert the given parameter vector to a policy as a PyTorch module.

    If the problem is configured to have observation normalization,
    the PyTorch module also contains an additional normalization layer.

    Args:
        x: An sequence of real numbers, containing the parameters
            of a policy. Can be a PyTorch tensor, a numpy array,
            or a Solution.
        clip_actions: Whether or not to add an action clipping layer so
            that the generated actions will always be within an
            acceptable range for the environment.
    Returns:
        The policy expressed by the parameters.
    """

    policy = self.make_net(x)

    if self.observation_normalization and (self._obs_stats.count > 0):
        policy = ObsNormWrapperModule(policy, self._obs_stats)

    if clip_actions and isinstance(self._get_env().action_space, gym.spaces.Box):
        policy = ActClipWrapperModule(policy, self._get_env().action_space)

    return policy
update_observation_stats(self, rs)

Update the observation stats via another RunningStat instance

Source code in evotorch/neuroevolution/gymne.py
def update_observation_stats(self, rs: RunningStat):
    """Update the observation stats via another RunningStat instance"""
    self._ensure_obsnorm()
    self._obs_stats.update(rs)
visualize(self, policy, *, update_stats=False, num_episodes=1, decrease_rewards_by=None)

Evaluate the policy and render its actions in the environment.

Parameters:

Name Type Description Default
policy Union[torch.nn.modules.module.Module, Iterable]

The policy to be evaluated. This can be a torch module or a sequence of real numbers representing the parameters of a policy network.

required
update_stats bool

Whether or not to update the observation normalization data while running the policy. If observation normalization is not enabled, then this argument will be ignored.

False
num_episodes Optional[int]

Over how many episodes will the policy be evaluated. Expected as None (which is the default), or as an integer. If given as None, then the num_episodes value that was given while initializing this GymNE will be used.

1
decrease_rewards_by Optional[float]

How much each reward value should be decreased. If left as None, the decrease_rewards_by value value that was given while initializing this GymNE will be used.

None

Returns:

Type Description
dict

A dictionary containing the score and the timestep count.

Source code in evotorch/neuroevolution/gymne.py
def visualize(
    self,
    policy: Union[nn.Module, Iterable],
    *,
    update_stats: bool = False,
    num_episodes: Optional[int] = 1,
    decrease_rewards_by: Optional[float] = None,
) -> dict:
    """
    Evaluate the policy and render its actions in the environment.

    Args:
        policy: The policy to be evaluated. This can be a torch module
            or a sequence of real numbers representing the parameters
            of a policy network.
        update_stats: Whether or not to update the observation
            normalization data while running the policy. If observation
            normalization is not enabled, then this argument will be
            ignored.
        num_episodes: Over how many episodes will the policy be evaluated.
            Expected as None (which is the default), or as an integer.
            If given as None, then the `num_episodes` value that was given
            while initializing this GymNE will be used.
        decrease_rewards_by: How much each reward value should be
            decreased. If left as None, the `decrease_rewards_by` value
            value that was given while initializing this GymNE will be
            used.
    Returns:
        A dictionary containing the score and the timestep count.
    """
    return self.run(
        policy=policy,
        update_stats=update_stats,
        visualize=True,
        num_episodes=num_episodes,
        decrease_rewards_by=decrease_rewards_by,
    )

neproblem

This namespace contains the NeuroevolutionProblem class.

NEProblem (BaseNEProblem)

Base class for neuro-evolution problems where the goal is to optimize the parameters of a neural network represented as a PyTorch module.

Any problem inheriting from this class is expected to override the method _evaluate_network(self, net: torch.nn.Module) -> Union[torch.Tensor, float] where net is the neural network to be evaluated, and the return value is a scalar or a vector (for multi-objective cases) expressing the fitness value(s).

Alternatively, this class can be directly instantiated in the following way:

def f(module: MyTorchModuleClass) -> Union[float, torch.Tensor, tuple]:
    # Evaluate the given PyTorch module here
    fitness = ...
    return fitness


problem = NEProblem("min", MyTorchModuleClass, f, ...)

which specifies that the problem's goal is to minimize the return of the function f. For multi-objective cases, the fitness returned by f is expected as a 1-dimensional tensor. For when the problem has additional evaluation data, a two-element tuple can be returned by f instead, where the first element is the fitness value(s) and the second element is a 1-dimensional tensor storing the additional data.

Source code in evotorch/neuroevolution/neproblem.py
class NEProblem(BaseNEProblem):
    """
    Base class for neuro-evolution problems where the goal is to optimize the
    parameters of a neural network represented as a PyTorch module.

    Any problem inheriting from this class is expected to override the method
    `_evaluate_network(self, net: torch.nn.Module) -> Union[torch.Tensor, float]`
    where `net` is the neural network to be evaluated, and the return value
    is a scalar or a vector (for multi-objective cases) expressing the
    fitness value(s).

    Alternatively, this class can be directly instantiated in the following
    way:

    ```python
    def f(module: MyTorchModuleClass) -> Union[float, torch.Tensor, tuple]:
        # Evaluate the given PyTorch module here
        fitness = ...
        return fitness


    problem = NEProblem("min", MyTorchModuleClass, f, ...)
    ```

    which specifies that the problem's goal is to minimize the return of the
    function `f`.
    For multi-objective cases, the fitness returned by `f` is expected as a
    1-dimensional tensor. For when the problem has additional evaluation data,
    a two-element tuple can be returned by `f` instead, where the first
    element is the fitness value(s) and the second element is a 1-dimensional
    tensor storing the additional data.
    """

    def __init__(
        self,
        objective_sense: ObjectiveSense,
        network: Union[str, nn.Module, Callable[[], nn.Module]],
        network_eval_func: Optional[Callable] = None,
        *,
        network_args: Optional[dict] = None,
        initial_bounds: Optional[BoundsPairLike] = (-0.00001, 0.00001),
        eval_dtype: Optional[DType] = None,
        eval_data_length: int = 0,
        seed: Optional[int] = None,
        num_actors: Optional[Union[int, str]] = None,
        actor_config: Optional[dict] = None,
        num_gpus_per_actor: Optional[Union[int, float, str]] = None,
        num_subbatches: Optional[int] = None,
        subbatch_size: Optional[int] = None,
        device: Optional[Device] = None,
    ):
        """
        `__init__(...)`: Initialize the NEProblem.

        Args:
            objective_sense: The objective sense, expected as "min" or "max"
                for single-objective cases, or as a sequence of strings
                (each string being "min" or "max") for multi-objective cases.
            network: A network structure string, or a Callable (which can be
                a class inheriting from `torch.nn.Module`, or a function
                which returns a `torch.nn.Module` instance), or an instance
                of `torch.nn.Module`.
                The object provided here determines the structure of the
                neural network whose parameters will be evolved.
                A network structure string is a string which can be processed
                by `evotorch.neuroevolution.net.str_to_net(...)`.
                Please see the documentation of the function
                `evotorch.neuroevolution.net.str_to_net(...)` to see how such
                a neural network structure string looks like.
            network_eval_func: Optionally a function (or any Callable object)
                which receives a PyTorch module as its argument, and returns
                either a fitness, or a two-element tuple containing the fitness
                and the additional evaluation data. The fitness can be a scalar
                (for single-objective cases) or a 1-dimensional tensor (for
                multi-objective cases). The additional evaluation data is
                expected as a 1-dimensional tensor.
                If this argument is left as None, it will be expected that
                the method `_evaluate_network(...)` is overriden by the
                inheriting class.
            network_args: Optionally a dict-like object, storing keyword
                arguments to be passed to the network while instantiating it.
            initial_bounds: Specifies an interval from which the values of the
                initial neural network parameters will be drawn.
            eval_dtype: dtype to be used for fitnesses. If not specified, then
                `eval_dtype` will be inferred from the dtype of the parameters
                of the neural network.
                In more details, if the neural network's parameters have a
                float dtype, `eval_dtype` will be a compatible float.
                Otherwise, it will be "float32".
            eval_data_length: Length of the extra evaluation data.
            seed: Random number seed. If left as None, this NEProblem instance
                will not have its own random generator, and the global random
                generator of PyTorch will be used instead.
            num_actors: Number of actors to create for parallelized
                evaluation of the solutions.
                Certain string values are also accepted.
                When given as "max" or as "num_cpus", the number of actors
                will be equal to the number of all available CPUs in the ray
                cluster.
                When given as "num_gpus", the number of actors will be
                equal to the number of all available GPUs in the ray
                cluster, and each actor will be assigned a GPU.
                When given as "num_devices", the number of actors will be
                equal to the minimum among the number of CPUs and the number
                of GPUs available in the cluster (or will be equal to the
                number of CPUs if there is no GPU), and each actor will be
                assigned a GPU (if available).
                If `num_actors` is given as "num_gpus" or "num_devices",
                the argument `num_gpus_per_actor` must not be used,
                and the `actor_config` dictionary must not contain the
                key "num_gpus".
                If `num_actors` is given as something other than "num_gpus"
                or "num_devices", and if you wish to assign GPUs to each
                actor, then please see the argument `num_gpus_per_actor`.
            actor_config: A dictionary, representing the keyword arguments
                to be passed to the options(...) used when creating the
                ray actor objects. To be used for explicitly allocating
                resources per each actor.
                For example, for declaring that each actor is to use a GPU,
                one can pass `actor_config=dict(num_gpus=1)`.
                Can also be given as None (which is the default),
                if no such options are to be passed.
            num_gpus_per_actor: Number of GPUs to be allocated by each
                remote actor.
                The default behavior is to NOT allocate any GPU at all
                (which is the default behavior of the ray library as well).
                When given as a number `n`, each actor will be given
                `n` GPUs (where `n` can be an integer, or can be a `float`
                for fractional allocation).
                When given as a string "max", then the available GPUs
                across the entire ray cluster (or within the local computer
                in the simplest cases) will be equally distributed among
                the actors.
                When given as a string "all", then each actor will have
                access to all the GPUs (this will be achieved by suppressing
                the environment variable `CUDA_VISIBLE_DEVICES` for each
                actor).
                When the problem is not distributed (i.e. when there are
                no actors), this argument is expected to be left as None.
            num_subbatches: If `num_subbatches` is None (assuming that
                `subbatch_size` is also None), then, when evaluating a
                population, the population will be split into n pieces, `n`
                being the number of actors, and each actor will evaluate
                its assigned piece. If `num_subbatches` is an integer `m`,
                then the population will be split into `m` pieces,
                and actors will continually accept the next unevaluated
                piece as they finish their current tasks.
                The arguments `num_subbatches` and `subbatch_size` cannot
                be given values other than None at the same time.
                While using a distributed algorithm, this argument determines
                how many sub-batches will be generated, and therefore,
                how many gradients will be computed by the remote actors.
            subbatch_size: If `subbatch_size` is None (assuming that
                `num_subbatches` is also None), then, when evaluating a
                population, the population will be split into `n` pieces, `n`
                being the number of actors, and each actor will evaluate its
                assigned piece. If `subbatch_size` is an integer `m`,
                then the population will be split into pieces of size `m`,
                and actors will continually accept the next unevaluated
                piece as they finish their current tasks.
                When there can be significant difference across the solutions
                in terms of computational requirements, specifying a
                `subbatch_size` can be beneficial, because, while one
                actor is busy with a subbatch containing computationally
                challenging solutions, other actors can accept more
                tasks and save time.
                The arguments `num_subbatches` and `subbatch_size` cannot
                be given values other than None at the same time.
                While using a distributed algorithm, this argument determines
                the size of a sub-batch (or sub-population) sampled by a
                remote actor for computing a gradient.
                In distributed mode, it is expected that the population size
                is divisible by `subbatch_size`.
            device: Default device in which a new population will be generated
                and the neural networks will operate.
                If not specified, "cpu" will be used.
        """
        # Set the main device of the problem
        # Although the operation of setting the main device is done by the main Problem class,
        # here we need this at an earlier stage.
        if device is None:
            device = "cpu"
        self._device = torch.device(device)

        # Set the network
        self._original_network = network
        self._network_args = {} if network_args is None else deepcopy(network_args)
        if isinstance(self._original_network, nn.Module):
            self._original_network = self._original_network.cpu()

        # Store the function that will evaluate the network, if available
        self._network_eval_func: Optional[Callable] = network_eval_func

        self.instantiated_network: nn.Module = None

        # Create temporary network
        temp_network = self._instantiate_net(self._original_network, device="cpu")

        super().__init__(
            objective_sense=objective_sense,
            initial_bounds=initial_bounds,
            bounds=None,  # Neuroevolution is an unbounded problem
            solution_length=count_parameters(temp_network),  # The solution length is inherited from the network passed
            dtype=next(temp_network.parameters()).dtype,  # The datatype is inherited from the network passed
            eval_dtype=eval_dtype,
            device=device,
            eval_data_length=eval_data_length,
            seed=seed,
            num_actors=num_actors,
            num_gpus_per_actor=num_gpus_per_actor,
            actor_config=actor_config,
            num_subbatches=num_subbatches,
            subbatch_size=subbatch_size,
            store_solution_stats=None,
        )

    @property
    def network_device(self) -> Device:
        """The device on which the problem should place data e.g. the network"""
        cpu_device = torch.device("cpu")
        if self.is_main:
            # This is the case where this is the main process (not a remote actor)
            if self.device == cpu_device:
                # If the main device of the problem is "cpu", then we assume that the network is going to be on the cpu as well
                return cpu_device
            else:
                # If the main device of the problem is some other device, then it is that device into which the network will be put
                return self.device
        else:
            # If this is a remote actor, then the network will be put into the auxiliary device allocated for that actor
            return self.aux_device

    @property
    def _str_network_constants(self) -> dict:
        """
        Named constants which will be passed to `str_to_net`.
        To be overridden by the user for custom fixed constants for a problem.
        """
        return {}

    @property
    def _network_constants(self) -> dict:
        """
        Named constants which will be passed to the network instantiation.
        To be overridden by the user for custom fixed constants for a problem.
        """
        return {}

    def network_constants(self) -> dict:
        """Named constants which can be passed to the network instantiation"""
        constants = {}
        constants.update(self._network_constants)
        constants.update(self._network_args)
        return constants

    @property
    def _nonserialized_attribs(self) -> List[str]:
        return ["instantiated_network"]

    def _instantiate_net(self, network: Union[str, nn.Module, dict], device: Optional[Device] = None) -> nn.Module:
        """Instantiate the network on the target device, to be overridden by the user for custom behaviour
        Returns:
            instantiated_network (nn.Module): The network instantiated on the target device
        """
        # Branching point determines instantiation of network
        if isinstance(network, str):
            # Passed argument was a string representation of a torch module
            net_consts = {}
            net_consts.update(self.network_constants())
            net_consts.update(self._str_network_constants)
            instantiated_network = str_to_net(network, **net_consts)
        elif isinstance(network, nn.Module):
            # Passed argument was directly a torch module
            instantiated_network = network
        else:
            # Passed argument was callable yielding network
            instantiated_network = pass_info_if_needed(network, self._network_constants)(**self._network_args)

        # Map to device
        device = self.network_device if device is None else device
        instantiated_network = instantiated_network.to(device)

        return instantiated_network

    def _prepare(self) -> None:
        """Instantiate the network on the target device, if not already done"""
        self.instantiated_network = self._instantiate_net(self._original_network)
        # Clear reference to original network
        self._original_network = None

    def make_net(self, parameters: Iterable) -> nn.Module:
        """
        Make a new network filled with the provided parameters.

        Args:
            parameters: Parameters to be used as weights within the network.
                Can be a Solution, or any 1-dimensional Iterable that can be
                converted to a PyTorch tensor.
        Returns:
            A new network, as a `torch.Module` instance.
        """
        if isinstance(parameters, Solution):
            parameters = parameters.access_values(keep_evals=True)
        else:
            parameters = self.as_tensor(parameters)
        with torch.no_grad():
            net = deepcopy(self.parameterize_net(parameters))
        return net

    def parameterize_net(self, parameters: torch.Tensor) -> nn.Module:
        """Parameterize the network with a given set of parameters.
        Args:
            parameters (torch.Tensor): The parameters with which to instantiate the network
        Returns:
            instantiated_network (nn.Module): The network instantiated with the parameters
        """
        # Check if network exists
        if self.instantiated_network is None:
            self.instantiated_network = self._instantiate_net(self._original_network)

        network = self.instantiated_network

        # Move the parameters if needed
        if parameters.device != self.network_device:
            parameters = parameters.to(self.network_device)

        # Fill the network with the parameters
        fill_parameters(network, parameters)

        # Return the network
        return network

    @property
    def _grad_device(self) -> Device:
        """
        Get the device in which new solutions will be made in distributed mode.

        In more details, in distributed mode, each actor creates its own
        sub-populations, evaluates them, and computes its own gradient
        (all such actor gradients eventually being collected by the
        distribution-based search algorithm in the main process).
        For some problem types, it can make sense for the remote actors to
        create their temporary sub-populations on another device
        (e.g. on the GPU that is allocated specifically for them).
        For such situations, one is encouraged to override this property
        and make it return whatever device is to be used.

        In the case of NEProblem, this property returns whatever device
        is specified by the property `network_device`.
        """
        return self.network_device

    def _evaluate_network(self, network: nn.Module) -> Union[float, torch.Tensor, tuple]:
        """
        Evaluate a network and return the evaluation result(s).

        In the case where the `__init__` of `NEProblem` was not given
        a network evaluator function (via the argument `network_eval_func`),
        it will be expected that the inheriting class overrides this
        method and defines how a network should be evaluated.

        Args:
            network (nn.Module): The network to evaluate
        Returns:
            fitness: The networks' fitness value(s), as a scalar for
                single-objective cases, or as a 1-dimensional tensor
                for multi-objective cases. The returned value can also
                be a two-element tuple where the first element is the
                fitness (as a scalar or as a vector) and the second
                element is a 1-dimensional vector storing the extra
                evaluation data.
        """
        raise NotImplementedError

    def _evaluate(self, solution: Solution):
        """
        Evaluate a single solution.
        This is achieved by parameterising the problem's attribute
        named `instantiated_network`, and then evaluating the network
        with the method `_evaluate_network(...)`.

        Args:
            solution (Solution): The solution to evaluate.
        """
        parameters = solution.values

        if self._network_eval_func is None:
            evaluator = self._evaluate_network
        else:
            evaluator = self._network_eval_func

        fitnesses = evaluator(self.parameterize_net(parameters))

        if isinstance(fitnesses, tuple):
            solution.set_evals(*fitnesses)
        else:
            solution.set_evals(fitnesses)
network_device: Union[str, torch.device] property readonly

The device on which the problem should place data e.g. the network

__init__(self, objective_sense, network, network_eval_func=None, *, network_args=None, initial_bounds=(-1e-05, 1e-05), eval_dtype=None, eval_data_length=0, seed=None, num_actors=None, actor_config=None, num_gpus_per_actor=None, num_subbatches=None, subbatch_size=None, device=None) special

__init__(...): Initialize the NEProblem.

Parameters:

Name Type Description Default
objective_sense Union[str, Iterable[str]]

The objective sense, expected as "min" or "max" for single-objective cases, or as a sequence of strings (each string being "min" or "max") for multi-objective cases.

required
network Union[str, torch.nn.modules.module.Module, Callable[[], torch.nn.modules.module.Module]]

A network structure string, or a Callable (which can be a class inheriting from torch.nn.Module, or a function which returns a torch.nn.Module instance), or an instance of torch.nn.Module. The object provided here determines the structure of the neural network whose parameters will be evolved. A network structure string is a string which can be processed by evotorch.neuroevolution.net.str_to_net(...). Please see the documentation of the function evotorch.neuroevolution.net.str_to_net(...) to see how such a neural network structure string looks like.

required
network_eval_func Optional[Callable]

Optionally a function (or any Callable object) which receives a PyTorch module as its argument, and returns either a fitness, or a two-element tuple containing the fitness and the additional evaluation data. The fitness can be a scalar (for single-objective cases) or a 1-dimensional tensor (for multi-objective cases). The additional evaluation data is expected as a 1-dimensional tensor. If this argument is left as None, it will be expected that the method _evaluate_network(...) is overriden by the inheriting class.

None
network_args Optional[dict]

Optionally a dict-like object, storing keyword arguments to be passed to the network while instantiating it.

None
initial_bounds Union[Iterable[Union[float, Iterable[float], torch.Tensor]], evotorch.core.BoundsPair]

Specifies an interval from which the values of the initial neural network parameters will be drawn.

(-1e-05, 1e-05)
eval_dtype Union[str, torch.dtype, numpy.dtype, Type]

dtype to be used for fitnesses. If not specified, then eval_dtype will be inferred from the dtype of the parameters of the neural network. In more details, if the neural network's parameters have a float dtype, eval_dtype will be a compatible float. Otherwise, it will be "float32".

None
eval_data_length int

Length of the extra evaluation data.

0
seed Optional[int]

Random number seed. If left as None, this NEProblem instance will not have its own random generator, and the global random generator of PyTorch will be used instead.

None
num_actors Union[int, str]

Number of actors to create for parallelized evaluation of the solutions. Certain string values are also accepted. When given as "max" or as "num_cpus", the number of actors will be equal to the number of all available CPUs in the ray cluster. When given as "num_gpus", the number of actors will be equal to the number of all available GPUs in the ray cluster, and each actor will be assigned a GPU. When given as "num_devices", the number of actors will be equal to the minimum among the number of CPUs and the number of GPUs available in the cluster (or will be equal to the number of CPUs if there is no GPU), and each actor will be assigned a GPU (if available). If num_actors is given as "num_gpus" or "num_devices", the argument num_gpus_per_actor must not be used, and the actor_config dictionary must not contain the key "num_gpus". If num_actors is given as something other than "num_gpus" or "num_devices", and if you wish to assign GPUs to each actor, then please see the argument num_gpus_per_actor.

None
actor_config Optional[dict]

A dictionary, representing the keyword arguments to be passed to the options(...) used when creating the ray actor objects. To be used for explicitly allocating resources per each actor. For example, for declaring that each actor is to use a GPU, one can pass actor_config=dict(num_gpus=1). Can also be given as None (which is the default), if no such options are to be passed.

None
num_gpus_per_actor Union[int, float, str]

Number of GPUs to be allocated by each remote actor. The default behavior is to NOT allocate any GPU at all (which is the default behavior of the ray library as well). When given as a number n, each actor will be given n GPUs (where n can be an integer, or can be a float for fractional allocation). When given as a string "max", then the available GPUs across the entire ray cluster (or within the local computer in the simplest cases) will be equally distributed among the actors. When given as a string "all", then each actor will have access to all the GPUs (this will be achieved by suppressing the environment variable CUDA_VISIBLE_DEVICES for each actor). When the problem is not distributed (i.e. when there are no actors), this argument is expected to be left as None.

None
num_subbatches Optional[int]

If num_subbatches is None (assuming that subbatch_size is also None), then, when evaluating a population, the population will be split into n pieces, n being the number of actors, and each actor will evaluate its assigned piece. If num_subbatches is an integer m, then the population will be split into m pieces, and actors will continually accept the next unevaluated piece as they finish their current tasks. The arguments num_subbatches and subbatch_size cannot be given values other than None at the same time. While using a distributed algorithm, this argument determines how many sub-batches will be generated, and therefore, how many gradients will be computed by the remote actors.

None
subbatch_size Optional[int]

If subbatch_size is None (assuming that num_subbatches is also None), then, when evaluating a population, the population will be split into n pieces, n being the number of actors, and each actor will evaluate its assigned piece. If subbatch_size is an integer m, then the population will be split into pieces of size m, and actors will continually accept the next unevaluated piece as they finish their current tasks. When there can be significant difference across the solutions in terms of computational requirements, specifying a subbatch_size can be beneficial, because, while one actor is busy with a subbatch containing computationally challenging solutions, other actors can accept more tasks and save time. The arguments num_subbatches and subbatch_size cannot be given values other than None at the same time. While using a distributed algorithm, this argument determines the size of a sub-batch (or sub-population) sampled by a remote actor for computing a gradient. In distributed mode, it is expected that the population size is divisible by subbatch_size.

None
device Union[str, torch.device]

Default device in which a new population will be generated and the neural networks will operate. If not specified, "cpu" will be used.

None
Source code in evotorch/neuroevolution/neproblem.py
def __init__(
    self,
    objective_sense: ObjectiveSense,
    network: Union[str, nn.Module, Callable[[], nn.Module]],
    network_eval_func: Optional[Callable] = None,
    *,
    network_args: Optional[dict] = None,
    initial_bounds: Optional[BoundsPairLike] = (-0.00001, 0.00001),
    eval_dtype: Optional[DType] = None,
    eval_data_length: int = 0,
    seed: Optional[int] = None,
    num_actors: Optional[Union[int, str]] = None,
    actor_config: Optional[dict] = None,
    num_gpus_per_actor: Optional[Union[int, float, str]] = None,
    num_subbatches: Optional[int] = None,
    subbatch_size: Optional[int] = None,
    device: Optional[Device] = None,
):
    """
    `__init__(...)`: Initialize the NEProblem.

    Args:
        objective_sense: The objective sense, expected as "min" or "max"
            for single-objective cases, or as a sequence of strings
            (each string being "min" or "max") for multi-objective cases.
        network: A network structure string, or a Callable (which can be
            a class inheriting from `torch.nn.Module`, or a function
            which returns a `torch.nn.Module` instance), or an instance
            of `torch.nn.Module`.
            The object provided here determines the structure of the
            neural network whose parameters will be evolved.
            A network structure string is a string which can be processed
            by `evotorch.neuroevolution.net.str_to_net(...)`.
            Please see the documentation of the function
            `evotorch.neuroevolution.net.str_to_net(...)` to see how such
            a neural network structure string looks like.
        network_eval_func: Optionally a function (or any Callable object)
            which receives a PyTorch module as its argument, and returns
            either a fitness, or a two-element tuple containing the fitness
            and the additional evaluation data. The fitness can be a scalar
            (for single-objective cases) or a 1-dimensional tensor (for
            multi-objective cases). The additional evaluation data is
            expected as a 1-dimensional tensor.
            If this argument is left as None, it will be expected that
            the method `_evaluate_network(...)` is overriden by the
            inheriting class.
        network_args: Optionally a dict-like object, storing keyword
            arguments to be passed to the network while instantiating it.
        initial_bounds: Specifies an interval from which the values of the
            initial neural network parameters will be drawn.
        eval_dtype: dtype to be used for fitnesses. If not specified, then
            `eval_dtype` will be inferred from the dtype of the parameters
            of the neural network.
            In more details, if the neural network's parameters have a
            float dtype, `eval_dtype` will be a compatible float.
            Otherwise, it will be "float32".
        eval_data_length: Length of the extra evaluation data.
        seed: Random number seed. If left as None, this NEProblem instance
            will not have its own random generator, and the global random
            generator of PyTorch will be used instead.
        num_actors: Number of actors to create for parallelized
            evaluation of the solutions.
            Certain string values are also accepted.
            When given as "max" or as "num_cpus", the number of actors
            will be equal to the number of all available CPUs in the ray
            cluster.
            When given as "num_gpus", the number of actors will be
            equal to the number of all available GPUs in the ray
            cluster, and each actor will be assigned a GPU.
            When given as "num_devices", the number of actors will be
            equal to the minimum among the number of CPUs and the number
            of GPUs available in the cluster (or will be equal to the
            number of CPUs if there is no GPU), and each actor will be
            assigned a GPU (if available).
            If `num_actors` is given as "num_gpus" or "num_devices",
            the argument `num_gpus_per_actor` must not be used,
            and the `actor_config` dictionary must not contain the
            key "num_gpus".
            If `num_actors` is given as something other than "num_gpus"
            or "num_devices", and if you wish to assign GPUs to each
            actor, then please see the argument `num_gpus_per_actor`.
        actor_config: A dictionary, representing the keyword arguments
            to be passed to the options(...) used when creating the
            ray actor objects. To be used for explicitly allocating
            resources per each actor.
            For example, for declaring that each actor is to use a GPU,
            one can pass `actor_config=dict(num_gpus=1)`.
            Can also be given as None (which is the default),
            if no such options are to be passed.
        num_gpus_per_actor: Number of GPUs to be allocated by each
            remote actor.
            The default behavior is to NOT allocate any GPU at all
            (which is the default behavior of the ray library as well).
            When given as a number `n`, each actor will be given
            `n` GPUs (where `n` can be an integer, or can be a `float`
            for fractional allocation).
            When given as a string "max", then the available GPUs
            across the entire ray cluster (or within the local computer
            in the simplest cases) will be equally distributed among
            the actors.
            When given as a string "all", then each actor will have
            access to all the GPUs (this will be achieved by suppressing
            the environment variable `CUDA_VISIBLE_DEVICES` for each
            actor).
            When the problem is not distributed (i.e. when there are
            no actors), this argument is expected to be left as None.
        num_subbatches: If `num_subbatches` is None (assuming that
            `subbatch_size` is also None), then, when evaluating a
            population, the population will be split into n pieces, `n`
            being the number of actors, and each actor will evaluate
            its assigned piece. If `num_subbatches` is an integer `m`,
            then the population will be split into `m` pieces,
            and actors will continually accept the next unevaluated
            piece as they finish their current tasks.
            The arguments `num_subbatches` and `subbatch_size` cannot
            be given values other than None at the same time.
            While using a distributed algorithm, this argument determines
            how many sub-batches will be generated, and therefore,
            how many gradients will be computed by the remote actors.
        subbatch_size: If `subbatch_size` is None (assuming that
            `num_subbatches` is also None), then, when evaluating a
            population, the population will be split into `n` pieces, `n`
            being the number of actors, and each actor will evaluate its
            assigned piece. If `subbatch_size` is an integer `m`,
            then the population will be split into pieces of size `m`,
            and actors will continually accept the next unevaluated
            piece as they finish their current tasks.
            When there can be significant difference across the solutions
            in terms of computational requirements, specifying a
            `subbatch_size` can be beneficial, because, while one
            actor is busy with a subbatch containing computationally
            challenging solutions, other actors can accept more
            tasks and save time.
            The arguments `num_subbatches` and `subbatch_size` cannot
            be given values other than None at the same time.
            While using a distributed algorithm, this argument determines
            the size of a sub-batch (or sub-population) sampled by a
            remote actor for computing a gradient.
            In distributed mode, it is expected that the population size
            is divisible by `subbatch_size`.
        device: Default device in which a new population will be generated
            and the neural networks will operate.
            If not specified, "cpu" will be used.
    """
    # Set the main device of the problem
    # Although the operation of setting the main device is done by the main Problem class,
    # here we need this at an earlier stage.
    if device is None:
        device = "cpu"
    self._device = torch.device(device)

    # Set the network
    self._original_network = network
    self._network_args = {} if network_args is None else deepcopy(network_args)
    if isinstance(self._original_network, nn.Module):
        self._original_network = self._original_network.cpu()

    # Store the function that will evaluate the network, if available
    self._network_eval_func: Optional[Callable] = network_eval_func

    self.instantiated_network: nn.Module = None

    # Create temporary network
    temp_network = self._instantiate_net(self._original_network, device="cpu")

    super().__init__(
        objective_sense=objective_sense,
        initial_bounds=initial_bounds,
        bounds=None,  # Neuroevolution is an unbounded problem
        solution_length=count_parameters(temp_network),  # The solution length is inherited from the network passed
        dtype=next(temp_network.parameters()).dtype,  # The datatype is inherited from the network passed
        eval_dtype=eval_dtype,
        device=device,
        eval_data_length=eval_data_length,
        seed=seed,
        num_actors=num_actors,
        num_gpus_per_actor=num_gpus_per_actor,
        actor_config=actor_config,
        num_subbatches=num_subbatches,
        subbatch_size=subbatch_size,
        store_solution_stats=None,
    )
make_net(self, parameters)

Make a new network filled with the provided parameters.

Parameters:

Name Type Description Default
parameters Iterable

Parameters to be used as weights within the network. Can be a Solution, or any 1-dimensional Iterable that can be converted to a PyTorch tensor.

required

Returns:

Type Description
Module

A new network, as a torch.Module instance.

Source code in evotorch/neuroevolution/neproblem.py
def make_net(self, parameters: Iterable) -> nn.Module:
    """
    Make a new network filled with the provided parameters.

    Args:
        parameters: Parameters to be used as weights within the network.
            Can be a Solution, or any 1-dimensional Iterable that can be
            converted to a PyTorch tensor.
    Returns:
        A new network, as a `torch.Module` instance.
    """
    if isinstance(parameters, Solution):
        parameters = parameters.access_values(keep_evals=True)
    else:
        parameters = self.as_tensor(parameters)
    with torch.no_grad():
        net = deepcopy(self.parameterize_net(parameters))
    return net
network_constants(self)

Named constants which can be passed to the network instantiation

Source code in evotorch/neuroevolution/neproblem.py
def network_constants(self) -> dict:
    """Named constants which can be passed to the network instantiation"""
    constants = {}
    constants.update(self._network_constants)
    constants.update(self._network_args)
    return constants
parameterize_net(self, parameters)

Parameterize the network with a given set of parameters.

Parameters:

Name Type Description Default
parameters torch.Tensor

The parameters with which to instantiate the network

required

Returns:

Type Description
instantiated_network (nn.Module)

The network instantiated with the parameters

Source code in evotorch/neuroevolution/neproblem.py
def parameterize_net(self, parameters: torch.Tensor) -> nn.Module:
    """Parameterize the network with a given set of parameters.
    Args:
        parameters (torch.Tensor): The parameters with which to instantiate the network
    Returns:
        instantiated_network (nn.Module): The network instantiated with the parameters
    """
    # Check if network exists
    if self.instantiated_network is None:
        self.instantiated_network = self._instantiate_net(self._original_network)

    network = self.instantiated_network

    # Move the parameters if needed
    if parameters.device != self.network_device:
        parameters = parameters.to(self.network_device)

    # Fill the network with the parameters
    fill_parameters(network, parameters)

    # Return the network
    return network

net special

Utility classes and functions for neural networks

functional

ModuleExpectingFlatParameters

A wrapper which brings a functional interface around a torch module.

Similar to functorch.FunctionalModule, ModuleExpectingFlatParameters turns a torch.nn.Module instance to a function which expects a new leftmost argument representing the parameters of the network. Unlike functorch.FunctionalModule, a ModuleExpectingFlatParameters instance, as its name suggests, expects the network parameters to be given as a 1-dimensional (i.e. flattened) tensor. Also, unlike functorch.FunctionalModule, an instance of ModuleExpectingFlatParameters is NOT an instance of torch.nn.Module.

PyTorch modules with buffers can be wrapped by this class, but it is assumed that those buffers are constant. If the wrapped module changes the value(s) of its buffer(s) during its forward passes, most probably things will NOT work right.

As an example, let us consider the following linear layer.

import torch
from torch import nn

net = nn.Linear(3, 8)

The functional counterpart of net can be obtained via:

from evotorch.neuroevolution.net import ModuleExpectingFlatParameters

fnet = ModuleExpectingFlatParameters(net)

Now, fnet is a callable object which expects network parameters and network inputs. Let us call fnet with randomly generated network parameters and with a randomly generated input tensor.

param_length = fnet.parameter_length
random_parameters = torch.randn(param_length)
random_input = torch.randn(3)

result = fnet(random_parameters, random_input)
Source code in evotorch/neuroevolution/net/functional.py
class ModuleExpectingFlatParameters:
    """
    A wrapper which brings a functional interface around a torch module.

    Similar to `functorch.FunctionalModule`, `ModuleExpectingFlatParameters`
    turns a `torch.nn.Module` instance to a function which expects a new
    leftmost argument representing the parameters of the network.
    Unlike `functorch.FunctionalModule`, a `ModuleExpectingFlatParameters`
    instance, as its name suggests, expects the network parameters to be
    given as a 1-dimensional (i.e. flattened) tensor.
    Also, unlike `functorch.FunctionalModule`, an instance of
    `ModuleExpectingFlatParameters` is NOT an instance of `torch.nn.Module`.

    PyTorch modules with buffers can be wrapped by this class, but it is
    assumed that those buffers are constant. If the wrapped module changes
    the value(s) of its buffer(s) during its forward passes, most probably
    things will NOT work right.

    As an example, let us consider the following linear layer.

    ```python
    import torch
    from torch import nn

    net = nn.Linear(3, 8)
    ```

    The functional counterpart of `net` can be obtained via:

    ```python
    from evotorch.neuroevolution.net import ModuleExpectingFlatParameters

    fnet = ModuleExpectingFlatParameters(net)
    ```

    Now, `fnet` is a callable object which expects network parameters
    and network inputs. Let us call `fnet` with randomly generated network
    parameters and with a randomly generated input tensor.

    ```python
    param_length = fnet.parameter_length
    random_parameters = torch.randn(param_length)
    random_input = torch.randn(3)

    result = fnet(random_parameters, random_input)
    ```
    """

    @torch.no_grad()
    def __init__(self, net: nn.Module, *, disable_autograd_tracking: bool = False):
        """
        `__init__(...)`: Initialize the `ModuleExpectingFlatParameters` instance.

        Args:
            net: The module that is to be wrapped by a functional interface.
            disable_autograd_tracking: If given as True, all operations
                regarding the wrapped module will be performed in the context
                `torch.no_grad()`, forcefully disabling the autograd.
                If given as False, autograd will not be affected.
                The default is False.
        """

        # Declare the variables which will store information regarding the parameters of the module.
        self.__param_names = []
        self.__param_shapes = []
        self.__param_length = 0
        self.__param_slices = []
        self.__num_params = 0

        # Iterate over the parameters of the module and fill the related information.
        i = 0
        j = 0
        for pname, p in net.named_parameters():
            self.__param_names.append(pname)

            shape = p.shape
            self.__param_shapes.append(shape)

            length = _shape_length(shape)
            self.__param_length += length

            j = i + length
            self.__param_slices.append(slice(i, j))
            i = j

            self.__num_params += 1

        self.__buffer_dict = {bname: b.clone() for bname, b in net.named_buffers()}

        self.__net = deepcopy(net)
        self.__net.to("meta")
        self.__disable_autograd_tracking = bool(disable_autograd_tracking)

    def __transfer_buffers(self, x: torch.Tensor):
        """
        Transfer the buffer tensors to the device of the given tensor.

        Args:
            x: The tensor whose device will also store the buffer tensors.
        """
        for bname in self.__buffer_dict.keys():
            self.__buffer_dict[bname] = torch.as_tensor(self.__buffer_dict[bname], device=x.device)

    @property
    def buffers(self) -> tuple:
        """Get the stored buffers"""
        return tuple(self.__buffer_dict)

    @property
    def parameter_length(self) -> int:
        return self.__param_length

    def __call__(self, parameter_vector: torch.Tensor, x: torch.Tensor, h: Any = None) -> Any:
        """
        Call the wrapped module's forward pass procedure.

        Args:
            parameter_vector: A 1-dimensional tensor which represents the
                parameters of the tensor.
            x: The inputs.
            h: Hidden state(s), in case this is a recurrent network.
        Returns:
            The result of the forward pass.
        """
        if parameter_vector.ndim != 1:
            raise ValueError(
                f"Expected the parameters as 1 dimensional,"
                f" but the received parameter vector has {parameter_vector.ndim} dimensions"
            )
        if len(parameter_vector) != self.__param_length:
            raise ValueError(
                f"Expected a parameter vector of length {self.__param_length},"
                f" but the received parameter vector's length is {len(parameter_vector)}."
            )
        state_args = [] if h is None else [h]

        params_and_buffers = {}
        for i, pname in enumerate(self.__param_names):
            param_slice = self.__param_slices[i]
            param_shape = self.__param_shapes[i]
            param = parameter_vector[param_slice].reshape(param_shape)
            params_and_buffers[pname] = param

        # Make sure that the buffer tensors are in the same device with x
        self.__transfer_buffers(x)

        # Add the buffer tensors to the dictionary `params_and_buffers`
        params_and_buffers.update(self.__buffer_dict)

        # Prepare the no-gradient context if gradient tracking is disabled
        context = torch.no_grad() if self.__disable_autograd_tracking else nullcontext()

        # Run the module and return the results
        with context:
            return functional_call(self.__net, params_and_buffers, tuple([x, *state_args]))
buffers: tuple property readonly

Get the stored buffers

__call__(self, parameter_vector, x, h=None) special

Call the wrapped module's forward pass procedure.

Parameters:

Name Type Description Default
parameter_vector Tensor

A 1-dimensional tensor which represents the parameters of the tensor.

required
x Tensor

The inputs.

required
h Any

Hidden state(s), in case this is a recurrent network.

None

Returns:

Type Description
Any

The result of the forward pass.

Source code in evotorch/neuroevolution/net/functional.py
def __call__(self, parameter_vector: torch.Tensor, x: torch.Tensor, h: Any = None) -> Any:
    """
    Call the wrapped module's forward pass procedure.

    Args:
        parameter_vector: A 1-dimensional tensor which represents the
            parameters of the tensor.
        x: The inputs.
        h: Hidden state(s), in case this is a recurrent network.
    Returns:
        The result of the forward pass.
    """
    if parameter_vector.ndim != 1:
        raise ValueError(
            f"Expected the parameters as 1 dimensional,"
            f" but the received parameter vector has {parameter_vector.ndim} dimensions"
        )
    if len(parameter_vector) != self.__param_length:
        raise ValueError(
            f"Expected a parameter vector of length {self.__param_length},"
            f" but the received parameter vector's length is {len(parameter_vector)}."
        )
    state_args = [] if h is None else [h]

    params_and_buffers = {}
    for i, pname in enumerate(self.__param_names):
        param_slice = self.__param_slices[i]
        param_shape = self.__param_shapes[i]
        param = parameter_vector[param_slice].reshape(param_shape)
        params_and_buffers[pname] = param

    # Make sure that the buffer tensors are in the same device with x
    self.__transfer_buffers(x)

    # Add the buffer tensors to the dictionary `params_and_buffers`
    params_and_buffers.update(self.__buffer_dict)

    # Prepare the no-gradient context if gradient tracking is disabled
    context = torch.no_grad() if self.__disable_autograd_tracking else nullcontext()

    # Run the module and return the results
    with context:
        return functional_call(self.__net, params_and_buffers, tuple([x, *state_args]))
__init__(self, net, *, disable_autograd_tracking=False) special

__init__(...): Initialize the ModuleExpectingFlatParameters instance.

Parameters:

Name Type Description Default
net Module

The module that is to be wrapped by a functional interface.

required
disable_autograd_tracking bool

If given as True, all operations regarding the wrapped module will be performed in the context torch.no_grad(), forcefully disabling the autograd. If given as False, autograd will not be affected. The default is False.

False
Source code in evotorch/neuroevolution/net/functional.py
@torch.no_grad()
def __init__(self, net: nn.Module, *, disable_autograd_tracking: bool = False):
    """
    `__init__(...)`: Initialize the `ModuleExpectingFlatParameters` instance.

    Args:
        net: The module that is to be wrapped by a functional interface.
        disable_autograd_tracking: If given as True, all operations
            regarding the wrapped module will be performed in the context
            `torch.no_grad()`, forcefully disabling the autograd.
            If given as False, autograd will not be affected.
            The default is False.
    """

    # Declare the variables which will store information regarding the parameters of the module.
    self.__param_names = []
    self.__param_shapes = []
    self.__param_length = 0
    self.__param_slices = []
    self.__num_params = 0

    # Iterate over the parameters of the module and fill the related information.
    i = 0
    j = 0
    for pname, p in net.named_parameters():
        self.__param_names.append(pname)

        shape = p.shape
        self.__param_shapes.append(shape)

        length = _shape_length(shape)
        self.__param_length += length

        j = i + length
        self.__param_slices.append(slice(i, j))
        i = j

        self.__num_params += 1

    self.__buffer_dict = {bname: b.clone() for bname, b in net.named_buffers()}

    self.__net = deepcopy(net)
    self.__net.to("meta")
    self.__disable_autograd_tracking = bool(disable_autograd_tracking)
make_functional_module(net, *, disable_autograd_tracking=False)

Wrap a torch module so that it has a functional interface.

Similar to functorch.make_functional(...), this function turns a torch.nn.Module instance to a function which expects a new leftmost argument representing the parameters of the network. Unlike with functorch.make_functional(...), the parameters of the network are expected in a 1-dimensional (i.e. flattened) tensor.

PyTorch modules with buffers can be wrapped by this class, but it is assumed that those buffers are constant. If the wrapped module changes the value(s) of its buffer(s) during its forward passes, most probably things will NOT work right.

As an example, let us consider the following linear layer.

import torch
from torch import nn

net = nn.Linear(3, 8)

The functional counterpart of net can be obtained via:

from evotorch.neuroevolution.net import make_functional_module

fnet = make_functional_module(net)

Now, fnet is a callable object which expects network parameters and network inputs. Let us call fnet with randomly generated network parameters and with a randomly generated input tensor.

param_length = fnet.parameter_length
random_parameters = torch.randn(param_length)
random_input = torch.randn(3)

result = fnet(random_parameters, random_input)

Parameters:

Name Type Description Default
net Module

The torch.nn.Module instance to be wrapped by a functional interface.

required
disable_autograd_tracking bool

If given as True, all operations regarding the wrapped module will be performed in the context torch.no_grad(), forcefully disabling the autograd. If given as False, autograd will not be affected. The default is False.

False

Returns:

Type Description
ModuleExpectingFlatParameters

The functional wrapper, as an instance of evotorch.neuroevolution.net.ModuleExpectingFlatParameters.

Source code in evotorch/neuroevolution/net/functional.py
def make_functional_module(net: nn.Module, *, disable_autograd_tracking: bool = False) -> ModuleExpectingFlatParameters:
    """
    Wrap a torch module so that it has a functional interface.

    Similar to `functorch.make_functional(...)`, this function turns a
    `torch.nn.Module` instance to a function which expects a new leftmost
    argument representing the parameters of the network.
    Unlike with `functorch.make_functional(...)`, the parameters of the
    network are expected in a 1-dimensional (i.e. flattened) tensor.

    PyTorch modules with buffers can be wrapped by this class, but it is
    assumed that those buffers are constant. If the wrapped module changes
    the value(s) of its buffer(s) during its forward passes, most probably
    things will NOT work right.

    As an example, let us consider the following linear layer.

    ```python
    import torch
    from torch import nn

    net = nn.Linear(3, 8)
    ```

    The functional counterpart of `net` can be obtained via:

    ```python
    from evotorch.neuroevolution.net import make_functional_module

    fnet = make_functional_module(net)
    ```

    Now, `fnet` is a callable object which expects network parameters
    and network inputs. Let us call `fnet` with randomly generated network
    parameters and with a randomly generated input tensor.

    ```python
    param_length = fnet.parameter_length
    random_parameters = torch.randn(param_length)
    random_input = torch.randn(3)

    result = fnet(random_parameters, random_input)
    ```

    Args:
        net: The `torch.nn.Module` instance to be wrapped by a functional
            interface.
        disable_autograd_tracking: If given as True, all operations
            regarding the wrapped module will be performed in the context
            `torch.no_grad()`, forcefully disabling the autograd.
            If given as False, autograd will not be affected.
            The default is False.
    Returns:
        The functional wrapper, as an instance of
        `evotorch.neuroevolution.net.ModuleExpectingFlatParameters`.
    """
    return ModuleExpectingFlatParameters(net, disable_autograd_tracking=disable_autograd_tracking)

layers

Various neural network layer types

Apply (Module)

A torch module for applying an arithmetic operator on an input tensor

Source code in evotorch/neuroevolution/net/layers.py
class Apply(nn.Module):
    """A torch module for applying an arithmetic operator on an input tensor"""

    def __init__(self, operator: str, argument: float):
        """`__init__(...)`: Initialize the Apply module.

        Args:
            operator: Must be '+', '-', '*', '/', or '**'.
                Indicates which operation will be done
                on the input tensor.
            argument: Expected as a float, represents
                the right-argument of the operation
                (the left-argument being the input
                tensor).
        """
        nn.Module.__init__(self)

        self._operator = str(operator)
        assert self._operator in ("+", "-", "*", "/", "**")

        self._argument = float(argument)

    def forward(self, x):
        op = self._operator
        arg = self._argument
        if op == "+":
            return x + arg
        elif op == "-":
            return x - arg
        elif op == "*":
            return x * arg
        elif op == "/":
            return x / arg
        elif op == "**":
            return x**arg
        else:
            raise ValueError("Unknown operator:" + repr(op))

    def extra_repr(self):
        return "operator={}, argument={}".format(repr(self._operator), self._argument)
__init__(self, operator, argument) special

__init__(...): Initialize the Apply module.

Parameters:

Name Type Description Default
operator str

Must be '+', '-', '', '/', or '*'. Indicates which operation will be done on the input tensor.

required
argument float

Expected as a float, represents the right-argument of the operation (the left-argument being the input tensor).

required
Source code in evotorch/neuroevolution/net/layers.py
def __init__(self, operator: str, argument: float):
    """`__init__(...)`: Initialize the Apply module.

    Args:
        operator: Must be '+', '-', '*', '/', or '**'.
            Indicates which operation will be done
            on the input tensor.
        argument: Expected as a float, represents
            the right-argument of the operation
            (the left-argument being the input
            tensor).
    """
    nn.Module.__init__(self)

    self._operator = str(operator)
    assert self._operator in ("+", "-", "*", "/", "**")

    self._argument = float(argument)
extra_repr(self)

Set the extra representation of the module

To print customized extra information, you should re-implement this method in your own modules. Both single-line and multi-line strings are acceptable.

Source code in evotorch/neuroevolution/net/layers.py
def extra_repr(self):
    return "operator={}, argument={}".format(repr(self._operator), self._argument)
forward(self, x)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x):
    op = self._operator
    arg = self._argument
    if op == "+":
        return x + arg
    elif op == "-":
        return x - arg
    elif op == "*":
        return x * arg
    elif op == "/":
        return x / arg
    elif op == "**":
        return x**arg
    else:
        raise ValueError("Unknown operator:" + repr(op))
Bin (Module)

A small torch module for binning the values of tensors.

In more details, considering a lower bound value lb, an upper bound value ub, and an input tensor x, each value within x closer to lb will be converted to lb and each value within x closer to ub will be converted to ub.

Source code in evotorch/neuroevolution/net/layers.py
class Bin(nn.Module):
    """A small torch module for binning the values of tensors.

    In more details, considering a lower bound value lb,
    an upper bound value ub, and an input tensor x,
    each value within x closer to lb will be converted to lb
    and each value within x closer to ub will be converted to ub.
    """

    def __init__(self, lb: float, ub: float):
        """`__init__(...)`: Initialize the Clip operator.

        Args:
            lb: Lower bound
            ub: Upper bound
        """
        nn.Module.__init__(self)
        self._lb = float(lb)
        self._ub = float(ub)
        self._interval_size = self._ub - self._lb
        self._shrink_amount = self._interval_size / 2.0
        self._shift_amount = (self._ub + self._lb) / 2.0

    def forward(self, x: torch.Tensor):
        x = x - self._shift_amount
        x = x / self._shrink_amount
        x = torch.sign(x)
        x = x * self._shrink_amount
        x = x + self._shift_amount
        return x

    def extra_repr(self):
        return "lb={}, ub={}".format(self._lb, self._ub)
__init__(self, lb, ub) special

__init__(...): Initialize the Clip operator.

Parameters:

Name Type Description Default
lb float

Lower bound

required
ub float

Upper bound

required
Source code in evotorch/neuroevolution/net/layers.py
def __init__(self, lb: float, ub: float):
    """`__init__(...)`: Initialize the Clip operator.

    Args:
        lb: Lower bound
        ub: Upper bound
    """
    nn.Module.__init__(self)
    self._lb = float(lb)
    self._ub = float(ub)
    self._interval_size = self._ub - self._lb
    self._shrink_amount = self._interval_size / 2.0
    self._shift_amount = (self._ub + self._lb) / 2.0
extra_repr(self)

Set the extra representation of the module

To print customized extra information, you should re-implement this method in your own modules. Both single-line and multi-line strings are acceptable.

Source code in evotorch/neuroevolution/net/layers.py
def extra_repr(self):
    return "lb={}, ub={}".format(self._lb, self._ub)
forward(self, x)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x: torch.Tensor):
    x = x - self._shift_amount
    x = x / self._shrink_amount
    x = torch.sign(x)
    x = x * self._shrink_amount
    x = x + self._shift_amount
    return x
Clip (Module)

A small torch module for clipping the values of tensors

Source code in evotorch/neuroevolution/net/layers.py
class Clip(nn.Module):
    """A small torch module for clipping the values of tensors"""

    def __init__(self, lb: float, ub: float):
        """`__init__(...)`: Initialize the Clip operator.

        Args:
            lb: Lower bound. Values less than this will be clipped.
            ub: Upper bound. Values greater than this will be clipped.
        """
        nn.Module.__init__(self)
        self._lb = float(lb)
        self._ub = float(ub)

    def forward(self, x: torch.Tensor):
        return x.clamp(self._lb, self._ub)

    def extra_repr(self):
        return "lb={}, ub={}".format(self._lb, self._ub)
__init__(self, lb, ub) special

__init__(...): Initialize the Clip operator.

Parameters:

Name Type Description Default
lb float

Lower bound. Values less than this will be clipped.

required
ub float

Upper bound. Values greater than this will be clipped.

required
Source code in evotorch/neuroevolution/net/layers.py
def __init__(self, lb: float, ub: float):
    """`__init__(...)`: Initialize the Clip operator.

    Args:
        lb: Lower bound. Values less than this will be clipped.
        ub: Upper bound. Values greater than this will be clipped.
    """
    nn.Module.__init__(self)
    self._lb = float(lb)
    self._ub = float(ub)
extra_repr(self)

Set the extra representation of the module

To print customized extra information, you should re-implement this method in your own modules. Both single-line and multi-line strings are acceptable.

Source code in evotorch/neuroevolution/net/layers.py
def extra_repr(self):
    return "lb={}, ub={}".format(self._lb, self._ub)
forward(self, x)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x: torch.Tensor):
    return x.clamp(self._lb, self._ub)
FeedForwardNet (Module)

Representation of a feed forward neural network as a torch Module.

An example initialization of a FeedForwardNet is as follows:

net = drt.FeedForwardNet(4, [(8, 'tanh'), (6, 'tanh')])

which means that we would like to have a network which expects an input vector of length 4 and passes its input through 2 tanh-activated hidden layers (with neurons count 8 and 6, respectively). The output of the last hidden layer (of length 6) is the final output vector.

The string representation of the module obtained via the example above is:

FeedForwardNet(
  (layer_0): Linear(in_features=4, out_features=8, bias=True)
  (actfunc_0): Tanh()
  (layer_1): Linear(in_features=8, out_features=6, bias=True)
  (actfunc_1): Tanh()
)
Source code in evotorch/neuroevolution/net/layers.py
class FeedForwardNet(nn.Module):
    """
    Representation of a feed forward neural network as a torch Module.

    An example initialization of a FeedForwardNet is as follows:

        net = drt.FeedForwardNet(4, [(8, 'tanh'), (6, 'tanh')])

    which means that we would like to have a network which expects an input
    vector of length 4 and passes its input through 2 tanh-activated hidden
    layers (with neurons count 8 and 6, respectively).
    The output of the last hidden layer (of length 6) is the final
    output vector.

    The string representation of the module obtained via the example above
    is:

        FeedForwardNet(
          (layer_0): Linear(in_features=4, out_features=8, bias=True)
          (actfunc_0): Tanh()
          (layer_1): Linear(in_features=8, out_features=6, bias=True)
          (actfunc_1): Tanh()
        )
    """

    LengthActTuple = Tuple[int, Union[str, Callable]]
    LengthActBiasTuple = Tuple[int, Union[str, Callable], Union[bool]]

    def __init__(self, input_size: int, layers: List[Union[LengthActTuple, LengthActBiasTuple]]):
        """`__init__(...)`: Initialize the FeedForward network.

        Args:
            input_size: Input size of the network, expected as an int.
            layers: Expected as a list of tuples,
                where each tuple is either of the form
                `(layer_size, activation_function)`
                or of the form
                `(layer_size, activation_function, bias)`
                in which
                (i) `layer_size` is an int, specifying the number of neurons;
                (ii) `activation_function` is None, or a callable object,
                or a string containing the name of the activation function
                ('relu', 'selu', 'elu', 'tanh', 'hardtanh', or 'sigmoid');
                (iii) `bias` is a boolean, specifying whether the layer
                is to have a bias or not.
                When omitted, bias is set to True.
        """

        nn.Module.__init__(self)

        for i, layer in enumerate(layers):
            if len(layer) == 2:
                size, actfunc = layer
                bias = True
            elif len(layer) == 3:
                size, actfunc, bias = layer
            else:
                assert False, "A layer tuple of invalid size is encountered"

            setattr(self, "layer_" + str(i), nn.Linear(input_size, size, bias=bias))

            if isinstance(actfunc, str):
                if actfunc == "relu":
                    actfunc = nn.ReLU()
                elif actfunc == "selu":
                    actfunc = nn.SELU()
                elif actfunc == "elu":
                    actfunc = nn.ELU()
                elif actfunc == "tanh":
                    actfunc = nn.Tanh()
                elif actfunc == "hardtanh":
                    actfunc = nn.Hardtanh()
                elif actfunc == "sigmoid":
                    actfunc = nn.Sigmoid()
                elif actfunc == "round":
                    actfunc = Round()
                else:
                    raise ValueError("Unknown activation function: " + repr(actfunc))

            setattr(self, "actfunc_" + str(i), actfunc)

            input_size = size

    def forward(self, x):
        i = 0
        while hasattr(self, "layer_" + str(i)):
            x = getattr(self, "layer_" + str(i))(x)
            f = getattr(self, "actfunc_" + str(i))
            if f is not None:
                x = f(x)
            i += 1
        return x
__init__(self, input_size, layers) special

__init__(...): Initialize the FeedForward network.

Parameters:

Name Type Description Default
input_size int

Input size of the network, expected as an int.

required
layers List[Union[Tuple[int, Union[str, Callable]], Tuple[int, Union[str, Callable], bool]]]

Expected as a list of tuples, where each tuple is either of the form (layer_size, activation_function) or of the form (layer_size, activation_function, bias) in which (i) layer_size is an int, specifying the number of neurons; (ii) activation_function is None, or a callable object, or a string containing the name of the activation function ('relu', 'selu', 'elu', 'tanh', 'hardtanh', or 'sigmoid'); (iii) bias is a boolean, specifying whether the layer is to have a bias or not. When omitted, bias is set to True.

required
Source code in evotorch/neuroevolution/net/layers.py
def __init__(self, input_size: int, layers: List[Union[LengthActTuple, LengthActBiasTuple]]):
    """`__init__(...)`: Initialize the FeedForward network.

    Args:
        input_size: Input size of the network, expected as an int.
        layers: Expected as a list of tuples,
            where each tuple is either of the form
            `(layer_size, activation_function)`
            or of the form
            `(layer_size, activation_function, bias)`
            in which
            (i) `layer_size` is an int, specifying the number of neurons;
            (ii) `activation_function` is None, or a callable object,
            or a string containing the name of the activation function
            ('relu', 'selu', 'elu', 'tanh', 'hardtanh', or 'sigmoid');
            (iii) `bias` is a boolean, specifying whether the layer
            is to have a bias or not.
            When omitted, bias is set to True.
    """

    nn.Module.__init__(self)

    for i, layer in enumerate(layers):
        if len(layer) == 2:
            size, actfunc = layer
            bias = True
        elif len(layer) == 3:
            size, actfunc, bias = layer
        else:
            assert False, "A layer tuple of invalid size is encountered"

        setattr(self, "layer_" + str(i), nn.Linear(input_size, size, bias=bias))

        if isinstance(actfunc, str):
            if actfunc == "relu":
                actfunc = nn.ReLU()
            elif actfunc == "selu":
                actfunc = nn.SELU()
            elif actfunc == "elu":
                actfunc = nn.ELU()
            elif actfunc == "tanh":
                actfunc = nn.Tanh()
            elif actfunc == "hardtanh":
                actfunc = nn.Hardtanh()
            elif actfunc == "sigmoid":
                actfunc = nn.Sigmoid()
            elif actfunc == "round":
                actfunc = Round()
            else:
                raise ValueError("Unknown activation function: " + repr(actfunc))

        setattr(self, "actfunc_" + str(i), actfunc)

        input_size = size
forward(self, x)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x):
    i = 0
    while hasattr(self, "layer_" + str(i)):
        x = getattr(self, "layer_" + str(i))(x)
        f = getattr(self, "actfunc_" + str(i))
        if f is not None:
            x = f(x)
        i += 1
    return x
LSTM (Module)
Source code in evotorch/neuroevolution/net/layers.py
class LSTM(nn.Module):
    def __init__(
        self,
        input_size: int,
        hidden_size: int,
        *,
        dtype: torch.dtype = torch.float32,
        device: Union[str, torch.device] = "cpu",
    ):
        super().__init__()
        input_size = int(input_size)
        hidden_size = int(hidden_size)

        self.input_size = input_size
        self.hidden_size = hidden_size

        def input_weight():
            return nn.Parameter(torch.randn(self.hidden_size, self.input_size, dtype=dtype, device=device))

        def weight():
            return nn.Parameter(torch.randn(self.hidden_size, self.hidden_size, dtype=dtype, device=device))

        def bias():
            return nn.Parameter(torch.zeros(self.hidden_size, dtype=dtype, device=device))

        self.W_ii = input_weight()
        self.W_if = input_weight()
        self.W_ig = input_weight()
        self.W_io = input_weight()

        self.W_hi = weight()
        self.W_hf = weight()
        self.W_hg = weight()
        self.W_ho = weight()

        self.b_ii = bias()
        self.b_if = bias()
        self.b_ig = bias()
        self.b_io = bias()

        self.b_hi = bias()
        self.b_hf = bias()
        self.b_hg = bias()
        self.b_ho = bias()

    def forward(self, x: torch.Tensor, hidden=None) -> tuple:
        sigm = torch.sigmoid
        tanh = torch.tanh

        if hidden is None:
            h_prev = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
            c_prev = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
        else:
            h_prev, c_prev = hidden

        i_t = sigm(self.W_ii @ x + self.b_ii + self.W_hi @ h_prev + self.b_hi)
        f_t = sigm(self.W_if @ x + self.b_if + self.W_hf @ h_prev + self.b_hf)
        g_t = tanh(self.W_ig @ x + self.b_ig + self.W_hg @ h_prev + self.b_hg)
        o_t = sigm(self.W_io @ x + self.b_io + self.W_ho @ h_prev + self.b_ho)
        c_t = f_t * c_prev + i_t * g_t
        h_t = o_t * tanh(c_t)

        return h_t, (h_t, c_t)

    def __repr__(self) -> str:
        clsname = type(self).__name__
        return f"{clsname}(input_size={self.input_size}, hidden_size={self.hidden_size})"
forward(self, x, hidden=None)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x: torch.Tensor, hidden=None) -> tuple:
    sigm = torch.sigmoid
    tanh = torch.tanh

    if hidden is None:
        h_prev = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
        c_prev = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
    else:
        h_prev, c_prev = hidden

    i_t = sigm(self.W_ii @ x + self.b_ii + self.W_hi @ h_prev + self.b_hi)
    f_t = sigm(self.W_if @ x + self.b_if + self.W_hf @ h_prev + self.b_hf)
    g_t = tanh(self.W_ig @ x + self.b_ig + self.W_hg @ h_prev + self.b_hg)
    o_t = sigm(self.W_io @ x + self.b_io + self.W_ho @ h_prev + self.b_ho)
    c_t = f_t * c_prev + i_t * g_t
    h_t = o_t * tanh(c_t)

    return h_t, (h_t, c_t)
LSTMNet (Module)
Source code in evotorch/neuroevolution/net/layers.py
class LSTM(nn.Module):
    def __init__(
        self,
        input_size: int,
        hidden_size: int,
        *,
        dtype: torch.dtype = torch.float32,
        device: Union[str, torch.device] = "cpu",
    ):
        super().__init__()
        input_size = int(input_size)
        hidden_size = int(hidden_size)

        self.input_size = input_size
        self.hidden_size = hidden_size

        def input_weight():
            return nn.Parameter(torch.randn(self.hidden_size, self.input_size, dtype=dtype, device=device))

        def weight():
            return nn.Parameter(torch.randn(self.hidden_size, self.hidden_size, dtype=dtype, device=device))

        def bias():
            return nn.Parameter(torch.zeros(self.hidden_size, dtype=dtype, device=device))

        self.W_ii = input_weight()
        self.W_if = input_weight()
        self.W_ig = input_weight()
        self.W_io = input_weight()

        self.W_hi = weight()
        self.W_hf = weight()
        self.W_hg = weight()
        self.W_ho = weight()

        self.b_ii = bias()
        self.b_if = bias()
        self.b_ig = bias()
        self.b_io = bias()

        self.b_hi = bias()
        self.b_hf = bias()
        self.b_hg = bias()
        self.b_ho = bias()

    def forward(self, x: torch.Tensor, hidden=None) -> tuple:
        sigm = torch.sigmoid
        tanh = torch.tanh

        if hidden is None:
            h_prev = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
            c_prev = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
        else:
            h_prev, c_prev = hidden

        i_t = sigm(self.W_ii @ x + self.b_ii + self.W_hi @ h_prev + self.b_hi)
        f_t = sigm(self.W_if @ x + self.b_if + self.W_hf @ h_prev + self.b_hf)
        g_t = tanh(self.W_ig @ x + self.b_ig + self.W_hg @ h_prev + self.b_hg)
        o_t = sigm(self.W_io @ x + self.b_io + self.W_ho @ h_prev + self.b_ho)
        c_t = f_t * c_prev + i_t * g_t
        h_t = o_t * tanh(c_t)

        return h_t, (h_t, c_t)

    def __repr__(self) -> str:
        clsname = type(self).__name__
        return f"{clsname}(input_size={self.input_size}, hidden_size={self.hidden_size})"
forward(self, x, hidden=None)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x: torch.Tensor, hidden=None) -> tuple:
    sigm = torch.sigmoid
    tanh = torch.tanh

    if hidden is None:
        h_prev = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
        c_prev = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
    else:
        h_prev, c_prev = hidden

    i_t = sigm(self.W_ii @ x + self.b_ii + self.W_hi @ h_prev + self.b_hi)
    f_t = sigm(self.W_if @ x + self.b_if + self.W_hf @ h_prev + self.b_hf)
    g_t = tanh(self.W_ig @ x + self.b_ig + self.W_hg @ h_prev + self.b_hg)
    o_t = sigm(self.W_io @ x + self.b_io + self.W_ho @ h_prev + self.b_ho)
    c_t = f_t * c_prev + i_t * g_t
    h_t = o_t * tanh(c_t)

    return h_t, (h_t, c_t)
LocomotorNet (Module)

This is a control network which consists of two components: one linear, and one non-linear. The non-linear component is an input-independent set of sinusoidals waves whose amplitudes, frequencies and phases are trainable. Upon execution of a forward pass, the output of the non-linear component is the sum of all these sinusoidal waves. The linear component is a linear layer (optionally with bias) whose weights (and biases) are trainable. The final output of the LocomotorNet at the end of a forward pass is the sum of the linear and the non-linear components.

Note that this is a stateful network, where the only state is the timestep t, which starts from 0 and gets incremented by 1 at the end of each forward pass. The reset() method resets t back to 0.

Reference

Mario Srouji, Jian Zhang, Ruslan Salakhutdinov (2018). Structured Control Nets for Deep Reinforcement Learning.

Source code in evotorch/neuroevolution/net/layers.py
class LocomotorNet(nn.Module):
    """LocomotorNet: A locomotion-specific structured control net.

    This is a control network which consists of two components:
    one linear, and one non-linear. The non-linear component
    is an input-independent set of sinusoidals waves whose
    amplitudes, frequencies and phases are trainable.
    Upon execution of a forward pass, the output of the non-linear
    component is the sum of all these sinusoidal waves.
    The linear component is a linear layer (optionally with bias)
    whose weights (and biases) are trainable.
    The final output of the LocomotorNet at the end of a forward pass
    is the sum of the linear and the non-linear components.

    Note that this is a stateful network, where the only state
    is the timestep t, which starts from 0 and gets incremented by 1
    at the end of each forward pass. The `reset()` method resets
    t back to 0.

    Reference:
        Mario Srouji, Jian Zhang, Ruslan Salakhutdinov (2018).
        Structured Control Nets for Deep Reinforcement Learning.
    """

    def __init__(self, *, in_features: int, out_features: int, bias: bool = True, num_sinusoids=16):
        """`__init__(...)`: Initialize the LocomotorNet.

        Args:
            in_features: Length of the input vector
            out_features: Length of the output vector
            bias: Whether or not the linear component is to have a bias
            num_sinusoids: Number of sinusoidal waves
        """

        nn.Module.__init__(self)

        self._in_features = in_features
        self._out_features = out_features
        self._bias = bias
        self._num_sinusoids = num_sinusoids

        self._linear_component = nn.Linear(
            in_features=self._in_features, out_features=self._out_features, bias=self._bias
        )

        self._amplitudes = nn.ParameterList()
        self._frequencies = nn.ParameterList()
        self._phases = nn.ParameterList()

        for _ in range(self._num_sinusoids):
            for paramlist in (self._amplitudes, self._frequencies, self._phases):
                paramlist.append(nn.Parameter(torch.randn(self._out_features, dtype=torch.float32)))

        self.reset()

    def reset(self):
        """Set the timestep t to 0"""
        self._t = 0

    @property
    def t(self) -> int:
        """The current timestep t"""
        return self._t

    @property
    def in_features(self) -> int:
        """Get the length of the input vector"""
        return self._in_features

    @property
    def out_features(self) -> int:
        """Get the length of the output vector"""
        return self._out_features

    @property
    def num_sinusoids(self) -> int:
        """Get the number of sinusoidal waves of the non-linear component"""
        return self._num_sinusoids

    @property
    def bias(self) -> bool:
        """Get whether or not the linear component has bias"""
        return self._bias

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """Execute a forward pass"""
        u_linear = self._linear_component(x)

        t = self._t
        u_nonlinear = torch.zeros(self._out_features)
        for i in range(self._num_sinusoids):
            A = self._amplitudes[i]
            w = self._frequencies[i]
            phi = self._phases[i]
            u_nonlinear = u_nonlinear + (A * torch.sin(w * t + phi))

        self._t += 1

        return u_linear + u_nonlinear
bias: bool property readonly

Get whether or not the linear component has bias

in_features: int property readonly

Get the length of the input vector

num_sinusoids: int property readonly

Get the number of sinusoidal waves of the non-linear component

out_features: int property readonly

Get the length of the output vector

t: int property readonly

The current timestep t

__init__(self, *, in_features, out_features, bias=True, num_sinusoids=16) special

__init__(...): Initialize the LocomotorNet.

Parameters:

Name Type Description Default
in_features int

Length of the input vector

required
out_features int

Length of the output vector

required
bias bool

Whether or not the linear component is to have a bias

True
num_sinusoids

Number of sinusoidal waves

16
Source code in evotorch/neuroevolution/net/layers.py
def __init__(self, *, in_features: int, out_features: int, bias: bool = True, num_sinusoids=16):
    """`__init__(...)`: Initialize the LocomotorNet.

    Args:
        in_features: Length of the input vector
        out_features: Length of the output vector
        bias: Whether or not the linear component is to have a bias
        num_sinusoids: Number of sinusoidal waves
    """

    nn.Module.__init__(self)

    self._in_features = in_features
    self._out_features = out_features
    self._bias = bias
    self._num_sinusoids = num_sinusoids

    self._linear_component = nn.Linear(
        in_features=self._in_features, out_features=self._out_features, bias=self._bias
    )

    self._amplitudes = nn.ParameterList()
    self._frequencies = nn.ParameterList()
    self._phases = nn.ParameterList()

    for _ in range(self._num_sinusoids):
        for paramlist in (self._amplitudes, self._frequencies, self._phases):
            paramlist.append(nn.Parameter(torch.randn(self._out_features, dtype=torch.float32)))

    self.reset()
forward(self, x)

Execute a forward pass

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x: torch.Tensor) -> torch.Tensor:
    """Execute a forward pass"""
    u_linear = self._linear_component(x)

    t = self._t
    u_nonlinear = torch.zeros(self._out_features)
    for i in range(self._num_sinusoids):
        A = self._amplitudes[i]
        w = self._frequencies[i]
        phi = self._phases[i]
        u_nonlinear = u_nonlinear + (A * torch.sin(w * t + phi))

    self._t += 1

    return u_linear + u_nonlinear
reset(self)

Set the timestep t to 0

Source code in evotorch/neuroevolution/net/layers.py
def reset(self):
    """Set the timestep t to 0"""
    self._t = 0
RNN (Module)
Source code in evotorch/neuroevolution/net/layers.py
class RNN(nn.Module):
    def __init__(
        self,
        input_size: int,
        hidden_size: int,
        nonlinearity: str = "tanh",
        *,
        dtype: torch.dtype = torch.float32,
        device: Union[str, torch.device] = "cpu",
    ):
        super().__init__()

        input_size = int(input_size)
        hidden_size = int(hidden_size)
        nonlinearity = str(nonlinearity)

        self.W1 = nn.Parameter(torch.randn(hidden_size, input_size, dtype=dtype, device=device))
        self.W2 = nn.Parameter(torch.randn(hidden_size, hidden_size, dtype=dtype, device=device))
        self.b1 = nn.Parameter(torch.zeros(hidden_size, dtype=dtype, device=device))
        self.b2 = nn.Parameter(torch.zeros(hidden_size, dtype=dtype, device=device))

        if nonlinearity == "tanh":
            self.actfunc = torch.tanh
        else:
            self.actfunc = getattr(nnf, nonlinearity)

        self.nonlinearity = nonlinearity
        self.input_size = input_size
        self.hidden_size = hidden_size

    def forward(self, x: torch.Tensor, h: Optional[torch.Tensor] = None) -> tuple:
        if h is None:
            h = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
        act = self.actfunc
        W1 = self.W1
        W2 = self.W2
        b1 = self.b1.unsqueeze(-1)
        b2 = self.b2.unsqueeze(-1)
        x = x.unsqueeze(-1)
        h = h.unsqueeze(-1)
        y = act(((W1 @ x) + b1) + ((W2 @ h) + b2))
        y = y.squeeze(-1)
        return y, y

    def __repr__(self) -> str:
        clsname = type(self).__name__
        return f"{clsname}(input_size={self.input_size}, hidden_size={self.hidden_size}, nonlinearity={repr(self.nonlinearity)})"
forward(self, x, h=None)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x: torch.Tensor, h: Optional[torch.Tensor] = None) -> tuple:
    if h is None:
        h = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
    act = self.actfunc
    W1 = self.W1
    W2 = self.W2
    b1 = self.b1.unsqueeze(-1)
    b2 = self.b2.unsqueeze(-1)
    x = x.unsqueeze(-1)
    h = h.unsqueeze(-1)
    y = act(((W1 @ x) + b1) + ((W2 @ h) + b2))
    y = y.squeeze(-1)
    return y, y
RecurrentNet (Module)
Source code in evotorch/neuroevolution/net/layers.py
class RNN(nn.Module):
    def __init__(
        self,
        input_size: int,
        hidden_size: int,
        nonlinearity: str = "tanh",
        *,
        dtype: torch.dtype = torch.float32,
        device: Union[str, torch.device] = "cpu",
    ):
        super().__init__()

        input_size = int(input_size)
        hidden_size = int(hidden_size)
        nonlinearity = str(nonlinearity)

        self.W1 = nn.Parameter(torch.randn(hidden_size, input_size, dtype=dtype, device=device))
        self.W2 = nn.Parameter(torch.randn(hidden_size, hidden_size, dtype=dtype, device=device))
        self.b1 = nn.Parameter(torch.zeros(hidden_size, dtype=dtype, device=device))
        self.b2 = nn.Parameter(torch.zeros(hidden_size, dtype=dtype, device=device))

        if nonlinearity == "tanh":
            self.actfunc = torch.tanh
        else:
            self.actfunc = getattr(nnf, nonlinearity)

        self.nonlinearity = nonlinearity
        self.input_size = input_size
        self.hidden_size = hidden_size

    def forward(self, x: torch.Tensor, h: Optional[torch.Tensor] = None) -> tuple:
        if h is None:
            h = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
        act = self.actfunc
        W1 = self.W1
        W2 = self.W2
        b1 = self.b1.unsqueeze(-1)
        b2 = self.b2.unsqueeze(-1)
        x = x.unsqueeze(-1)
        h = h.unsqueeze(-1)
        y = act(((W1 @ x) + b1) + ((W2 @ h) + b2))
        y = y.squeeze(-1)
        return y, y

    def __repr__(self) -> str:
        clsname = type(self).__name__
        return f"{clsname}(input_size={self.input_size}, hidden_size={self.hidden_size}, nonlinearity={repr(self.nonlinearity)})"
forward(self, x, h=None)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x: torch.Tensor, h: Optional[torch.Tensor] = None) -> tuple:
    if h is None:
        h = torch.zeros(self.hidden_size, dtype=x.dtype, device=x.device)
    act = self.actfunc
    W1 = self.W1
    W2 = self.W2
    b1 = self.b1.unsqueeze(-1)
    b2 = self.b2.unsqueeze(-1)
    x = x.unsqueeze(-1)
    h = h.unsqueeze(-1)
    y = act(((W1 @ x) + b1) + ((W2 @ h) + b2))
    y = y.squeeze(-1)
    return y, y
Round (Module)

A small torch module for rounding the values of an input tensor

Source code in evotorch/neuroevolution/net/layers.py
class Round(nn.Module):
    """A small torch module for rounding the values of an input tensor"""

    def __init__(self, ndigits: int = 0):
        nn.Module.__init__(self)
        self._ndigits = int(ndigits)
        self._q = 10.0**self._ndigits

    def forward(self, x):
        x = x * self._q
        x = torch.round(x)
        x = x / self._q
        return x

    def extra_repr(self):
        return "ndigits=" + str(self._ndigits)
extra_repr(self)

Set the extra representation of the module

To print customized extra information, you should re-implement this method in your own modules. Both single-line and multi-line strings are acceptable.

Source code in evotorch/neuroevolution/net/layers.py
def extra_repr(self):
    return "ndigits=" + str(self._ndigits)
forward(self, x)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x):
    x = x * self._q
    x = torch.round(x)
    x = x / self._q
    return x
Slice (Module)

A small torch module for getting the slice of an input tensor

Source code in evotorch/neuroevolution/net/layers.py
class Slice(nn.Module):
    """A small torch module for getting the slice of an input tensor"""

    def __init__(self, from_index: int, to_index: int):
        """`__init__(...)`: Initialize the Slice operator.

        Args:
            from_index: The index from which the slice begins.
            to_index: The exclusive index at which the slice ends.
        """
        nn.Module.__init__(self)
        self._from_index = from_index
        self._to_index = to_index

    def forward(self, x):
        return x[self._from_index : self._to_index]

    def extra_repr(self):
        return "from_index={}, to_index={}".format(self._from_index, self._to_index)
__init__(self, from_index, to_index) special

__init__(...): Initialize the Slice operator.

Parameters:

Name Type Description Default
from_index int

The index from which the slice begins.

required
to_index int

The exclusive index at which the slice ends.

required
Source code in evotorch/neuroevolution/net/layers.py
def __init__(self, from_index: int, to_index: int):
    """`__init__(...)`: Initialize the Slice operator.

    Args:
        from_index: The index from which the slice begins.
        to_index: The exclusive index at which the slice ends.
    """
    nn.Module.__init__(self)
    self._from_index = from_index
    self._to_index = to_index
extra_repr(self)

Set the extra representation of the module

To print customized extra information, you should re-implement this method in your own modules. Both single-line and multi-line strings are acceptable.

Source code in evotorch/neuroevolution/net/layers.py
def extra_repr(self):
    return "from_index={}, to_index={}".format(self._from_index, self._to_index)
forward(self, x)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x):
    return x[self._from_index : self._to_index]
StructuredControlNet (Module)

Structured Control Net.

This is a control network consisting of two components: (i) a non-linear component, which is a feed-forward network; and (ii) a linear component, which is a linear layer. Both components take the input vector provided to the structured control network. The final output is the sum of the outputs of both components.

Reference

Mario Srouji, Jian Zhang, Ruslan Salakhutdinov (2018). Structured Control Nets for Deep Reinforcement Learning.

Source code in evotorch/neuroevolution/net/layers.py
class StructuredControlNet(nn.Module):
    """Structured Control Net.

    This is a control network consisting of two components:
    (i) a non-linear component, which is a feed-forward network; and
    (ii) a linear component, which is a linear layer.
    Both components take the input vector provided to the
    structured control network.
    The final output is the sum of the outputs of both components.

    Reference:
        Mario Srouji, Jian Zhang, Ruslan Salakhutdinov (2018).
        Structured Control Nets for Deep Reinforcement Learning.
    """

    def __init__(
        self,
        *,
        in_features: int,
        out_features: int,
        num_layers: int,
        hidden_size: int,
        bias: bool = True,
        nonlinearity: Union[str, Callable] = "tanh",
    ):
        """`__init__(...)`: Initialize the structured control net.

        Args:
            in_features: Length of the input vector
            out_features: Length of the output vector
            num_layers: Number of hidden layers for the non-linear component
            hidden_size: Number of neurons in a hidden layer of the
                non-linear component
            bias: Whether or not the linear component is to have bias
            nonlinearity: Activation function
        """

        nn.Module.__init__(self)

        self._in_features = in_features
        self._out_features = out_features
        self._num_layers = num_layers
        self._hidden_size = hidden_size
        self._bias = bias
        self._nonlinearity = nonlinearity

        self._linear_component = nn.Linear(
            in_features=self._in_features, out_features=self._out_features, bias=self._bias
        )

        self._nonlinear_component = FeedForwardNet(
            input_size=self._in_features,
            layers=(
                list((self._hidden_size, self._nonlinearity) for _ in range(self._num_layers))
                + [(self._out_features, self._nonlinearity)]
            ),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """TODO: documentation"""
        return self._linear_component(x) + self._nonlinear_component(x)

    @property
    def in_features(self):
        """TODO: documentation"""
        return self._in_features

    @property
    def out_features(self):
        """TODO: documentation"""
        return self._out_features

    @property
    def num_layers(self):
        """TODO: documentation"""
        return self._num_layers

    @property
    def hidden_size(self):
        """TODO: documentation"""
        return self._hidden_size

    @property
    def bias(self):
        """TODO: documentation"""
        return self._bias

    @property
    def nonlinearity(self):
        """TODO: documentation"""
        return self._nonlinearity
bias property readonly
hidden_size property readonly
in_features property readonly
nonlinearity property readonly
num_layers property readonly
out_features property readonly
__init__(self, *, in_features, out_features, num_layers, hidden_size, bias=True, nonlinearity='tanh') special

__init__(...): Initialize the structured control net.

Parameters:

Name Type Description Default
in_features int

Length of the input vector

required
out_features int

Length of the output vector

required
num_layers int

Number of hidden layers for the non-linear component

required
hidden_size int

Number of neurons in a hidden layer of the non-linear component

required
bias bool

Whether or not the linear component is to have bias

True
nonlinearity Union[str, Callable]

Activation function

'tanh'
Source code in evotorch/neuroevolution/net/layers.py
def __init__(
    self,
    *,
    in_features: int,
    out_features: int,
    num_layers: int,
    hidden_size: int,
    bias: bool = True,
    nonlinearity: Union[str, Callable] = "tanh",
):
    """`__init__(...)`: Initialize the structured control net.

    Args:
        in_features: Length of the input vector
        out_features: Length of the output vector
        num_layers: Number of hidden layers for the non-linear component
        hidden_size: Number of neurons in a hidden layer of the
            non-linear component
        bias: Whether or not the linear component is to have bias
        nonlinearity: Activation function
    """

    nn.Module.__init__(self)

    self._in_features = in_features
    self._out_features = out_features
    self._num_layers = num_layers
    self._hidden_size = hidden_size
    self._bias = bias
    self._nonlinearity = nonlinearity

    self._linear_component = nn.Linear(
        in_features=self._in_features, out_features=self._out_features, bias=self._bias
    )

    self._nonlinear_component = FeedForwardNet(
        input_size=self._in_features,
        layers=(
            list((self._hidden_size, self._nonlinearity) for _ in range(self._num_layers))
            + [(self._out_features, self._nonlinearity)]
        ),
    )
forward(self, x)
Source code in evotorch/neuroevolution/net/layers.py
def forward(self, x: torch.Tensor) -> torch.Tensor:
    """TODO: documentation"""
    return self._linear_component(x) + self._nonlinear_component(x)

misc

Utilities for reading and for writing neural network parameters

count_parameters(net)

Get the number of parameters the network.

Parameters:

Name Type Description Default
net Module

The torch module whose parameters will be counted.

required

Returns:

Type Description
int

The number of parameters, as an integer.

Source code in evotorch/neuroevolution/net/misc.py
def count_parameters(net: nn.Module) -> int:
    """
    Get the number of parameters the network.

    Args:
        net: The torch module whose parameters will be counted.
    Returns:
        The number of parameters, as an integer.
    """

    count = 0

    for p in net.parameters():
        count += p.numel()

    return count
device_of_module(m, default=None)

Get the device in which the module exists.

This function looks at the first parameter of the module, and returns its device. This function is not meant to be used on modules whose parameters exist on different devices.

Parameters:

Name Type Description Default
m Module

The module whose device is being queried.

required
default Union[str, torch.device]

The fallback device to return if the module has no parameters. If this is left as None, the fallback device is assumed to be "cpu".

None

Returns:

Type Description
device

The device of the module, determined from its first parameter.

Source code in evotorch/neuroevolution/net/misc.py
def device_of_module(m: nn.Module, default: Optional[Union[str, torch.device]] = None) -> torch.device:
    """
    Get the device in which the module exists.

    This function looks at the first parameter of the module, and returns
    its device. This function is not meant to be used on modules whose
    parameters exist on different devices.

    Args:
        m: The module whose device is being queried.
        default: The fallback device to return if the module has no
            parameters. If this is left as None, the fallback device
            is assumed to be "cpu".
    Returns:
        The device of the module, determined from its first parameter.
    """
    if default is None:
        default = torch.device("cpu")

    device = default

    for p in m.parameters():
        device = p.device
        break

    return device
fill_parameters(net, vector)

Fill the parameters of a torch module (net) from a vector.

No gradient information is kept.

The vector's length must be exactly the same with the number of parameters of the PyTorch module.

Parameters:

Name Type Description Default
net Module

The torch module whose parameter values will be filled.

required
vector Tensor

A 1-D torch tensor which stores the parameter values.

required
Source code in evotorch/neuroevolution/net/misc.py
@torch.no_grad()
def fill_parameters(net: nn.Module, vector: torch.Tensor):
    """Fill the parameters of a torch module (net) from a vector.

    No gradient information is kept.

    The vector's length must be exactly the same with the number
    of parameters of the PyTorch module.

    Args:
        net: The torch module whose parameter values will be filled.
        vector: A 1-D torch tensor which stores the parameter values.
    """
    address = 0
    for p in net.parameters():
        d = p.data.view(-1)
        n = len(d)
        d[:] = torch.as_tensor(vector[address : address + n], device=d.device)
        address += n

    if address != len(vector):
        raise IndexError("The parameter vector is larger than expected")
parameter_vector(net, *, device=None)

Get all the parameters of a torch module (net) into a vector

No gradient information is kept.

Parameters:

Name Type Description Default
net Module

The torch module whose parameters will be extracted.

required
device Union[str, torch.device]

The device in which the parameter vector will be constructed. If the network has parameter across multiple devices, you can specify this argument so that concatenation of all the parameters will be successful.

None

Returns:

Type Description
Tensor

The parameters of the module in a 1-D tensor.

Source code in evotorch/neuroevolution/net/misc.py
@torch.no_grad()
def parameter_vector(net: nn.Module, *, device: Optional[Device] = None) -> torch.Tensor:
    """Get all the parameters of a torch module (net) into a vector

    No gradient information is kept.

    Args:
        net: The torch module whose parameters will be extracted.
        device: The device in which the parameter vector will be constructed.
            If the network has parameter across multiple devices,
            you can specify this argument so that concatenation of all the
            parameters will be successful.
    Returns:
        The parameters of the module in a 1-D tensor.
    """
    dev_kwarg = {} if device is None else {"device": device}

    all_vectors = []
    for p in net.parameters():
        all_vectors.append(torch.as_tensor(p.data.view(-1), **dev_kwarg))

    return torch.cat(all_vectors)

multilayered

MultiLayered (Module)
Source code in evotorch/neuroevolution/net/multilayered.py
class MultiLayered(nn.Module):
    def __init__(self, *layers: nn.Module):
        super().__init__()
        self._submodules = nn.ModuleList(layers)

    def forward(self, x: torch.Tensor, h: Optional[dict] = None):
        if h is None:
            h = {}

        new_h = {}

        for i, layer in enumerate(self._submodules):
            layer_h = h.get(i, None)
            if layer_h is None:
                layer_result = layer(x)
            else:
                layer_result = layer(x, h[i])

            if isinstance(layer_result, tuple):
                if len(layer_result) == 2:
                    x, layer_new_h = layer_result
                else:
                    raise ValueError(
                        f"The layer number {i} returned a tuple of length {len(layer_result)}."
                        f" A tensor or a tuple of two elements was expected."
                    )
            elif isinstance(layer_result, torch.Tensor):
                x = layer_result
                layer_new_h = None
            else:
                raise TypeError(
                    f"The layer number {i} returned an object of type {type(layer_result)}."
                    f" A tensor or a tuple of two elements was expected."
                )

            if layer_new_h is not None:
                new_h[i] = layer_new_h

        if len(new_h) == 0:
            return x
        else:
            return x, new_h

    def __iter__(self):
        return self._submodules.__iter__()

    def __getitem__(self, i):
        return self._submodules[i]

    def __len__(self):
        return len(self._submodules)

    def append(self, module: nn.Module):
        self._submodules.append(module)
forward(self, x, h=None)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/multilayered.py
def forward(self, x: torch.Tensor, h: Optional[dict] = None):
    if h is None:
        h = {}

    new_h = {}

    for i, layer in enumerate(self._submodules):
        layer_h = h.get(i, None)
        if layer_h is None:
            layer_result = layer(x)
        else:
            layer_result = layer(x, h[i])

        if isinstance(layer_result, tuple):
            if len(layer_result) == 2:
                x, layer_new_h = layer_result
            else:
                raise ValueError(
                    f"The layer number {i} returned a tuple of length {len(layer_result)}."
                    f" A tensor or a tuple of two elements was expected."
                )
        elif isinstance(layer_result, torch.Tensor):
            x = layer_result
            layer_new_h = None
        else:
            raise TypeError(
                f"The layer number {i} returned an object of type {type(layer_result)}."
                f" A tensor or a tuple of two elements was expected."
            )

        if layer_new_h is not None:
            new_h[i] = layer_new_h

    if len(new_h) == 0:
        return x
    else:
        return x, new_h

parser

Utilities for parsing string representations of neural net policies

NetParsingError (Exception)

Representation of a parsing error

Source code in evotorch/neuroevolution/net/parser.py
class NetParsingError(Exception):
    """
    Representation of a parsing error
    """

    def __init__(
        self,
        message: str,
        lineno: Optional[int] = None,
        col_offset: Optional[int] = None,
        original_error: Optional[Exception] = None,
    ):
        """
        `__init__(...)`: Initialize the NetParsingError.

        Args:
            message: Error message, as string.
            lineno: Erroneous line number in the string representation of the
                neural network structure.
            col_offset: Erroneous column number in the string representation
                of the neural network structure.
            original_error: If another error caused this parsing error,
                that original error can be attached to this `NetParsingError`
                instance via this argument.
        """
        super().__init__()
        self.message = message
        self.lineno = lineno
        self.col_offset = col_offset
        self.original_error = original_error

    def _to_string(self) -> str:
        parts = []

        parts.append(type(self).__name__)

        if self.lineno is not None:
            parts.append(" at line(")
            parts.append(str(self.lineno - 1))
            parts.append(")")

        if self.col_offset is not None:
            parts.append(" at column(")
            parts.append(str(self.col_offset + 1))
            parts.append(")")

        parts.append(": ")
        parts.append(self.message)

        return "".join(parts)

    def __str__(self) -> str:
        return self._to_string()

    def __repr__(self) -> str:
        return self._to_string()
__init__(self, message, lineno=None, col_offset=None, original_error=None) special

__init__(...): Initialize the NetParsingError.

Parameters:

Name Type Description Default
message str

Error message, as string.

required
lineno Optional[int]

Erroneous line number in the string representation of the neural network structure.

None
col_offset Optional[int]

Erroneous column number in the string representation of the neural network structure.

None
original_error Optional[Exception]

If another error caused this parsing error, that original error can be attached to this NetParsingError instance via this argument.

None
Source code in evotorch/neuroevolution/net/parser.py
def __init__(
    self,
    message: str,
    lineno: Optional[int] = None,
    col_offset: Optional[int] = None,
    original_error: Optional[Exception] = None,
):
    """
    `__init__(...)`: Initialize the NetParsingError.

    Args:
        message: Error message, as string.
        lineno: Erroneous line number in the string representation of the
            neural network structure.
        col_offset: Erroneous column number in the string representation
            of the neural network structure.
        original_error: If another error caused this parsing error,
            that original error can be attached to this `NetParsingError`
            instance via this argument.
    """
    super().__init__()
    self.message = message
    self.lineno = lineno
    self.col_offset = col_offset
    self.original_error = original_error
str_to_net(s, **constants)

Read a string representation of a neural net structure, and return a torch.nn.Module instance out of it.

Let us imagine that one wants to describe the following neural network structure:

from torch import nn
from evotorch.neuroevolution.net import MultiLayered

net = MultiLayered(nn.Linear(8, 16), nn.Tanh(), nn.Linear(16, 4, bias=False), nn.ReLU())

By using str_to_net(...) one can construct an equivalent module via:

from evotorch.neuroevolution.net import str_to_net

net = str_to_net("Linear(8, 16) >> Tanh() >> Linear(16, 4, bias=False) >> ReLU()")

The string can also be multi-line:

net = str_to_net(
    '''
    Linear(8, 16)
    >> Tanh()
    >> Linear(16, 4, bias=False)
    >> ReLU()
    '''
)

One can also define constants for using them in strings:

net = str_to_net(
    '''
    Linear(input_size, hidden_size)
    >> Tanh()
    >> Linear(hidden_size, output_size, bias=False)
    >> ReLU()
    ''',
    input_size=8,
    hidden_size=16,
    output_size=4,
)

In the neural net structure string, when one refers to a module type, say, Linear, first the name Linear is searched for in the namespace evotorch.neuroevolution.net.layers, and then in the namespace torch.nn. In the case of Linear, the searched name exists in torch.nn, and therefore, the layer type to be instantiated is accepted as torch.nn.Linear. Instead of Linear, if one had used the name, say, StructuredControlNet, then, the layer type to be instantiated would be evotorch.neuroevolution.net.layers.StructuredControlNet.

The namespace evotorch.neuroevolution.net.layers contains its own implementations for RNN and LSTM. These recurrent layer implementations work similarly to their counterparts torch.nn.RNN and torch.nn.LSTM, except that EvoTorch's implementations do not expect the data with extra leftmost dimensions for batching and for timesteps. Instead, they expect to receive a single input and a single current hidden state, and produce a single output and a single new hidden state. These recurrent layer implementations of EvoTorch can be used within a neural net structure string. Therefore, the following examples are valid:

rnn1 = str_to_net("RNN(4, 8) >> Linear(8, 2)")

rnn2 = str_to_net(
    '''
    Linear(4, 10)
    >> Tanh()
    >> RNN(input_size=10, hidden_size=24, nonlinearity='tanh'
    >> Linear(24, 2)
    '''
)

lstm1 = str_to_net("LSTM(4, 32) >> Linear(32, 2)")

lstm2 = str_to_net("LSTM(input_size=4, hidden_size=32) >> Linear(32, 2)")

Notes regarding usage with evotorch.neuroevolution.GymNE or with evotorch.neuroevolution.VecGymNE:

While instantiating a GymNE or a VecGymNE, one can specify a neural net structure string as the policy. Therefore, while filling the policy string for a GymNE, all these rules mentioned above apply. Additionally, while using str_to_net(...) internally, GymNE and VecGymNE define these extra constants: obs_length (length of the observation vector), act_length (length of the action vector for continuous-action environments, or number of actions for discrete-action environments), and obs_shape (shape of the observation as a tuple, assuming that the observation space is of type gym.spaces.Box, usable within the string like obs_shape[0], obs_shape[1], etc., or simply obs_shape to refer to the entire tuple).

Therefore, while instantiating a GymNE or a VecGymNE, one can define a single-hidden-layered policy via this string:

"Linear(obs_length, 16) >> Tanh() >> Linear(16, act_length) >> Tanh()"

In the policy string above, one might choose to omit the last Tanh(), as GymNE and VecGymNE will clip the final output of the policy to conform to the action boundaries defined by the target reinforcement learning environment, and such a clipping operation might be seen as using an activation function similar to hard-tanh anyway.

Parameters:

Name Type Description Default
s str

The string which expresses the neural net structure.

required

Returns:

Type Description
Module

The PyTorch module of the specified structure.

Source code in evotorch/neuroevolution/net/parser.py
def str_to_net(s: str, **constants) -> nn.Module:
    """
    Read a string representation of a neural net structure,
    and return a `torch.nn.Module` instance out of it.

    Let us imagine that one wants to describe the following
    neural network structure:

    ```python
    from torch import nn
    from evotorch.neuroevolution.net import MultiLayered

    net = MultiLayered(nn.Linear(8, 16), nn.Tanh(), nn.Linear(16, 4, bias=False), nn.ReLU())
    ```

    By using `str_to_net(...)` one can construct an equivalent
    module via:

    ```python
    from evotorch.neuroevolution.net import str_to_net

    net = str_to_net("Linear(8, 16) >> Tanh() >> Linear(16, 4, bias=False) >> ReLU()")
    ```

    The string can also be multi-line:

    ```python
    net = str_to_net(
        '''
        Linear(8, 16)
        >> Tanh()
        >> Linear(16, 4, bias=False)
        >> ReLU()
        '''
    )
    ```

    One can also define constants for using them in strings:

    ```python
    net = str_to_net(
        '''
        Linear(input_size, hidden_size)
        >> Tanh()
        >> Linear(hidden_size, output_size, bias=False)
        >> ReLU()
        ''',
        input_size=8,
        hidden_size=16,
        output_size=4,
    )
    ```

    In the neural net structure string, when one refers to a module type,
    say, `Linear`, first the name `Linear` is searched for in the namespace
    `evotorch.neuroevolution.net.layers`, and then in the namespace `torch.nn`.
    In the case of `Linear`, the searched name exists in `torch.nn`,
    and therefore, the layer type to be instantiated is accepted as
    `torch.nn.Linear`.
    Instead of `Linear`, if one had used the name, say,
    `StructuredControlNet`, then, the layer type to be instantiated
    would be `evotorch.neuroevolution.net.layers.StructuredControlNet`.

    The namespace `evotorch.neuroevolution.net.layers` contains its own
    implementations for RNN and LSTM. These recurrent layer implementations
    work similarly to their counterparts `torch.nn.RNN` and `torch.nn.LSTM`,
    except that EvoTorch's implementations do not expect the data with extra
    leftmost dimensions for batching and for timesteps. Instead, they expect
    to receive a single input and a single current hidden state, and produce
    a single output and a single new hidden state. These recurrent layer
    implementations of EvoTorch can be used within a neural net structure
    string. Therefore, the following examples are valid:

    ```python
    rnn1 = str_to_net("RNN(4, 8) >> Linear(8, 2)")

    rnn2 = str_to_net(
        '''
        Linear(4, 10)
        >> Tanh()
        >> RNN(input_size=10, hidden_size=24, nonlinearity='tanh'
        >> Linear(24, 2)
        '''
    )

    lstm1 = str_to_net("LSTM(4, 32) >> Linear(32, 2)")

    lstm2 = str_to_net("LSTM(input_size=4, hidden_size=32) >> Linear(32, 2)")
    ```

    **Notes regarding usage with `evotorch.neuroevolution.GymNE`
    or with `evotorch.neuroevolution.VecGymNE`:**

    While instantiating a `GymNE` or a `VecGymNE`, one can specify a neural
    net structure string as the policy. Therefore, while filling the policy
    string for a `GymNE`, all these rules mentioned above apply. Additionally,
    while using `str_to_net(...)` internally, `GymNE` and `VecGymNE` define
    these extra constants:
    `obs_length` (length of the observation vector),
    `act_length` (length of the action vector for continuous-action
    environments, or number of actions for discrete-action
    environments), and
    `obs_shape` (shape of the observation as a tuple, assuming that the
    observation space is of type `gym.spaces.Box`, usable within the string
    like `obs_shape[0]`, `obs_shape[1]`, etc., or simply `obs_shape` to refer
    to the entire tuple).

    Therefore, while instantiating a `GymNE` or a `VecGymNE`, one can define a
    single-hidden-layered policy via this string:

    ```
    "Linear(obs_length, 16) >> Tanh() >> Linear(16, act_length) >> Tanh()"
    ```

    In the policy string above, one might choose to omit the last `Tanh()`, as
    `GymNE` and `VecGymNE` will clip the final output of the policy to conform
    to the action boundaries defined by the target reinforcement learning
    environment, and such a clipping operation might be seen as using an
    activation function similar to hard-tanh anyway.

    Args:
        s: The string which expresses the neural net structure.
    Returns:
        The PyTorch module of the specified structure.
    """
    s = f"(\n{s}\n)"
    return _process_expr(ast.parse(s, mode="eval").body, constants=constants)

rl

This namespace provides various reinforcement learning utilities.

ActClipWrapperModule (Module)
Source code in evotorch/neuroevolution/net/rl.py
class ActClipWrapperModule(nn.Module):
    def __init__(self, wrapped_module: nn.Module, obs_space: Box):
        super().__init__()

        device = device_of_module(wrapped_module)

        if not isinstance(obs_space, Box):
            raise TypeError(f"Unrecognized observation space: {obs_space}")

        self.wrapped_module = wrapped_module
        self.register_buffer("_low", torch.from_numpy(obs_space.low).to(device))
        self.register_buffer("_high", torch.from_numpy(obs_space.high).to(device))

    def forward(self, x: torch.Tensor, h: Any = None) -> Union[torch.Tensor, tuple]:
        if h is None:
            result = self.wrapped_module(x)
        else:
            result = self.wrapped_module(x, h)

        if isinstance(result, tuple):
            x, h = result
            got_h = True
        else:
            x = result
            h = None
            got_h = False

        x = torch.max(x, self._low)
        x = torch.min(x, self._high)

        if got_h:
            return x, h
        else:
            return x
forward(self, x, h=None)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/rl.py
def forward(self, x: torch.Tensor, h: Any = None) -> Union[torch.Tensor, tuple]:
    if h is None:
        result = self.wrapped_module(x)
    else:
        result = self.wrapped_module(x, h)

    if isinstance(result, tuple):
        x, h = result
        got_h = True
    else:
        x = result
        h = None
        got_h = False

    x = torch.max(x, self._low)
    x = torch.min(x, self._high)

    if got_h:
        return x, h
    else:
        return x
AliveBonusScheduleWrapper (Wrapper)

A Wrapper which awards the agent for being alive in a scheduled manner This wrapper is meant to be used for non-vectorized environments.

Source code in evotorch/neuroevolution/net/rl.py
class AliveBonusScheduleWrapper(gym.Wrapper):
    """
    A Wrapper which awards the agent for being alive in a scheduled manner
    This wrapper is meant to be used for non-vectorized environments.
    """

    def __init__(self, env: gym.Env, alive_bonus_schedule: tuple, **kwargs):
        """
        `__init__(...)`: Initialize the AliveBonusScheduleWrapper.

        Args:
            env: Environment to wrap.
            alive_bonus_schedule: If given as a tuple `(t, b)`, an alive
                bonus `b` will be added onto all the rewards beyond the
                timestep `t`.
                If given as a tuple `(t0, t1, b)`, a partial (linearly
                increasing towards `b`) alive bonus will be added onto
                all the rewards between the timesteps `t0` and `t1`,
                and a full alive bonus (which equals to `b`) will be added
                onto all the rewards beyond the timestep `t1`.
            kwargs: Expected in the form of additional keyword arguments,
                these will be passed to the initialization method of the
                superclass.
        """
        super().__init__(env, **kwargs)
        self.__t: Optional[int] = None

        if len(alive_bonus_schedule) == 3:
            self.__t0, self.__t1, self.__bonus = (
                int(alive_bonus_schedule[0]),
                int(alive_bonus_schedule[1]),
                float(alive_bonus_schedule[2]),
            )
        elif len(alive_bonus_schedule) == 2:
            self.__t0, self.__t1, self.__bonus = (
                int(alive_bonus_schedule[0]),
                int(alive_bonus_schedule[0]),
                float(alive_bonus_schedule[1]),
            )
        else:
            raise ValueError(
                f"The argument `alive_bonus_schedule` was expected to have 2 or 3 elements."
                f" However, its value is {repr(alive_bonus_schedule)} (having {len(alive_bonus_schedule)} elements)."
            )

        if self.__t1 > self.__t0:
            self.__gap = self.__t1 - self.__t0
        else:
            self.__gap = None

    def reset(self, *args, **kwargs):
        self.__t = 0
        return self.env.reset(*args, **kwargs)

    def step(self, action) -> tuple:
        step_result = self.env.step(action)
        self.__t += 1

        observation = step_result[0]
        reward = step_result[1]
        rest = step_result[2:]

        if self.__t >= self.__t1:
            reward = reward + self.__bonus
        elif (self.__gap is not None) and (self.__t >= self.__t0):
            reward = reward + ((self.__t - self.__t0) / self.__gap) * self.__bonus

        return (observation, reward) + rest
__init__(self, env, alive_bonus_schedule, **kwargs) special

__init__(...): Initialize the AliveBonusScheduleWrapper.

Parameters:

Name Type Description Default
env Env

Environment to wrap.

required
alive_bonus_schedule tuple

If given as a tuple (t, b), an alive bonus b will be added onto all the rewards beyond the timestep t. If given as a tuple (t0, t1, b), a partial (linearly increasing towards b) alive bonus will be added onto all the rewards between the timesteps t0 and t1, and a full alive bonus (which equals to b) will be added onto all the rewards beyond the timestep t1.

required
kwargs

Expected in the form of additional keyword arguments, these will be passed to the initialization method of the superclass.

{}
Source code in evotorch/neuroevolution/net/rl.py
def __init__(self, env: gym.Env, alive_bonus_schedule: tuple, **kwargs):
    """
    `__init__(...)`: Initialize the AliveBonusScheduleWrapper.

    Args:
        env: Environment to wrap.
        alive_bonus_schedule: If given as a tuple `(t, b)`, an alive
            bonus `b` will be added onto all the rewards beyond the
            timestep `t`.
            If given as a tuple `(t0, t1, b)`, a partial (linearly
            increasing towards `b`) alive bonus will be added onto
            all the rewards between the timesteps `t0` and `t1`,
            and a full alive bonus (which equals to `b`) will be added
            onto all the rewards beyond the timestep `t1`.
        kwargs: Expected in the form of additional keyword arguments,
            these will be passed to the initialization method of the
            superclass.
    """
    super().__init__(env, **kwargs)
    self.__t: Optional[int] = None

    if len(alive_bonus_schedule) == 3:
        self.__t0, self.__t1, self.__bonus = (
            int(alive_bonus_schedule[0]),
            int(alive_bonus_schedule[1]),
            float(alive_bonus_schedule[2]),
        )
    elif len(alive_bonus_schedule) == 2:
        self.__t0, self.__t1, self.__bonus = (
            int(alive_bonus_schedule[0]),
            int(alive_bonus_schedule[0]),
            float(alive_bonus_schedule[1]),
        )
    else:
        raise ValueError(
            f"The argument `alive_bonus_schedule` was expected to have 2 or 3 elements."
            f" However, its value is {repr(alive_bonus_schedule)} (having {len(alive_bonus_schedule)} elements)."
        )

    if self.__t1 > self.__t0:
        self.__gap = self.__t1 - self.__t0
    else:
        self.__gap = None
reset(self, *args, **kwargs)

Uses the :meth:reset of the :attr:env that can be overwritten to change the returned data.

Source code in evotorch/neuroevolution/net/rl.py
def reset(self, *args, **kwargs):
    self.__t = 0
    return self.env.reset(*args, **kwargs)
step(self, action)

Uses the :meth:step of the :attr:env that can be overwritten to change the returned data.

Source code in evotorch/neuroevolution/net/rl.py
def step(self, action) -> tuple:
    step_result = self.env.step(action)
    self.__t += 1

    observation = step_result[0]
    reward = step_result[1]
    rest = step_result[2:]

    if self.__t >= self.__t1:
        reward = reward + self.__bonus
    elif (self.__gap is not None) and (self.__t >= self.__t0):
        reward = reward + ((self.__t - self.__t0) / self.__gap) * self.__bonus

    return (observation, reward) + rest
ObsNormWrapperModule (Module)
Source code in evotorch/neuroevolution/net/rl.py
class ObsNormWrapperModule(nn.Module):
    def __init__(self, wrapped_module: nn.Module, rn: Union[RunningStat, RunningNorm]):
        super().__init__()

        device = device_of_module(wrapped_module)
        self.wrapped_module = wrapped_module

        with torch.no_grad():
            normalizer = deepcopy(rn.to_layer()).to(device)
        self.normalizer = normalizer

    def forward(self, x: torch.Tensor, h: Any = None) -> Union[torch.Tensor, tuple]:
        x = self.normalizer(x)

        if h is None:
            result = self.wrapped_module(x)
        else:
            result = self.wrapped_module(x, h)

        if isinstance(result, tuple):
            x, h = result
            got_h = True
        else:
            x = result
            h = None
            got_h = False

        if got_h:
            return x, h
        else:
            return x
forward(self, x, h=None)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/rl.py
def forward(self, x: torch.Tensor, h: Any = None) -> Union[torch.Tensor, tuple]:
    x = self.normalizer(x)

    if h is None:
        result = self.wrapped_module(x)
    else:
        result = self.wrapped_module(x, h)

    if isinstance(result, tuple):
        x, h = result
        got_h = True
    else:
        x = result
        h = None
        got_h = False

    if got_h:
        return x, h
    else:
        return x
reset_env(env)

Reset a gymnasium environment.

Even though the gymnasium library switched to a new API where the reset() method returns a tuple (observation, info), this function follows the conventions of the classical gym library and returns only the observation of the newly reset environment.

Parameters:

Name Type Description Default
env Env

The gymnasium environment which will be reset.

required

Returns:

Type Description
Iterable

The initial observation

Source code in evotorch/neuroevolution/net/rl.py
def reset_env(env: gym.Env) -> Iterable:
    """
    Reset a gymnasium environment.

    Even though the `gymnasium` library switched to a new API where the
    `reset()` method returns a tuple `(observation, info)`, this function
    follows the conventions of the classical `gym` library and returns
    only the observation of the newly reset environment.

    Args:
        env: The gymnasium environment which will be reset.
    Returns:
        The initial observation
    """
    result = env.reset()
    if isinstance(result, tuple) and (len(result) == 2):
        result = result[0]
    return result
take_step_in_env(env, action)

Take a step in the gymnasium environment. Taking a step means performing the action provided via the arguments.

Even though the gymnasium library switched to a new API where the step() method returns a 5-element tuple of the form (observation, reward, terminated, truncated, info), this function follows the conventions of the classical gym library and returns a 4-element tuple (observation, reward, done, info).

Parameters:

Name Type Description Default
env Env

The gymnasium environment in which the action will be performed.

required
action Iterable

The action to be performed.

required

Returns:

Type Description
tuple

A tuple in the form (observation, reward, done, info) where observation is the observation received after performing the action, reward is the amount of reward gained, done is a boolean value indicating whether or not the episode has ended, and info is additional information (usually as a dictionary).

Source code in evotorch/neuroevolution/net/rl.py
def take_step_in_env(env: gym.Env, action: Iterable) -> tuple:
    """
    Take a step in the gymnasium environment.
    Taking a step means performing the action provided via the arguments.

    Even though the `gymnasium` library switched to a new API where the
    `step()` method returns a 5-element tuple of the form
    `(observation, reward, terminated, truncated, info)`, this function
    follows the conventions of the classical `gym` library and returns
    a 4-element tuple `(observation, reward, done, info)`.

    Args:
        env: The gymnasium environment in which the action will be performed.
        action: The action to be performed.
    Returns:
        A tuple in the form `(observation, reward, done, info)` where
        `observation` is the observation received after performing the action,
        `reward` is the amount of reward gained,
        `done` is a boolean value indicating whether or not the episode has
        ended, and
        `info` is additional information (usually as a dictionary).
    """
    result = env.step(action)
    if isinstance(result, tuple):
        n = len(result)
        if n == 4:
            observation, reward, done, info = result
        elif n == 5:
            observation, reward, terminated, truncated, info = result
            done = terminated or truncated
        else:
            raise ValueError(
                f"The result of the `step(...)` method of the gym environment"
                f" was expected as a tuple of length 4 or 5."
                f" However, the received result is {repr(result)}, which is"
                f" of length {len(result)}."
            )
    else:
        raise TypeError(
            f"The result of the `step(...)` method of the gym environment"
            f" was expected as a tuple of length 4 or 5."
            f" However, the received result is {repr(result)}, which is"
            f" of type {type(result)}."
        )
    return observation, reward, done, info

runningnorm

CollectedStats (tuple)

CollectedStats(mean, stdev)

__getnewargs__(self) special

Return self as a plain tuple. Used by copy and pickle.

Source code in evotorch/neuroevolution/net/runningnorm.py
def __getnewargs__(self):
    'Return self as a plain tuple.  Used by copy and pickle.'
    return _tuple(self)
__new__(_cls, mean, stdev) special staticmethod

Create new instance of CollectedStats(mean, stdev)

__repr__(self) special

Return a nicely formatted representation string

Source code in evotorch/neuroevolution/net/runningnorm.py
def __repr__(self):
    'Return a nicely formatted representation string'
    return self.__class__.__name__ + repr_fmt % self
ObsNormLayer (Module)

An observation normalizer which behaves as a PyTorch Module.

Source code in evotorch/neuroevolution/net/runningnorm.py
class ObsNormLayer(nn.Module):
    """
    An observation normalizer which behaves as a PyTorch Module.
    """

    def __init__(
        self, mean: torch.Tensor, stdev: torch.Tensor, low: Optional[float] = None, high: Optional[float] = None
    ) -> None:
        """
        `__init__(...)`: Initialize the ObsNormLayer.

        Args:
            mean: The mean according to which the observations are to be
                normalized.
            stdev: The standard deviation according to which the observations
                are to be normalized.
            low: Optionally a real number if the result of the normalization
                is to be clipped. Represents the lower bound for the clipping
                operation.
            high: Optionally a real number if the result of the normalization
                is to be clipped. Represents the upper bound for the clipping
                operation.
        """
        super().__init__()
        self.register_buffer("_mean", mean)
        self.register_buffer("_stdev", stdev)
        self._lb = None if low is None else float(low)
        self._ub = None if high is None else float(high)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Normalize an observation or a batch of observations.

        Args:
            x: The observation(s).
        Returns:
            The normalized counterpart of the observation(s).
        """
        return _clamp((x - self._mean) / self._stdev, self._lb, self._ub)
__init__(self, mean, stdev, low=None, high=None) special

__init__(...): Initialize the ObsNormLayer.

Parameters:

Name Type Description Default
mean Tensor

The mean according to which the observations are to be normalized.

required
stdev Tensor

The standard deviation according to which the observations are to be normalized.

required
low Optional[float]

Optionally a real number if the result of the normalization is to be clipped. Represents the lower bound for the clipping operation.

None
high Optional[float]

Optionally a real number if the result of the normalization is to be clipped. Represents the upper bound for the clipping operation.

None
Source code in evotorch/neuroevolution/net/runningnorm.py
def __init__(
    self, mean: torch.Tensor, stdev: torch.Tensor, low: Optional[float] = None, high: Optional[float] = None
) -> None:
    """
    `__init__(...)`: Initialize the ObsNormLayer.

    Args:
        mean: The mean according to which the observations are to be
            normalized.
        stdev: The standard deviation according to which the observations
            are to be normalized.
        low: Optionally a real number if the result of the normalization
            is to be clipped. Represents the lower bound for the clipping
            operation.
        high: Optionally a real number if the result of the normalization
            is to be clipped. Represents the upper bound for the clipping
            operation.
    """
    super().__init__()
    self.register_buffer("_mean", mean)
    self.register_buffer("_stdev", stdev)
    self._lb = None if low is None else float(low)
    self._ub = None if high is None else float(high)
forward(self, x)

Normalize an observation or a batch of observations.

Parameters:

Name Type Description Default
x Tensor

The observation(s).

required

Returns:

Type Description
Tensor

The normalized counterpart of the observation(s).

Source code in evotorch/neuroevolution/net/runningnorm.py
def forward(self, x: torch.Tensor) -> torch.Tensor:
    """
    Normalize an observation or a batch of observations.

    Args:
        x: The observation(s).
    Returns:
        The normalized counterpart of the observation(s).
    """
    return _clamp((x - self._mean) / self._stdev, self._lb, self._ub)
RunningNorm

An online observation normalization tool

Source code in evotorch/neuroevolution/net/runningnorm.py
class RunningNorm:
    """
    An online observation normalization tool
    """

    def __init__(
        self,
        *,
        shape: Union[tuple, int],
        dtype: DType,
        device: Optional[Device] = None,
        min_variance: float = 1e-2,
        clip: Optional[tuple] = None,
    ) -> None:
        """
        `__init__(...)`: Initialize the RunningNorm

        Args:
            shape: Observation shape. Can be an integer or a tuple.
            dtype: The dtype of the observations.
            device: The device in which the observation stats are held.
                If left as None, the device is assumed to be "cpu".
            min_variance: A lower bound for the variance to be used in
                the normalization computations.
                In other words, if the computed variance according to the
                collected observations ends up lower than `min_variance`,
                this `min_variance` will be used instead (in an elementwise
                manner) while computing the normalized observations.
                As in Salimans et al. (2017), the default is 1e-2.
            clip: Can be left as None (which is the default), or can be
                given as a pair of real numbers.
                This is used for clipping the observations after the
                normalization operation.
                In Salimans et al. (2017), (-5.0, +5.0) was used.
        """

        # Make sure that the shape is stored as a torch.Size object.
        if isinstance(shape, Iterable):
            self._shape = torch.Size(shape)
        else:
            self._shape = torch.Size([int(shape)])

        # Store the number of dimensions
        self._ndim = len(self._shape)

        # Store the dtype and the device
        self._dtype = to_torch_dtype(dtype)
        self._device = "cpu" if device is None else device

        # Initialize the internally stored data as empty
        self._sum: Optional[torch.Tensor] = None
        self._sum_of_squares: Optional[torch.Tensor] = None
        self._count: int = 0

        # Store the minimum variance
        self._min_variance = float(min_variance)

        if clip is not None:
            # If a clip tuple was provided, store the specified lower and upper bounds
            lb, ub = clip
            self._lb = float(lb)
            self._ub = float(ub)
        else:
            # If a clip tuple was not provided the bounds are stored as None
            self._lb = None
            self._ub = None

    def to(self, device: Device) -> "RunningNorm":
        """
        If the target device is a different device, then make a copy of this
        RunningNorm instance on the target device.
        If the target device is the same with this RunningNorm's device, then
        return this RunningNorm itself.

        Args:
            device: The target device.
        Returns:
            The RunningNorm on the target device. This can be a copy, or the
            original RunningNorm instance itself.
        """
        if torch.device(device) == torch.device(self.device):
            return self
        else:
            new_running_norm = object.__new__(type(self))

            already_handled = {"_sum", "_sum_of_squares", "_device"}
            new_running_norm._sum = self._sum.to(device)
            new_running_norm._sum_of_squares = self._sum_of_squares.to(device)
            new_running_norm._device = device

            for k, v in self.__dict__.items():
                if k not in already_handled:
                    setattr(new_running_norm, k, deepcopy(v))

            return new_running_norm

    @property
    def device(self) -> Device:
        """
        The device in which the observation stats are held
        """
        return self._device

    @property
    def dtype(self) -> DType:
        """
        The dtype of the stored observation stats
        """
        return self._dtype

    @property
    def shape(self) -> tuple:
        """
        Observation shape
        """
        return self._shape

    @property
    def min_variance(self) -> float:
        """
        Minimum variance
        """
        return self._min_variance

    @property
    def low(self) -> Optional[float]:
        """
        The lower component of the bounds given in the `clip` tuple.
        If `clip` was initialized as None, this is also None.
        """
        return self._lb

    @property
    def high(self) -> Optional[float]:
        """
        The higher (upper) component of the bounds given in the `clip` tuple.
        If `clip` was initialized as None, this is also None.
        """
        return self._ub

    def _like_its_own(self, x: Iterable) -> torch.Tensor:
        return torch.as_tensor(x, dtype=self._dtype, device=self._device)

    def _verify(self, x: Iterable) -> torch.Tensor:
        x = self._like_its_own(x)
        if x.ndim == self._ndim:
            if x.shape != self._shape:
                raise ValueError(
                    f"This RunningNorm instance was initialized with shape: {self._shape}."
                    f" However, the provided tensor has an incompatible shape: {x._shape}."
                )
        elif x.ndim == (self._ndim + 1):
            if x.shape[1:] != self._shape:
                raise ValueError(
                    f"This RunningNorm instance was initialized with shape: {self._shape}."
                    f" The provided tensor is shaped {x.shape}."
                    f" Accepting the tensor's leftmost dimension as the batch size,"
                    f" the remaining shape is incompatible: {x.shape[1:]}"
                )
        else:
            raise ValueError(
                f"This RunningNorm instance was initialized with shape: {self._shape}."
                f" The provided tensor is shaped {x.shape}."
                f" The number of dimensions of the given tensor is incompatible."
            )
        return x

    def _has_no_data(self) -> bool:
        return (self._sum is None) and (self._sum_of_squares is None) and (self._count == 0)

    def _has_data(self) -> bool:
        return (self._sum is not None) and (self._sum_of_squares is not None) and (self._count > 0)

    def reset(self):
        """
        Remove all the collected observation data.
        """
        self._sum = None
        self._sum_of_squares = None
        self._count = 0

    @torch.no_grad()
    def update(self, x: Union[Iterable, "RunningNorm"], mask: Optional[Iterable] = None, *, verify: bool = True):
        """
        Update the stored stats with new observation data.

        Args:
            x: The new observation(s), as a PyTorch tensor, or any Iterable
                that can be converted to a PyTorch tensor, or another
                RunningNorm instance.
                If given as a tensor or as an Iterable, the shape of `x` can
                be the same with observation shape, or it can be augmented
                with an extra leftmost dimension.
                In the case of augmented dimension, `x` is interpreted not as
                a single observation, but as a batch of observations.
                If `x` is another RunningNorm instance, the stats stored by
                this RunningNorm instance will be updated with all the data
                stored by `x`.
            mask: Can be given as a 1-dimensional Iterable of booleans ONLY
                if `x` represents a batch of observations.
                If a `mask` is provided, the i-th observation within the
                observation batch `x` will be taken into account only if
                the i-th item of the `mask` is True.
            verify: Whether or not to verify the shape of the given Iterable
                objects. The default is True.
        """
        if isinstance(x, RunningNorm):
            # If we are to update our stats according to another RunningNorm instance

            if x._count > 0:
                # We bother only if x is non-empty

                if mask is not None:
                    # We were given another RunningNorm, not a batch of observations.
                    # So, we do not expect to receive a mask tensor.
                    # If a mask was provided, then this is an unexpected way of calling this function.
                    # We therefore raise an error.
                    raise ValueError(
                        "The `mask` argument is expected as None if the first argument is a RunningNorm."
                        " However, `mask` is found as something other than None."
                    )

                if self._shape != x._shape:
                    # If the shapes of this RunningNorm and of the other RunningNorm
                    # do not match, then we cannot use `x` for updating our stats.
                    # It might be the case that `x` was initialized for another
                    # task, with differently sized observations.
                    # We therefore raise an error.
                    raise ValueError(
                        f"The RunningNorm to be updated has the shape {self._shape}"
                        f" The other RunningNorm has the shape {self._shape}"
                        f" These shapes are incompatible."
                    )

                if self._has_no_data():
                    # If this RunningNorm has no data at all, then we clone the
                    # data of x.
                    self._sum = self._like_its_own(x._sum.clone())
                    self._sum_of_squares = self._like_its_own(x._sum_of_squares.clone())
                    self._count = x._count
                elif self._has_data():
                    # If this RunningNorm has its own data, then we update the
                    # stored data with the data stored by x.
                    self._sum += self._like_its_own(x._sum)
                    self._sum_of_squares += self._like_its_own(x._sum_of_squares)
                    self._count += x._count
                else:
                    assert False, "RunningNorm is in an invalid state! This might be a bug."
        else:
            # This is the case where the received argument x is not a
            # RunningNorm object, but an Iterable.

            if verify:
                # If we have the `verify` flag, then we make sure that
                # x is a tensor of the correct shape
                x = self._verify(x)

            if x.ndim == self._ndim:
                # If the shape of x is exactly the same with the observation shape
                # then we assume that x represents a single observation, and not a
                # batch of observations.

                if mask is not None:
                    # Since we are dealing with a single observation,
                    # we do not expect to receive a mask argument.
                    # If the mask argument was provided, then this is an unexpected
                    # usage of this function.
                    # We therefore raise an error.
                    raise ValueError(
                        "The `mask` argument is expected as None if the first argument is a single observation"
                        " (i.e. not a batch of observations, with an extra leftmost dimension)."
                        " However, `mask` is found as something other than None."
                    )

                # Since x is a single observation,
                # the sum of observations extracted from x is x itself,
                # and the sum of squared observations extracted from x is
                # the square of x itself.
                sum_of_x = x
                sum_of_x_squared = x.square()
                # We extracted a single observation from x
                n = 1
            elif x.ndim == (self._ndim + 1):
                # If the number of dimensions of x is one more than the number
                # of dimensions of this RunningNorm, then we assume that x is a batch
                # of observations.

                if mask is not None:
                    # If a mask is provided, then we first make sure that it is a tensor
                    # of dtype bool in the correct device.
                    mask = torch.as_tensor(mask, dtype=torch.bool, device=self._device)

                    if mask.ndim != 1:
                        # We expect the mask to be 1-dimensional.
                        # If not, we raise an error.
                        raise ValueError(
                            f"The `mask` tensor was expected as a 1-dimensional tensor."
                            f" However, its shape is {mask.shape}."
                        )

                    if len(mask) != x.shape[0]:
                        # If the length of the mask is not the batch size of x,
                        # then there is a mismatch.
                        # We therefore raise an error.
                        raise ValueError(
                            f"The shape of the given tensor is {x.shape}."
                            f" Therefore, the batch size of observations is {x.shape[0]}."
                            f" However, the given `mask` tensor does not has an incompatible length: {len(mask)}."
                        )

                    # We compute how many True items we have in the mask.
                    # This integer gives us how many observations we extract from x.
                    n = int(torch.sum(torch.as_tensor(mask, dtype=torch.int64, device=self._device)))

                    # We now re-cast the mask as the observation dtype (so that True items turn to 1.0
                    # and False items turn to 0.0), and then increase its number of dimensions so that
                    # it can operate directly with x.
                    mask = self._like_its_own(mask).reshape(torch.Size([x.shape[0]] + ([1] * (x.ndim - 1))))

                    # Finally, we multiply x with the mask. This means that the observations with corresponding
                    # mask values as False are zeroed out.
                    x = x * mask
                else:
                    # This is the case where we did not receive a mask.
                    # We can simply say that the number of observations to extract from x
                    # is the size of its leftmost dimension, i.e. the batch size.
                    n = x.shape[0]

                # With or without a mask, we are now ready to extract the sum and sum of squares
                # from x.
                sum_of_x = torch.sum(x, dim=0)
                sum_of_x_squared = torch.sum(x.square(), dim=0)
            else:
                # This is the case where the number of dimensions of x is unrecognized.
                # This case is actually already checked by the _verify(...) method earlier.
                # This defensive fallback case is only for when verify=False and it turned out
                # that the ndim is invalid.
                raise ValueError(f"Invalid shape: {x.shape}")

            # At this point, we handled all the valid cases regarding the Iterable x,
            # and we have our sum_of_x (sum of all observations), sum_of_squares
            # (sum of all squared observations), and n (number of observations extracted
            # from x).

            if self._has_no_data():
                # If our RunningNorm is empty, the observation data we extracted from x
                # become our RunningNorm's new data.
                self._sum = sum_of_x
                self._sum_of_squares = sum_of_x_squared
                self._count = n
            elif self._has_data():
                # If our RunningNorm is not empty, the stored data is updated with the
                # data extracted from x.
                self._sum += sum_of_x
                self._sum_of_squares += sum_of_x_squared
                self._count += n
            else:
                # This is an erroneous state where the internal data looks neither
                # existent nor completely empty.
                # This might be the result of a bug, or maybe this instance's
                # protected variables were tempered with from the outside.
                assert False, "RunningNorm is in an invalid state! This might be a bug."

    @property
    @torch.no_grad()
    def stats(self) -> CollectedStats:
        """
        The collected data's mean and standard deviation (stdev) in a tuple
        """

        # Using the internally stored sum, sum_of_squares, and count,
        # compute E[x] and E[x^2]
        E_x = self._sum / self._count
        E_x2 = self._sum_of_squares / self._count

        # The mean is E[x]
        mean = E_x

        # The variance is E[x^2] - (E[x])^2, elementwise clipped such that
        # it cannot go below min_variance
        variance = _clamp(E_x2 - E_x.square(), self._min_variance, None)

        # Standard deviation is finally computed as the square root of the variance
        stdev = torch.sqrt(variance)

        # Return the stats in a named tuple
        return CollectedStats(mean=mean, stdev=stdev)

    @property
    def mean(self) -> torch.Tensor:
        """
        The collected data's mean
        """
        return self._sum / self._count

    @property
    def stdev(self) -> torch.Tensor:
        """
        The collected data's standard deviation
        """
        return self.stats.stdev

    @property
    def sum(self) -> torch.Tensor:
        """
        The collected data's sum
        """
        return self._sum

    @property
    def sum_of_squares(self) -> torch.Tensor:
        """
        Sum of squares of the collected data
        """
        return self._sum_of_squares

    @property
    def count(self) -> int:
        """
        Number of observations encountered
        """
        return self._count

    @torch.no_grad()
    def normalize(self, x: Iterable, *, result_as_numpy: Optional[bool] = None, verify: bool = True) -> Iterable:
        """
        Normalize the given observation x.

        Args:
            x: The observation(s), as a PyTorch tensor, or any Iterable
                that is convertable to a PyTorch tensor.
                `x` can be a single observation, or it can be a batch
                of observations (with an extra leftmost dimension).
            result_as_numpy: Whether or not to return the normalized
                observation as a numpy array.
                If left as None (which is the default), then the returned
                type depends on x: a PyTorch tensor is returned if x is a
                PyTorch tensor, and a numpy array is returned otherwise.
                If True, the result is always a numpy array.
                If False, the result is always a PyTorch tensor.
            verify: Whether or not to check the type and dimensions of x.
                This is True by default.
                Note that, if `verify` is False, this function will not
                properly check the type of `x` and will assume that `x`
                is a PyTorch tensor.
        Returns:
            The normalized observation, as a PyTorch tensor or a numpy array.
        """

        if self._count == 0:
            # If this RunningNorm instance has no data yet,
            # then we do not know how to do the normalization.
            # We therefore raise an error.
            raise ValueError("Cannot do normalization because no data is collected yet.")

        if verify:
            # Here we verify the type and shape of x.

            if result_as_numpy is None:
                # If there is not an explicit request about the return type,
                # we infer the return type from the type of x:
                # if x is a tensor, we return a tensor;
                # otherwise, we assume x to be a CPU-bound iterable, and
                # therefore we return a numpy array.
                result_as_numpy = not isinstance(x, torch.Tensor)
            else:
                result_as_numpy = bool(result_as_numpy)

            # We call _verify() to make sure that x is of correct shape
            # and is properly converted to a PyTorch tensor.
            x = self._verify(x)

        # We get the mean and stdev of the collected data
        mean, stdev = self.stats

        # Now we compute the normalized observation, clipped according to the
        # lower and upper bounds expressed by the `clip` tuple, if exists.
        result = _clamp((x - mean) / stdev, self._lb, self._ub)

        if result_as_numpy:
            # If we are to return the result as a numpy array, we do the
            # necessary conversion.
            result = result.cpu().numpy()

        # Finally, return the result
        return result

    @torch.no_grad()
    def update_and_normalize(self, x: Iterable, mask: Optional[Iterable] = None) -> Iterable:
        """
        Update the observation stats according to x, then normalize x.

        Args:
            x: The observation(s), as a PyTorch tensor, or as an Iterable
                which can be converted to a PyTorch tensor.
                The shape of x can be the same with the observaiton shape,
                or it can be augmented with an extra leftmost dimension
                to express a batch of observations.
            mask: Can be given as a 1-dimensional Iterable of booleans ONLY
                if `x` represents a batch of observations.
                If a `mask` is provided, the i-th observation within the
                observation batch `x` will be taken into account only if
                the the i-th item of the `mask` is True.
        Returns:
            The normalized counterpart of the observation(s) expressed by x.
        """
        result_as_numpy = not isinstance(x, torch.Tensor)
        x = self._verify(x)

        self.update(x, mask, verify=False)
        result = self.normalize(x, verify=False)

        if result_as_numpy:
            result = result.cpu().numpy()

        return result

    def to_layer(self) -> "ObsNormLayer":
        """
        Make a PyTorch module which normalizes the its inputs.

        Returns:
            An ObsNormLayer instance.
        """
        mean, stdev = self.stats
        low = self.low
        high = self.high
        return ObsNormLayer(mean=mean, stdev=stdev, low=low, high=high)

    def __repr__(self) -> str:
        return f"<{self.__class__.__name__}, count: {self.count}>"

    def __copy__(self) -> "RunningNorm":
        return deepcopy(self)
count: int property readonly

Number of observations encountered

device: Union[str, torch.device] property readonly

The device in which the observation stats are held

dtype: Union[str, torch.dtype, numpy.dtype, Type] property readonly

The dtype of the stored observation stats

high: Optional[float] property readonly

The higher (upper) component of the bounds given in the clip tuple. If clip was initialized as None, this is also None.

low: Optional[float] property readonly

The lower component of the bounds given in the clip tuple. If clip was initialized as None, this is also None.

mean: Tensor property readonly

The collected data's mean

min_variance: float property readonly

Minimum variance

shape: tuple property readonly

Observation shape

stats: CollectedStats property readonly

The collected data's mean and standard deviation (stdev) in a tuple

stdev: Tensor property readonly

The collected data's standard deviation

sum: Tensor property readonly

The collected data's sum

sum_of_squares: Tensor property readonly

Sum of squares of the collected data

__init__(self, *, shape, dtype, device=None, min_variance=0.01, clip=None) special

__init__(...): Initialize the RunningNorm

Parameters:

Name Type Description Default
shape Union[tuple, int]

Observation shape. Can be an integer or a tuple.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

The dtype of the observations.

required
device Union[str, torch.device]

The device in which the observation stats are held. If left as None, the device is assumed to be "cpu".

None
min_variance float

A lower bound for the variance to be used in the normalization computations. In other words, if the computed variance according to the collected observations ends up lower than min_variance, this min_variance will be used instead (in an elementwise manner) while computing the normalized observations. As in Salimans et al. (2017), the default is 1e-2.

0.01
clip Optional[tuple]

Can be left as None (which is the default), or can be given as a pair of real numbers. This is used for clipping the observations after the normalization operation. In Salimans et al. (2017), (-5.0, +5.0) was used.

None
Source code in evotorch/neuroevolution/net/runningnorm.py
def __init__(
    self,
    *,
    shape: Union[tuple, int],
    dtype: DType,
    device: Optional[Device] = None,
    min_variance: float = 1e-2,
    clip: Optional[tuple] = None,
) -> None:
    """
    `__init__(...)`: Initialize the RunningNorm

    Args:
        shape: Observation shape. Can be an integer or a tuple.
        dtype: The dtype of the observations.
        device: The device in which the observation stats are held.
            If left as None, the device is assumed to be "cpu".
        min_variance: A lower bound for the variance to be used in
            the normalization computations.
            In other words, if the computed variance according to the
            collected observations ends up lower than `min_variance`,
            this `min_variance` will be used instead (in an elementwise
            manner) while computing the normalized observations.
            As in Salimans et al. (2017), the default is 1e-2.
        clip: Can be left as None (which is the default), or can be
            given as a pair of real numbers.
            This is used for clipping the observations after the
            normalization operation.
            In Salimans et al. (2017), (-5.0, +5.0) was used.
    """

    # Make sure that the shape is stored as a torch.Size object.
    if isinstance(shape, Iterable):
        self._shape = torch.Size(shape)
    else:
        self._shape = torch.Size([int(shape)])

    # Store the number of dimensions
    self._ndim = len(self._shape)

    # Store the dtype and the device
    self._dtype = to_torch_dtype(dtype)
    self._device = "cpu" if device is None else device

    # Initialize the internally stored data as empty
    self._sum: Optional[torch.Tensor] = None
    self._sum_of_squares: Optional[torch.Tensor] = None
    self._count: int = 0

    # Store the minimum variance
    self._min_variance = float(min_variance)

    if clip is not None:
        # If a clip tuple was provided, store the specified lower and upper bounds
        lb, ub = clip
        self._lb = float(lb)
        self._ub = float(ub)
    else:
        # If a clip tuple was not provided the bounds are stored as None
        self._lb = None
        self._ub = None
normalize(self, x, *, result_as_numpy=None, verify=True)

Normalize the given observation x.

Parameters:

Name Type Description Default
x Iterable

The observation(s), as a PyTorch tensor, or any Iterable that is convertable to a PyTorch tensor. x can be a single observation, or it can be a batch of observations (with an extra leftmost dimension).

required
result_as_numpy Optional[bool]

Whether or not to return the normalized observation as a numpy array. If left as None (which is the default), then the returned type depends on x: a PyTorch tensor is returned if x is a PyTorch tensor, and a numpy array is returned otherwise. If True, the result is always a numpy array. If False, the result is always a PyTorch tensor.

None
verify bool

Whether or not to check the type and dimensions of x. This is True by default. Note that, if verify is False, this function will not properly check the type of x and will assume that x is a PyTorch tensor.

True

Returns:

Type Description
Iterable

The normalized observation, as a PyTorch tensor or a numpy array.

Source code in evotorch/neuroevolution/net/runningnorm.py
@torch.no_grad()
def normalize(self, x: Iterable, *, result_as_numpy: Optional[bool] = None, verify: bool = True) -> Iterable:
    """
    Normalize the given observation x.

    Args:
        x: The observation(s), as a PyTorch tensor, or any Iterable
            that is convertable to a PyTorch tensor.
            `x` can be a single observation, or it can be a batch
            of observations (with an extra leftmost dimension).
        result_as_numpy: Whether or not to return the normalized
            observation as a numpy array.
            If left as None (which is the default), then the returned
            type depends on x: a PyTorch tensor is returned if x is a
            PyTorch tensor, and a numpy array is returned otherwise.
            If True, the result is always a numpy array.
            If False, the result is always a PyTorch tensor.
        verify: Whether or not to check the type and dimensions of x.
            This is True by default.
            Note that, if `verify` is False, this function will not
            properly check the type of `x` and will assume that `x`
            is a PyTorch tensor.
    Returns:
        The normalized observation, as a PyTorch tensor or a numpy array.
    """

    if self._count == 0:
        # If this RunningNorm instance has no data yet,
        # then we do not know how to do the normalization.
        # We therefore raise an error.
        raise ValueError("Cannot do normalization because no data is collected yet.")

    if verify:
        # Here we verify the type and shape of x.

        if result_as_numpy is None:
            # If there is not an explicit request about the return type,
            # we infer the return type from the type of x:
            # if x is a tensor, we return a tensor;
            # otherwise, we assume x to be a CPU-bound iterable, and
            # therefore we return a numpy array.
            result_as_numpy = not isinstance(x, torch.Tensor)
        else:
            result_as_numpy = bool(result_as_numpy)

        # We call _verify() to make sure that x is of correct shape
        # and is properly converted to a PyTorch tensor.
        x = self._verify(x)

    # We get the mean and stdev of the collected data
    mean, stdev = self.stats

    # Now we compute the normalized observation, clipped according to the
    # lower and upper bounds expressed by the `clip` tuple, if exists.
    result = _clamp((x - mean) / stdev, self._lb, self._ub)

    if result_as_numpy:
        # If we are to return the result as a numpy array, we do the
        # necessary conversion.
        result = result.cpu().numpy()

    # Finally, return the result
    return result
reset(self)

Remove all the collected observation data.

Source code in evotorch/neuroevolution/net/runningnorm.py
def reset(self):
    """
    Remove all the collected observation data.
    """
    self._sum = None
    self._sum_of_squares = None
    self._count = 0
to(self, device)

If the target device is a different device, then make a copy of this RunningNorm instance on the target device. If the target device is the same with this RunningNorm's device, then return this RunningNorm itself.

Parameters:

Name Type Description Default
device Union[str, torch.device]

The target device.

required

Returns:

Type Description
RunningNorm

The RunningNorm on the target device. This can be a copy, or the original RunningNorm instance itself.

Source code in evotorch/neuroevolution/net/runningnorm.py
def to(self, device: Device) -> "RunningNorm":
    """
    If the target device is a different device, then make a copy of this
    RunningNorm instance on the target device.
    If the target device is the same with this RunningNorm's device, then
    return this RunningNorm itself.

    Args:
        device: The target device.
    Returns:
        The RunningNorm on the target device. This can be a copy, or the
        original RunningNorm instance itself.
    """
    if torch.device(device) == torch.device(self.device):
        return self
    else:
        new_running_norm = object.__new__(type(self))

        already_handled = {"_sum", "_sum_of_squares", "_device"}
        new_running_norm._sum = self._sum.to(device)
        new_running_norm._sum_of_squares = self._sum_of_squares.to(device)
        new_running_norm._device = device

        for k, v in self.__dict__.items():
            if k not in already_handled:
                setattr(new_running_norm, k, deepcopy(v))

        return new_running_norm
to_layer(self)

Make a PyTorch module which normalizes the its inputs.

Returns:

Type Description
ObsNormLayer

An ObsNormLayer instance.

Source code in evotorch/neuroevolution/net/runningnorm.py
def to_layer(self) -> "ObsNormLayer":
    """
    Make a PyTorch module which normalizes the its inputs.

    Returns:
        An ObsNormLayer instance.
    """
    mean, stdev = self.stats
    low = self.low
    high = self.high
    return ObsNormLayer(mean=mean, stdev=stdev, low=low, high=high)
update(self, x, mask=None, *, verify=True)

Update the stored stats with new observation data.

Parameters:

Name Type Description Default
x Union[Iterable, RunningNorm]

The new observation(s), as a PyTorch tensor, or any Iterable that can be converted to a PyTorch tensor, or another RunningNorm instance. If given as a tensor or as an Iterable, the shape of x can be the same with observation shape, or it can be augmented with an extra leftmost dimension. In the case of augmented dimension, x is interpreted not as a single observation, but as a batch of observations. If x is another RunningNorm instance, the stats stored by this RunningNorm instance will be updated with all the data stored by x.

required
mask Optional[Iterable]

Can be given as a 1-dimensional Iterable of booleans ONLY if x represents a batch of observations. If a mask is provided, the i-th observation within the observation batch x will be taken into account only if the i-th item of the mask is True.

None
verify bool

Whether or not to verify the shape of the given Iterable objects. The default is True.

True
Source code in evotorch/neuroevolution/net/runningnorm.py
@torch.no_grad()
def update(self, x: Union[Iterable, "RunningNorm"], mask: Optional[Iterable] = None, *, verify: bool = True):
    """
    Update the stored stats with new observation data.

    Args:
        x: The new observation(s), as a PyTorch tensor, or any Iterable
            that can be converted to a PyTorch tensor, or another
            RunningNorm instance.
            If given as a tensor or as an Iterable, the shape of `x` can
            be the same with observation shape, or it can be augmented
            with an extra leftmost dimension.
            In the case of augmented dimension, `x` is interpreted not as
            a single observation, but as a batch of observations.
            If `x` is another RunningNorm instance, the stats stored by
            this RunningNorm instance will be updated with all the data
            stored by `x`.
        mask: Can be given as a 1-dimensional Iterable of booleans ONLY
            if `x` represents a batch of observations.
            If a `mask` is provided, the i-th observation within the
            observation batch `x` will be taken into account only if
            the i-th item of the `mask` is True.
        verify: Whether or not to verify the shape of the given Iterable
            objects. The default is True.
    """
    if isinstance(x, RunningNorm):
        # If we are to update our stats according to another RunningNorm instance

        if x._count > 0:
            # We bother only if x is non-empty

            if mask is not None:
                # We were given another RunningNorm, not a batch of observations.
                # So, we do not expect to receive a mask tensor.
                # If a mask was provided, then this is an unexpected way of calling this function.
                # We therefore raise an error.
                raise ValueError(
                    "The `mask` argument is expected as None if the first argument is a RunningNorm."
                    " However, `mask` is found as something other than None."
                )

            if self._shape != x._shape:
                # If the shapes of this RunningNorm and of the other RunningNorm
                # do not match, then we cannot use `x` for updating our stats.
                # It might be the case that `x` was initialized for another
                # task, with differently sized observations.
                # We therefore raise an error.
                raise ValueError(
                    f"The RunningNorm to be updated has the shape {self._shape}"
                    f" The other RunningNorm has the shape {self._shape}"
                    f" These shapes are incompatible."
                )

            if self._has_no_data():
                # If this RunningNorm has no data at all, then we clone the
                # data of x.
                self._sum = self._like_its_own(x._sum.clone())
                self._sum_of_squares = self._like_its_own(x._sum_of_squares.clone())
                self._count = x._count
            elif self._has_data():
                # If this RunningNorm has its own data, then we update the
                # stored data with the data stored by x.
                self._sum += self._like_its_own(x._sum)
                self._sum_of_squares += self._like_its_own(x._sum_of_squares)
                self._count += x._count
            else:
                assert False, "RunningNorm is in an invalid state! This might be a bug."
    else:
        # This is the case where the received argument x is not a
        # RunningNorm object, but an Iterable.

        if verify:
            # If we have the `verify` flag, then we make sure that
            # x is a tensor of the correct shape
            x = self._verify(x)

        if x.ndim == self._ndim:
            # If the shape of x is exactly the same with the observation shape
            # then we assume that x represents a single observation, and not a
            # batch of observations.

            if mask is not None:
                # Since we are dealing with a single observation,
                # we do not expect to receive a mask argument.
                # If the mask argument was provided, then this is an unexpected
                # usage of this function.
                # We therefore raise an error.
                raise ValueError(
                    "The `mask` argument is expected as None if the first argument is a single observation"
                    " (i.e. not a batch of observations, with an extra leftmost dimension)."
                    " However, `mask` is found as something other than None."
                )

            # Since x is a single observation,
            # the sum of observations extracted from x is x itself,
            # and the sum of squared observations extracted from x is
            # the square of x itself.
            sum_of_x = x
            sum_of_x_squared = x.square()
            # We extracted a single observation from x
            n = 1
        elif x.ndim == (self._ndim + 1):
            # If the number of dimensions of x is one more than the number
            # of dimensions of this RunningNorm, then we assume that x is a batch
            # of observations.

            if mask is not None:
                # If a mask is provided, then we first make sure that it is a tensor
                # of dtype bool in the correct device.
                mask = torch.as_tensor(mask, dtype=torch.bool, device=self._device)

                if mask.ndim != 1:
                    # We expect the mask to be 1-dimensional.
                    # If not, we raise an error.
                    raise ValueError(
                        f"The `mask` tensor was expected as a 1-dimensional tensor."
                        f" However, its shape is {mask.shape}."
                    )

                if len(mask) != x.shape[0]:
                    # If the length of the mask is not the batch size of x,
                    # then there is a mismatch.
                    # We therefore raise an error.
                    raise ValueError(
                        f"The shape of the given tensor is {x.shape}."
                        f" Therefore, the batch size of observations is {x.shape[0]}."
                        f" However, the given `mask` tensor does not has an incompatible length: {len(mask)}."
                    )

                # We compute how many True items we have in the mask.
                # This integer gives us how many observations we extract from x.
                n = int(torch.sum(torch.as_tensor(mask, dtype=torch.int64, device=self._device)))

                # We now re-cast the mask as the observation dtype (so that True items turn to 1.0
                # and False items turn to 0.0), and then increase its number of dimensions so that
                # it can operate directly with x.
                mask = self._like_its_own(mask).reshape(torch.Size([x.shape[0]] + ([1] * (x.ndim - 1))))

                # Finally, we multiply x with the mask. This means that the observations with corresponding
                # mask values as False are zeroed out.
                x = x * mask
            else:
                # This is the case where we did not receive a mask.
                # We can simply say that the number of observations to extract from x
                # is the size of its leftmost dimension, i.e. the batch size.
                n = x.shape[0]

            # With or without a mask, we are now ready to extract the sum and sum of squares
            # from x.
            sum_of_x = torch.sum(x, dim=0)
            sum_of_x_squared = torch.sum(x.square(), dim=0)
        else:
            # This is the case where the number of dimensions of x is unrecognized.
            # This case is actually already checked by the _verify(...) method earlier.
            # This defensive fallback case is only for when verify=False and it turned out
            # that the ndim is invalid.
            raise ValueError(f"Invalid shape: {x.shape}")

        # At this point, we handled all the valid cases regarding the Iterable x,
        # and we have our sum_of_x (sum of all observations), sum_of_squares
        # (sum of all squared observations), and n (number of observations extracted
        # from x).

        if self._has_no_data():
            # If our RunningNorm is empty, the observation data we extracted from x
            # become our RunningNorm's new data.
            self._sum = sum_of_x
            self._sum_of_squares = sum_of_x_squared
            self._count = n
        elif self._has_data():
            # If our RunningNorm is not empty, the stored data is updated with the
            # data extracted from x.
            self._sum += sum_of_x
            self._sum_of_squares += sum_of_x_squared
            self._count += n
        else:
            # This is an erroneous state where the internal data looks neither
            # existent nor completely empty.
            # This might be the result of a bug, or maybe this instance's
            # protected variables were tempered with from the outside.
            assert False, "RunningNorm is in an invalid state! This might be a bug."
update_and_normalize(self, x, mask=None)

Update the observation stats according to x, then normalize x.

Parameters:

Name Type Description Default
x Iterable

The observation(s), as a PyTorch tensor, or as an Iterable which can be converted to a PyTorch tensor. The shape of x can be the same with the observaiton shape, or it can be augmented with an extra leftmost dimension to express a batch of observations.

required
mask Optional[Iterable]

Can be given as a 1-dimensional Iterable of booleans ONLY if x represents a batch of observations. If a mask is provided, the i-th observation within the observation batch x will be taken into account only if the the i-th item of the mask is True.

None

Returns:

Type Description
Iterable

The normalized counterpart of the observation(s) expressed by x.

Source code in evotorch/neuroevolution/net/runningnorm.py
@torch.no_grad()
def update_and_normalize(self, x: Iterable, mask: Optional[Iterable] = None) -> Iterable:
    """
    Update the observation stats according to x, then normalize x.

    Args:
        x: The observation(s), as a PyTorch tensor, or as an Iterable
            which can be converted to a PyTorch tensor.
            The shape of x can be the same with the observaiton shape,
            or it can be augmented with an extra leftmost dimension
            to express a batch of observations.
        mask: Can be given as a 1-dimensional Iterable of booleans ONLY
            if `x` represents a batch of observations.
            If a `mask` is provided, the i-th observation within the
            observation batch `x` will be taken into account only if
            the the i-th item of the `mask` is True.
    Returns:
        The normalized counterpart of the observation(s) expressed by x.
    """
    result_as_numpy = not isinstance(x, torch.Tensor)
    x = self._verify(x)

    self.update(x, mask, verify=False)
    result = self.normalize(x, verify=False)

    if result_as_numpy:
        result = result.cpu().numpy()

    return result

runningstat

RunningStat

Tool for efficiently computing the mean and stdev of arrays. The arrays themselves are not stored separately, instead, they are accumulated.

This RunningStat is implemented as a wrapper around RunningNorm. The difference is that the interface of RunningStat is simplified to expect only numpy arrays, and expect only non-vectorized observations. With this simplified interface, RunningStat is meant to be used by GymNE, on classical non-vectorized gym tasks.

Source code in evotorch/neuroevolution/net/runningstat.py
class RunningStat:
    """
    Tool for efficiently computing the mean and stdev of arrays.
    The arrays themselves are not stored separately,
    instead, they are accumulated.

    This RunningStat is implemented as a wrapper around RunningNorm.
    The difference is that the interface of RunningStat is simplified
    to expect only numpy arrays, and expect only non-vectorized
    observations.
    With this simplified interface, RunningStat is meant to be used
    by GymNE, on classical non-vectorized gym tasks.
    """

    def __init__(self):
        """
        `__init__(...)`: Initialize the RunningStat.
        """
        self._rn: Optional[RunningNorm] = None
        self.reset()

    def reset(self):
        """
        Reset the RunningStat to its initial state.
        """
        self._rn = None

    @property
    def count(self) -> int:
        """
        Get the number of arrays accumulated.
        """
        if self._rn is None:
            return 0
        else:
            return self._rn.count

    @property
    def sum(self) -> np.ndarray:
        """
        Get the sum of all accumulated arrays.
        """
        return self._rn.sum.numpy()

    @property
    def sum_of_squares(self) -> np.ndarray:
        """
        Get the sum of squares of all accumulated arrays.
        """
        return self._rn.sum_of_squares.numpy()

    @property
    def mean(self) -> np.ndarray:
        """
        Get the mean of all accumulated arrays.
        """
        return self._rn.mean.numpy()

    @property
    def stdev(self) -> np.ndarray:
        """
        Get the standard deviation of all accumulated arrays.
        """
        return self._rn.stdev.numpy()

    def update(self, x: Union[np.ndarray, "RunningStat"]):
        """
        Accumulate more data into the RunningStat object.
        If the argument is an array, that array is added
        as one more data element.
        If the argument is another RunningStat instance,
        all the stats accumulated by that RunningStat object
        are added into this RunningStat object.
        """
        if isinstance(x, RunningStat):
            if x.count > 0:
                if self._rn is None:
                    self._rn = deepcopy(x._rn)
                else:
                    self._rn.update(x._rn)
        else:
            if self._rn is None:
                x = np.array(x, dtype="float32")
                self._rn = RunningNorm(shape=x.shape, dtype="float32", device="cpu")
            self._rn.update(x)

    def normalize(self, x: Union[np.ndarray, list]) -> np.ndarray:
        """
        Normalize the array x according to the accumulated stats.
        """
        if self._rn is None:
            return x
        else:
            x = np.array(x, dtype="float32")
            return self._rn.normalize(x)

    def __copy__(self):
        return deepcopy(self)

    def __repr__(self) -> str:
        return f"<{self.__class__.__name__}, count: {self.count}>"

    def to(self, device: Union[str, torch.device]) -> "RunningStat":
        """
        If the target device is cpu, return this RunningStat instance itself.
        A RunningStat object is meant to work with numpy arrays. Therefore,
        any device other than the cpu will trigger an error.

        Args:
            device: The target device. Only cpu is supported.
        Returns:
            The original RunningStat.
        """
        if torch.device(device) == torch.device("cpu"):
            return self
        else:
            raise ValueError(
                f"The received target device is {repr(device)}. However, RunningStat can only work on a cpu."
            )

    def to_layer(self) -> nn.Module:
        """
        Make a PyTorch module which normalizes the its inputs.

        Returns:
            An ObsNormLayer instance.
        """
        return self._rn.to_layer()
count: int property readonly

Get the number of arrays accumulated.

mean: ndarray property readonly

Get the mean of all accumulated arrays.

stdev: ndarray property readonly

Get the standard deviation of all accumulated arrays.

sum: ndarray property readonly

Get the sum of all accumulated arrays.

sum_of_squares: ndarray property readonly

Get the sum of squares of all accumulated arrays.

__init__(self) special

__init__(...): Initialize the RunningStat.

Source code in evotorch/neuroevolution/net/runningstat.py
def __init__(self):
    """
    `__init__(...)`: Initialize the RunningStat.
    """
    self._rn: Optional[RunningNorm] = None
    self.reset()
normalize(self, x)

Normalize the array x according to the accumulated stats.

Source code in evotorch/neuroevolution/net/runningstat.py
def normalize(self, x: Union[np.ndarray, list]) -> np.ndarray:
    """
    Normalize the array x according to the accumulated stats.
    """
    if self._rn is None:
        return x
    else:
        x = np.array(x, dtype="float32")
        return self._rn.normalize(x)
reset(self)

Reset the RunningStat to its initial state.

Source code in evotorch/neuroevolution/net/runningstat.py
def reset(self):
    """
    Reset the RunningStat to its initial state.
    """
    self._rn = None
to(self, device)

If the target device is cpu, return this RunningStat instance itself. A RunningStat object is meant to work with numpy arrays. Therefore, any device other than the cpu will trigger an error.

Parameters:

Name Type Description Default
device Union[str, torch.device]

The target device. Only cpu is supported.

required

Returns:

Type Description
RunningStat

The original RunningStat.

Source code in evotorch/neuroevolution/net/runningstat.py
def to(self, device: Union[str, torch.device]) -> "RunningStat":
    """
    If the target device is cpu, return this RunningStat instance itself.
    A RunningStat object is meant to work with numpy arrays. Therefore,
    any device other than the cpu will trigger an error.

    Args:
        device: The target device. Only cpu is supported.
    Returns:
        The original RunningStat.
    """
    if torch.device(device) == torch.device("cpu"):
        return self
    else:
        raise ValueError(
            f"The received target device is {repr(device)}. However, RunningStat can only work on a cpu."
        )
to_layer(self)

Make a PyTorch module which normalizes the its inputs.

Returns:

Type Description
Module

An ObsNormLayer instance.

Source code in evotorch/neuroevolution/net/runningstat.py
def to_layer(self) -> nn.Module:
    """
    Make a PyTorch module which normalizes the its inputs.

    Returns:
        An ObsNormLayer instance.
    """
    return self._rn.to_layer()
update(self, x)

Accumulate more data into the RunningStat object. If the argument is an array, that array is added as one more data element. If the argument is another RunningStat instance, all the stats accumulated by that RunningStat object are added into this RunningStat object.

Source code in evotorch/neuroevolution/net/runningstat.py
def update(self, x: Union[np.ndarray, "RunningStat"]):
    """
    Accumulate more data into the RunningStat object.
    If the argument is an array, that array is added
    as one more data element.
    If the argument is another RunningStat instance,
    all the stats accumulated by that RunningStat object
    are added into this RunningStat object.
    """
    if isinstance(x, RunningStat):
        if x.count > 0:
            if self._rn is None:
                self._rn = deepcopy(x._rn)
            else:
                self._rn.update(x._rn)
    else:
        if self._rn is None:
            x = np.array(x, dtype="float32")
            self._rn = RunningNorm(shape=x.shape, dtype="float32", device="cpu")
        self._rn.update(x)

statefulmodule

StatefulModule (Module)

A wrapper that provides a stateful interface for recurrent torch modules.

If the torch module to be wrapped is non-recurrent and its forward method has a single input (the input tensor) and a single output (the output tensor), then this wrapper module acts as a no-op wrapper.

If the torch module to be wrapped is recurrent and its forward method has two inputs (the input tensor and an optional second argument for the hidden state) and two outputs (the output tensor and the new hidden state), then this wrapper brings a new forward-passing interface. In this new interface, the forward method has a single input (the input tensor) and a single output (the output tensor). The hidden states, instead of being explicitly requested via a second argument and returned as a second result, are stored and used by the wrapper. When a new series of inputs is to be used, one has to call the reset() method of this wrapper.

Source code in evotorch/neuroevolution/net/statefulmodule.py
class StatefulModule(nn.Module):
    """
    A wrapper that provides a stateful interface for recurrent torch modules.

    If the torch module to be wrapped is non-recurrent and its forward method
    has a single input (the input tensor) and a single output (the output
    tensor), then this wrapper module acts as a no-op wrapper.

    If the torch module to be wrapped is recurrent and its forward method has
    two inputs (the input tensor and an optional second argument for the hidden
    state) and two outputs (the output tensor and the new hidden state), then
    this wrapper brings a new forward-passing interface. In this new interface,
    the forward method has a single input (the input tensor) and a single
    output (the output tensor). The hidden states, instead of being
    explicitly requested via a second argument and returned as a second
    result, are stored and used by the wrapper.
    When a new series of inputs is to be used, one has to call the `reset()`
    method of this wrapper.
    """

    def __init__(self, wrapped_module: nn.Module):
        """
        `__init__(...)`: Initialize the StatefulModule.

        Args:
            wrapped_module: The `torch.nn.Module` instance to wrap.
        """
        super().__init__()

        # Declare the variable that will store the hidden state of wrapped_module, if any.
        self._hidden: Any = None

        # Store the module that is wrapped.
        self.wrapped_module = wrapped_module

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        if self._hidden is None:
            # If there is no stored hidden state, then only pass the input tensor to the wrapped module.
            out = self.wrapped_module(x)
        else:
            # If there is a hidden state saved from the previous call to this `forward(...)` method, then pass the
            # input tensor and this stored hidden state.
            out = self.wrapped_module(x, self._hidden)

        if isinstance(out, tuple):
            # If the result of the wrapped module is a tuple, then we assume that the wrapped module returned an
            # output tensor and a hidden state. We assume the first element of this tuple as the output tensor,
            # and the second element as the new hidden state.
            # We set the variable y to the output tensor, and we store the new hidden state via the attribute
            # `_hidden`.
            y, self._hidden = out
        else:
            # If the result of the wrapped module is not a tuple, then we assume that the wrapped module returned
            # only the output tensor. We set the variable y to the output tensor, and set the attribute `_hidden`
            # as None to indicate that there was no hidden state received.
            y = out
            self._hidden = None

        # We return y, which stores the output received by the wrapped module.
        return y

    def reset(self):
        """
        Reset the hidden state, if any.
        """
        self._hidden = None
__init__(self, wrapped_module) special

__init__(...): Initialize the StatefulModule.

Parameters:

Name Type Description Default
wrapped_module Module

The torch.nn.Module instance to wrap.

required
Source code in evotorch/neuroevolution/net/statefulmodule.py
def __init__(self, wrapped_module: nn.Module):
    """
    `__init__(...)`: Initialize the StatefulModule.

    Args:
        wrapped_module: The `torch.nn.Module` instance to wrap.
    """
    super().__init__()

    # Declare the variable that will store the hidden state of wrapped_module, if any.
    self._hidden: Any = None

    # Store the module that is wrapped.
    self.wrapped_module = wrapped_module
forward(self, x)

Defines the computation performed at every call.

Should be overridden by all subclasses.

.. note:: Although the recipe for forward pass needs to be defined within this function, one should call the :class:Module instance afterwards instead of this since the former takes care of running the registered hooks while the latter silently ignores them.

Source code in evotorch/neuroevolution/net/statefulmodule.py
def forward(self, x: torch.Tensor) -> torch.Tensor:
    if self._hidden is None:
        # If there is no stored hidden state, then only pass the input tensor to the wrapped module.
        out = self.wrapped_module(x)
    else:
        # If there is a hidden state saved from the previous call to this `forward(...)` method, then pass the
        # input tensor and this stored hidden state.
        out = self.wrapped_module(x, self._hidden)

    if isinstance(out, tuple):
        # If the result of the wrapped module is a tuple, then we assume that the wrapped module returned an
        # output tensor and a hidden state. We assume the first element of this tuple as the output tensor,
        # and the second element as the new hidden state.
        # We set the variable y to the output tensor, and we store the new hidden state via the attribute
        # `_hidden`.
        y, self._hidden = out
    else:
        # If the result of the wrapped module is not a tuple, then we assume that the wrapped module returned
        # only the output tensor. We set the variable y to the output tensor, and set the attribute `_hidden`
        # as None to indicate that there was no hidden state received.
        y = out
        self._hidden = None

    # We return y, which stores the output received by the wrapped module.
    return y
reset(self)

Reset the hidden state, if any.

Source code in evotorch/neuroevolution/net/statefulmodule.py
def reset(self):
    """
    Reset the hidden state, if any.
    """
    self._hidden = None
ensure_stateful(net)

Ensure that a module is wrapped by StatefulModule.

If the given module is already wrapped by StatefulModule, then the module itself is returned. If the given module is not wrapped by StatefulModule, then this function first wraps the module via a new StatefulModule instance, and then this new wrapper is returned.

Parameters:

Name Type Description Default
net Module

The torch.nn.Module to be wrapped by StatefulModule (if it is not already wrapped by it).

required

Returns:

Type Description
StatefulModule

The module net, wrapped by StatefulModule.

Source code in evotorch/neuroevolution/net/statefulmodule.py
def ensure_stateful(net: nn.Module) -> StatefulModule:
    """
    Ensure that a module is wrapped by StatefulModule.

    If the given module is already wrapped by StatefulModule, then the
    module itself is returned.
    If the given module is not wrapped by StatefulModule, then this function
    first wraps the module via a new StatefulModule instance, and then this
    new wrapper is returned.

    Args:
        net: The `torch.nn.Module` to be wrapped by StatefulModule (if it is
            not already wrapped by it).
    Returns:
        The module `net`, wrapped by StatefulModule.
    """
    if not isinstance(net, StatefulModule):
        return StatefulModule(net)
    return net

vecrl

This namespace provides various vectorized reinforcement learning utilities.

Policy

A Policy for deciding the actions for a reinforcement learning environment.

This can be seen as a stateful wrapper around a PyTorch module.

Let us assume that we have the following PyTorch module:

from torch import nn

net = nn.Linear(5, 8)

which has 48 parameters (when all the parameters are flattened). Let us randomly generate a parameter vector for our module net:

parameters = torch.randn(48)

We can now prepare a policy:

policy = Policy(net)
policy.set_parameters(parameters)

If we generate a random observation:

observation = torch.randn(5)

We can receive our action as follows:

action = policy(observation)

If the PyTorch module that we wish to wrap is a recurrent network (i.e. a network which expects an optional second argument for the hidden state, and returns a second value which represents the updated hidden state), then, the hidden state is automatically managed by the Policy instance.

Let us assume that we have a recurrent network named recnet.

policy = Policy(recnet)
policy.set_parameters(parameters_of_recnet)

In this case, because the hidden state of the network is internally managed, the usage is still the same with our previous non-recurrent

Examples:

action = policy(observation)

When using a recurrent module on multiple episodes, it is important to reset the hidden state of the network. This is achieved by the reset method:

policy.reset()
action1 = policy(observation1)

# action2 will be computed with the hidden state generated by the
# previous forward-pass.
action2 = policy(observation2)

policy.reset()

# action3 will be computed according to the renewed hidden state.
action3 = policy(observation3)

Both for non-recurrent and recurrent networks, it is possible to perform vectorized operations. For now, let us return to our first non-recurrent example:

net = nn.Linear(5, 8)

Instead of generating only one parameter vector, we now generate a batch of parameter vectors. Let us say that our batch size is 10:

batch_of_parameters = torch.randn(10, 48)

Like we did in the non-batched examples, we can do:

policy = Policy(net)
policy.set_parameters(batch_of_parameters)

Because we are now in the batched mode, policy now expects a batch of observations and will return a batch of actions:

batch_of_observations = torch.randn(10, 5)
batch_of_actions = policy(batch_of_observations)

When doing vectorized reinforcement learning with a recurrent module, it can be the case that only some of the environments are finished, and therefore it is necessary to reset the hidden states associated with those environments only. The reset(...) method of Policy has a second argument to specify which of the recurrent network instances are to be reset. For example, if the episodes of the environments with indices 2 and 5 are about to restart (and therefore we wish to reset the states of the networks with indices 2 and 5), then, we can do:

policy.reset(torch.tensor([2, 5]))
Source code in evotorch/neuroevolution/net/vecrl.py
class Policy:
    """
    A Policy for deciding the actions for a reinforcement learning environment.

    This can be seen as a stateful wrapper around a PyTorch module.

    Let us assume that we have the following PyTorch module:

    ```python
    from torch import nn

    net = nn.Linear(5, 8)
    ```

    which has 48 parameters (when all the parameters are flattened).
    Let us randomly generate a parameter vector for our module `net`:

    ```python
    parameters = torch.randn(48)
    ```

    We can now prepare a policy:

    ```python
    policy = Policy(net)
    policy.set_parameters(parameters)
    ```

    If we generate a random observation:

    ```python
    observation = torch.randn(5)
    ```

    We can receive our action as follows:

    ```python
    action = policy(observation)
    ```

    If the PyTorch module that we wish to wrap is a recurrent network (i.e.
    a network which expects an optional second argument for the hidden state,
    and returns a second value which represents the updated hidden state),
    then, the hidden state is automatically managed by the Policy instance.

    Let us assume that we have a recurrent network named `recnet`.

    ```python
    policy = Policy(recnet)
    policy.set_parameters(parameters_of_recnet)
    ```

    In this case, because the hidden state of the network is internally
    managed, the usage is still the same with our previous non-recurrent
    example:

    ```python
    action = policy(observation)
    ```

    When using a recurrent module on multiple episodes, it is important
    to reset the hidden state of the network. This is achieved by the
    reset method:

    ```python
    policy.reset()
    action1 = policy(observation1)

    # action2 will be computed with the hidden state generated by the
    # previous forward-pass.
    action2 = policy(observation2)

    policy.reset()

    # action3 will be computed according to the renewed hidden state.
    action3 = policy(observation3)
    ```

    Both for non-recurrent and recurrent networks, it is possible to
    perform vectorized operations. For now, let us return to our
    first non-recurrent example:

    ```python
    net = nn.Linear(5, 8)
    ```

    Instead of generating only one parameter vector, we now generate
    a batch of parameter vectors. Let us say that our batch size is 10:

    ```python
    batch_of_parameters = torch.randn(10, 48)
    ```

    Like we did in the non-batched examples, we can do:

    ```python
    policy = Policy(net)
    policy.set_parameters(batch_of_parameters)
    ```

    Because we are now in the batched mode, `policy` now expects a batch
    of observations and will return a batch of actions:

    ```python
    batch_of_observations = torch.randn(10, 5)
    batch_of_actions = policy(batch_of_observations)
    ```

    When doing vectorized reinforcement learning with a recurrent module,
    it can be the case that only some of the environments are finished,
    and therefore it is necessary to reset the hidden states associated
    with those environments only. The `reset(...)` method of Policy
    has a second argument to specify which of the recurrent network
    instances are to be reset. For example, if the episodes of the
    environments with indices 2 and 5 are about to restart (and therefore
    we wish to reset the states of the networks with indices 2 and 5),
    then, we can do:

    ```python
    policy.reset(torch.tensor([2, 5]))
    ```
    """

    def __init__(self, net: Union[str, Callable, nn.Module], **kwargs):
        """
        `__init__(...)`: Initialize the Policy.

        Args:
            net: The network to be wrapped by the Policy object.
                This can be a string, a Callable (e.g. a `torch.nn.Module`
                subclass), or a `torch.nn.Module` instance.
                When this argument is a string, the network will be
                created with the help of the function
                `evotorch.neuroevolution.net.str_to_net(...)` and then
                wrapped. Please see the `str_to_net(...)` function's
                documentation for details regarding how a network structure
                can be expressed via strings.
            kwargs: Expected in the form of additional keyword arguments,
                these keyword arguments will be passed to the provided
                Callable object (if the argument `net` is a Callable)
                or to `str_to_net(...)` (if the argument `net` is a string)
                at the moment of generating the network.
                If the argument `net` is a `torch.nn.Module` instance,
                having any additional keyword arguments will trigger an
                error, because the network is already instantiated and
                therefore, it is not possible to pass these keyword arguments.
        """
        from ..net import str_to_net
        from ..net.functional import ModuleExpectingFlatParameters, make_functional_module

        if isinstance(net, str):
            self.__module = str_to_net(net, **kwargs)
        elif isinstance(net, nn.Module):
            if len(kwargs) > 0:
                raise ValueError(
                    f"When the network is given as an `nn.Module` instance, extra network arguments cannot be used"
                    f" (because the network is already instantiated)."
                    f" However, these extra keyword arguments were received: {kwargs}."
                )
            self.__module = net
        elif isinstance(net, Callable):
            self.__module = net(**kwargs)
        else:
            raise TypeError(
                f"The class `Policy` expected a string or an `nn.Module` instance, or a Callable, but received {net}"
                f" (whose type is {type(net)})."
            )

        self.__fmodule: ModuleExpectingFlatParameters = make_functional_module(self.__module)
        self.__state: Any = None
        self.__parameters: Optional[torch.Tensor] = None

    def set_parameters(self, parameters: torch.Tensor, indices: Optional[MaskOrIndices] = None, *, reset: bool = True):
        """
        Set the parameters of the policy.

        Args:
            parameters: A 1-dimensional or a 2-dimensional tensor containing
                the flattened parameters to be used with the neural network.
                If the given parameters are two-dimensional, then, given that
                the leftmost size of the parameter tensor is `n`, the
                observations will be expected in a batch with leftmost size
                `n`, and the returned actions will also be in a batch,
                again with the leftmost size `n`.
            indices: For when the parameters were previously given via a
                2-dimensional tensor, provide this argument if you would like
                to change only some rows of the previously given parameters.
                For example, if `indices` is given as `torch.tensor([2, 4])`
                and the argument `parameters` is given as a 2-dimensional
                tensor with leftmost size 2, then the rows with indices
                2 and 4 will be replaced by these new parameters provided
                via the argument `parameters`.
            reset: If given as True, the hidden states of the networks whose
                parameters just changed will be reset. If `indices` was not
                provided at all, then this means that the parameters of all
                networks are modified, in which case, all the hidden states
                will be reset.
                If given as False, no such resetting will be done.
        """
        if self.__parameters is None:
            if indices is not None:
                raise ValueError(
                    "The argument `indices` can be used only if network parameters were previously specified."
                    " However, it seems that the method `set_parameters(...)` was not called before."
                )
            self.__parameters = parameters
        else:
            if indices is None:
                self.__parameters = parameters
            else:
                self.__parameters[indices] = parameters

        if reset:
            self.reset(indices)

    def __call__(self, x: torch.Tensor) -> torch.Tensor:
        """
        Pass the given observations through the network.

        Args:
            x: The observations, as a PyTorch tensor.
                If the parameters were given (via the method
                `set_parameters(...)`) as a 1-dimensional tensor, then this
                argument is expected to store a single observation.
                If the parameters were given as a 2-dimensional tensor,
                then, this argument is expected to store a batch of
                observations, and the leftmost size of this observation
                tensor must match with the leftmost size of the parameter
                tensor.
        Returns:
            The output tensor, which represents the action to take.
        """
        if self.__parameters is None:
            raise ValueError("Please use the method `set_parameters(...)` before calling the policy.")

        if self.__state is None:
            further_args = (x,)
        else:
            further_args = (x, self.__state)

        parameters = self.__parameters
        ndim = parameters.ndim
        if ndim == 1:
            result = self.__fmodule(parameters, *further_args)
        elif ndim == 2:
            vmapped = vmap(self.__fmodule)
            result = vmapped(parameters, *further_args)
        else:
            raise ValueError(
                f"Expected the parameters as a 1 or 2 dimensional tensor."
                f" However, the received parameters tensor has {ndim} dimensions."
            )

        if isinstance(result, torch.Tensor):
            return result
        elif isinstance(result, tuple):
            result, state = result
            self.__state = state
            return result
        else:
            raise TypeError(f"The torch module used by the Policy returned an unexpected object: {result}")

    def reset(self, indices: Optional[MaskOrIndices] = None, *, copy: bool = True):
        """
        Reset the hidden states, if the contained module is a recurrent network.

        Args:
            indices: Optionally a sequence of integers or a sequence of
                booleans, specifying which networks' states will be
                reset. If left as None, then the states of all the networks
                will be reset.
            copy: When `indices` is given as something other than None,
                if `copy` is given as True, then the resetting will NOT
                be done in-place. Instead, a new copy of the hidden state
                will first be created, and then the specified regions
                of this new copy will be cleared, and then finally this
                modified copy will be declared as the new hidden state.
                It is a common practice for recurrent neural network
                implementations to return the same tensor both as its
                output and as (part of) its hidden state. With `copy=False`,
                the resetting would be done in-place, and the action
                tensor could be involuntarily reset as well.
                This in-place modification could cause silent bugs
                if the unintended modification on the action tensor
                happens BEFORE the action is sent to the reinforcement
                learning environment.
                To prevent such situations, the default value for the argument
                `copy` is True.
        """
        if indices is None:
            self.__state = None
        else:
            if self.__state is not None:
                with torch.no_grad():
                    if copy:
                        self.__state = deepcopy(self.__state)
                    reset_tensors(self.__state, indices)

    @property
    def parameters(self) -> torch.Tensor:
        """
        The currently used parameters.
        """
        return self.__parameters

    @property
    def h(self) -> Optional[torch.Tensor]:
        """
        The hidden state of the contained recurrent network, if any.

        If the contained recurrent network did not generate a hidden state
        yet, or if the contained network is not recurrent, then the result
        will be None.
        """
        return self.__state

    @property
    def parameter_length(self) -> int:
        """
        Length of the parameter tensor.
        """
        return self.__fmodule.parameter_length

    @property
    def wrapped_module(self) -> nn.Module:
        """
        The wrapped `torch.nn.Module` instance.
        """
        return self.__module

    def to_torch_module(self, parameter_vector: torch.Tensor) -> nn.Module:
        """
        Get a copy of the contained network, parameterized as specified.

        Args:
            parameter_vector: The parameters to be used by the new network.
        Returns:
            Copy of the contained network, as a `torch.nn.Module` instance.
        """
        with torch.no_grad():
            net = deepcopy(self.__module).to(parameter_vector.device)
            nnu.vector_to_parameters(parameter_vector, net.parameters())
        return net
h: Optional[torch.Tensor] property readonly

The hidden state of the contained recurrent network, if any.

If the contained recurrent network did not generate a hidden state yet, or if the contained network is not recurrent, then the result will be None.

parameter_length: int property readonly

Length of the parameter tensor.

parameters: Tensor property readonly

The currently used parameters.

wrapped_module: Module property readonly

The wrapped torch.nn.Module instance.

__call__(self, x) special

Pass the given observations through the network.

Parameters:

Name Type Description Default
x Tensor

The observations, as a PyTorch tensor. If the parameters were given (via the method set_parameters(...)) as a 1-dimensional tensor, then this argument is expected to store a single observation. If the parameters were given as a 2-dimensional tensor, then, this argument is expected to store a batch of observations, and the leftmost size of this observation tensor must match with the leftmost size of the parameter tensor.

required

Returns:

Type Description
Tensor

The output tensor, which represents the action to take.

Source code in evotorch/neuroevolution/net/vecrl.py
def __call__(self, x: torch.Tensor) -> torch.Tensor:
    """
    Pass the given observations through the network.

    Args:
        x: The observations, as a PyTorch tensor.
            If the parameters were given (via the method
            `set_parameters(...)`) as a 1-dimensional tensor, then this
            argument is expected to store a single observation.
            If the parameters were given as a 2-dimensional tensor,
            then, this argument is expected to store a batch of
            observations, and the leftmost size of this observation
            tensor must match with the leftmost size of the parameter
            tensor.
    Returns:
        The output tensor, which represents the action to take.
    """
    if self.__parameters is None:
        raise ValueError("Please use the method `set_parameters(...)` before calling the policy.")

    if self.__state is None:
        further_args = (x,)
    else:
        further_args = (x, self.__state)

    parameters = self.__parameters
    ndim = parameters.ndim
    if ndim == 1:
        result = self.__fmodule(parameters, *further_args)
    elif ndim == 2:
        vmapped = vmap(self.__fmodule)
        result = vmapped(parameters, *further_args)
    else:
        raise ValueError(
            f"Expected the parameters as a 1 or 2 dimensional tensor."
            f" However, the received parameters tensor has {ndim} dimensions."
        )

    if isinstance(result, torch.Tensor):
        return result
    elif isinstance(result, tuple):
        result, state = result
        self.__state = state
        return result
    else:
        raise TypeError(f"The torch module used by the Policy returned an unexpected object: {result}")
__init__(self, net, **kwargs) special

__init__(...): Initialize the Policy.

Parameters:

Name Type Description Default
net Union[str, Callable, torch.nn.modules.module.Module]

The network to be wrapped by the Policy object. This can be a string, a Callable (e.g. a torch.nn.Module subclass), or a torch.nn.Module instance. When this argument is a string, the network will be created with the help of the function evotorch.neuroevolution.net.str_to_net(...) and then wrapped. Please see the str_to_net(...) function's documentation for details regarding how a network structure can be expressed via strings.

required
kwargs

Expected in the form of additional keyword arguments, these keyword arguments will be passed to the provided Callable object (if the argument net is a Callable) or to str_to_net(...) (if the argument net is a string) at the moment of generating the network. If the argument net is a torch.nn.Module instance, having any additional keyword arguments will trigger an error, because the network is already instantiated and therefore, it is not possible to pass these keyword arguments.

{}
Source code in evotorch/neuroevolution/net/vecrl.py
def __init__(self, net: Union[str, Callable, nn.Module], **kwargs):
    """
    `__init__(...)`: Initialize the Policy.

    Args:
        net: The network to be wrapped by the Policy object.
            This can be a string, a Callable (e.g. a `torch.nn.Module`
            subclass), or a `torch.nn.Module` instance.
            When this argument is a string, the network will be
            created with the help of the function
            `evotorch.neuroevolution.net.str_to_net(...)` and then
            wrapped. Please see the `str_to_net(...)` function's
            documentation for details regarding how a network structure
            can be expressed via strings.
        kwargs: Expected in the form of additional keyword arguments,
            these keyword arguments will be passed to the provided
            Callable object (if the argument `net` is a Callable)
            or to `str_to_net(...)` (if the argument `net` is a string)
            at the moment of generating the network.
            If the argument `net` is a `torch.nn.Module` instance,
            having any additional keyword arguments will trigger an
            error, because the network is already instantiated and
            therefore, it is not possible to pass these keyword arguments.
    """
    from ..net import str_to_net
    from ..net.functional import ModuleExpectingFlatParameters, make_functional_module

    if isinstance(net, str):
        self.__module = str_to_net(net, **kwargs)
    elif isinstance(net, nn.Module):
        if len(kwargs) > 0:
            raise ValueError(
                f"When the network is given as an `nn.Module` instance, extra network arguments cannot be used"
                f" (because the network is already instantiated)."
                f" However, these extra keyword arguments were received: {kwargs}."
            )
        self.__module = net
    elif isinstance(net, Callable):
        self.__module = net(**kwargs)
    else:
        raise TypeError(
            f"The class `Policy` expected a string or an `nn.Module` instance, or a Callable, but received {net}"
            f" (whose type is {type(net)})."
        )

    self.__fmodule: ModuleExpectingFlatParameters = make_functional_module(self.__module)
    self.__state: Any = None
    self.__parameters: Optional[torch.Tensor] = None
reset(self, indices=None, *, copy=True)

Reset the hidden states, if the contained module is a recurrent network.

Parameters:

Name Type Description Default
indices Union[int, Iterable]

Optionally a sequence of integers or a sequence of booleans, specifying which networks' states will be reset. If left as None, then the states of all the networks will be reset.

None
copy bool

When indices is given as something other than None, if copy is given as True, then the resetting will NOT be done in-place. Instead, a new copy of the hidden state will first be created, and then the specified regions of this new copy will be cleared, and then finally this modified copy will be declared as the new hidden state. It is a common practice for recurrent neural network implementations to return the same tensor both as its output and as (part of) its hidden state. With copy=False, the resetting would be done in-place, and the action tensor could be involuntarily reset as well. This in-place modification could cause silent bugs if the unintended modification on the action tensor happens BEFORE the action is sent to the reinforcement learning environment. To prevent such situations, the default value for the argument copy is True.

True
Source code in evotorch/neuroevolution/net/vecrl.py
def reset(self, indices: Optional[MaskOrIndices] = None, *, copy: bool = True):
    """
    Reset the hidden states, if the contained module is a recurrent network.

    Args:
        indices: Optionally a sequence of integers or a sequence of
            booleans, specifying which networks' states will be
            reset. If left as None, then the states of all the networks
            will be reset.
        copy: When `indices` is given as something other than None,
            if `copy` is given as True, then the resetting will NOT
            be done in-place. Instead, a new copy of the hidden state
            will first be created, and then the specified regions
            of this new copy will be cleared, and then finally this
            modified copy will be declared as the new hidden state.
            It is a common practice for recurrent neural network
            implementations to return the same tensor both as its
            output and as (part of) its hidden state. With `copy=False`,
            the resetting would be done in-place, and the action
            tensor could be involuntarily reset as well.
            This in-place modification could cause silent bugs
            if the unintended modification on the action tensor
            happens BEFORE the action is sent to the reinforcement
            learning environment.
            To prevent such situations, the default value for the argument
            `copy` is True.
    """
    if indices is None:
        self.__state = None
    else:
        if self.__state is not None:
            with torch.no_grad():
                if copy:
                    self.__state = deepcopy(self.__state)
                reset_tensors(self.__state, indices)
set_parameters(self, parameters, indices=None, *, reset=True)

Set the parameters of the policy.

Parameters:

Name Type Description Default
parameters Tensor

A 1-dimensional or a 2-dimensional tensor containing the flattened parameters to be used with the neural network. If the given parameters are two-dimensional, then, given that the leftmost size of the parameter tensor is n, the observations will be expected in a batch with leftmost size n, and the returned actions will also be in a batch, again with the leftmost size n.

required
indices Union[int, Iterable]

For when the parameters were previously given via a 2-dimensional tensor, provide this argument if you would like to change only some rows of the previously given parameters. For example, if indices is given as torch.tensor([2, 4]) and the argument parameters is given as a 2-dimensional tensor with leftmost size 2, then the rows with indices 2 and 4 will be replaced by these new parameters provided via the argument parameters.

None
reset bool

If given as True, the hidden states of the networks whose parameters just changed will be reset. If indices was not provided at all, then this means that the parameters of all networks are modified, in which case, all the hidden states will be reset. If given as False, no such resetting will be done.

True
Source code in evotorch/neuroevolution/net/vecrl.py
def set_parameters(self, parameters: torch.Tensor, indices: Optional[MaskOrIndices] = None, *, reset: bool = True):
    """
    Set the parameters of the policy.

    Args:
        parameters: A 1-dimensional or a 2-dimensional tensor containing
            the flattened parameters to be used with the neural network.
            If the given parameters are two-dimensional, then, given that
            the leftmost size of the parameter tensor is `n`, the
            observations will be expected in a batch with leftmost size
            `n`, and the returned actions will also be in a batch,
            again with the leftmost size `n`.
        indices: For when the parameters were previously given via a
            2-dimensional tensor, provide this argument if you would like
            to change only some rows of the previously given parameters.
            For example, if `indices` is given as `torch.tensor([2, 4])`
            and the argument `parameters` is given as a 2-dimensional
            tensor with leftmost size 2, then the rows with indices
            2 and 4 will be replaced by these new parameters provided
            via the argument `parameters`.
        reset: If given as True, the hidden states of the networks whose
            parameters just changed will be reset. If `indices` was not
            provided at all, then this means that the parameters of all
            networks are modified, in which case, all the hidden states
            will be reset.
            If given as False, no such resetting will be done.
    """
    if self.__parameters is None:
        if indices is not None:
            raise ValueError(
                "The argument `indices` can be used only if network parameters were previously specified."
                " However, it seems that the method `set_parameters(...)` was not called before."
            )
        self.__parameters = parameters
    else:
        if indices is None:
            self.__parameters = parameters
        else:
            self.__parameters[indices] = parameters

    if reset:
        self.reset(indices)
to_torch_module(self, parameter_vector)

Get a copy of the contained network, parameterized as specified.

Parameters:

Name Type Description Default
parameter_vector Tensor

The parameters to be used by the new network.

required

Returns:

Type Description
Module

Copy of the contained network, as a torch.nn.Module instance.

Source code in evotorch/neuroevolution/net/vecrl.py
def to_torch_module(self, parameter_vector: torch.Tensor) -> nn.Module:
    """
    Get a copy of the contained network, parameterized as specified.

    Args:
        parameter_vector: The parameters to be used by the new network.
    Returns:
        Copy of the contained network, as a `torch.nn.Module` instance.
    """
    with torch.no_grad():
        net = deepcopy(self.__module).to(parameter_vector.device)
        nnu.vector_to_parameters(parameter_vector, net.parameters())
    return net
TorchWrapper (Wrapper)

A gym wrapper which ensures that the actions, observations, rewards, and the 'done' values are expressed as PyTorch tensors.

Source code in evotorch/neuroevolution/net/vecrl.py
class TorchWrapper(gym.Wrapper):
    """
    A gym wrapper which ensures that the actions, observations, rewards, and
    the 'done' values are expressed as PyTorch tensors.
    """

    def __init__(
        self,
        env: Union[gym.Env],
        *,
        force_classic_api: bool = False,
        discrete_to_continuous_act: bool = False,
        clip_actions: bool = False,
        **kwargs,
    ):
        """
        `__init__(...)`: Initialize the TorchWrapper.

        Args:
            env: The gym environment to be wrapped.
            force_classic_api: Set this as True if you would like to enable
                the classic API. In the classic API, the `reset(...)` method
                returns only the observation and the `step(...)` method
                returns 4 elements (not 5).
            discrete_to_continuous_act: When this is set as True and the
                wrapped environment has a Discrete action space, this wrapper
                will transform the action space to Box. A Discrete-action
                environment with `n` actions will be converted to a Box-action
                environment where the action length is `n`.
                The index of the largest value within the action vector will
                be applied to the underlying environment.
            clip_actions: Set this as True if you would like to clip the given
                actions so that they conform to the declared boundaries of the
                action space.
            kwargs: Expected in the form of additional keyword arguments.
                These additional keyword arguments are passed to the
                superclass.
        """
        super().__init__(env, **kwargs)

        # Declare the variable that will store the array type of the underlying environment.
        self.__array_type: Optional[str] = None

        if hasattr(env, "single_observation_space"):
            # If the underlying environment has the attribute "single_observation_space",
            # then this is a vectorized environment.
            self.__vectorized = True

            # Get the observation and action spaces.
            obs_space = env.single_observation_space
            act_space = env.single_action_space
        else:
            # If the underlying environment has the attribute "single_observation_space",
            # then this is a non-vectorized environment.
            self.__vectorized = False

            # Get the observation and action spaces.
            obs_space = env.observation_space
            act_space = env.action_space

        # Ensure that the observation and action spaces are supported.
        _must_be_supported_space(obs_space)
        _must_be_supported_space(act_space)

        # Store the choice of the user regarding "force_classic_api".
        self.__force_classic_api = bool(force_classic_api)

        if isinstance(act_space, Discrete) and discrete_to_continuous_act:
            # The underlying action space is Discrete and `discrete_to_continuous_act` is given as True.
            # Therefore, we convert the action space to continuous (to Box).

            # Take the shape and the dtype of the discrete action space.
            single_action_shape = (act_space.n,)
            single_action_dtype = torch.from_numpy(np.array([], dtype=act_space.dtype)).dtype

            # We store the integer dtype of the environment.
            self.__discrete_dtype = single_action_dtype

            if self.__vectorized:
                # If the environment is vectorized, we declare the new `action_space` and the `single_action_space`
                # for the enviornment.
                action_shape = (env.num_envs,) + single_action_shape
                self.single_action_space = Box(float("-inf"), float("inf"), shape=single_action_shape, dtype=np.float32)
                self.action_space = Box(float("-inf"), float("inf"), shape=action_shape, dtype=np.float32)
            else:
                # If the environment is not vectorized, we declare the new `action_space` for the environment.
                self.action_space = Box(float("-inf"), float("inf"), shape=single_action_shape, dtype=np.float32)
        else:
            # This is the case where we do not transform the action space.
            # The discrete dtype will not be used, so, we set it as None.
            self.__discrete_dtype = None

        if isinstance(act_space, Box) and clip_actions:
            # If the action space is Box and the wrapper is configured to clip the actions, then we store the lower
            # and the upper bounds for the actions.
            self.__act_lb = torch.from_numpy(act_space.low)
            self.__act_ub = torch.from_numpy(act_space.high)
        else:
            # If there will not be any action clipping, then we store the lower and the upper bounds as None.
            self.__act_lb = None
            self.__act_ub = None

    @property
    def array_type(self) -> Optional[str]:
        """
        Get the array type of the wrapped environment.
        This can be "jax", "torch", or "numpy".
        """
        return self.__array_type

    def __infer_array_type(self, observation):
        if self.__array_type is None:
            # If the array type is not determined yet, set it as the array type of the received observation.
            # If the observation has an unrecognized type, set the array type as "numpy".
            self.__array_type = array_type(observation, "numpy")

    def reset(self, *args, **kwargs):
        """Reset the environment"""

        # Call the reset method of the wrapped environment.
        reset_result = self.env.reset(*args, **kwargs)

        if isinstance(reset_result, tuple):
            # If we received a tuple of two elements, then we assume that this is the new gym API.
            # We note that we received an info dictionary.
            got_info = True
            # We keep the received observation and info.
            observation, info = reset_result
        else:
            # If we did not receive a tuple, then we assume that this is the old gym API.
            # We note that we did not receive an info dictionary.
            got_info = False
            # We keep the received observation.
            observation = reset_result
            # We did not receive an info dictionary, so, we set it as an empty dictionary.
            info = {}

        # We understand the array type of the underlying environment from the first observation.
        self.__infer_array_type(observation)

        # Convert the observation to a PyTorch tensor.
        observation = convert_to_torch(observation)

        if self.__force_classic_api:
            # If the option `force_classic_api` was set as True, then we only return the observation.
            return observation
        else:
            # Here we handle the case where `force_classic_api` was set as False.
            if got_info:
                # If we got an additional info dictionary, we return it next to the observation.
                return observation, info
            else:
                # If we did not get any info dictionary, we return only the observation.
                return observation

    def step(self, action, *args, **kwargs):
        """Take a step in the environment"""

        if self.__array_type is None:
            # If the array type is not known yet, then probably `reset()` has not been called yet.
            # We raise an error.
            raise ValueError(
                "Could not understand what type of array this environment works with."
                " Perhaps the `reset()` method has not been called yet?"
            )

        if self.__discrete_dtype is not None:
            # If the wrapped environment is discrete-actioned, then we take the integer counterpart of the action.
            action = torch.argmax(action, dim=-1).to(dtype=self.__discrete_dtype)

        if self.__act_lb is not None:
            # The internal variable `__act_lb` having a value other than None means that the initialization argument
            # `clip_actions` was given as True.
            # Therefore, we clip the actions.
            self.__act_lb = self.__act_lb.to(action.device)
            self.__act_ub = self.__act_ub.to(action.device)
            action = torch.max(action, self.__act_lb)
            action = torch.min(action, self.__act_ub)

        # Convert the action tensor to the expected array type of the underlying environment.
        action = convert_from_torch(action, self.__array_type)

        # Perform the step and get the result.
        result = self.env.step(action, *args, **kwargs)

        if not isinstance(result, tuple):
            # If the `step(...)` method returned anything other than tuple, we raise an error.
            raise TypeError(f"Expected a tuple as the result of the `step()` method, but received a {type(result)}")

        if len(result) == 5:
            # If the result is a tuple of 5 elements, then we note that we are using the new API.
            using_new_api = True
            # Take the observation, reward, two boolean variables done and done2 indicating that the episode(s)
            # has/have ended, and additional info.
            # `done` indicates whether or not the episode(s) reached terminal state(s).
            # `done2` indicates whether or not the episode(s) got truncated because of the timestep limit.
            observation, reward, done, done2, info = result
        elif len(result) == 4:
            # If the result is a tuple of 5 elements, then we note that we are not using the new API.
            using_new_api = False
            # Take the observation, reward, the done boolean flag, and additional info.
            observation, reward, done, info = result
            done2 = None
        else:
            raise ValueError(f"Unexpected number of elements were returned from step(): {len(result)}")

        # Convert the observation, reward, and done variables to PyTorch tensors.
        observation = convert_to_torch(observation)
        reward = convert_to_torch(reward)
        done = convert_to_torch_bool(done)
        if done2 is not None:
            done2 = convert_to_torch_bool(done2)

        if self.__force_classic_api:
            # This is the case where the initialization argument `force_classic_api` was set as True.
            if done2 is not None:
                # We combine the terminal state and truncation signals into a single boolean tensor indicating
                # whether or not the episode(s) ended.
                done = done | done2
            # Return 4 elements, compatible with the classic gym API.
            return observation, reward, done, info
        else:
            # This is the case where the initialization argument `force_classic_api` was set as False.
            if using_new_api:
                # If we are using the new API, then we return the 5-element result.
                return observation, reward, done, done2, info
            else:
                # If we are using the new API, then we return the 4-element result.
                return observation, reward, done, info
array_type: Optional[str] property readonly

Get the array type of the wrapped environment. This can be "jax", "torch", or "numpy".

__init__(self, env, *, force_classic_api=False, discrete_to_continuous_act=False, clip_actions=False, **kwargs) special

__init__(...): Initialize the TorchWrapper.

Parameters:

Name Type Description Default
env Env

The gym environment to be wrapped.

required
force_classic_api bool

Set this as True if you would like to enable the classic API. In the classic API, the reset(...) method returns only the observation and the step(...) method returns 4 elements (not 5).

False
discrete_to_continuous_act bool

When this is set as True and the wrapped environment has a Discrete action space, this wrapper will transform the action space to Box. A Discrete-action environment with n actions will be converted to a Box-action environment where the action length is n. The index of the largest value within the action vector will be applied to the underlying environment.

False
clip_actions bool

Set this as True if you would like to clip the given actions so that they conform to the declared boundaries of the action space.

False
kwargs

Expected in the form of additional keyword arguments. These additional keyword arguments are passed to the superclass.

{}
Source code in evotorch/neuroevolution/net/vecrl.py
def __init__(
    self,
    env: Union[gym.Env],
    *,
    force_classic_api: bool = False,
    discrete_to_continuous_act: bool = False,
    clip_actions: bool = False,
    **kwargs,
):
    """
    `__init__(...)`: Initialize the TorchWrapper.

    Args:
        env: The gym environment to be wrapped.
        force_classic_api: Set this as True if you would like to enable
            the classic API. In the classic API, the `reset(...)` method
            returns only the observation and the `step(...)` method
            returns 4 elements (not 5).
        discrete_to_continuous_act: When this is set as True and the
            wrapped environment has a Discrete action space, this wrapper
            will transform the action space to Box. A Discrete-action
            environment with `n` actions will be converted to a Box-action
            environment where the action length is `n`.
            The index of the largest value within the action vector will
            be applied to the underlying environment.
        clip_actions: Set this as True if you would like to clip the given
            actions so that they conform to the declared boundaries of the
            action space.
        kwargs: Expected in the form of additional keyword arguments.
            These additional keyword arguments are passed to the
            superclass.
    """
    super().__init__(env, **kwargs)

    # Declare the variable that will store the array type of the underlying environment.
    self.__array_type: Optional[str] = None

    if hasattr(env, "single_observation_space"):
        # If the underlying environment has the attribute "single_observation_space",
        # then this is a vectorized environment.
        self.__vectorized = True

        # Get the observation and action spaces.
        obs_space = env.single_observation_space
        act_space = env.single_action_space
    else:
        # If the underlying environment has the attribute "single_observation_space",
        # then this is a non-vectorized environment.
        self.__vectorized = False

        # Get the observation and action spaces.
        obs_space = env.observation_space
        act_space = env.action_space

    # Ensure that the observation and action spaces are supported.
    _must_be_supported_space(obs_space)
    _must_be_supported_space(act_space)

    # Store the choice of the user regarding "force_classic_api".
    self.__force_classic_api = bool(force_classic_api)

    if isinstance(act_space, Discrete) and discrete_to_continuous_act:
        # The underlying action space is Discrete and `discrete_to_continuous_act` is given as True.
        # Therefore, we convert the action space to continuous (to Box).

        # Take the shape and the dtype of the discrete action space.
        single_action_shape = (act_space.n,)
        single_action_dtype = torch.from_numpy(np.array([], dtype=act_space.dtype)).dtype

        # We store the integer dtype of the environment.
        self.__discrete_dtype = single_action_dtype

        if self.__vectorized:
            # If the environment is vectorized, we declare the new `action_space` and the `single_action_space`
            # for the enviornment.
            action_shape = (env.num_envs,) + single_action_shape
            self.single_action_space = Box(float("-inf"), float("inf"), shape=single_action_shape, dtype=np.float32)
            self.action_space = Box(float("-inf"), float("inf"), shape=action_shape, dtype=np.float32)
        else:
            # If the environment is not vectorized, we declare the new `action_space` for the environment.
            self.action_space = Box(float("-inf"), float("inf"), shape=single_action_shape, dtype=np.float32)
    else:
        # This is the case where we do not transform the action space.
        # The discrete dtype will not be used, so, we set it as None.
        self.__discrete_dtype = None

    if isinstance(act_space, Box) and clip_actions:
        # If the action space is Box and the wrapper is configured to clip the actions, then we store the lower
        # and the upper bounds for the actions.
        self.__act_lb = torch.from_numpy(act_space.low)
        self.__act_ub = torch.from_numpy(act_space.high)
    else:
        # If there will not be any action clipping, then we store the lower and the upper bounds as None.
        self.__act_lb = None
        self.__act_ub = None
reset(self, *args, **kwargs)

Reset the environment

Source code in evotorch/neuroevolution/net/vecrl.py
def reset(self, *args, **kwargs):
    """Reset the environment"""

    # Call the reset method of the wrapped environment.
    reset_result = self.env.reset(*args, **kwargs)

    if isinstance(reset_result, tuple):
        # If we received a tuple of two elements, then we assume that this is the new gym API.
        # We note that we received an info dictionary.
        got_info = True
        # We keep the received observation and info.
        observation, info = reset_result
    else:
        # If we did not receive a tuple, then we assume that this is the old gym API.
        # We note that we did not receive an info dictionary.
        got_info = False
        # We keep the received observation.
        observation = reset_result
        # We did not receive an info dictionary, so, we set it as an empty dictionary.
        info = {}

    # We understand the array type of the underlying environment from the first observation.
    self.__infer_array_type(observation)

    # Convert the observation to a PyTorch tensor.
    observation = convert_to_torch(observation)

    if self.__force_classic_api:
        # If the option `force_classic_api` was set as True, then we only return the observation.
        return observation
    else:
        # Here we handle the case where `force_classic_api` was set as False.
        if got_info:
            # If we got an additional info dictionary, we return it next to the observation.
            return observation, info
        else:
            # If we did not get any info dictionary, we return only the observation.
            return observation
step(self, action, *args, **kwargs)

Take a step in the environment

Source code in evotorch/neuroevolution/net/vecrl.py
def step(self, action, *args, **kwargs):
    """Take a step in the environment"""

    if self.__array_type is None:
        # If the array type is not known yet, then probably `reset()` has not been called yet.
        # We raise an error.
        raise ValueError(
            "Could not understand what type of array this environment works with."
            " Perhaps the `reset()` method has not been called yet?"
        )

    if self.__discrete_dtype is not None:
        # If the wrapped environment is discrete-actioned, then we take the integer counterpart of the action.
        action = torch.argmax(action, dim=-1).to(dtype=self.__discrete_dtype)

    if self.__act_lb is not None:
        # The internal variable `__act_lb` having a value other than None means that the initialization argument
        # `clip_actions` was given as True.
        # Therefore, we clip the actions.
        self.__act_lb = self.__act_lb.to(action.device)
        self.__act_ub = self.__act_ub.to(action.device)
        action = torch.max(action, self.__act_lb)
        action = torch.min(action, self.__act_ub)

    # Convert the action tensor to the expected array type of the underlying environment.
    action = convert_from_torch(action, self.__array_type)

    # Perform the step and get the result.
    result = self.env.step(action, *args, **kwargs)

    if not isinstance(result, tuple):
        # If the `step(...)` method returned anything other than tuple, we raise an error.
        raise TypeError(f"Expected a tuple as the result of the `step()` method, but received a {type(result)}")

    if len(result) == 5:
        # If the result is a tuple of 5 elements, then we note that we are using the new API.
        using_new_api = True
        # Take the observation, reward, two boolean variables done and done2 indicating that the episode(s)
        # has/have ended, and additional info.
        # `done` indicates whether or not the episode(s) reached terminal state(s).
        # `done2` indicates whether or not the episode(s) got truncated because of the timestep limit.
        observation, reward, done, done2, info = result
    elif len(result) == 4:
        # If the result is a tuple of 5 elements, then we note that we are not using the new API.
        using_new_api = False
        # Take the observation, reward, the done boolean flag, and additional info.
        observation, reward, done, info = result
        done2 = None
    else:
        raise ValueError(f"Unexpected number of elements were returned from step(): {len(result)}")

    # Convert the observation, reward, and done variables to PyTorch tensors.
    observation = convert_to_torch(observation)
    reward = convert_to_torch(reward)
    done = convert_to_torch_bool(done)
    if done2 is not None:
        done2 = convert_to_torch_bool(done2)

    if self.__force_classic_api:
        # This is the case where the initialization argument `force_classic_api` was set as True.
        if done2 is not None:
            # We combine the terminal state and truncation signals into a single boolean tensor indicating
            # whether or not the episode(s) ended.
            done = done | done2
        # Return 4 elements, compatible with the classic gym API.
        return observation, reward, done, info
    else:
        # This is the case where the initialization argument `force_classic_api` was set as False.
        if using_new_api:
            # If we are using the new API, then we return the 5-element result.
            return observation, reward, done, done2, info
        else:
            # If we are using the new API, then we return the 4-element result.
            return observation, reward, done, info
array_type(x, fallback=None)

Get the type of an array as a string ("jax", "torch", or "numpy"). If the type of the array cannot be determined and a fallback is provided, then the fallback value will be returned.

Parameters:

Name Type Description Default
x Any

The array whose type will be determined.

required
fallback Optional[str]

Fallback value, as a string, which will be returned if the array type cannot be determined.

None

Returns:

Type Description
str

The array type as a string ("jax", "torch", or "numpy").

Exceptions:

Type Description
TypeError

if the array type cannot be determined and a fallback value is not provided.

Source code in evotorch/neuroevolution/net/vecrl.py
def array_type(x: Any, fallback: Optional[str] = None) -> str:
    """
    Get the type of an array as a string ("jax", "torch", or "numpy").
    If the type of the array cannot be determined and a fallback is provided,
    then the fallback value will be returned.

    Args:
        x: The array whose type will be determined.
        fallback: Fallback value, as a string, which will be returned if the
            array type cannot be determined.
    Returns:
        The array type as a string ("jax", "torch", or "numpy").
    Raises:
        TypeError: if the array type cannot be determined and a fallback
            value is not provided.
    """
    if is_jax_array(x):
        return "jax"
    elif isinstance(x, torch.Tensor):
        return "torch"
    elif isinstance(x, np.ndarray):
        return "numpy"
    elif fallback is not None:
        return fallback
    else:
        raise TypeError(f"The object has an unrecognized type: {type(x)}")
convert_from_torch(x, array_type)

Convert the given PyTorch tensor to an array of the specified type.

Parameters:

Name Type Description Default
x Tensor

The PyTorch array that will be converted.

required
array_type str

Type to which the PyTorch tensor will be converted. Expected as one of these strings: "jax", "torch", "numpy".

required

Returns:

Type Description
Any

The array of the specified type. Can be a JAX array, a numpy array, or PyTorch tensor.

Exceptions:

Type Description
ValueError

if the array type cannot be determined.

Source code in evotorch/neuroevolution/net/vecrl.py
def convert_from_torch(x: torch.Tensor, array_type: str) -> Any:
    """
    Convert the given PyTorch tensor to an array of the specified type.

    Args:
        x: The PyTorch array that will be converted.
        array_type: Type to which the PyTorch tensor will be converted.
            Expected as one of these strings: "jax", "torch", "numpy".
    Returns:
        The array of the specified type. Can be a JAX array, a numpy array,
        or PyTorch tensor.
    Raises:
        ValueError: if the array type cannot be determined.
    """
    if array_type == "torch":
        return x
    elif array_type == "jax":
        return torch_to_jax(x)
    elif array_type == "numpy":
        return x.cpu().numpy()
    else:
        raise ValueError(f"Unrecognized array type: {array_type}")
convert_to_torch(x)

Convert the given array to PyTorch tensor.

Parameters:

Name Type Description Default
x Any

Array to be converted. Can be a JAX array, a numpy array, a PyTorch tensor (in which case the input tensor will be returned as it is) or any Iterable object.

required

Returns:

Type Description
Tensor

The PyTorch counterpart of the given array.

Source code in evotorch/neuroevolution/net/vecrl.py
def convert_to_torch(x: Any) -> torch.Tensor:
    """
    Convert the given array to PyTorch tensor.

    Args:
        x: Array to be converted. Can be a JAX array, a numpy array,
            a PyTorch tensor (in which case the input tensor will be
            returned as it is) or any Iterable object.
    Returns:
        The PyTorch counterpart of the given array.
    """
    if isinstance(x, torch.Tensor):
        return x
    elif is_jax_array(x):
        return jax_to_torch(x)
    elif isinstance(x, np.ndarray):
        return torch.from_numpy(x)
    else:
        return torch.as_tensor(x)
convert_to_torch_bool(x)

Convert the given array to a PyTorch tensor of bools.

If the given object is an array of floating point numbers, then, values that are near to 0.0 (with a tolerance of 1e-4) will be converted to False, and the others will be converted to True. If the given object is an array of integers, then zero values will be converted to False, and non-zero values will be converted to True. If the given object is an array of booleans, then no change will be made to those boolean values.

The given object can be a JAX array, a numpy array, or a PyTorch tensor. The result will always be a PyTorch tensor.

Parameters:

Name Type Description Default
x Any

Array to be converted.

required

Returns:

Type Description
Tensor

The array converted to a PyTorch tensor with its dtype set as bool.

Source code in evotorch/neuroevolution/net/vecrl.py
def convert_to_torch_bool(x: Any) -> torch.Tensor:
    """
    Convert the given array to a PyTorch tensor of bools.

    If the given object is an array of floating point numbers, then, values
    that are near to 0.0 (with a tolerance of 1e-4) will be converted to
    False, and the others will be converted to True.
    If the given object is an array of integers, then zero values will be
    converted to False, and non-zero values will be converted to True.
    If the given object is an array of booleans, then no change will be made
    to those boolean values.

    The given object can be a JAX array, a numpy array, or a PyTorch tensor.
    The result will always be a PyTorch tensor.

    Args:
        x: Array to be converted.
    Returns:
        The array converted to a PyTorch tensor with its dtype set as bool.
    """
    x = convert_to_torch(x)
    if x.dtype == torch.bool:
        pass  # nothing to do
    elif "float" in str(x.dtype):
        x = torch.abs(x) > 1e-4
    else:
        x = torch.as_tensor(x, dtype=torch.bool)

    return x
make_brax_env(env_name, *, force_classic_api=False, num_envs=None, discrete_to_continuous_act=False, clip_actions=False, **kwargs)

Make a brax environment and wrap it via TorchWrapper.

Parameters:

Name Type Description Default
env_name str

Name of the brax environment, as string (e.g. "humanoid"). If the string starts with "old::" (e.g. "old::humanoid", etc.), then the environment will be made using the namespace brax.v1 (which was introduced in brax version 0.9.0 where the updated implementations of the environments became default and the classical ones moved into brax.v1). You can use the prefix "old::" for reproducing previous results that were obtained or reported using an older version of brax.

required
force_classic_api bool

Whether or not the classic gym API is to be used.

False
num_envs Optional[int]

Batch size for the vectorized environment.

None
discrete_to_continuous_act bool

Whether or not the the discrete action space of the environment is to be converted to a continuous one. This does nothing if the environment's action space is not discrete.

False
clip_actions bool

Whether or not the actions should be explicitly clipped so that they stay within the declared action boundaries.

False
kwargs

Expected in the form of additional keyword arguments, these are passed to the environment.

{}

Returns:

Type Description
TorchWrapper

The brax environment, wrapped by TorchWrapper.

Source code in evotorch/neuroevolution/net/vecrl.py
def make_brax_env(
    env_name: str,
    *,
    force_classic_api: bool = False,
    num_envs: Optional[int] = None,
    discrete_to_continuous_act: bool = False,
    clip_actions: bool = False,
    **kwargs,
) -> TorchWrapper:
    """
    Make a brax environment and wrap it via TorchWrapper.

    Args:
        env_name: Name of the brax environment, as string (e.g. "humanoid").
            If the string starts with "old::" (e.g. "old::humanoid", etc.),
            then the environment will be made using the namespace `brax.v1`
            (which was introduced in brax version 0.9.0 where the updated
            implementations of the environments became default and the classical
            ones moved into `brax.v1`).
            You can use the prefix "old::" for reproducing previous results
            that were obtained or reported using an older version of brax.
        force_classic_api: Whether or not the classic gym API is to be used.
        num_envs: Batch size for the vectorized environment.
        discrete_to_continuous_act: Whether or not the the discrete action
            space of the environment is to be converted to a continuous one.
            This does nothing if the environment's action space is not
            discrete.
        clip_actions: Whether or not the actions should be explicitly clipped
            so that they stay within the declared action boundaries.
        kwargs: Expected in the form of additional keyword arguments, these
            are passed to the environment.
    Returns:
        The brax environment, wrapped by TorchWrapper.
    """

    if brax is not None:
        config = {}
        config.update(kwargs)
        if num_envs is not None:
            config["num_envs"] = num_envs
        env = VectorEnvFromBrax(env_name, **config)
        env = TorchWrapper(
            env,
            force_classic_api=force_classic_api,
            discrete_to_continuous_act=discrete_to_continuous_act,
            clip_actions=clip_actions,
        )
        return env
    else:
        _brax_is_missing()
make_gym_env(env_name, *, force_classic_api=False, num_envs=None, discrete_to_continuous_act=False, clip_actions=False, **kwargs)

Make gymnasium environments and wrap them via SyncVectorEnv and TorchWrapper.

Parameters:

Name Type Description Default
env_name str

Name of the gymnasium environment, as string (e.g. "Humanoid-v4").

required
force_classic_api bool

Whether or not the classic gym API is to be used.

False
num_envs Optional[int]

Batch size for the vectorized environment.

None
discrete_to_continuous_act bool

Whether or not the the discrete action space of the environment is to be converted to a continuous one. This does nothing if the environment's action space is not discrete.

False
clip_actions bool

Whether or not the actions should be explicitly clipped so that they stay within the declared action boundaries.

False
kwargs

Expected in the form of additional keyword arguments, these are passed to the environment.

{}

Returns:

Type Description
TorchWrapper

The gymnasium environments, wrapped by a TorchWrapper.

Source code in evotorch/neuroevolution/net/vecrl.py
def make_gym_env(
    env_name: str,
    *,
    force_classic_api: bool = False,
    num_envs: Optional[int] = None,
    discrete_to_continuous_act: bool = False,
    clip_actions: bool = False,
    **kwargs,
) -> TorchWrapper:
    """
    Make gymnasium environments and wrap them via SyncVectorEnv and TorchWrapper.

    Args:
        env_name: Name of the gymnasium environment, as string (e.g. "Humanoid-v4").
        force_classic_api: Whether or not the classic gym API is to be used.
        num_envs: Batch size for the vectorized environment.
        discrete_to_continuous_act: Whether or not the the discrete action
            space of the environment is to be converted to a continuous one.
            This does nothing if the environment's action space is not
            discrete.
        clip_actions: Whether or not the actions should be explicitly clipped
            so that they stay within the declared action boundaries.
        kwargs: Expected in the form of additional keyword arguments, these
            are passed to the environment.
    Returns:
        The gymnasium environments, wrapped by a TorchWrapper.
    """

    def make_the_env():
        return gym.make(env_name, **kwargs)

    env_fns = [make_the_env for _ in range(num_envs)]
    vec_env = TorchWrapper(
        SyncVectorEnv(env_fns),
        force_classic_api=force_classic_api,
        discrete_to_continuous_act=discrete_to_continuous_act,
        clip_actions=clip_actions,
    )

    return vec_env
make_vector_env(env_name, *, force_classic_api=False, num_envs=None, discrete_to_continuous_act=False, clip_actions=False, **kwargs)

Make a new vectorized environment and wrap it via TorchWrapper.

Parameters:

Name Type Description Default
env_name str

Name of the environment, as string. If the string starts with "gym::" (e.g. "gym::Humanoid-v4", etc.), then it is assumed that the target environment is a traditional non-vectorized gymnasium environment. This non-vectorized will first be duplicated and wrapped via a SyncVectorEnv so that it gains a vectorized interface, and then, it will be wrapped via TorchWrapper. If the string starts with "brax::" (e.g. "brax::humanoid", etc.), then it is assumed that the target environment is a brax environment which will be wrapped via TorchWrapper. If the string starts with "brax::old::" (e.g. "brax::old::humanoid", etc.), then the environment will be made using the namespace brax.v1 (which was introduced in brax version 0.9.0 where the updated implementations of the environments became default and the classical ones moved into brax.v1). You can use the prefix "brax::old::" for reproducing previous results that were obtained or reported using an older version of brax. If the string does not contain "::" at all (e.g. "Humanoid-v4"), then it is assumed that the target environment is a gymnasium environment. Therefore, "gym::Humanoid-v4" and "Humanoid-v4" are equivalent.

required
force_classic_api bool

Whether or not the classic gym API is to be used.

False
num_envs Optional[int]

Batch size for the vectorized environment.

None
discrete_to_continuous_act bool

Whether or not the the discrete action space of the environment is to be converted to a continuous one. This does nothing if the environment's action space is not discrete.

False
clip_actions bool

Whether or not the actions should be explicitly clipped so that they stay within the declared action boundaries.

False
kwargs

Expected in the form of additional keyword arguments, these are passed to the environment.

{}

Returns:

Type Description
TorchWrapper

The vectorized gymnasium environment, wrapped by TorchWrapper.

Source code in evotorch/neuroevolution/net/vecrl.py
def make_vector_env(
    env_name: str,
    *,
    force_classic_api: bool = False,
    num_envs: Optional[int] = None,
    discrete_to_continuous_act: bool = False,
    clip_actions: bool = False,
    **kwargs,
) -> TorchWrapper:
    """
    Make a new vectorized environment and wrap it via TorchWrapper.

    Args:
        env_name: Name of the environment, as string.
            If the string starts with "gym::" (e.g. "gym::Humanoid-v4", etc.),
            then it is assumed that the target environment is a traditional
            non-vectorized gymnasium environment. This non-vectorized
            will first be duplicated and wrapped via a `SyncVectorEnv` so that
            it gains a vectorized interface, and then, it will be wrapped via
            `TorchWrapper`.
            If the string starts with "brax::" (e.g. "brax::humanoid", etc.),
            then it is assumed that the target environment is a brax
            environment which will be wrapped via TorchWrapper.
            If the string starts with "brax::old::" (e.g.
            "brax::old::humanoid", etc.), then the environment will be made
            using the namespace `brax.v1` (which was introduced in brax
            version 0.9.0 where the updated implementations of the environments
            became default and the classical ones moved into `brax.v1`).
            You can use the prefix "brax::old::" for reproducing previous
            results that were obtained or reported using an older version of
            brax.
            If the string does not contain "::" at all (e.g. "Humanoid-v4"),
            then it is assumed that the target environment is a gymnasium
            environment. Therefore, "gym::Humanoid-v4" and "Humanoid-v4"
            are equivalent.
        force_classic_api: Whether or not the classic gym API is to be used.
        num_envs: Batch size for the vectorized environment.
        discrete_to_continuous_act: Whether or not the the discrete action
            space of the environment is to be converted to a continuous one.
            This does nothing if the environment's action space is not
            discrete.
        clip_actions: Whether or not the actions should be explicitly clipped
            so that they stay within the declared action boundaries.
        kwargs: Expected in the form of additional keyword arguments, these
            are passed to the environment.
    Returns:
        The vectorized gymnasium environment, wrapped by TorchWrapper.
    """

    env_parts = str(env_name).split("::", maxsplit=1)

    if len(env_parts) == 0:
        raise ValueError(f"Invalid value for `env_name`: {repr(env_name)}")
    elif len(env_parts) == 1:
        fn = make_gym_env
    elif len(env_parts) == 2:
        env_name = env_parts[1]
        if env_parts[0] == "gym":
            fn = make_gym_env
        elif env_parts[0] == "brax":
            fn = make_brax_env
        else:
            invalid_value = env_parts[0] + "::"
            raise ValueError(
                f"The argument `env_name` starts with {repr(invalid_value)}, implying that the environment is stored"
                f" in a registry named {repr(env_parts[0])}."
                f" However, the registry {repr(env_parts[0])} is not recognized."
                f" Supported environment registries are: 'gym', 'brax'."
            )
    else:
        assert False, "Unexpected value received from len(env_parts)"

    return fn(
        env_name,
        force_classic_api=force_classic_api,
        num_envs=num_envs,
        discrete_to_continuous_act=discrete_to_continuous_act,
        clip_actions=clip_actions,
        **kwargs,
    )
reset_tensors(x, indices)

Reset the specified regions of the given tensor(s) as 0.

Note that the resetting is performed in-place, which means, the provided tensors are modified.

The regions are determined by the argument indices, which can be a sequence of booleans (in which case it is interpreted as a mask), or a sequence of integers (in which case it is interpreted as the list of indices).

For example, let us imagine that we have the following tensor:

import torch

x = torch.tensor(
    [
        [0, 1, 2, 3],
        [4, 5, 6, 7],
        [8, 9, 10, 11],
        [12, 13, 14, 15],
    ],
    dtype=torch.float32,
)

If we wish to reset the rows with indices 0 and 2, we could use:

reset_tensors(x, [0, 2])

The new value of x would then be:

torch.tensor(
    [
        [0, 0, 0, 0],
        [4, 5, 6, 7],
        [0, 0, 0, 0],
        [12, 13, 14, 15],
    ],
    dtype=torch.float32,
)

The first argument does not have to be a single tensor. Instead, it can be a container (i.e. a dictionary-like object or an iterable) that stores tensors. In this case, each tensor stored by the container will be subject to resetting. In more details, each tensor within the iterable(s) and each tensor within the value part of the dictionary-like object(s) will be reset.

As an example, let us assume that we have the following collection:

a = torch.tensor(
    [
        [0, 1],
        [2, 3],
        [4, 5],
    ],
    dtype=torch.float32,
)

b = torch.tensor(
    [
        [0, 10, 20],
        [30, 40, 50],
        [60, 70, 80],
    ],
    dtype=torch.float32,
)

c = torch.tensor(
    [
        [100],
        [200],
        [300],
    ],
    dtype=torch.float32,
)

d = torch.tensor([-1, -2, -3], dtype=torch.float32)

my_tensors = [a, {"1": b, "2": (c, d)}]

To clear the regions with indices, e.g, (1, 2), we could do:

reset_tensors(my_tensors, [1, 2])

and the result would be:

>>> print(a)
torch.tensor(
    [
        [0, 1],
        [0, 0],
        [0, 0],
    ],
    dtype=torch.float32,
)

>>> print(b)
torch.tensor(
    [
        [0, 10, 20],
        [0, 0, 0],
        [0, 0, 0],
    ],
    dtype=torch.float32,
)

>>> print(c)
c = torch.tensor(
    [
        [100],
        [0],
        [0],
    ],
    dtype=torch.float32,
)

>>> print(d)
torch.tensor([-1, 0, 0], dtype=torch.float32)

Parameters:

Name Type Description Default
x Any

A tensor or a collection of tensors, whose values are subject to resetting.

required
indices Union[int, Iterable]

A sequence of integers or booleans, specifying which regions of the tensor(s) will be reset.

required
Source code in evotorch/neuroevolution/net/vecrl.py
def reset_tensors(x: Any, indices: MaskOrIndices):
    """
    Reset the specified regions of the given tensor(s) as 0.

    Note that the resetting is performed in-place, which means, the provided tensors are modified.

    The regions are determined by the argument `indices`, which can be a sequence of booleans (in which case it is
    interpreted as a mask), or a sequence of integers (in which case it is interpreted as the list of indices).

    For example, let us imagine that we have the following tensor:

    ```python
    import torch

    x = torch.tensor(
        [
            [0, 1, 2, 3],
            [4, 5, 6, 7],
            [8, 9, 10, 11],
            [12, 13, 14, 15],
        ],
        dtype=torch.float32,
    )
    ```

    If we wish to reset the rows with indices 0 and 2, we could use:

    ```python
    reset_tensors(x, [0, 2])
    ```

    The new value of `x` would then be:

    ```
    torch.tensor(
        [
            [0, 0, 0, 0],
            [4, 5, 6, 7],
            [0, 0, 0, 0],
            [12, 13, 14, 15],
        ],
        dtype=torch.float32,
    )
    ```

    The first argument does not have to be a single tensor.
    Instead, it can be a container (i.e. a dictionary-like object or an iterable) that stores tensors.
    In this case, each tensor stored by the container will be subject to resetting.
    In more details, each tensor within the iterable(s) and each tensor within the value part of the dictionary-like
    object(s) will be reset.

    As an example, let us assume that we have the following collection:

    ```python
    a = torch.tensor(
        [
            [0, 1],
            [2, 3],
            [4, 5],
        ],
        dtype=torch.float32,
    )

    b = torch.tensor(
        [
            [0, 10, 20],
            [30, 40, 50],
            [60, 70, 80],
        ],
        dtype=torch.float32,
    )

    c = torch.tensor(
        [
            [100],
            [200],
            [300],
        ],
        dtype=torch.float32,
    )

    d = torch.tensor([-1, -2, -3], dtype=torch.float32)

    my_tensors = [a, {"1": b, "2": (c, d)}]
    ```

    To clear the regions with indices, e.g, (1, 2), we could do:

    ```python
    reset_tensors(my_tensors, [1, 2])
    ```

    and the result would be:

    ```
    >>> print(a)
    torch.tensor(
        [
            [0, 1],
            [0, 0],
            [0, 0],
        ],
        dtype=torch.float32,
    )

    >>> print(b)
    torch.tensor(
        [
            [0, 10, 20],
            [0, 0, 0],
            [0, 0, 0],
        ],
        dtype=torch.float32,
    )

    >>> print(c)
    c = torch.tensor(
        [
            [100],
            [0],
            [0],
        ],
        dtype=torch.float32,
    )

    >>> print(d)
    torch.tensor([-1, 0, 0], dtype=torch.float32)
    ```

    Args:
        x: A tensor or a collection of tensors, whose values are subject to resetting.
        indices: A sequence of integers or booleans, specifying which regions of the tensor(s) will be reset.
    """
    if isinstance(x, torch.Tensor):
        # If the first argument is a tensor, then we clear it according to the indices we received.
        x[indices] = 0
    elif isinstance(x, (str, bytes, bytearray)):
        # str, bytes, and bytearray are the types of `Iterable` that we do not wish to process.
        # Therefore, we explicitly add a condition for them here, and explicitly state that nothing should be done
        # when instances of them are encountered.
        pass
    elif isinstance(x, Mapping):
        # If the first argument is a Mapping (i.e. a dictionary-like object), then, for each value part of the
        # Mapping instance, we call this function itself.
        for key, value in x.items():
            reset_tensors(value, indices)
    elif isinstance(x, Iterable):
        # If the first argument is an Iterable (e.g. a list, a tuple, etc.), then, for each value contained by this
        # Iterable instance, we call this function itself.
        for value in x:
            reset_tensors(value, indices)

supervisedne

SupervisedNE (NEProblem)

Representation of a neuro-evolution problem where the goal is to minimize a loss function in a supervised learning setting.

A supervised learning problem can be defined via subclassing this class and overriding the methods _loss(y_hat, y) (which is to define how the loss is computed) and _make_dataloader() (which is to define how a new DataLoader is created).

Alternatively, this class can be directly instantiated as follows:

def my_loss_function(output_of_network, desired_output):
    loss = ...  # compute the loss here
    return loss


problem = SupervisedNE(
    my_dataset, MyTorchModuleClass, my_loss_function, minibatch_size=..., ...
)
Source code in evotorch/neuroevolution/supervisedne.py
class SupervisedNE(NEProblem):
    """
    Representation of a neuro-evolution problem where the goal is to minimize
    a loss function in a supervised learning setting.

    A supervised learning problem can be defined via subclassing this class
    and overriding the methods
    `_loss(y_hat, y)` (which is to define how the loss is computed)
    and `_make_dataloader()` (which is to define how a new DataLoader is
    created).

    Alternatively, this class can be directly instantiated as follows:

    ```python
    def my_loss_function(output_of_network, desired_output):
        loss = ...  # compute the loss here
        return loss


    problem = SupervisedNE(
        my_dataset, MyTorchModuleClass, my_loss_function, minibatch_size=..., ...
    )
    ```
    """

    def __init__(
        self,
        dataset: Dataset,
        network: Union[str, nn.Module, Callable[[], nn.Module]],
        loss_func: Optional[Callable] = None,
        *,
        network_args: Optional[dict] = None,
        initial_bounds: Optional[BoundsPairLike] = (-0.00001, 0.00001),
        minibatch_size: Optional[int] = None,
        num_minibatches: Optional[int] = None,
        num_actors: Optional[Union[int, str]] = None,
        common_minibatch: bool = True,
        num_gpus_per_actor: Optional[Union[int, float, str]] = None,
        actor_config: Optional[dict] = None,
        num_subbatches: Optional[int] = None,
        subbatch_size: Optional[int] = None,
        device: Optional[Device] = None,
    ):
        """
        `__init__(...)`: Initialize the SupervisedNE.

        Args:
            dataset: The Dataset from which the minibatches will be pulled
            network: A network structure string, or a Callable (which can be
                a class inheriting from `torch.nn.Module`, or a function
                which returns a `torch.nn.Module` instance), or an instance
                of `torch.nn.Module`.
                The object provided here determines the structure of the
                neural network whose parameters will be evolved.
                A network structure string is a string which can be processed
                by `evotorch.neuroevolution.net.str_to_net(...)`.
                Please see the documentation of the function
                `evotorch.neuroevolution.net.str_to_net(...)` to see how such
                a neural network structure string looks like.
            loss_func: Optionally a function (or a Callable object) which
                receives `y_hat` (the output generated by the neural network)
                and `y` (the desired output), and returns the loss as a
                scalar.
                This argument can also be left as None, in which case it will
                be expected that the method `_loss(self, y_hat, y)` is
                overridden by the inheriting class.
            network_args: Optionally a dict-like object, storing keyword
                arguments to be passed to the network while instantiating it.
            initial_bounds: Specifies an interval from which the values of the
                initial neural network parameters will be drawn.
            minibatch_size: Optionally an integer, describing the size of a
                minibatch when pulling data from the dataset.
                Can also be left as None, in which case it will be expected
                that the inheriting class overrides the method
                `_make_dataloader()` and defines how a new DataLoader is to be
                made.
            num_minibatches: An integer, specifying over how many minibatches
                will a single neural network be evaluated.
                If not specified, it will be assumed that the desired number
                of minibatches per network evaluation is 1.
            num_actors: Number of actors to create for parallelized
                evaluation of the solutions.
                Certain string values are also accepted.
                When given as "max" or as "num_cpus", the number of actors
                will be equal to the number of all available CPUs in the ray
                cluster.
                When given as "num_gpus", the number of actors will be
                equal to the number of all available GPUs in the ray
                cluster, and each actor will be assigned a GPU.
                When given as "num_devices", the number of actors will be
                equal to the minimum among the number of CPUs and the number
                of GPUs available in the cluster (or will be equal to the
                number of CPUs if there is no GPU), and each actor will be
                assigned a GPU (if available).
                If `num_actors` is given as "num_gpus" or "num_devices",
                the argument `num_gpus_per_actor` must not be used,
                and the `actor_config` dictionary must not contain the
                key "num_gpus".
                If `num_actors` is given as something other than "num_gpus"
                or "num_devices", and if you wish to assign GPUs to each
                actor, then please see the argument `num_gpus_per_actor`.
            common_minibatch: Whether the same minibatches will be
                used when evaluating the solutions or not.
            actor_config: A dictionary, representing the keyword arguments
                to be passed to the options(...) used when creating the
                ray actor objects. To be used for explicitly allocating
                resources per each actor.
                For example, for declaring that each actor is to use a GPU,
                one can pass `actor_config=dict(num_gpus=1)`.
                Can also be given as None (which is the default),
                if no such options are to be passed.
            num_gpus_per_actor: Number of GPUs to be allocated by each
                remote actor.
                The default behavior is to NOT allocate any GPU at all
                (which is the default behavior of the ray library as well).
                When given as a number `n`, each actor will be given
                `n` GPUs (where `n` can be an integer, or can be a `float`
                for fractional allocation).
                When given as a string "max", then the available GPUs
                across the entire ray cluster (or within the local computer
                in the simplest cases) will be equally distributed among
                the actors.
                When given as a string "all", then each actor will have
                access to all the GPUs (this will be achieved by suppressing
                the environment variable `CUDA_VISIBLE_DEVICES` for each
                actor).
                When the problem is not distributed (i.e. when there are
                no actors), this argument is expected to be left as None.
            num_subbatches: If `num_subbatches` is None (assuming that
                `subbatch_size` is also None), then, when evaluating a
                population, the population will be split into n pieces, `n`
                being the number of actors, and each actor will evaluate
                its assigned piece. If `num_subbatches` is an integer `m`,
                then the population will be split into `m` pieces,
                and actors will continually accept the next unevaluated
                piece as they finish their current tasks.
                The arguments `num_subbatches` and `subbatch_size` cannot
                be given values other than None at the same time.
                While using a distributed algorithm, this argument determines
                how many sub-batches will be generated, and therefore,
                how many gradients will be computed by the remote actors.
            subbatch_size: If `subbatch_size` is None (assuming that
                `num_subbatches` is also None), then, when evaluating a
                population, the population will be split into `n` pieces, `n`
                being the number of actors, and each actor will evaluate its
                assigned piece. If `subbatch_size` is an integer `m`,
                then the population will be split into pieces of size `m`,
                and actors will continually accept the next unevaluated
                piece as they finish their current tasks.
                When there can be significant difference across the solutions
                in terms of computational requirements, specifying a
                `subbatch_size` can be beneficial, because, while one
                actor is busy with a subbatch containing computationally
                challenging solutions, other actors can accept more
                tasks and save time.
                The arguments `num_subbatches` and `subbatch_size` cannot
                be given values other than None at the same time.
                While using a distributed algorithm, this argument determines
                the size of a sub-batch (or sub-population) sampled by a
                remote actor for computing a gradient.
                In distributed mode, it is expected that the population size
                is divisible by `subbatch_size`.
            device: Default device in which a new population will be generated
                and the neural networks will operate.
                If not specified, "cpu" will be used.
        """
        super().__init__(
            objective_sense="min",
            network=network,
            network_args=network_args,
            initial_bounds=initial_bounds,
            num_actors=num_actors,
            num_gpus_per_actor=num_gpus_per_actor,
            actor_config=actor_config,
            num_subbatches=num_subbatches,
            subbatch_size=subbatch_size,
            device=device,
        )

        self.dataset = dataset
        self.dataloader: DataLoader = None
        self.dataloader_iterator = None

        self._loss_func = loss_func
        self._minibatch_size = None if minibatch_size is None else int(minibatch_size)
        self._num_minibatches = 1 if num_minibatches is None else int(num_minibatches)
        self._common_minibatch = common_minibatch
        self._current_minibatches: Optional[list] = None

    def _make_dataloader(self) -> DataLoader:
        """
        Make a new DataLoader.

        This method, in its default state, does not contain an implementation.
        In the case where the `__init__` of `SupervisedNE` is not provided
        with a minibatch size, it will be expected that this method is
        overridden by the inheriting class and that the operation of creating
        a new DataLoader is defined here.

        Returns:
            The new DataLoader.
        """
        raise NotImplementedError

    def make_dataloader(self) -> DataLoader:
        """
        Make a new DataLoader.

        If the `__init__` of `SupervisedNE` was provided with a minibatch size
        via the argument `minibatch_size`, then a new DataLoader will be made
        with that minibatch size.
        Otherwise, it will be expected that the method `_make_dataloader(...)`
        was overridden to contain details regarding how the DataLoader should be
        created, and that method will be executed.

        Returns:
            The created DataLoader.
        """
        if self._minibatch_size is None:
            return self._make_dataloader()
        else:
            return DataLoader(self.dataset, shuffle=True, batch_size=self._minibatch_size)

    def _evaluate_using_minibatch(self, network: nn.Module, batch: Any) -> Union[float, torch.Tensor]:
        """
        Pass a minibatch through a network, and compute the loss.

        Args:
            network: The network using which the loss will be computed.
            batch: The minibatch that will be used as data.
        Returns:
            The loss.
        """
        with torch.no_grad():
            x, y = batch
            yhat = network(x)
            return self.loss(yhat, y)

    def _loss(self, y_hat: Any, y: Any) -> Union[float, torch.Tensor]:
        """
        The loss function.

        This method, in its default state, does not contain an implementation.
        In the case where `__init__` of `SupervisedNE` class was not given
        a loss function via the argument `loss_func`, it will be expected
        that this method is overridden by the inheriting class and that the
        operation of computing the loss is defined here.

        Args:
            y_hat: The output estimated by the network
            y: The desired output
        Returns:
            A scalar, representing the loss
        """
        raise NotImplementedError

    def loss(self, y_hat: Any, y: Any) -> Union[float, torch.Tensor]:
        """
        Run the loss function and return the loss.

        If the `__init__` of `SupervisedNE` class was given a loss
        function via the argument `loss_func`, then that loss function
        will be used. Otherwise, it will be expected that the method
        `_loss(...)` is overriden with a loss definition, and that method
        will be used to compute the loss.
        The computed loss will be returned.

        Args:
            y_hat: The output estimated by the network
            y: The desired output
        Returns:
            A scalar, representing the loss
        """
        if self._loss_func is None:
            return self._loss(y_hat, y)
        else:
            return self._loss_func(y_hat, y)

    def _prepare(self) -> None:
        self.dataloader = self.make_dataloader()

    def get_minibatch(self) -> Any:
        """
        Get the next minibatch from the DataLoader.
        """
        if self.dataloader is None:
            self._prepare()

        if self.dataloader_iterator is None:
            self.dataloader_iterator = iter(self.dataloader)

        batch = None
        try:
            batch = next(self.dataloader_iterator)
        except StopIteration:
            pass

        if batch is None:
            self.dataloader_iterator = iter(self.dataloader)
            batch = next(self.dataloader_iterator)

        # Move batch to device of network
        return [var.to(self.network_device) for var in batch]

    def _evaluate_network(self, network: nn.Module) -> torch.Tensor:
        loss = 0.0
        for batch_idx in range(self._num_minibatches):
            if not self._common_minibatch:
                self._current_minibatch = self.get_minibatch()
            else:
                self._current_minibatch = self._current_minibatches[batch_idx]
            loss += self._evaluate_using_minibatch(network, self._current_minibatch) / self._num_minibatches
        return loss

    def _evaluate_batch(self, batch: SolutionBatch):
        if self._common_minibatch:
            # If using a common data batch, generate them now and use them for the entire batch of solutions
            self._current_minibatches = [self.get_minibatch() for _ in range(self._num_minibatches)]
        return super()._evaluate_batch(batch)
__init__(self, dataset, network, loss_func=None, *, network_args=None, initial_bounds=(-1e-05, 1e-05), minibatch_size=None, num_minibatches=None, num_actors=None, common_minibatch=True, num_gpus_per_actor=None, actor_config=None, num_subbatches=None, subbatch_size=None, device=None) special

__init__(...): Initialize the SupervisedNE.

Parameters:

Name Type Description Default
dataset Dataset

The Dataset from which the minibatches will be pulled

required
network Union[str, torch.nn.modules.module.Module, Callable[[], torch.nn.modules.module.Module]]

A network structure string, or a Callable (which can be a class inheriting from torch.nn.Module, or a function which returns a torch.nn.Module instance), or an instance of torch.nn.Module. The object provided here determines the structure of the neural network whose parameters will be evolved. A network structure string is a string which can be processed by evotorch.neuroevolution.net.str_to_net(...). Please see the documentation of the function evotorch.neuroevolution.net.str_to_net(...) to see how such a neural network structure string looks like.

required
loss_func Optional[Callable]

Optionally a function (or a Callable object) which receives y_hat (the output generated by the neural network) and y (the desired output), and returns the loss as a scalar. This argument can also be left as None, in which case it will be expected that the method _loss(self, y_hat, y) is overridden by the inheriting class.

None
network_args Optional[dict]

Optionally a dict-like object, storing keyword arguments to be passed to the network while instantiating it.

None
initial_bounds Union[Iterable[Union[float, Iterable[float], torch.Tensor]], evotorch.core.BoundsPair]

Specifies an interval from which the values of the initial neural network parameters will be drawn.

(-1e-05, 1e-05)
minibatch_size Optional[int]

Optionally an integer, describing the size of a minibatch when pulling data from the dataset. Can also be left as None, in which case it will be expected that the inheriting class overrides the method _make_dataloader() and defines how a new DataLoader is to be made.

None
num_minibatches Optional[int]

An integer, specifying over how many minibatches will a single neural network be evaluated. If not specified, it will be assumed that the desired number of minibatches per network evaluation is 1.

None
num_actors Union[int, str]

Number of actors to create for parallelized evaluation of the solutions. Certain string values are also accepted. When given as "max" or as "num_cpus", the number of actors will be equal to the number of all available CPUs in the ray cluster. When given as "num_gpus", the number of actors will be equal to the number of all available GPUs in the ray cluster, and each actor will be assigned a GPU. When given as "num_devices", the number of actors will be equal to the minimum among the number of CPUs and the number of GPUs available in the cluster (or will be equal to the number of CPUs if there is no GPU), and each actor will be assigned a GPU (if available). If num_actors is given as "num_gpus" or "num_devices", the argument num_gpus_per_actor must not be used, and the actor_config dictionary must not contain the key "num_gpus". If num_actors is given as something other than "num_gpus" or "num_devices", and if you wish to assign GPUs to each actor, then please see the argument num_gpus_per_actor.

None
common_minibatch bool

Whether the same minibatches will be used when evaluating the solutions or not.

True
actor_config Optional[dict]

A dictionary, representing the keyword arguments to be passed to the options(...) used when creating the ray actor objects. To be used for explicitly allocating resources per each actor. For example, for declaring that each actor is to use a GPU, one can pass actor_config=dict(num_gpus=1). Can also be given as None (which is the default), if no such options are to be passed.

None
num_gpus_per_actor Union[int, float, str]

Number of GPUs to be allocated by each remote actor. The default behavior is to NOT allocate any GPU at all (which is the default behavior of the ray library as well). When given as a number n, each actor will be given n GPUs (where n can be an integer, or can be a float for fractional allocation). When given as a string "max", then the available GPUs across the entire ray cluster (or within the local computer in the simplest cases) will be equally distributed among the actors. When given as a string "all", then each actor will have access to all the GPUs (this will be achieved by suppressing the environment variable CUDA_VISIBLE_DEVICES for each actor). When the problem is not distributed (i.e. when there are no actors), this argument is expected to be left as None.

None
num_subbatches Optional[int]

If num_subbatches is None (assuming that subbatch_size is also None), then, when evaluating a population, the population will be split into n pieces, n being the number of actors, and each actor will evaluate its assigned piece. If num_subbatches is an integer m, then the population will be split into m pieces, and actors will continually accept the next unevaluated piece as they finish their current tasks. The arguments num_subbatches and subbatch_size cannot be given values other than None at the same time. While using a distributed algorithm, this argument determines how many sub-batches will be generated, and therefore, how many gradients will be computed by the remote actors.

None
subbatch_size Optional[int]

If subbatch_size is None (assuming that num_subbatches is also None), then, when evaluating a population, the population will be split into n pieces, n being the number of actors, and each actor will evaluate its assigned piece. If subbatch_size is an integer m, then the population will be split into pieces of size m, and actors will continually accept the next unevaluated piece as they finish their current tasks. When there can be significant difference across the solutions in terms of computational requirements, specifying a subbatch_size can be beneficial, because, while one actor is busy with a subbatch containing computationally challenging solutions, other actors can accept more tasks and save time. The arguments num_subbatches and subbatch_size cannot be given values other than None at the same time. While using a distributed algorithm, this argument determines the size of a sub-batch (or sub-population) sampled by a remote actor for computing a gradient. In distributed mode, it is expected that the population size is divisible by subbatch_size.

None
device Union[str, torch.device]

Default device in which a new population will be generated and the neural networks will operate. If not specified, "cpu" will be used.

None
Source code in evotorch/neuroevolution/supervisedne.py
def __init__(
    self,
    dataset: Dataset,
    network: Union[str, nn.Module, Callable[[], nn.Module]],
    loss_func: Optional[Callable] = None,
    *,
    network_args: Optional[dict] = None,
    initial_bounds: Optional[BoundsPairLike] = (-0.00001, 0.00001),
    minibatch_size: Optional[int] = None,
    num_minibatches: Optional[int] = None,
    num_actors: Optional[Union[int, str]] = None,
    common_minibatch: bool = True,
    num_gpus_per_actor: Optional[Union[int, float, str]] = None,
    actor_config: Optional[dict] = None,
    num_subbatches: Optional[int] = None,
    subbatch_size: Optional[int] = None,
    device: Optional[Device] = None,
):
    """
    `__init__(...)`: Initialize the SupervisedNE.

    Args:
        dataset: The Dataset from which the minibatches will be pulled
        network: A network structure string, or a Callable (which can be
            a class inheriting from `torch.nn.Module`, or a function
            which returns a `torch.nn.Module` instance), or an instance
            of `torch.nn.Module`.
            The object provided here determines the structure of the
            neural network whose parameters will be evolved.
            A network structure string is a string which can be processed
            by `evotorch.neuroevolution.net.str_to_net(...)`.
            Please see the documentation of the function
            `evotorch.neuroevolution.net.str_to_net(...)` to see how such
            a neural network structure string looks like.
        loss_func: Optionally a function (or a Callable object) which
            receives `y_hat` (the output generated by the neural network)
            and `y` (the desired output), and returns the loss as a
            scalar.
            This argument can also be left as None, in which case it will
            be expected that the method `_loss(self, y_hat, y)` is
            overridden by the inheriting class.
        network_args: Optionally a dict-like object, storing keyword
            arguments to be passed to the network while instantiating it.
        initial_bounds: Specifies an interval from which the values of the
            initial neural network parameters will be drawn.
        minibatch_size: Optionally an integer, describing the size of a
            minibatch when pulling data from the dataset.
            Can also be left as None, in which case it will be expected
            that the inheriting class overrides the method
            `_make_dataloader()` and defines how a new DataLoader is to be
            made.
        num_minibatches: An integer, specifying over how many minibatches
            will a single neural network be evaluated.
            If not specified, it will be assumed that the desired number
            of minibatches per network evaluation is 1.
        num_actors: Number of actors to create for parallelized
            evaluation of the solutions.
            Certain string values are also accepted.
            When given as "max" or as "num_cpus", the number of actors
            will be equal to the number of all available CPUs in the ray
            cluster.
            When given as "num_gpus", the number of actors will be
            equal to the number of all available GPUs in the ray
            cluster, and each actor will be assigned a GPU.
            When given as "num_devices", the number of actors will be
            equal to the minimum among the number of CPUs and the number
            of GPUs available in the cluster (or will be equal to the
            number of CPUs if there is no GPU), and each actor will be
            assigned a GPU (if available).
            If `num_actors` is given as "num_gpus" or "num_devices",
            the argument `num_gpus_per_actor` must not be used,
            and the `actor_config` dictionary must not contain the
            key "num_gpus".
            If `num_actors` is given as something other than "num_gpus"
            or "num_devices", and if you wish to assign GPUs to each
            actor, then please see the argument `num_gpus_per_actor`.
        common_minibatch: Whether the same minibatches will be
            used when evaluating the solutions or not.
        actor_config: A dictionary, representing the keyword arguments
            to be passed to the options(...) used when creating the
            ray actor objects. To be used for explicitly allocating
            resources per each actor.
            For example, for declaring that each actor is to use a GPU,
            one can pass `actor_config=dict(num_gpus=1)`.
            Can also be given as None (which is the default),
            if no such options are to be passed.
        num_gpus_per_actor: Number of GPUs to be allocated by each
            remote actor.
            The default behavior is to NOT allocate any GPU at all
            (which is the default behavior of the ray library as well).
            When given as a number `n`, each actor will be given
            `n` GPUs (where `n` can be an integer, or can be a `float`
            for fractional allocation).
            When given as a string "max", then the available GPUs
            across the entire ray cluster (or within the local computer
            in the simplest cases) will be equally distributed among
            the actors.
            When given as a string "all", then each actor will have
            access to all the GPUs (this will be achieved by suppressing
            the environment variable `CUDA_VISIBLE_DEVICES` for each
            actor).
            When the problem is not distributed (i.e. when there are
            no actors), this argument is expected to be left as None.
        num_subbatches: If `num_subbatches` is None (assuming that
            `subbatch_size` is also None), then, when evaluating a
            population, the population will be split into n pieces, `n`
            being the number of actors, and each actor will evaluate
            its assigned piece. If `num_subbatches` is an integer `m`,
            then the population will be split into `m` pieces,
            and actors will continually accept the next unevaluated
            piece as they finish their current tasks.
            The arguments `num_subbatches` and `subbatch_size` cannot
            be given values other than None at the same time.
            While using a distributed algorithm, this argument determines
            how many sub-batches will be generated, and therefore,
            how many gradients will be computed by the remote actors.
        subbatch_size: If `subbatch_size` is None (assuming that
            `num_subbatches` is also None), then, when evaluating a
            population, the population will be split into `n` pieces, `n`
            being the number of actors, and each actor will evaluate its
            assigned piece. If `subbatch_size` is an integer `m`,
            then the population will be split into pieces of size `m`,
            and actors will continually accept the next unevaluated
            piece as they finish their current tasks.
            When there can be significant difference across the solutions
            in terms of computational requirements, specifying a
            `subbatch_size` can be beneficial, because, while one
            actor is busy with a subbatch containing computationally
            challenging solutions, other actors can accept more
            tasks and save time.
            The arguments `num_subbatches` and `subbatch_size` cannot
            be given values other than None at the same time.
            While using a distributed algorithm, this argument determines
            the size of a sub-batch (or sub-population) sampled by a
            remote actor for computing a gradient.
            In distributed mode, it is expected that the population size
            is divisible by `subbatch_size`.
        device: Default device in which a new population will be generated
            and the neural networks will operate.
            If not specified, "cpu" will be used.
    """
    super().__init__(
        objective_sense="min",
        network=network,
        network_args=network_args,
        initial_bounds=initial_bounds,
        num_actors=num_actors,
        num_gpus_per_actor=num_gpus_per_actor,
        actor_config=actor_config,
        num_subbatches=num_subbatches,
        subbatch_size=subbatch_size,
        device=device,
    )

    self.dataset = dataset
    self.dataloader: DataLoader = None
    self.dataloader_iterator = None

    self._loss_func = loss_func
    self._minibatch_size = None if minibatch_size is None else int(minibatch_size)
    self._num_minibatches = 1 if num_minibatches is None else int(num_minibatches)
    self._common_minibatch = common_minibatch
    self._current_minibatches: Optional[list] = None
get_minibatch(self)

Get the next minibatch from the DataLoader.

Source code in evotorch/neuroevolution/supervisedne.py
def get_minibatch(self) -> Any:
    """
    Get the next minibatch from the DataLoader.
    """
    if self.dataloader is None:
        self._prepare()

    if self.dataloader_iterator is None:
        self.dataloader_iterator = iter(self.dataloader)

    batch = None
    try:
        batch = next(self.dataloader_iterator)
    except StopIteration:
        pass

    if batch is None:
        self.dataloader_iterator = iter(self.dataloader)
        batch = next(self.dataloader_iterator)

    # Move batch to device of network
    return [var.to(self.network_device) for var in batch]
loss(self, y_hat, y)

Run the loss function and return the loss.

If the __init__ of SupervisedNE class was given a loss function via the argument loss_func, then that loss function will be used. Otherwise, it will be expected that the method _loss(...) is overriden with a loss definition, and that method will be used to compute the loss. The computed loss will be returned.

Parameters:

Name Type Description Default
y_hat Any

The output estimated by the network

required
y Any

The desired output

required

Returns:

Type Description
Union[float, torch.Tensor]

A scalar, representing the loss

Source code in evotorch/neuroevolution/supervisedne.py
def loss(self, y_hat: Any, y: Any) -> Union[float, torch.Tensor]:
    """
    Run the loss function and return the loss.

    If the `__init__` of `SupervisedNE` class was given a loss
    function via the argument `loss_func`, then that loss function
    will be used. Otherwise, it will be expected that the method
    `_loss(...)` is overriden with a loss definition, and that method
    will be used to compute the loss.
    The computed loss will be returned.

    Args:
        y_hat: The output estimated by the network
        y: The desired output
    Returns:
        A scalar, representing the loss
    """
    if self._loss_func is None:
        return self._loss(y_hat, y)
    else:
        return self._loss_func(y_hat, y)
make_dataloader(self)

Make a new DataLoader.

If the __init__ of SupervisedNE was provided with a minibatch size via the argument minibatch_size, then a new DataLoader will be made with that minibatch size. Otherwise, it will be expected that the method _make_dataloader(...) was overridden to contain details regarding how the DataLoader should be created, and that method will be executed.

Returns:

Type Description
DataLoader

The created DataLoader.

Source code in evotorch/neuroevolution/supervisedne.py
def make_dataloader(self) -> DataLoader:
    """
    Make a new DataLoader.

    If the `__init__` of `SupervisedNE` was provided with a minibatch size
    via the argument `minibatch_size`, then a new DataLoader will be made
    with that minibatch size.
    Otherwise, it will be expected that the method `_make_dataloader(...)`
    was overridden to contain details regarding how the DataLoader should be
    created, and that method will be executed.

    Returns:
        The created DataLoader.
    """
    if self._minibatch_size is None:
        return self._make_dataloader()
    else:
        return DataLoader(self.dataset, shuffle=True, batch_size=self._minibatch_size)

vecgymne

VecGymNE (BaseNEProblem)

An EvoTorch problem for solving vectorized gym environments

Source code in evotorch/neuroevolution/vecgymne.py
class VecGymNE(BaseNEProblem):
    """
    An EvoTorch problem for solving vectorized gym environments
    """

    def __init__(
        self,
        env: Union[str, Callable],
        network: Union[str, Callable, nn.Module],
        *,
        env_config: Optional[Mapping] = None,
        max_num_envs: Optional[int] = None,
        network_args: Optional[Mapping] = None,
        observation_normalization: bool = False,
        decrease_rewards_by: Optional[float] = None,
        alive_bonus_schedule: Optional[tuple] = None,
        action_noise_stdev: Optional[float] = None,
        num_episodes: int = 1,
        device: Optional[Device] = None,
        num_actors: Optional[Union[int, str]] = None,
        num_gpus_per_actor: Optional[int] = None,
        num_subbatches: Optional[int] = None,
        subbatch_size: Optional[int] = None,
        actor_config: Optional[Mapping] = None,
    ):
        """
        Initialize the VecGymNE.

        Args:
            env: Environment to be solved.
                If this is given as a string starting with "gym::" (e.g.
                "gym::Humanoid-v4", etc.), then it is assumed that the target
                environment is a classical gym environment.
                If this is given as a string starting with "brax::" (e.g.
                "brax::humanoid", etc.), then it is assumed that the target
                environment is a brax environment.
                If this is given as a string which does not contain "::" at
                all (e.g. "Humanoid-v4", etc.), then it is assumed that the
                target environment is a classical gym environment. Therefore,
                "gym::Humanoid-v4" and "Humanoid-v4" are equivalent.
                If this argument is given as a Callable (maybe a function or a
                class), then, with the assumption that this Callable expects
                a keyword argument `num_envs: int`, this Callable is called
                and its result (expected as a `gym.vector.VectorEnv` instance)
                is used as the environment.
            network: A network structure string, or a Callable (which can be
                a class inheriting from `torch.nn.Module`, or a function
                which returns a `torch.nn.Module` instance), or an instance
                of `torch.nn.Module`.
                The object provided here determines the structure of the
                neural network whose parameters will be evolved.
                A network structure string is a string which can be processed
                by `evotorch.neuroevolution.net.str_to_net(...)`.
                Please see the documentation of the function
                `evotorch.neuroevolution.net.str_to_net(...)` to see how such
                a neural network structure string looks like.
                Note that this network can be a recurrent network.
                When the network's `forward(...)` method can optionally accept
                an additional positional argument for the hidden state of the
                network and returns an additional value for its next state,
                then the policy is treated as a recurrent one.
                When the network is given as a callable object (e.g.
                a subclass of `nn.Module` or a function) and this callable
                object is decorated via `evotorch.decorators.pass_info`,
                the following keyword arguments will be passed:
                (i) `obs_length` (the length of the observation vector),
                (ii) `act_length` (the length of the action vector),
                (iii) `obs_shape` (the shape tuple of the observation space),
                (iv) `act_shape` (the shape tuple of the action space),
                (v) `obs_space` (the Box object specifying the observation
                space, and
                (vi) `act_space` (the Box object specifying the action
                space). Note that `act_space` will always be given as a
                `gym.spaces.Box` instance, even when the actual gym
                environment has a discrete action space. This because
                `VecGymNE` always expects the neural network to return
                a tensor of floating-point numbers.
            env_config: Keyword arguments to pass to the environment while
                it is being created.
            max_num_envs: Maximum number of environments to be instantiated.
                By default, this is None, which means that the number of
                environments can go up to the population size (or up to the
                number of solutions that a remote actor receives, if the
                problem object is configured to have parallelization).
                For situations where the current reinforcement learning task
                requires large amount of resources (e.g. memory), allocating
                environments as much as the number of solutions might not
                be feasible. In such cases, one can set `max_num_envs` as an
                integer to bring an upper bound (in total, across all the
                remote actors, for when the problem is parallelized) to how
                many environments can be allocated.
            network_args: Any additional keyword argument to be used when
                instantiating the network can be specified via `network_args`
                as a dictionary. If there are no such additional keyword
                arguments, then `network_args` can be left as None.
                Note that the argument `network_args` is expected to be None
                when the network is specified as a `torch.nn.Module` instance.
            observation_normalization: Whether or not online normalization
                will be done on the encountered observations.
            decrease_rewards_by: If given as a float, each reward will be
                decreased by this amount. For example, if the environment's
                reward function has a constant "alive bonus" (i.e. a bonus
                that is constantly added onto the reward as long as the
                agent is alive), and if you wish to negate this bonus,
                you can set `decrease_rewards_by` to this bonus amount,
                and the bonus will be nullified.
                If you do not wish to affect the rewards in this manner,
                keep this as None.
            alive_bonus_schedule: Use this to add a customized amount of
                alive bonus.
                If left as None (which is the default), additional alive
                bonus will not be added.
                If given as a tuple `(t, b)`, an alive bonus `b` will be
                added onto all the rewards beyond the timestep `t`.
                If given as a tuple `(t0, t1, b)`, a partial (linearly
                increasing towards `b`) alive bonus will be added onto
                all the rewards between the timesteps `t0` and `t1`,
                and a full alive bonus (which equals to `b`) will be added
                onto all the rewards beyond the timestep `t1`.
            action_noise_stdev: If given as a real number `s`, then, for
                each generated action, Gaussian noise with standard
                deviation `s` will be sampled, and then this sampled noise
                will be added onto the action.
                If action noise is not desired, then this argument can be
                left as None.
                For sampling the noise, the global random number generator
                of PyTorch on the simulator's device will be used.
            num_episodes: Number of episodes over which each policy will
                be evaluated. The default is 1.
            device: The device in which the population will be kept.
                If you wish to do a single-GPU evolution, we recommend
                to set this as "cuda" (or "cuda:0", or "cuda:1", etc.),
                assuming that the simulator will also instantiate itself
                on that same device.
                Alternatively, if you wish to do a multi-GPU evolution,
                we recommend to leave this as None or set this as "cpu",
                so that the main population will be kept on the cpu
                and the remote actors will perform their evaluations on
                the GPUs that are assigned to them.
            num_actors: Number of actors to create for parallelized
                evaluation of the solutions.
                Certain string values are also accepted.
                When given as "max" or as "num_cpus", the number of actors
                will be equal to the number of all available CPUs in the ray
                cluster.
                When given as "num_gpus", the number of actors will be
                equal to the number of all available GPUs in the ray
                cluster, and each actor will be assigned a GPU.
                When given as "num_devices", the number of actors will be
                equal to the minimum among the number of CPUs and the number
                of GPUs available in the cluster (or will be equal to the
                number of CPUs if there is no GPU), and each actor will be
                assigned a GPU (if available).
                If `num_actors` is given as "num_gpus" or "num_devices",
                the argument `num_gpus_per_actor` must not be used,
                and the `actor_config` dictionary must not contain the
                key "num_gpus".
                If `num_actors` is given as something other than "num_gpus"
                or "num_devices", and if you wish to assign GPUs to each
                actor, then please see the argument `num_gpus_per_actor`.
            num_gpus_per_actor: Number of GPUs to be assigned to each
                actor. This can be an integer or a float (for when you
                wish to assign fractional amounts of GPUs to actors).
                When `num_actors` has the special value "num_devices",
                the argument `num_gpus_per_actor` is expected to be left as
                None.
            num_subbatches: For when there are multiple actors, you can
                set this to an integer n if you wish the population
                to be divided exactly into n sub-batches. The actors, as they
                finish their currently assigned sub-batch of solutions,
                will pick the next un-evaluated sub-batch.
                If you specify too large numbers for this argument, then
                each sub-batch will be smaller.
                When working with vectorized simulators on GPU, having too
                many and too small sub-batches can hurt the performance.
                This argument can be left as None, in which case, assuming
                that `subbatch_size` is also None, the population will be
                split to m sub-batches, m being the number of actors.
            subbatch_size: For when there are multiple actors, you can
                set this to an integer n if you wish the population to be
                divided into sub-batches in such a way that each sub-batch
                will consist of exactly n solutions. The actors, as they
                finish their currently assigned sub-batch of solutions,
                will pick the next un-evaluated sub-batch.
                If you specify too small numbers for this argument, then
                there will be many sub-batches, each sub-batch having a
                small number of solutions.
                When working with vectorized simulators on GPU, having too
                many and too small sub-batches can hurt the performance.
                This argument can be left as None, in which case, assuming
                that `num_subbatches` is also None, the population will be
                split to m sub-batches, m being the number of actors.
            actor_config: Additional configuration to be used when creating
                each actor with the help of `ray` library.
                Can be left as None if additional configuration is not needed.
        """

        # Store the string or the Callable that will be used to generate the reinforcement learning environment.
        self._env_maker = env

        # Declare the variable which will store the environment.
        self._env: Optional[TorchWrapper] = None

        # Declare the variable which will store the batch size of the vectorized environment.
        self._num_envs: Optional[int] = None

        # Store the upper bound (if any) regarding how many environments can exist at the same time.
        self._max_num_envs: Optional[int] = None if max_num_envs is None else int(max_num_envs)

        # Actor-specific upper bound regarding how many environments can exist at the same time.
        # This variable will be filled by the `_parallelize(...)` method.
        self._actor_max_num_envs: Optional[int] = None

        # Declare the variable which stores whether or not we properly initialized the `_actor_max_num_envs` variable.
        self._actor_max_num_envs_ready: bool = False

        # Store the additional configurations to be used as keyword arguments while instantiating the environment.
        self._env_config: dict = {} if env_config is None else dict(env_config)

        # Declare the variable that will store the device of the simulator.
        # This variable will be filled when the first observation is received from the environment.
        # The device of the observation array received from the environment will determine the value of this variable.
        self._simulator_device: Optional[torch.device] = None

        # Store the neural network architecture (that might be a string or an `nn.Module` instance).
        self._architecture = network

        if network_args is None:
            # If `network_args` is given as None, change it to an empty dictionary
            network_args = {}

        if isinstance(network, str):
            # If the network is given as a string, then we will need the values for the constants `obs_length`,
            # `act_length`, and `obs_space`. To obtain those values, we use our helper function
            # `_env_constants_for_str_net(...)` which temporarily instantiates the specified environment and returns
            # its needed constants.
            env_constants = _env_constants_for_str_net(self._env_maker, **(self._env_config))
        elif isinstance(network, nn.Module):
            # If the network is an already instantiated nn.Module, then we do not prepare any pre-defined constants.
            env_constants = {}
        else:
            # If the network is given as a Callable, then we will need the values for the constants `obs_length`,
            # `act_length`, and `obs_space`. To obtain those values, we use our helper function
            # `_env_constants_for_callable_net(...)` which temporarily instantiates the specified environment and
            # returns its needed constants.
            env_constants = _env_constants_for_callable_net(self._env_maker, **(self._env_config))

        # Build a `Policy` instance according to the given architecture, and store it.
        if isinstance(network, str):
            instantiated_net = str_to_net(network, **{**env_constants, **network_args})
        elif isinstance(network, nn.Module):
            instantiated_net = network
        else:
            instantiated_net = pass_info_if_needed(network, env_constants)(**network_args)
        self._policy = Policy(instantiated_net)

        # Store the boolean which indicates whether or not there will be observation normalization.
        self._observation_normalization = bool(observation_normalization)

        # Declare the variables that will store the observation-related stats if observation normalization is enabled.
        self._obs_stats: Optional[RunningNorm] = None
        self._collected_stats: Optional[RunningNorm] = None

        # Store the number of episodes configuration given by the user.
        self._num_episodes = int(num_episodes)

        # Store the `decrease_rewards_by` configuration given by the user.
        self._decrease_rewards_by = None if decrease_rewards_by is None else float(decrease_rewards_by)

        if alive_bonus_schedule is None:
            # If `alive_bonus_schedule` argument is None, then we store it as None as well.
            self._alive_bonus_schedule = None
        else:
            # This is the case where the user has specified an `alive_bonus_schedule`.
            alive_bonus_schedule = list(alive_bonus_schedule)
            alive_bonus_schedule_length = len(alive_bonus_schedule)
            if alive_bonus_schedule_length == 2:
                # If `alive_bonus_schedule` was given as a 2-element sequence (t, b), then store it as (t, t, b).
                # This means that the partial alive bonus time window starts and ends at t, therefore, there will
                # be no alive bonus until t, and beginning with t, there will be full alive bonus.
                self._alive_bonus_schedule = [
                    int(alive_bonus_schedule[0]),
                    int(alive_bonus_schedule[0]),
                    float(alive_bonus_schedule[1]),
                ]
            elif alive_bonus_schedule_length == 3:
                # If `alive_bonus_schedule` was given as a 3-element sequence (t0, t1, b), then store those 3
                # elements.
                self._alive_bonus_schedule = [
                    int(alive_bonus_schedule[0]),
                    int(alive_bonus_schedule[1]),
                    float(alive_bonus_schedule[2]),
                ]
            else:
                # `alive_bonus_schedule` sequences with unrecognized lengths trigger an error.
                raise ValueError(
                    f"Received invalid number elements as the alive bonus schedule."
                    f" Expected 2 or 3 items, but got these: {self._alive_bonus_schedule}"
                    f" (having a length of {len(self._alive_bonus_schedule)})."
                )

        # If `action_noise_stdev` is specified, store it.
        self._action_noise_stdev = None if action_noise_stdev is None else float(action_noise_stdev)

        # Initialize the counters for the number of simulator interactions and the number of episodes.
        self._interaction_count: int = 0
        self._episode_count: int = 0

        # Call the superclass
        super().__init__(
            objective_sense="max",
            initial_bounds=(-0.00001, 0.00001),
            solution_length=self._policy.parameter_length,
            device=device,
            dtype=torch.float32,
            num_actors=num_actors,
            num_gpus_per_actor=num_gpus_per_actor,
            actor_config=actor_config,
            num_subbatches=num_subbatches,
            subbatch_size=subbatch_size,
        )

    def _parallelize(self):
        super()._parallelize()
        if self.is_main:
            if not self._actor_max_num_envs_ready:
                if self._actors is None:
                    self._actor_max_num_envs = self._max_num_envs
                else:
                    if self._max_num_envs is not None:
                        max_num_envs_per_actor = split_workload(self._max_num_envs, len(self._actors))
                        for i_actor, actor in enumerate(self._actors):
                            actor.call.remote("_set_actor_max_num_envs", max_num_envs_per_actor[i_actor])
                self._actor_max_num_envs_ready = True

    def _set_actor_max_num_envs(self, n: int):
        self._actor_max_num_envs = n
        self._actor_max_num_envs_ready = True

    @property
    def observation_normalization(self) -> bool:
        return self._observation_normalization

    def set_episode_count(self, n: int):
        """
        Set the episode count manually.
        """
        self._episode_count = int(n)

    def set_interaction_count(self, n: int):
        """
        Set the interaction count manually.
        """
        self._interaction_count = int(n)

    @property
    def interaction_count(self) -> int:
        """
        Get the total number of simulator interactions made.
        """
        return self._interaction_count

    @property
    def episode_count(self) -> int:
        """
        Get the total number of episodes completed.
        """
        return self._episode_count

    def _get_local_episode_count(self) -> int:
        return self.episode_count

    def _get_local_interaction_count(self) -> int:
        return self.interaction_count

    def _get_env(self, num_policies: int) -> TorchWrapper:
        # Get the existing environment instance stored by this VecGymNE, after (re)building it if needed.

        if (self._env is None) or (num_policies > self._num_envs):
            # If this VecGymNE does not have its environment ready yet (i.e. the `_env` attribute is None)
            # or it the batch size of the previously instantiated environment is not enough to deal with
            # the number of policies (i.e. the `_num_envs` attribute is less than `num_policies`), then
            # we (re)build the environment.

            # Keyword arguments to pass to the TorchWrapper.
            torch_wrapper_cfg = dict(
                force_classic_api=True,
                discrete_to_continuous_act=True,
                clip_actions=True,
            )

            if isinstance(self._env_maker, str):
                # If the environment is specified via a string, then we use our `make_vector_env` function.
                self._env = make_vector_env(
                    self._env_maker, num_envs=num_policies, **torch_wrapper_cfg, **(self._env_config)
                )
            else:
                # If the environment is specified via a Callable, then we call it.
                # We expect this Callable to accept a keyword argument named `num_envs`, and additionally, we pass
                # the environment configuration dictionary as keyword arguments.
                self._env = self._env_maker(num_envs=num_policies, **(self._env_config))

                if not isinstance(self._env, gym.vector.VectorEnv):
                    # If what is returned by the Callable is not a vectorized environment, then we trigger an error.
                    raise TypeError("This is not a vectorized environment")

                # We wrap the returned vectorized environment with a TorchWrapper, so that the actions that we send
                # and the observations and rewards that we receive are PyTorch tensors.
                self._env = TorchWrapper(self._env, **torch_wrapper_cfg)

            if self._env.num_envs != num_policies:
                # If the finally obtained vectorized environment has a different number of batch size, then we trigger
                # an error.
                raise ValueError("Incompatible number of environments")

            # We update the batch size of the created environment.
            self._num_envs = num_policies

            if not isinstance(self._env.single_observation_space, Box):
                # If the observation space is not Box, then we trigger an error.
                raise TypeError(
                    f"Unsupported observation type: {self._env.single_observation_space}."
                    f" Only Box-typed observation spaces are supported."
                )

            try:
                # If possible, use the `seed(...)` method to explicitly randomize the environment.
                # Although the new gym API removed the seed method, some environments define their own `seed(...)`
                # method for randomization.
                new_seed = random.randint(0, (2**32) - 1)
                self._env.seed(new_seed)
            except Exception:
                # Our attempt at manually seeding the environment has failed.
                # This could be because the environment does not have a `seed(...)` method.
                # Nothing to do.
                pass

        return self._env

    @property
    def _nonserialized_attribs(self):
        # Call the `_nonserialized_attribs` property implementation of the superclass to receive the base list
        # of non-serialized attributes, then add "_env" to this base list, and then return the resulting list.
        return super()._nonserialized_attribs + ["_env"]

    @property
    def _grad_device(self) -> torch.device:
        # For distributed mode, this property determines the device in which the temporary populations will be made
        # for gradient computation.

        if self._simulator_device is None:
            # If the simulator device is not known yet, then we return the cpu device.
            return torch.device("cpu")
        else:
            # If the simulator device is known, then we return that device.
            return self._simulator_device

    def _make_running_norm(self, observation: torch.Tensor) -> RunningNorm:
        # Make a new RunningNorm instance according to the observation tensor.
        # The dtype and the device of the new RunningNorm is taken from the observation.
        # This new RunningNorm is empty (i.e. does not contain any stats yet).
        return RunningNorm(shape=observation.shape[1:], dtype=observation.dtype, device=observation.device)

    def _transfer_running_norm(self, rn: RunningNorm, observation: torch.Tensor) -> RunningNorm:
        # Transfer (if necessary) the RunningNorm to the device of the observation tensor.
        # The returned RunningNorm may be the RunningNorm itself (if the device did not change)
        # or a new copy (if the device did change).
        if torch.device(rn.device) != torch.device(observation.device):
            rn = rn.to(observation.device)
        return rn

    def _normalize_observation(
        self, observation: torch.Tensor, *, mask: Optional[torch.Tensor] = None, update_stats: bool = True
    ) -> torch.Tensor:
        # This function normalizes the received observation batch.
        # If a mask is given (as a tensor of booleans), only observations with corresponding mask value set as True
        # will be taken into consideration.
        # If `update_stats` is given as True and observation normalization is enabled, then we will update the
        # RunningNorm instances as well.

        if self._observation_normalization:
            # This is the case where observation normalization is enabled.
            if self._obs_stats is None:
                # If we do not have observation stats yet, we build a new one (according to the dtype and device
                # of the observation).
                self._obs_stats = self._make_running_norm(observation)
            else:
                # If we already have observation stats, we make sure that it is in the correct device.
                self._obs_stats = self._transfer_running_norm(self._obs_stats, observation)

            if update_stats:
                # This is the case where the `update_stats` argument was encountered as True.
                if self._collected_stats is None:
                    # If the RunningNorm responsible to collect new stats is not built yet, we build it here
                    # (according to the dtype and device of the observation).
                    self._collected_stats = self._make_running_norm(observation)
                else:
                    # If the RunningNorm responsible to collect new stats already exists, then we make sure
                    # that it is in the correct device.
                    self._collected_stats = self._transfer_running_norm(self._collected_stats, observation)

                # We first update the RunningNorm responsible for collecting the new stats.
                self._collected_stats.update(observation, mask)

                # We now update the RunningNorm which stores all the stats, and return the normalized observation.
                result = self._obs_stats.update_and_normalize(observation, mask)
            else:
                # This is the case where the `update_stats` argument was encountered as False.
                # Here we normalize the observation but do not update our existing RunningNorm instances.
                result = self._obs_stats.update(observation, mask)
            return result
        else:
            # This is the case where observation normalization is disabled.
            # In this case, we just return the observation as it is.
            return observation

    def _ensure_obsnorm(self):
        if not self.observation_normalization:
            raise ValueError("This feature can only be used when observation_normalization=True.")

    def get_observation_stats(self) -> RunningNorm:
        """Get the observation stats"""
        self._ensure_obsnorm()
        return self._obs_stats

    def _make_sync_data_for_actors(self) -> Any:
        if self.observation_normalization:
            obs_stats = self.get_observation_stats()
            if obs_stats is not None:
                obs_stats = obs_stats.to("cpu")
            return dict(obs_stats=obs_stats)
        else:
            return None

    def set_observation_stats(self, rn: RunningNorm):
        """Set the observation stats"""
        self._ensure_obsnorm()
        self._obs_stats = rn

    def _use_sync_data_from_main(self, received: dict):
        for k, v in received.items():
            if k == "obs_stats":
                self.set_observation_stats(v)

    def pop_observation_stats(self) -> RunningNorm:
        """Get and clear the collected observation stats"""
        self._ensure_obsnorm()
        result = self._collected_stats
        self._collected_stats = None
        return result

    def _make_sync_data_for_main(self) -> Any:
        result = dict(episode_count=self.episode_count, interaction_count=self.interaction_count)

        if self.observation_normalization:
            collected = self.pop_observation_stats()
            if collected is not None:
                collected = collected.to("cpu")
            result["obs_stats_delta"] = collected

        return result

    def update_observation_stats(self, rn: RunningNorm):
        """Update the observation stats via another RunningNorm instance"""
        self._ensure_obsnorm()
        if self._obs_stats is None:
            self._obs_stats = rn
        else:
            self._obs_stats.update(rn)

    def _use_sync_data_from_actors(self, received: list):
        total_episode_count = 0
        total_interaction_count = 0

        for data in received:
            data: dict
            total_episode_count += data["episode_count"]
            total_interaction_count += data["interaction_count"]
            if self.observation_normalization:
                self.update_observation_stats(data["obs_stats_delta"])

        self.set_episode_count(total_episode_count)
        self.set_interaction_count(total_interaction_count)

    def _make_pickle_data_for_main(self) -> dict:
        # For when the main Problem object (the non-remote one) gets pickled,
        # this function returns the counters of this remote Problem instance,
        # to be sent to the main one.
        return dict(interaction_count=self.interaction_count, episode_count=self.episode_count)

    def _use_pickle_data_from_main(self, state: dict):
        # For when a newly unpickled Problem object gets (re)parallelized,
        # this function restores the inner states specific to this remote
        # worker. In the case of GymNE, those inner states are episode
        # and interaction counters.
        for k, v in state.items():
            if k == "episode_count":
                self.set_episode_count(v)
            elif k == "interaction_count":
                self.set_interaction_count(v)
            else:
                raise ValueError(f"When restoring the inner state of a remote worker, unrecognized state key: {k}")

    def _evaluate_batch(self, batch: SolutionBatch):
        if self._actor_max_num_envs is None:
            self._evaluate_subbatch(batch)
        else:
            subbatches = batch.split(max_size=self._actor_max_num_envs)
            for subbatch in subbatches:
                self._evaluate_subbatch(subbatch)

    def _evaluate_subbatch(self, batch: SolutionBatch):
        # Get the number of solutions and the solution batch from the shape of the batch.
        num_solutions, solution_length = batch.values_shape

        # Get (possibly after (re)building) the environment object.
        env = self._get_env(num_solutions)

        # Reset the environment and receive the first observation batch.
        obs_per_env = env.reset()

        # Update the simulator device according to the device of the observation batch received.
        self._simulator_device = obs_per_env.device

        # Get the number of environments.
        num_envs = obs_per_env.shape[0]

        # Transfer (if necessary) the solutions (which are the network parameters) to the simulator device.
        batch_values = batch.values.to(self._simulator_device)

        if num_solutions == num_envs:
            # If the number of solutions is equal to the number of environments, then we declare all of the solutions
            # as the network parameters, and we declare all of these environments active.
            params_per_env = batch_values
            active_per_env = torch.ones(num_solutions, dtype=torch.bool, device=self._simulator_device)
        elif num_solutions < num_envs:
            # If the number of solutions is less than the number of environments, then we allocate a new empty
            # tensor to represent the network parameters.
            params_per_env = torch.empty((num_envs, solution_length), dtype=batch.dtype, device=self._simulator_device)

            # The first `num_solutions` rows of this new parameters tensor is filled with the values of the solutions.
            params_per_env[:num_solutions, :] = batch_values

            # The remaining parameters become the clones of the first solution.
            params_per_env[num_solutions:, :] = batch_values[0]

            # At first, all the environments are declared as inactive.
            active_per_env = torch.zeros(num_envs, dtype=torch.bool, device=self._simulator_device)

            # Now, the first `num_solutions` amount of environments is declared as active.
            # The remaining ones remain inactive.
            active_per_env[:num_solutions] = True
        else:
            assert False, "Received incompatible number of environments"

        # We get the policy and fill it with the parameters stored by the solutions.
        policy = self._policy
        policy.set_parameters(params_per_env)

        # Declare the counter which stores the total timesteps encountered during this evaluation.
        total_timesteps = 0

        # Declare the counters (one for each environment) storing the number of episodes completed.
        num_eps_per_env = torch.zeros(num_envs, dtype=torch.int64, device=self._simulator_device)

        # Declare the scores (one for each environment).
        score_per_env = torch.zeros(num_envs, dtype=torch.float32, device=self._simulator_device)

        if self._alive_bonus_schedule is not None:
            # If an alive_bonus_schedule was provided, then we extract the timesteps.
            # bonus_t0 is the timestep where the partial alive bonus will start.
            # bonus_t1 is the timestep where the full alive bonus will start.
            # alive_bonus is the amount that will be added to reward if the agent is alive.
            bonus_t0, bonus_t1, alive_bonus = self._alive_bonus_schedule

            if bonus_t1 > bonus_t0:
                # If bonus_t1 is bigger than bonus_t0, then we have a partial alive bonus time window.
                add_partial_alive_bonus = True

                # We compute and store the length of the time window.
                bonus_t_gap_as_float = float(bonus_t1 - bonus_t0)
            else:
                # If bonus_t1 is NOT bigger than bonus_t0, then we do NOT have a partial alive bonus time window.
                add_partial_alive_bonus = False

            # To properly give the alive bonus for each solution, we need to keep track of the timesteps for all
            # the running solutions. So, we declare the following variable.
            t_per_env = torch.zeros(num_envs, dtype=torch.int64, device=self._simulator_device)

        # We normalize the initial observation.
        obs_per_env = self._normalize_observation(obs_per_env, mask=active_per_env)

        while True:
            # Pass the observations through the policy and get the actions to perform.
            action_per_env = policy(torch.as_tensor(obs_per_env, dtype=params_per_env.dtype))

            if self._action_noise_stdev is not None:
                # If we are to apply action noise, we sample from a Gaussian distribution and add the noise onto
                # the actions.
                action_per_env = action_per_env + (torch.rand_like(action_per_env) * self._action_noise_stdev)

            # Apply the actions, get the observations, rewards, and the 'done' flags.
            obs_per_env, reward_per_env, done_per_env, _ = env.step(action_per_env)

            if self._decrease_rewards_by is not None:
                # We decrease the rewards, if we have the configuration to do so.
                reward_per_env = reward_per_env - self._decrease_rewards_by

            if self._alive_bonus_schedule is not None:
                # Here we handle the alive bonus schedule.

                # For each environment, increment the timestep.
                t_per_env[active_per_env] += 1

                # For those who are within the full alive bonus time region, increase the scores by the full amount.
                in_full_bonus_t_per_env = active_per_env & (t_per_env >= bonus_t1)
                score_per_env[in_full_bonus_t_per_env] += alive_bonus

                if add_partial_alive_bonus:
                    # Here we handle the partial alive bonus time window.
                    # We first determine which environments are in the partial alive bonus time window.
                    in_partial_bonus_t_per_env = active_per_env & (t_per_env >= bonus_t0) & (t_per_env < bonus_t1)

                    # Here we compute the partial alive bonuses and add those bonuses to the scores.
                    score_per_env[in_partial_bonus_t_per_env] += alive_bonus * (
                        torch.as_tensor(t_per_env[in_partial_bonus_t_per_env] - bonus_t0, dtype=torch.float32)
                        / bonus_t_gap_as_float
                    )

                # Determine which environments just finished their episodes.
                just_finished_per_env = active_per_env & done_per_env

                # Reset the timestep counters of the environments which are just finished.
                t_per_env[just_finished_per_env] = 0

            # For each active environment, increase the score by the reward received.
            score_per_env[active_per_env] += reward_per_env[active_per_env]

            # Update the total timesteps counter.
            total_timesteps += int(torch.sum(active_per_env))

            # Reset the policies whose episodes are done (so that their hidden states become 0).
            policy.reset(done_per_env)

            # Update the number of episodes counter for each environment.
            num_eps_per_env[done_per_env] += 1

            # Solutions with number of completed episodes larger than the number of allowed episodes become inactive.
            active_per_env[:num_solutions] = num_eps_per_env[:num_solutions] < self._num_episodes

            if not torch.any(active_per_env[:num_solutions]):
                # If there is not a single active solution left, then we exit this loop.
                break

            # For the next iteration of this loop, we normalize the observation.
            obs_per_env = self._normalize_observation(obs_per_env, mask=active_per_env)

        # Update the interaction count and the episode count stored by this VecGymNE instance.
        self._interaction_count += total_timesteps
        self._episode_count += num_solutions * self._num_episodes

        # Compute the fitnesses
        fitnesses = score_per_env[:num_solutions]
        if self._num_episodes > 1:
            fitnesses /= self._num_episodes

        # Assign the scores to the solutions as fitnesses.
        batch.set_evals(fitnesses)

    def get_env(self) -> Optional[gym.Env]:
        """
        Get the gym environment.

        Returns:
            The gym environment if it is built. If not built yet, None.
        """
        return self._env

    def to_policy(self, solution: Iterable, *, with_wrapper_modules: bool = True) -> nn.Module:
        """
        Convert the given solution to a policy.

        Args:
            solution: A solution which can be given as a `torch.Tensor`, as a
                `Solution`, or as any `Iterable`.
            with_wrapper_modules: Whether or not to wrap the policy module
                with helper modules so that observations are normalized
                and actions are clipped to be within the correct boundaries.
                The default and the recommended value is True.
        Returns:
            The policy, as a `torch.nn.Module` instance.
        """
        # Get the gym environment
        env = self._get_env(1)

        # Get the observation space, its lower and higher bounds.
        obs_space = env.single_action_space
        low = obs_space.low
        high = obs_space.high

        # If the lower and higher bounds are not -inf and +inf respectively, then this environment needs clipping.
        needs_clipping = _numpy_arrays_specify_bounds(low, high)

        # Convert the solution to a PyTorch tensor on cpu.
        if isinstance(solution, torch.Tensor):
            solution = solution.to("cpu")
        elif isinstance(solution, Solution):
            solution = solution.values.clone().to("cpu")
        else:
            solution = torch.as_tensor(solution, dtype=torch.float32, device="cpu")

        # Convert the internally stored policy to a PyTorch module.
        result = self._policy.to_torch_module(solution)

        if with_wrapper_modules:
            if self.observation_normalization and (self._obs_stats is not None):
                # If observation normalization is needed and there are collected observation stats, then we wrap the
                # policy with an ObsNormWrapperModule.
                result = ObsNormWrapperModule(result, self._obs_stats)

            if needs_clipping:
                # If clipping is needed, then we wrap the policy with an ActClipWrapperModule
                result = ActClipWrapperModule(result, obs_space)

        return result

    def save_solution(self, solution: Iterable, fname: Union[str, Path]):
        """
        Save the solution into a pickle file.
        Among the saved data within the pickle file are the solution
        (as a PyTorch tensor), the policy (as a `torch.nn.Module` instance),
        and observation stats (if any).

        Args:
            solution: The solution to be saved. This can be a PyTorch tensor,
                a `Solution` instance, or any `Iterable`.
            fname: The file name of the pickle file to be created.
        """

        # Convert the solution to a PyTorch tensor on the cpu.
        if isinstance(solution, torch.Tensor):
            solution = solution.to("cpu")
        elif isinstance(solution, Solution):
            solution = solution.values.clone().to("cpu")
        else:
            solution = torch.as_tensor(solution, dtype=torch.float32, device="cpu")

        if isinstance(solution, ReadOnlyTensor):
            solution = solution.as_subclass(torch.Tensor)

        # Store the solution and the policy.
        result = {
            "solution": solution,
            "policy": self.to_policy(solution),
        }

        # If available, store the observation stats.
        if self.observation_normalization and (self._obs_stats is not None):
            result["obs_mean"] = self._obs_stats.mean.to("cpu")
            result["obs_stdev"] = self._obs_stats.stdev.to("cpu")
            result["obs_sum"] = self._obs_stats.sum.to("cpu")
            result["obs_sum_of_squares"] = self._obs_stats.sum_of_squares.to("cpu")

        # Some additional data.
        result["interaction_count"] = self.interaction_count
        result["episode_count"] = self.episode_count
        result["time"] = datetime.now()

        if isinstance(self._env_maker, str):
            # If the environment was specified via a string, store the string.
            result["env"] = self._env_maker

        # Store the network architecture.
        result["architecture"] = self._architecture

        # Save the dictionary which stores the data.
        with open(fname, "wb") as f:
            pickle.dump(result, f)

    @property
    def max_num_envs(self) -> Optional[int]:
        """
        Maximum number of environments to be allocated.

        If a maximum number of environments is not set, then None is returned.
        If this problem instance is the main one, then the overall maximum
        number of environments is returned.
        If this problem instance is a remote one (i.e. is on a remote actor)
        then the maximum number of environments for that actor is returned.
        """
        if self.is_main:
            return self._max_num_envs
        else:
            return self._actor_max_num_envs

    def make_net(self, solution: Iterable) -> nn.Module:
        """
        Make a new policy network parameterized by the given solution.
        Note that this parameterized network assumes that the observation
        is already normalized, and it does not do action clipping to ensure
        that the generated actions are within valid bounds.

        To have a policy network which has its own observation normalization
        and action clipping layers, please see the method `to_policy(...)`.

        Args:
            solution: The solution which stores the parameters.
                This can be a Solution instance, or a 1-dimensional tensor,
                or any Iterable of real numbers.
        Returns:
            The policy network, as a PyTorch module.
        """
        return self.to_policy(solution, with_wrapper_modules=False)

    @property
    def network_device(self) -> Optional[Device]:
        """
        The device on which the policy networks will operate.

        Specific to VecGymNE, the network device is determined only
        after receiving the first observation from the reinforcement
        learning environment. Until then, this property has the value
        None.
        """
        return self._simulator_device
episode_count: int property readonly

Get the total number of episodes completed.

interaction_count: int property readonly

Get the total number of simulator interactions made.

max_num_envs: Optional[int] property readonly

Maximum number of environments to be allocated.

If a maximum number of environments is not set, then None is returned. If this problem instance is the main one, then the overall maximum number of environments is returned. If this problem instance is a remote one (i.e. is on a remote actor) then the maximum number of environments for that actor is returned.

network_device: Union[str, torch.device] property readonly

The device on which the policy networks will operate.

Specific to VecGymNE, the network device is determined only after receiving the first observation from the reinforcement learning environment. Until then, this property has the value None.

__init__(self, env, network, *, env_config=None, max_num_envs=None, network_args=None, observation_normalization=False, decrease_rewards_by=None, alive_bonus_schedule=None, action_noise_stdev=None, num_episodes=1, device=None, num_actors=None, num_gpus_per_actor=None, num_subbatches=None, subbatch_size=None, actor_config=None) special

Initialize the VecGymNE.

Parameters:

Name Type Description Default
env Union[str, Callable]

Environment to be solved. If this is given as a string starting with "gym::" (e.g. "gym::Humanoid-v4", etc.), then it is assumed that the target environment is a classical gym environment. If this is given as a string starting with "brax::" (e.g. "brax::humanoid", etc.), then it is assumed that the target environment is a brax environment. If this is given as a string which does not contain "::" at all (e.g. "Humanoid-v4", etc.), then it is assumed that the target environment is a classical gym environment. Therefore, "gym::Humanoid-v4" and "Humanoid-v4" are equivalent. If this argument is given as a Callable (maybe a function or a class), then, with the assumption that this Callable expects a keyword argument num_envs: int, this Callable is called and its result (expected as a gym.vector.VectorEnv instance) is used as the environment.

required
network Union[str, Callable, torch.nn.modules.module.Module]

A network structure string, or a Callable (which can be a class inheriting from torch.nn.Module, or a function which returns a torch.nn.Module instance), or an instance of torch.nn.Module. The object provided here determines the structure of the neural network whose parameters will be evolved. A network structure string is a string which can be processed by evotorch.neuroevolution.net.str_to_net(...). Please see the documentation of the function evotorch.neuroevolution.net.str_to_net(...) to see how such a neural network structure string looks like. Note that this network can be a recurrent network. When the network's forward(...) method can optionally accept an additional positional argument for the hidden state of the network and returns an additional value for its next state, then the policy is treated as a recurrent one. When the network is given as a callable object (e.g. a subclass of nn.Module or a function) and this callable object is decorated via evotorch.decorators.pass_info, the following keyword arguments will be passed: (i) obs_length (the length of the observation vector), (ii) act_length (the length of the action vector), (iii) obs_shape (the shape tuple of the observation space), (iv) act_shape (the shape tuple of the action space), (v) obs_space (the Box object specifying the observation space, and (vi) act_space (the Box object specifying the action space). Note that act_space will always be given as a gym.spaces.Box instance, even when the actual gym environment has a discrete action space. This because VecGymNE always expects the neural network to return a tensor of floating-point numbers.

required
env_config Optional[collections.abc.Mapping]

Keyword arguments to pass to the environment while it is being created.

None
max_num_envs Optional[int]

Maximum number of environments to be instantiated. By default, this is None, which means that the number of environments can go up to the population size (or up to the number of solutions that a remote actor receives, if the problem object is configured to have parallelization). For situations where the current reinforcement learning task requires large amount of resources (e.g. memory), allocating environments as much as the number of solutions might not be feasible. In such cases, one can set max_num_envs as an integer to bring an upper bound (in total, across all the remote actors, for when the problem is parallelized) to how many environments can be allocated.

None
network_args Optional[collections.abc.Mapping]

Any additional keyword argument to be used when instantiating the network can be specified via network_args as a dictionary. If there are no such additional keyword arguments, then network_args can be left as None. Note that the argument network_args is expected to be None when the network is specified as a torch.nn.Module instance.

None
observation_normalization bool

Whether or not online normalization will be done on the encountered observations.

False
decrease_rewards_by Optional[float]

If given as a float, each reward will be decreased by this amount. For example, if the environment's reward function has a constant "alive bonus" (i.e. a bonus that is constantly added onto the reward as long as the agent is alive), and if you wish to negate this bonus, you can set decrease_rewards_by to this bonus amount, and the bonus will be nullified. If you do not wish to affect the rewards in this manner, keep this as None.

None
alive_bonus_schedule Optional[tuple]

Use this to add a customized amount of alive bonus. If left as None (which is the default), additional alive bonus will not be added. If given as a tuple (t, b), an alive bonus b will be added onto all the rewards beyond the timestep t. If given as a tuple (t0, t1, b), a partial (linearly increasing towards b) alive bonus will be added onto all the rewards between the timesteps t0 and t1, and a full alive bonus (which equals to b) will be added onto all the rewards beyond the timestep t1.

None
action_noise_stdev Optional[float]

If given as a real number s, then, for each generated action, Gaussian noise with standard deviation s will be sampled, and then this sampled noise will be added onto the action. If action noise is not desired, then this argument can be left as None. For sampling the noise, the global random number generator of PyTorch on the simulator's device will be used.

None
num_episodes int

Number of episodes over which each policy will be evaluated. The default is 1.

1
device Union[str, torch.device]

The device in which the population will be kept. If you wish to do a single-GPU evolution, we recommend to set this as "cuda" (or "cuda:0", or "cuda:1", etc.), assuming that the simulator will also instantiate itself on that same device. Alternatively, if you wish to do a multi-GPU evolution, we recommend to leave this as None or set this as "cpu", so that the main population will be kept on the cpu and the remote actors will perform their evaluations on the GPUs that are assigned to them.

None
num_actors Union[int, str]

Number of actors to create for parallelized evaluation of the solutions. Certain string values are also accepted. When given as "max" or as "num_cpus", the number of actors will be equal to the number of all available CPUs in the ray cluster. When given as "num_gpus", the number of actors will be equal to the number of all available GPUs in the ray cluster, and each actor will be assigned a GPU. When given as "num_devices", the number of actors will be equal to the minimum among the number of CPUs and the number of GPUs available in the cluster (or will be equal to the number of CPUs if there is no GPU), and each actor will be assigned a GPU (if available). If num_actors is given as "num_gpus" or "num_devices", the argument num_gpus_per_actor must not be used, and the actor_config dictionary must not contain the key "num_gpus". If num_actors is given as something other than "num_gpus" or "num_devices", and if you wish to assign GPUs to each actor, then please see the argument num_gpus_per_actor.

None
num_gpus_per_actor Optional[int]

Number of GPUs to be assigned to each actor. This can be an integer or a float (for when you wish to assign fractional amounts of GPUs to actors). When num_actors has the special value "num_devices", the argument num_gpus_per_actor is expected to be left as None.

None
num_subbatches Optional[int]

For when there are multiple actors, you can set this to an integer n if you wish the population to be divided exactly into n sub-batches. The actors, as they finish their currently assigned sub-batch of solutions, will pick the next un-evaluated sub-batch. If you specify too large numbers for this argument, then each sub-batch will be smaller. When working with vectorized simulators on GPU, having too many and too small sub-batches can hurt the performance. This argument can be left as None, in which case, assuming that subbatch_size is also None, the population will be split to m sub-batches, m being the number of actors.

None
subbatch_size Optional[int]

For when there are multiple actors, you can set this to an integer n if you wish the population to be divided into sub-batches in such a way that each sub-batch will consist of exactly n solutions. The actors, as they finish their currently assigned sub-batch of solutions, will pick the next un-evaluated sub-batch. If you specify too small numbers for this argument, then there will be many sub-batches, each sub-batch having a small number of solutions. When working with vectorized simulators on GPU, having too many and too small sub-batches can hurt the performance. This argument can be left as None, in which case, assuming that num_subbatches is also None, the population will be split to m sub-batches, m being the number of actors.

None
actor_config Optional[collections.abc.Mapping]

Additional configuration to be used when creating each actor with the help of ray library. Can be left as None if additional configuration is not needed.

None
Source code in evotorch/neuroevolution/vecgymne.py
def __init__(
    self,
    env: Union[str, Callable],
    network: Union[str, Callable, nn.Module],
    *,
    env_config: Optional[Mapping] = None,
    max_num_envs: Optional[int] = None,
    network_args: Optional[Mapping] = None,
    observation_normalization: bool = False,
    decrease_rewards_by: Optional[float] = None,
    alive_bonus_schedule: Optional[tuple] = None,
    action_noise_stdev: Optional[float] = None,
    num_episodes: int = 1,
    device: Optional[Device] = None,
    num_actors: Optional[Union[int, str]] = None,
    num_gpus_per_actor: Optional[int] = None,
    num_subbatches: Optional[int] = None,
    subbatch_size: Optional[int] = None,
    actor_config: Optional[Mapping] = None,
):
    """
    Initialize the VecGymNE.

    Args:
        env: Environment to be solved.
            If this is given as a string starting with "gym::" (e.g.
            "gym::Humanoid-v4", etc.), then it is assumed that the target
            environment is a classical gym environment.
            If this is given as a string starting with "brax::" (e.g.
            "brax::humanoid", etc.), then it is assumed that the target
            environment is a brax environment.
            If this is given as a string which does not contain "::" at
            all (e.g. "Humanoid-v4", etc.), then it is assumed that the
            target environment is a classical gym environment. Therefore,
            "gym::Humanoid-v4" and "Humanoid-v4" are equivalent.
            If this argument is given as a Callable (maybe a function or a
            class), then, with the assumption that this Callable expects
            a keyword argument `num_envs: int`, this Callable is called
            and its result (expected as a `gym.vector.VectorEnv` instance)
            is used as the environment.
        network: A network structure string, or a Callable (which can be
            a class inheriting from `torch.nn.Module`, or a function
            which returns a `torch.nn.Module` instance), or an instance
            of `torch.nn.Module`.
            The object provided here determines the structure of the
            neural network whose parameters will be evolved.
            A network structure string is a string which can be processed
            by `evotorch.neuroevolution.net.str_to_net(...)`.
            Please see the documentation of the function
            `evotorch.neuroevolution.net.str_to_net(...)` to see how such
            a neural network structure string looks like.
            Note that this network can be a recurrent network.
            When the network's `forward(...)` method can optionally accept
            an additional positional argument for the hidden state of the
            network and returns an additional value for its next state,
            then the policy is treated as a recurrent one.
            When the network is given as a callable object (e.g.
            a subclass of `nn.Module` or a function) and this callable
            object is decorated via `evotorch.decorators.pass_info`,
            the following keyword arguments will be passed:
            (i) `obs_length` (the length of the observation vector),
            (ii) `act_length` (the length of the action vector),
            (iii) `obs_shape` (the shape tuple of the observation space),
            (iv) `act_shape` (the shape tuple of the action space),
            (v) `obs_space` (the Box object specifying the observation
            space, and
            (vi) `act_space` (the Box object specifying the action
            space). Note that `act_space` will always be given as a
            `gym.spaces.Box` instance, even when the actual gym
            environment has a discrete action space. This because
            `VecGymNE` always expects the neural network to return
            a tensor of floating-point numbers.
        env_config: Keyword arguments to pass to the environment while
            it is being created.
        max_num_envs: Maximum number of environments to be instantiated.
            By default, this is None, which means that the number of
            environments can go up to the population size (or up to the
            number of solutions that a remote actor receives, if the
            problem object is configured to have parallelization).
            For situations where the current reinforcement learning task
            requires large amount of resources (e.g. memory), allocating
            environments as much as the number of solutions might not
            be feasible. In such cases, one can set `max_num_envs` as an
            integer to bring an upper bound (in total, across all the
            remote actors, for when the problem is parallelized) to how
            many environments can be allocated.
        network_args: Any additional keyword argument to be used when
            instantiating the network can be specified via `network_args`
            as a dictionary. If there are no such additional keyword
            arguments, then `network_args` can be left as None.
            Note that the argument `network_args` is expected to be None
            when the network is specified as a `torch.nn.Module` instance.
        observation_normalization: Whether or not online normalization
            will be done on the encountered observations.
        decrease_rewards_by: If given as a float, each reward will be
            decreased by this amount. For example, if the environment's
            reward function has a constant "alive bonus" (i.e. a bonus
            that is constantly added onto the reward as long as the
            agent is alive), and if you wish to negate this bonus,
            you can set `decrease_rewards_by` to this bonus amount,
            and the bonus will be nullified.
            If you do not wish to affect the rewards in this manner,
            keep this as None.
        alive_bonus_schedule: Use this to add a customized amount of
            alive bonus.
            If left as None (which is the default), additional alive
            bonus will not be added.
            If given as a tuple `(t, b)`, an alive bonus `b` will be
            added onto all the rewards beyond the timestep `t`.
            If given as a tuple `(t0, t1, b)`, a partial (linearly
            increasing towards `b`) alive bonus will be added onto
            all the rewards between the timesteps `t0` and `t1`,
            and a full alive bonus (which equals to `b`) will be added
            onto all the rewards beyond the timestep `t1`.
        action_noise_stdev: If given as a real number `s`, then, for
            each generated action, Gaussian noise with standard
            deviation `s` will be sampled, and then this sampled noise
            will be added onto the action.
            If action noise is not desired, then this argument can be
            left as None.
            For sampling the noise, the global random number generator
            of PyTorch on the simulator's device will be used.
        num_episodes: Number of episodes over which each policy will
            be evaluated. The default is 1.
        device: The device in which the population will be kept.
            If you wish to do a single-GPU evolution, we recommend
            to set this as "cuda" (or "cuda:0", or "cuda:1", etc.),
            assuming that the simulator will also instantiate itself
            on that same device.
            Alternatively, if you wish to do a multi-GPU evolution,
            we recommend to leave this as None or set this as "cpu",
            so that the main population will be kept on the cpu
            and the remote actors will perform their evaluations on
            the GPUs that are assigned to them.
        num_actors: Number of actors to create for parallelized
            evaluation of the solutions.
            Certain string values are also accepted.
            When given as "max" or as "num_cpus", the number of actors
            will be equal to the number of all available CPUs in the ray
            cluster.
            When given as "num_gpus", the number of actors will be
            equal to the number of all available GPUs in the ray
            cluster, and each actor will be assigned a GPU.
            When given as "num_devices", the number of actors will be
            equal to the minimum among the number of CPUs and the number
            of GPUs available in the cluster (or will be equal to the
            number of CPUs if there is no GPU), and each actor will be
            assigned a GPU (if available).
            If `num_actors` is given as "num_gpus" or "num_devices",
            the argument `num_gpus_per_actor` must not be used,
            and the `actor_config` dictionary must not contain the
            key "num_gpus".
            If `num_actors` is given as something other than "num_gpus"
            or "num_devices", and if you wish to assign GPUs to each
            actor, then please see the argument `num_gpus_per_actor`.
        num_gpus_per_actor: Number of GPUs to be assigned to each
            actor. This can be an integer or a float (for when you
            wish to assign fractional amounts of GPUs to actors).
            When `num_actors` has the special value "num_devices",
            the argument `num_gpus_per_actor` is expected to be left as
            None.
        num_subbatches: For when there are multiple actors, you can
            set this to an integer n if you wish the population
            to be divided exactly into n sub-batches. The actors, as they
            finish their currently assigned sub-batch of solutions,
            will pick the next un-evaluated sub-batch.
            If you specify too large numbers for this argument, then
            each sub-batch will be smaller.
            When working with vectorized simulators on GPU, having too
            many and too small sub-batches can hurt the performance.
            This argument can be left as None, in which case, assuming
            that `subbatch_size` is also None, the population will be
            split to m sub-batches, m being the number of actors.
        subbatch_size: For when there are multiple actors, you can
            set this to an integer n if you wish the population to be
            divided into sub-batches in such a way that each sub-batch
            will consist of exactly n solutions. The actors, as they
            finish their currently assigned sub-batch of solutions,
            will pick the next un-evaluated sub-batch.
            If you specify too small numbers for this argument, then
            there will be many sub-batches, each sub-batch having a
            small number of solutions.
            When working with vectorized simulators on GPU, having too
            many and too small sub-batches can hurt the performance.
            This argument can be left as None, in which case, assuming
            that `num_subbatches` is also None, the population will be
            split to m sub-batches, m being the number of actors.
        actor_config: Additional configuration to be used when creating
            each actor with the help of `ray` library.
            Can be left as None if additional configuration is not needed.
    """

    # Store the string or the Callable that will be used to generate the reinforcement learning environment.
    self._env_maker = env

    # Declare the variable which will store the environment.
    self._env: Optional[TorchWrapper] = None

    # Declare the variable which will store the batch size of the vectorized environment.
    self._num_envs: Optional[int] = None

    # Store the upper bound (if any) regarding how many environments can exist at the same time.
    self._max_num_envs: Optional[int] = None if max_num_envs is None else int(max_num_envs)

    # Actor-specific upper bound regarding how many environments can exist at the same time.
    # This variable will be filled by the `_parallelize(...)` method.
    self._actor_max_num_envs: Optional[int] = None

    # Declare the variable which stores whether or not we properly initialized the `_actor_max_num_envs` variable.
    self._actor_max_num_envs_ready: bool = False

    # Store the additional configurations to be used as keyword arguments while instantiating the environment.
    self._env_config: dict = {} if env_config is None else dict(env_config)

    # Declare the variable that will store the device of the simulator.
    # This variable will be filled when the first observation is received from the environment.
    # The device of the observation array received from the environment will determine the value of this variable.
    self._simulator_device: Optional[torch.device] = None

    # Store the neural network architecture (that might be a string or an `nn.Module` instance).
    self._architecture = network

    if network_args is None:
        # If `network_args` is given as None, change it to an empty dictionary
        network_args = {}

    if isinstance(network, str):
        # If the network is given as a string, then we will need the values for the constants `obs_length`,
        # `act_length`, and `obs_space`. To obtain those values, we use our helper function
        # `_env_constants_for_str_net(...)` which temporarily instantiates the specified environment and returns
        # its needed constants.
        env_constants = _env_constants_for_str_net(self._env_maker, **(self._env_config))
    elif isinstance(network, nn.Module):
        # If the network is an already instantiated nn.Module, then we do not prepare any pre-defined constants.
        env_constants = {}
    else:
        # If the network is given as a Callable, then we will need the values for the constants `obs_length`,
        # `act_length`, and `obs_space`. To obtain those values, we use our helper function
        # `_env_constants_for_callable_net(...)` which temporarily instantiates the specified environment and
        # returns its needed constants.
        env_constants = _env_constants_for_callable_net(self._env_maker, **(self._env_config))

    # Build a `Policy` instance according to the given architecture, and store it.
    if isinstance(network, str):
        instantiated_net = str_to_net(network, **{**env_constants, **network_args})
    elif isinstance(network, nn.Module):
        instantiated_net = network
    else:
        instantiated_net = pass_info_if_needed(network, env_constants)(**network_args)
    self._policy = Policy(instantiated_net)

    # Store the boolean which indicates whether or not there will be observation normalization.
    self._observation_normalization = bool(observation_normalization)

    # Declare the variables that will store the observation-related stats if observation normalization is enabled.
    self._obs_stats: Optional[RunningNorm] = None
    self._collected_stats: Optional[RunningNorm] = None

    # Store the number of episodes configuration given by the user.
    self._num_episodes = int(num_episodes)

    # Store the `decrease_rewards_by` configuration given by the user.
    self._decrease_rewards_by = None if decrease_rewards_by is None else float(decrease_rewards_by)

    if alive_bonus_schedule is None:
        # If `alive_bonus_schedule` argument is None, then we store it as None as well.
        self._alive_bonus_schedule = None
    else:
        # This is the case where the user has specified an `alive_bonus_schedule`.
        alive_bonus_schedule = list(alive_bonus_schedule)
        alive_bonus_schedule_length = len(alive_bonus_schedule)
        if alive_bonus_schedule_length == 2:
            # If `alive_bonus_schedule` was given as a 2-element sequence (t, b), then store it as (t, t, b).
            # This means that the partial alive bonus time window starts and ends at t, therefore, there will
            # be no alive bonus until t, and beginning with t, there will be full alive bonus.
            self._alive_bonus_schedule = [
                int(alive_bonus_schedule[0]),
                int(alive_bonus_schedule[0]),
                float(alive_bonus_schedule[1]),
            ]
        elif alive_bonus_schedule_length == 3:
            # If `alive_bonus_schedule` was given as a 3-element sequence (t0, t1, b), then store those 3
            # elements.
            self._alive_bonus_schedule = [
                int(alive_bonus_schedule[0]),
                int(alive_bonus_schedule[1]),
                float(alive_bonus_schedule[2]),
            ]
        else:
            # `alive_bonus_schedule` sequences with unrecognized lengths trigger an error.
            raise ValueError(
                f"Received invalid number elements as the alive bonus schedule."
                f" Expected 2 or 3 items, but got these: {self._alive_bonus_schedule}"
                f" (having a length of {len(self._alive_bonus_schedule)})."
            )

    # If `action_noise_stdev` is specified, store it.
    self._action_noise_stdev = None if action_noise_stdev is None else float(action_noise_stdev)

    # Initialize the counters for the number of simulator interactions and the number of episodes.
    self._interaction_count: int = 0
    self._episode_count: int = 0

    # Call the superclass
    super().__init__(
        objective_sense="max",
        initial_bounds=(-0.00001, 0.00001),
        solution_length=self._policy.parameter_length,
        device=device,
        dtype=torch.float32,
        num_actors=num_actors,
        num_gpus_per_actor=num_gpus_per_actor,
        actor_config=actor_config,
        num_subbatches=num_subbatches,
        subbatch_size=subbatch_size,
    )
get_env(self)

Get the gym environment.

Returns:

Type Description
Optional[gymnasium.core.Env]

The gym environment if it is built. If not built yet, None.

Source code in evotorch/neuroevolution/vecgymne.py
def get_env(self) -> Optional[gym.Env]:
    """
    Get the gym environment.

    Returns:
        The gym environment if it is built. If not built yet, None.
    """
    return self._env
get_observation_stats(self)

Get the observation stats

Source code in evotorch/neuroevolution/vecgymne.py
def get_observation_stats(self) -> RunningNorm:
    """Get the observation stats"""
    self._ensure_obsnorm()
    return self._obs_stats
make_net(self, solution)

Make a new policy network parameterized by the given solution. Note that this parameterized network assumes that the observation is already normalized, and it does not do action clipping to ensure that the generated actions are within valid bounds.

To have a policy network which has its own observation normalization and action clipping layers, please see the method to_policy(...).

Parameters:

Name Type Description Default
solution Iterable

The solution which stores the parameters. This can be a Solution instance, or a 1-dimensional tensor, or any Iterable of real numbers.

required

Returns:

Type Description
Module

The policy network, as a PyTorch module.

Source code in evotorch/neuroevolution/vecgymne.py
def make_net(self, solution: Iterable) -> nn.Module:
    """
    Make a new policy network parameterized by the given solution.
    Note that this parameterized network assumes that the observation
    is already normalized, and it does not do action clipping to ensure
    that the generated actions are within valid bounds.

    To have a policy network which has its own observation normalization
    and action clipping layers, please see the method `to_policy(...)`.

    Args:
        solution: The solution which stores the parameters.
            This can be a Solution instance, or a 1-dimensional tensor,
            or any Iterable of real numbers.
    Returns:
        The policy network, as a PyTorch module.
    """
    return self.to_policy(solution, with_wrapper_modules=False)
pop_observation_stats(self)

Get and clear the collected observation stats

Source code in evotorch/neuroevolution/vecgymne.py
def pop_observation_stats(self) -> RunningNorm:
    """Get and clear the collected observation stats"""
    self._ensure_obsnorm()
    result = self._collected_stats
    self._collected_stats = None
    return result
save_solution(self, solution, fname)

Save the solution into a pickle file. Among the saved data within the pickle file are the solution (as a PyTorch tensor), the policy (as a torch.nn.Module instance), and observation stats (if any).

Parameters:

Name Type Description Default
solution Iterable

The solution to be saved. This can be a PyTorch tensor, a Solution instance, or any Iterable.

required
fname Union[str, pathlib.Path]

The file name of the pickle file to be created.

required
Source code in evotorch/neuroevolution/vecgymne.py
def save_solution(self, solution: Iterable, fname: Union[str, Path]):
    """
    Save the solution into a pickle file.
    Among the saved data within the pickle file are the solution
    (as a PyTorch tensor), the policy (as a `torch.nn.Module` instance),
    and observation stats (if any).

    Args:
        solution: The solution to be saved. This can be a PyTorch tensor,
            a `Solution` instance, or any `Iterable`.
        fname: The file name of the pickle file to be created.
    """

    # Convert the solution to a PyTorch tensor on the cpu.
    if isinstance(solution, torch.Tensor):
        solution = solution.to("cpu")
    elif isinstance(solution, Solution):
        solution = solution.values.clone().to("cpu")
    else:
        solution = torch.as_tensor(solution, dtype=torch.float32, device="cpu")

    if isinstance(solution, ReadOnlyTensor):
        solution = solution.as_subclass(torch.Tensor)

    # Store the solution and the policy.
    result = {
        "solution": solution,
        "policy": self.to_policy(solution),
    }

    # If available, store the observation stats.
    if self.observation_normalization and (self._obs_stats is not None):
        result["obs_mean"] = self._obs_stats.mean.to("cpu")
        result["obs_stdev"] = self._obs_stats.stdev.to("cpu")
        result["obs_sum"] = self._obs_stats.sum.to("cpu")
        result["obs_sum_of_squares"] = self._obs_stats.sum_of_squares.to("cpu")

    # Some additional data.
    result["interaction_count"] = self.interaction_count
    result["episode_count"] = self.episode_count
    result["time"] = datetime.now()

    if isinstance(self._env_maker, str):
        # If the environment was specified via a string, store the string.
        result["env"] = self._env_maker

    # Store the network architecture.
    result["architecture"] = self._architecture

    # Save the dictionary which stores the data.
    with open(fname, "wb") as f:
        pickle.dump(result, f)
set_episode_count(self, n)

Set the episode count manually.

Source code in evotorch/neuroevolution/vecgymne.py
def set_episode_count(self, n: int):
    """
    Set the episode count manually.
    """
    self._episode_count = int(n)
set_interaction_count(self, n)

Set the interaction count manually.

Source code in evotorch/neuroevolution/vecgymne.py
def set_interaction_count(self, n: int):
    """
    Set the interaction count manually.
    """
    self._interaction_count = int(n)
set_observation_stats(self, rn)

Set the observation stats

Source code in evotorch/neuroevolution/vecgymne.py
def set_observation_stats(self, rn: RunningNorm):
    """Set the observation stats"""
    self._ensure_obsnorm()
    self._obs_stats = rn
to_policy(self, solution, *, with_wrapper_modules=True)

Convert the given solution to a policy.

Parameters:

Name Type Description Default
solution Iterable

A solution which can be given as a torch.Tensor, as a Solution, or as any Iterable.

required
with_wrapper_modules bool

Whether or not to wrap the policy module with helper modules so that observations are normalized and actions are clipped to be within the correct boundaries. The default and the recommended value is True.

True

Returns:

Type Description
Module

The policy, as a torch.nn.Module instance.

Source code in evotorch/neuroevolution/vecgymne.py
def to_policy(self, solution: Iterable, *, with_wrapper_modules: bool = True) -> nn.Module:
    """
    Convert the given solution to a policy.

    Args:
        solution: A solution which can be given as a `torch.Tensor`, as a
            `Solution`, or as any `Iterable`.
        with_wrapper_modules: Whether or not to wrap the policy module
            with helper modules so that observations are normalized
            and actions are clipped to be within the correct boundaries.
            The default and the recommended value is True.
    Returns:
        The policy, as a `torch.nn.Module` instance.
    """
    # Get the gym environment
    env = self._get_env(1)

    # Get the observation space, its lower and higher bounds.
    obs_space = env.single_action_space
    low = obs_space.low
    high = obs_space.high

    # If the lower and higher bounds are not -inf and +inf respectively, then this environment needs clipping.
    needs_clipping = _numpy_arrays_specify_bounds(low, high)

    # Convert the solution to a PyTorch tensor on cpu.
    if isinstance(solution, torch.Tensor):
        solution = solution.to("cpu")
    elif isinstance(solution, Solution):
        solution = solution.values.clone().to("cpu")
    else:
        solution = torch.as_tensor(solution, dtype=torch.float32, device="cpu")

    # Convert the internally stored policy to a PyTorch module.
    result = self._policy.to_torch_module(solution)

    if with_wrapper_modules:
        if self.observation_normalization and (self._obs_stats is not None):
            # If observation normalization is needed and there are collected observation stats, then we wrap the
            # policy with an ObsNormWrapperModule.
            result = ObsNormWrapperModule(result, self._obs_stats)

        if needs_clipping:
            # If clipping is needed, then we wrap the policy with an ActClipWrapperModule
            result = ActClipWrapperModule(result, obs_space)

    return result
update_observation_stats(self, rn)

Update the observation stats via another RunningNorm instance

Source code in evotorch/neuroevolution/vecgymne.py
def update_observation_stats(self, rn: RunningNorm):
    """Update the observation stats via another RunningNorm instance"""
    self._ensure_obsnorm()
    if self._obs_stats is None:
        self._obs_stats = rn
    else:
        self._obs_stats.update(rn)

operators special

This module provides various common operators to be used within evolutionary algorithms.

Each operator is provided as a separate class, which is to be instantiated in this form:

op = OperatorName(
    problem,  # where problem is a Problem instance
    hyperparameter1=...,
    hyperparameter2=...,
    # ...
)

Each operator has its __call__(...) method overriden so that it can be used like a function. For example, if the operator op instantiated above were a mutation operator, it would be used like this:

# Apply mutation on a SolutionBatch
mutated_solution = op(my_solution_batch)

Please see the documentations of the provided operator classes for details about how to instantiate them, and how to call them.

A common usage for the operators provided here is to use them with GeneticAlgorithm, as shown below:

from evotorch.algorithms import GeneticAlgorithm
from evotorch.operators import SimulatedBinaryCrossOver, GaussianMutation

problem = ...  # initialize the Problem

ga = GeneticAlgorithm(
    problem,
    operators=[
        SimulatedBinaryCrossOver(
            problem,
            tournament_size=...,
            cross_over_rate=...,
            eta=...,
        ),
        GaussianMutation(
            problem,
            stdev=...,
        ),
    ],
    popsize=...,
)

base

Base classes for various operators

CopyingOperator (Operator)

Base class for operators which do not do in-place modifications.

This class does not add any functionality to the Operator class. Instead, the annotations of the __call__(...) method is updated so that it makes it clear that a new SolutionBatch is returned.

One is expected to override the definition of the method _do(...) in an inheriting subclass to define a custom CopyingOperator.

From outside, a subclass of CopyingOperator is meant to be called like a function, as follows:

my_new_batch = my_copying_operator_instance(my_batch)
Source code in evotorch/operators/base.py
class CopyingOperator(Operator):
    """
    Base class for operators which do not do in-place modifications.

    This class does not add any functionality to the Operator class.
    Instead, the annotations of the `__call__(...)` method is
    updated so that it makes it clear that a new SolutionBatch is
    returned.

    One is expected to override the definition of the method `_do(...)`
    in an inheriting subclass to define a custom `CopyingOperator`.

    From outside, a subclass of `CopyingOperator` is meant to be called like
    a function, as follows:

        my_new_batch = my_copying_operator_instance(my_batch)
    """

    def __init__(self, problem: Problem):
        """
        `__init__(...)`: Initialize the CopyingOperator.

        Args:
            problem: The problem object which is being worked on.
        """
        super().__init__(problem)

    def __call__(self, batch: SolutionBatch) -> SolutionBatch:
        return self._do(batch)

    def _do(self, batch: SolutionBatch) -> SolutionBatch:
        """The actual definition of the operation on the batch.
        Expected to be overriden by a subclass.
        """
        raise NotImplementedError
__init__(self, problem) special

__init__(...): Initialize the CopyingOperator.

Parameters:

Name Type Description Default
problem Problem

The problem object which is being worked on.

required
Source code in evotorch/operators/base.py
def __init__(self, problem: Problem):
    """
    `__init__(...)`: Initialize the CopyingOperator.

    Args:
        problem: The problem object which is being worked on.
    """
    super().__init__(problem)

CrossOver (CopyingOperator)

Base class for any CrossOver operator.

One is expected to override the definition of the method _do_cross_over(...) in an inheriting subclass to define a custom CrossOver.

From outside, a CrossOver instance is meant to be called like this:

child_solution_batch = my_cross_over_instance(population_batch)

which causes the CrossOver instance to select parents from the population_batch, recombine their values according to what is instructed in _do_cross_over(...), and return the newly made solutions in a SolutionBatch.

Source code in evotorch/operators/base.py
class CrossOver(CopyingOperator):
    """
    Base class for any CrossOver operator.

    One is expected to override the definition of the method
    `_do_cross_over(...)` in an inheriting subclass to define a
    custom `CrossOver`.

    From outside, a `CrossOver` instance is meant to be called like this:

        child_solution_batch = my_cross_over_instance(population_batch)

    which causes the `CrossOver` instance to select parents from the
    `population_batch`, recombine their values according to what is
    instructed in `_do_cross_over(...)`, and return the newly made solutions
    in a `SolutionBatch`.
    """

    def __init__(
        self,
        problem: Problem,
        *,
        tournament_size: int,
        obj_index: Optional[int] = None,
        num_children: Optional[int] = None,
        cross_over_rate: Optional[float] = None,
    ):
        """
        `__init__(...)`: Initialize the CrossOver.

        Args:
            problem: The problem object which is being worked on.
            tournament_size: Size of the tournament which will be used for
                doing selection.
            obj_index: Index of the objective according to which the selection
                will be done.
                If `obj_index` is None and the problem is single-objective,
                then the selection will be done according to that single
                objective.
                If `obj_index` is None and the problem is multi-objective,
                then the selection will be done according to pareto-dominance
                and crowding criteria, as done in NSGA-II.
                If `obj_index` is an integer `i`, then the selection will be
                done according to the i-th objective only, even when the
                problem is multi-objective.
            num_children: How many children to generate.
                Expected as an even number.
                Cannot be used together with `cross_over_rate`.
            cross_over_rate: Rate of the cross-over operations in comparison
                with the population size.
                1.0 means that the number of generated children will be equal
                to the original population size.
                Cannot be used together with `num_children`.
        """
        super().__init__(problem)

        self._obj_index = None if obj_index is None else problem.normalize_obj_index(obj_index)
        self._tournament_size = int(tournament_size)

        if num_children is not None and cross_over_rate is not None:
            raise ValueError(
                "Received both `num_children` and `cross_over_rate` as values other than None."
                " It was expected to receive both of them as None, or one of them as None,"
                " but not both of them as values other than None."
            )

        self._num_children = None if num_children is None else int(num_children)
        self._cross_over_rate = None if cross_over_rate is None else float(cross_over_rate)

    def _compute_num_tournaments(self, batch: SolutionBatch) -> int:
        if self._num_children is None and self._cross_over_rate is None:
            # return len(batch) * 2
            result = len(batch)
            if (result % 2) != 0:
                result += 1
            return result
        elif self._num_children is not None:
            if (self._num_children % 2) != 0:
                raise ValueError(
                    f"The initialization argument `num_children` was expected as an even number."
                    f" However, it was found as an odd number: {self._num_children}"
                )
            return self._num_children
        elif self._cross_over_rate is not None:
            f = len(batch) * self._cross_over_rate
            result1 = math.ceil(f)
            result2 = math.floor(f)
            if result1 == result2:
                result = result1
                if (result % 2) != 0:
                    result += 1
            else:
                if (result1 % 2) == 0:
                    result = result1
                else:
                    result = result2

            return result
        else:
            assert False, "Exection should not have reached this point"

    @property
    def obj_index(self) -> Optional[int]:
        """The objective index according to which the selection will be done"""
        return self._obj_index

    @torch.no_grad()
    def _do_tournament(self, batch: SolutionBatch) -> tuple:
        # Compute the required number of tournaments
        num_tournaments = self._compute_num_tournaments(batch)

        if self._problem.is_multi_objective and self._obj_index is None:
            # If the problem is multi-objective, and an objective index is not specified,
            # then we do a multi-objective-specific cross-over

            # At first, pareto-sort the solutions
            ranks, _ = batch.compute_pareto_ranks(crowdsort=False)
            n_fronts = torch.amax(ranks) + 1

            # In NSGA-II-inspired pareto-sorting, smallest rank means the best front.
            # Right now, we want the opposite: we want the solutions in the best front
            # to have rank values which are numerically highest.
            # The following line re-arranges the rank values such that the solutions
            # in the best front have their ranks equal to n_fronts, and the ones
            # in the worst front have their ranks equal to 1.
            ranks = (n_fronts - ranks).to(torch.float)

            # Because the ranks are computed front the fronts indices, we expect many
            # solutions to end up with the same rank values.
            # To ensure that a randomized selection will be made when comparing two
            # solutions with the same rank, we add random noise to the ranks
            # (between 0.0 and 0.1).
            ranks += self._problem.make_uniform(len(batch), dtype=self._problem.eval_dtype, device=batch.device) * 0.1
        else:
            # Rank the solutions. Worst gets -0.5, best gets 0.5
            ranks = batch.utility(self._obj_index, ranking_method="centered")

        # Get the internal values tensor of the solution batch
        indata = batch._data

        # Get a tensor of random integers in the shape (num_tournaments, tournament_size)
        tournament_indices = self.problem.make_randint(
            (num_tournaments, self._tournament_size), n=len(batch), device=indata.device
        )
        tournament_ranks = ranks[tournament_indices]

        # Imagine tournament size is 2, and the solutions are [ worst, bad, best, good ].
        # So, what we have is (0.2s are actually 0.166666...):
        #
        #    ranks = [ -0.5, -0.2, 0.5, 0.2 ]
        #
        #    tournament     tournament
        #     indices         ranks
        #
        #       0, 1        -0.5, -0.2
        #       2, 3         0.5,  0.2
        #       1, 0        -0.2, -0.5
        #       3, 2         0.2,  0.5
        #       1, 2        -0.2,  0.5
        #       0, 3        -0.5,  0.2
        #       2, 0         0.5, -0.5
        #       3, 1         0.2, -0.2
        #
        # According to tournament_indices, there are 8 tournaments.
        #   In tournament 0 (topmost row), parent0 and parent1 compete.
        #   In tournament 1 (next row), parent2 and parent3 compete; and so on.
        # tournament_ranks tells us:
        #   In tournament 0, left-candidate has rank -0.5, and right-candidate has -0.2.
        #   In tournament 1, left-candidate has rank 0.5, and right-candidate has 0.2; and so on.

        tournament_rows = torch.arange(0, num_tournaments, device=indata.device)
        parents = tournament_indices[tournament_rows, torch.argmax(tournament_ranks, dim=-1)]

        # Continuing from the [ worst, bad, best, good ] example, we end up with:
        #
        #            T                                    T
        #  tournament   tournament     tournament   argmax     parents
        #    rows        indices         ranks      dim=-1
        #
        #      0          0, 1        -0.5, -0.2      1           1
        #      1          2, 3         0.5,  0.2      0           2
        #      2          1, 0        -0.2, -0.5      0           1
        #      3          3, 2         0.2,  0.5      1           2
        #      4          1, 2        -0.2,  0.5      1           2
        #      5          0, 3        -0.5,  0.2      1           3
        #      6          2, 0         0.5, -0.5      0           2
        #      7          3, 1         0.2, -0.2      0           3
        #
        # where tournament_rows represents row indices in tournament_indices tensor (from 0 to 7).
        # argmax() tells us who won the competition (0: left-candidate won, 1: right-candidate won).
        #
        # tournament_rows and argmax() together give us the row and column of the winner in tensor
        # tournament_indices, which in turn gives us the index of the winner solution in the batch.

        # We split the parents array from the middle
        split_point = int(len(parents) / 2)
        parents1 = indata[parents][:split_point]
        parents2 = indata[parents][split_point:]
        # We now have:
        #
        #   parents1            parents2
        #   ===============     ===============
        #   values of sln 1     values of sln 2   (solution1 is to generate a child with solution2)
        #   values of sln 2     values of sln 3   (solution2 is to generate a child with solution3)
        #   values of sln 1     values of sln 2   (solution1 is to generate another child with solution2)
        #   values of sln 2     values of sln 3   (solution2 is to generate another child with solution3)
        #
        # With this, the tournament selection phase is over.
        return parents1, parents2

    def _do_cross_over(
        self,
        parents1: Union[torch.Tensor, ObjectArray],
        parents2: Union[torch.Tensor, ObjectArray],
    ) -> SolutionBatch:
        """
        The actual definition of the cross-over operation.

        This is a protected method, meant to be overriden by the inheriting
        subclass.

        The arguments passed to this function are the decision values of the
        first and the second half of the selected parents, both as PyTorch
        tensors or as `ObjectArray`s.

        In the overriding function, for each integer i, one is expected to
        recombine the values of the i-th row of `parents1` with the values of
        the i-th row of `parents2` twice (twice because each pairing is
        expected to generate two children).
        After that, one is expected to generate a SolutionBatch and place
        all the recombination results into the values of that new batch.

        Args:
            parents1: The decision values of the first half of the
                selected parents.
            parents2: The decision values of the second half of the
                selected parents.
        Returns:
            A new SolutionBatch which contains the recombination
            of the parents.
        """
        raise NotImplementedError

    def _make_children_batch(self, child_values: Union[torch.Tensor, ObjectArray]) -> SolutionBatch:
        result = SolutionBatch(self.problem, device=child_values.device, empty=True, popsize=child_values.shape[0])
        result._data = child_values
        return result

    def _do(self, batch: SolutionBatch) -> SolutionBatch:
        parents1, parents2 = self._do_tournament(batch)
        if len(parents1) != len(parents2):
            raise ValueError(
                f"_do_tournament() returned parents1 and parents2 with incompatible sizes. "
                f"len(parents1): {len(parents1)}; len(parents2): {len(parents2)}."
            )
        return self._do_cross_over(parents1, parents2)
obj_index: Optional[int] property readonly

The objective index according to which the selection will be done

__init__(self, problem, *, tournament_size, obj_index=None, num_children=None, cross_over_rate=None) special

__init__(...): Initialize the CrossOver.

Parameters:

Name Type Description Default
problem Problem

The problem object which is being worked on.

required
tournament_size int

Size of the tournament which will be used for doing selection.

required
obj_index Optional[int]

Index of the objective according to which the selection will be done. If obj_index is None and the problem is single-objective, then the selection will be done according to that single objective. If obj_index is None and the problem is multi-objective, then the selection will be done according to pareto-dominance and crowding criteria, as done in NSGA-II. If obj_index is an integer i, then the selection will be done according to the i-th objective only, even when the problem is multi-objective.

None
num_children Optional[int]

How many children to generate. Expected as an even number. Cannot be used together with cross_over_rate.

None
cross_over_rate Optional[float]

Rate of the cross-over operations in comparison with the population size. 1.0 means that the number of generated children will be equal to the original population size. Cannot be used together with num_children.

None
Source code in evotorch/operators/base.py
def __init__(
    self,
    problem: Problem,
    *,
    tournament_size: int,
    obj_index: Optional[int] = None,
    num_children: Optional[int] = None,
    cross_over_rate: Optional[float] = None,
):
    """
    `__init__(...)`: Initialize the CrossOver.

    Args:
        problem: The problem object which is being worked on.
        tournament_size: Size of the tournament which will be used for
            doing selection.
        obj_index: Index of the objective according to which the selection
            will be done.
            If `obj_index` is None and the problem is single-objective,
            then the selection will be done according to that single
            objective.
            If `obj_index` is None and the problem is multi-objective,
            then the selection will be done according to pareto-dominance
            and crowding criteria, as done in NSGA-II.
            If `obj_index` is an integer `i`, then the selection will be
            done according to the i-th objective only, even when the
            problem is multi-objective.
        num_children: How many children to generate.
            Expected as an even number.
            Cannot be used together with `cross_over_rate`.
        cross_over_rate: Rate of the cross-over operations in comparison
            with the population size.
            1.0 means that the number of generated children will be equal
            to the original population size.
            Cannot be used together with `num_children`.
    """
    super().__init__(problem)

    self._obj_index = None if obj_index is None else problem.normalize_obj_index(obj_index)
    self._tournament_size = int(tournament_size)

    if num_children is not None and cross_over_rate is not None:
        raise ValueError(
            "Received both `num_children` and `cross_over_rate` as values other than None."
            " It was expected to receive both of them as None, or one of them as None,"
            " but not both of them as values other than None."
        )

    self._num_children = None if num_children is None else int(num_children)
    self._cross_over_rate = None if cross_over_rate is None else float(cross_over_rate)

Operator

Base class for various operations on SolutionBatch objects.

Some subclasses of Operator may be operating on the batches in-place, while some others may generate new batches, leaving the original batches untouched.

One is expected to override the definition of the method _do(...) in an inheriting subclass to define a custom Operator.

From outside, a subclass of Operator is meant to be called like a function. In more details, operators which apply in-place modifications are meant to be called like this:

my_operator_instance(my_batch)

Operators which return a new batch are meant to be called like this:

my_new_batch = my_operator_instance(my_batch)
Source code in evotorch/operators/base.py
class Operator:
    """Base class for various operations on SolutionBatch objects.

    Some subclasses of Operator may be operating on the batches in-place,
    while some others may generate new batches, leaving the original batches
    untouched.

    One is expected to override the definition of the method `_do(...)`
    in an inheriting subclass to define a custom `Operator`.

    From outside, a subclass of Operator is meant to be called like
    a function. In more details, operators which apply in-place modifications
    are meant to be called like this:

        my_operator_instance(my_batch)

    Operators which return a new batch are meant to be called like this:

        my_new_batch = my_operator_instance(my_batch)
    """

    def __init__(self, problem: Problem):
        """
        `__init__(...)`: Initialize the Operator.

        Args:
            problem: The problem object which is being worked on.
        """
        if not isinstance(problem, Problem):
            raise TypeError(f"Expected a Problem object, but received {repr(problem)}")
        self._problem = problem
        self._lb = clone(self._problem.lower_bounds)
        self._ub = clone(self._problem.upper_bounds)

    @property
    def problem(self) -> Problem:
        """Get the problem to which this cross-over operator is bound"""
        return self._problem

    @property
    def dtype(self) -> DType:
        """Get the dtype of the bound problem.
        If the problem does not work with Solution and
        therefore it does not have a dtype, None is returned.
        """
        return self.problem.dtype

    @torch.no_grad()
    def _respect_bounds(self, x: torch.Tensor) -> torch.Tensor:
        """
        Make sure that a given PyTorch tensor respects the problem's bounds.

        This is a protected method which might be used by the
        inheriting subclasses to ensure that the result of their
        various operations are clipped properly to respect the
        boundaries set by the problem object.

        Note that this function might return the tensor itself
        is the problem is not bounded.

        Args:
            x: The PyTorch tensor to be clipped.
        Returns:
            The clipped tensor.
        """
        if self._lb is not None:
            self._lb = torch.as_tensor(self._lb, dtype=x.dtype, device=x.device)
            x = torch.max(self._lb, x)
        if self._ub is not None:
            self._ub = torch.as_tensor(self._ub, dtype=x.dtype, device=x.device)
            x = torch.min(self._ub, x)
        return x

    def __call__(self, batch: SolutionBatch):
        """
        Apply the operator on the given batch.
        """
        if not isinstance(batch, SolutionBatch):
            raise TypeError(
                f"The operation {self.__class__.__name__} can only work on"
                f" SolutionBatch objects, but it received an object of type"
                f" {repr(type(batch))}."
            )
        self._do(batch)

    def _do(self, batch: SolutionBatch):
        """
        The actual definition of the operation on the batch.
        Expected to be overriden by a subclass.
        """
        raise NotImplementedError
dtype: Union[str, torch.dtype, numpy.dtype, Type] property readonly

Get the dtype of the bound problem. If the problem does not work with Solution and therefore it does not have a dtype, None is returned.

problem: Problem property readonly

Get the problem to which this cross-over operator is bound

__call__(self, batch) special

Apply the operator on the given batch.

Source code in evotorch/operators/base.py
def __call__(self, batch: SolutionBatch):
    """
    Apply the operator on the given batch.
    """
    if not isinstance(batch, SolutionBatch):
        raise TypeError(
            f"The operation {self.__class__.__name__} can only work on"
            f" SolutionBatch objects, but it received an object of type"
            f" {repr(type(batch))}."
        )
    self._do(batch)
__init__(self, problem) special

__init__(...): Initialize the Operator.

Parameters:

Name Type Description Default
problem Problem

The problem object which is being worked on.

required
Source code in evotorch/operators/base.py
def __init__(self, problem: Problem):
    """
    `__init__(...)`: Initialize the Operator.

    Args:
        problem: The problem object which is being worked on.
    """
    if not isinstance(problem, Problem):
        raise TypeError(f"Expected a Problem object, but received {repr(problem)}")
    self._problem = problem
    self._lb = clone(self._problem.lower_bounds)
    self._ub = clone(self._problem.upper_bounds)

SingleObjOperator (Operator)

Base class for all the operators which focus on only one objective.

One is expected to override the definition of the method _do(...) in an inheriting subclass to define a custom SingleObjOperator.

Source code in evotorch/operators/base.py
class SingleObjOperator(Operator):
    """
    Base class for all the operators which focus on only one objective.

    One is expected to override the definition of the method `_do(...)`
    in an inheriting subclass to define a custom `SingleObjOperator`.
    """

    def __init__(self, problem: Problem, obj_index: Optional[int] = None):
        """
        Initialize the SingleObjOperator.

        Args:
            problem: The problem object which is being worked on.
            obj_index: Index of the objective to focus on.
                Can be given as None if the problem is single-objective.
        """
        super().__init__(problem)
        self._obj_index: int = problem.normalize_obj_index(obj_index)

    @property
    def obj_index(self) -> int:
        """Index of the objective on which this operator is to be applied"""
        return self._obj_index
obj_index: int property readonly

Index of the objective on which this operator is to be applied

__init__(self, problem, obj_index=None) special

Initialize the SingleObjOperator.

Parameters:

Name Type Description Default
problem Problem

The problem object which is being worked on.

required
obj_index Optional[int]

Index of the objective to focus on. Can be given as None if the problem is single-objective.

None
Source code in evotorch/operators/base.py
def __init__(self, problem: Problem, obj_index: Optional[int] = None):
    """
    Initialize the SingleObjOperator.

    Args:
        problem: The problem object which is being worked on.
        obj_index: Index of the objective to focus on.
            Can be given as None if the problem is single-objective.
    """
    super().__init__(problem)
    self._obj_index: int = problem.normalize_obj_index(obj_index)

real

This module contains operators defined to work with problems whose dtypes are real numbers (e.g. torch.float32).

CosynePermutation (CopyingOperator)

Representation of permutation operation on a SolutionBatch.

For each decision variable index, a permutation operation across all or a subset of solutions, is performed. The result is returned on a new SolutionBatch. The original SolutionBatch remains unmodified.

Reference:

F.Gomez, J.Schmidhuber, R.Miikkulainen (2008).
Accelerated Neural Evolution through Cooperatively Coevolved Synapses
Journal of Machine Learning Research 9, 937-965
Source code in evotorch/operators/real.py
class CosynePermutation(CopyingOperator):
    """
    Representation of permutation operation on a SolutionBatch.

    For each decision variable index, a permutation operation across
    all or a subset of solutions, is performed.
    The result is returned on a new SolutionBatch.
    The original SolutionBatch remains unmodified.

    Reference:

        F.Gomez, J.Schmidhuber, R.Miikkulainen (2008).
        Accelerated Neural Evolution through Cooperatively Coevolved Synapses
        Journal of Machine Learning Research 9, 937-965
    """

    def __init__(self, problem: Problem, obj_index: Optional[int] = None, *, permute_all: bool = False):
        """
        `__init__(...)`: Initialize the CosynePermutation.

        Args:
            problem: The problem object to work on.
            obj_index: The index of the objective according to which the
                candidates for permutation will be selected.
                Can be left as None if the problem is single-objective,
                or if `permute_all` is given as True (in which case there
                will be no candidate selection as the entire population will
                be subject to permutation).
            permute_all: Whether or not to apply permutation on the entire
                population, instead of using a selective permutation.
        """

        if permute_all:
            if obj_index is not None:
                raise ValueError(
                    "When `permute_all` is given as True (which seems to be the case)"
                    " `obj_index` is expected as None,"
                    " because the operator is independent of any objective and any fitness in this mode."
                    " However, `permute_all` was found to be something other than None."
                )
            self._obj_index = None
        else:
            self._obj_index = problem.normalize_obj_index(obj_index)

        super().__init__(problem)

        self._permute_all = bool(permute_all)

    @property
    def obj_index(self) -> Optional[int]:
        """Objective index according to which the operator will run.
        If `permute_all` was given as True, objectives are irrelevant, in which case
        `obj_index` is returned as None.
        If `permute_all` was given as False, the relevant `obj_index` is provided
        as an integer.
        """
        return self._obj_index

    @torch.no_grad()
    def _do(self, batch: SolutionBatch) -> SolutionBatch:
        indata = batch._data

        if not self._permute_all:
            n = batch.solution_length
            ranks = batch.utility(self._obj_index, ranking_method="centered")
            # fitnesses = batch.evals[:, self._obj_index].clone().reshape(-1)
            # ranks = rank(
            #    fitnesses, ranking_method="centered", higher_is_better=(self.problem.senses[self.obj_index] == "max")
            # )
            prob_permute = (1 - (ranks + 0.5).pow(1 / float(n))).unsqueeze(1).expand(len(batch), batch.solution_length)
        else:
            prob_permute = torch.ones_like(indata)

        perm_mask = self.problem.make_uniform_shaped_like(prob_permute) <= prob_permute

        perm_mask_sorted = torch.sort(perm_mask.to(torch.long), descending=True, dim=0)[0].to(
            torch.bool
        )  # Sort permutations

        perm_rand = self.problem.make_uniform_shaped_like(prob_permute)
        perm_rand[torch.logical_not(perm_mask)] = 1.0
        permutations = torch.argsort(perm_rand, dim=0)  # Generate permutations

        perm_sort = (
            torch.arange(0, perm_mask.shape[0], device=indata.device).unsqueeze(-1).repeat(1, perm_mask.shape[1])
        )
        perm_sort[torch.logical_not(perm_mask)] += perm_mask.shape[0] + 1
        perm_sort = torch.sort(perm_sort, dim=0)[0]  # Generate the origin of permutations

        _, permutation_columns = torch.nonzero(perm_mask_sorted, as_tuple=True)
        permutation_origin_indices = perm_sort[perm_mask_sorted]
        permutation_target_indices = permutations[perm_mask_sorted]

        newbatch = SolutionBatch(like=batch, empty=True)
        newdata = newbatch._data
        newdata[:] = indata[:]
        newdata[permutation_origin_indices, permutation_columns] = newdata[
            permutation_target_indices, permutation_columns
        ]

        return newbatch
obj_index: Optional[int] property readonly

Objective index according to which the operator will run. If permute_all was given as True, objectives are irrelevant, in which case obj_index is returned as None. If permute_all was given as False, the relevant obj_index is provided as an integer.

__init__(self, problem, obj_index=None, *, permute_all=False) special

__init__(...): Initialize the CosynePermutation.

Parameters:

Name Type Description Default
problem Problem

The problem object to work on.

required
obj_index Optional[int]

The index of the objective according to which the candidates for permutation will be selected. Can be left as None if the problem is single-objective, or if permute_all is given as True (in which case there will be no candidate selection as the entire population will be subject to permutation).

None
permute_all bool

Whether or not to apply permutation on the entire population, instead of using a selective permutation.

False
Source code in evotorch/operators/real.py
def __init__(self, problem: Problem, obj_index: Optional[int] = None, *, permute_all: bool = False):
    """
    `__init__(...)`: Initialize the CosynePermutation.

    Args:
        problem: The problem object to work on.
        obj_index: The index of the objective according to which the
            candidates for permutation will be selected.
            Can be left as None if the problem is single-objective,
            or if `permute_all` is given as True (in which case there
            will be no candidate selection as the entire population will
            be subject to permutation).
        permute_all: Whether or not to apply permutation on the entire
            population, instead of using a selective permutation.
    """

    if permute_all:
        if obj_index is not None:
            raise ValueError(
                "When `permute_all` is given as True (which seems to be the case)"
                " `obj_index` is expected as None,"
                " because the operator is independent of any objective and any fitness in this mode."
                " However, `permute_all` was found to be something other than None."
            )
        self._obj_index = None
    else:
        self._obj_index = problem.normalize_obj_index(obj_index)

    super().__init__(problem)

    self._permute_all = bool(permute_all)

GaussianMutation (CopyingOperator)

Gaussian mutation operator.

Follows the algorithm description in:

Sean Luke, 2013, Essentials of Metaheuristics, Lulu, second edition
available for free at http://cs.gmu.edu/~sean/book/metaheuristics/
Source code in evotorch/operators/real.py
class GaussianMutation(CopyingOperator):
    """
    Gaussian mutation operator.

    Follows the algorithm description in:

        Sean Luke, 2013, Essentials of Metaheuristics, Lulu, second edition
        available for free at http://cs.gmu.edu/~sean/book/metaheuristics/
    """

    def __init__(self, problem: Problem, *, stdev: float, mutation_probability: Optional[float] = None):
        """
        `__init__(...)`: Initialize the GaussianMutation.

        Args:
            problem: The problem object to work with.
            stdev: The standard deviation of the Gaussian noise to apply on
                each decision variable.
            mutation_probability: The probability of mutation, for each
                decision variable.
                If None, the value of this argument becomes 1.0, which means
                that all of the decision variables will be affected by the
                mutation. Defatuls to None
        """

        super().__init__(problem)
        self._mutation_probability = 1.0 if mutation_probability is None else float(mutation_probability)
        self._stdev = float(stdev)

    @torch.no_grad()
    def _do(self, batch: SolutionBatch) -> SolutionBatch:
        result = deepcopy(batch)
        data = result.access_values()
        mutation_matrix = self.problem.make_uniform_shaped_like(data) <= self._mutation_probability
        data[mutation_matrix] += self._stdev * self.problem.make_gaussian_shaped_like(data[mutation_matrix])
        data[:] = self._respect_bounds(data)
        return result
__init__(self, problem, *, stdev, mutation_probability=None) special

__init__(...): Initialize the GaussianMutation.

Parameters:

Name Type Description Default
problem Problem

The problem object to work with.

required
stdev float

The standard deviation of the Gaussian noise to apply on each decision variable.

required
mutation_probability Optional[float]

The probability of mutation, for each decision variable. If None, the value of this argument becomes 1.0, which means that all of the decision variables will be affected by the mutation. Defatuls to None

None
Source code in evotorch/operators/real.py
def __init__(self, problem: Problem, *, stdev: float, mutation_probability: Optional[float] = None):
    """
    `__init__(...)`: Initialize the GaussianMutation.

    Args:
        problem: The problem object to work with.
        stdev: The standard deviation of the Gaussian noise to apply on
            each decision variable.
        mutation_probability: The probability of mutation, for each
            decision variable.
            If None, the value of this argument becomes 1.0, which means
            that all of the decision variables will be affected by the
            mutation. Defatuls to None
    """

    super().__init__(problem)
    self._mutation_probability = 1.0 if mutation_probability is None else float(mutation_probability)
    self._stdev = float(stdev)

MultiPointCrossOver (CrossOver)

Representation of a multi-point cross-over operator.

When this operator is applied on a SolutionBatch, a tournament selection technique is used for selecting parent solutions from the batch, and then those parent solutions are mated via cutting from a random position and recombining. The result of these recombination operations is a new SolutionBatch, containing the children solutions. The original SolutionBatch stays unmodified.

This operator is a generalization over the standard cross-over operators OnePointCrossOver and TwoPointCrossOver. In more details, instead of having one or two cutting points, this operator is configurable in terms of how many cutting points is desired. This generalized cross-over implementation follows the procedure described in:

Sean Luke, 2013, Essentials of Metaheuristics, Lulu, second edition
available for free at http://cs.gmu.edu/~sean/book/metaheuristics/
Source code in evotorch/operators/real.py
class MultiPointCrossOver(CrossOver):
    """
    Representation of a multi-point cross-over operator.

    When this operator is applied on a SolutionBatch, a tournament selection
    technique is used for selecting parent solutions from the batch, and then
    those parent solutions are mated via cutting from a random position and
    recombining. The result of these recombination operations is a new
    SolutionBatch, containing the children solutions. The original
    SolutionBatch stays unmodified.

    This operator is a generalization over the standard cross-over operators
    [OnePointCrossOver][evotorch.operators.real.OnePointCrossOver]
    and [TwoPointCrossOver][evotorch.operators.real.TwoPointCrossOver].
    In more details, instead of having one or two cutting points, this operator
    is configurable in terms of how many cutting points is desired.
    This generalized cross-over implementation follows the procedure described
    in:

        Sean Luke, 2013, Essentials of Metaheuristics, Lulu, second edition
        available for free at http://cs.gmu.edu/~sean/book/metaheuristics/
    """

    def __init__(
        self,
        problem: Problem,
        *,
        tournament_size: int,
        obj_index: Optional[int] = None,
        num_points: Optional[int] = None,
        num_children: Optional[int] = None,
        cross_over_rate: Optional[float] = None,
    ):
        """
        `__init__(...)`: Initialize the MultiPointCrossOver.

        Args:
            problem: The problem object to work on.
            tournament_size: What is the size (or length) of a tournament
                when selecting a parent candidate from a population
            obj_index: Objective index according to which the selection
                will be done.
            num_points: Number of cutting points for the cross-over operator.
            num_children: Optionally a number of children to produce by the
                cross-over operation.
                Not to be used together with `cross_over_rate`.
                If `num_children` and `cross_over_rate` are both None,
                then the number of children is equal to the number
                of solutions received.
            cross_over_rate: Optionally expected as a real number between
                0.0 and 1.0. Specifies the number of cross-over operations
                to perform. 1.0 means `1.0 * len(solution_batch)` amount of
                cross overs will be performed, resulting in
                `2.0 * len(solution_batch)` amount of children.
                Not to be used together with `num_children`.
                If `num_children` and `cross_over_rate` are both None,
                then the number of children is equal to the number
                of solutions received.
        """

        super().__init__(
            problem,
            tournament_size=tournament_size,
            obj_index=obj_index,
            num_children=num_children,
            cross_over_rate=cross_over_rate,
        )

        self._num_points = int(num_points)
        if self._num_points < 1:
            raise ValueError(
                f"Invalid `num_points`: {self._num_points}."
                f" Please provide a `num_points` which is greater than or equal to 1"
            )

    @torch.no_grad()
    def _do_cross_over(self, parents1: torch.Tensor, parents2: torch.Tensor) -> SolutionBatch:
        # What we expect here is this:
        #
        #    parents1      parents2
        #    ==========    ==========
        #    parents1[0]   parents2[0]
        #    parents1[1]   parents2[1]
        #    ...           ...
        #    parents1[N]   parents2[N]
        #
        # where parents1 and parents2 are 2D tensors, each containing values of N solutions.
        # For each row i, we will apply cross-over on parents1[i] and parents2[i].
        # From each cross-over, we will obtain 2 children.
        # This means, there are N pairings, and 2N children.

        num_pairings = parents1.shape[0]
        # num_children = num_pairings * 2

        device = parents1[0].device
        solution_length = len(parents1[0])
        num_points = self._num_points

        # For each pairing, generate all gene indices (i.e. [0, 1, 2, ...] for each pairing)
        gene_indices = (
            torch.arange(0, solution_length, device=device).unsqueeze(0).expand(num_pairings, solution_length)
        )

        if num_points == 1:
            # For each pairing, generate a gene index at which the parent solutions will be cut and recombined
            crossover_point = self.problem.make_randint((num_pairings, 1), n=(solution_length - 1), device=device) + 1

            # Make a mask for crossing over
            # (False: take the value from one parent, True: take the value from the other parent).
            # For gene indices less than crossover_point of that pairing, the mask takes the value 0.
            # Otherwise, the mask takes the value 1.
            crossover_mask = gene_indices >= crossover_point
        else:
            # For each pairing, generate gene indices at which the parent solutions will be cut and recombined
            crossover_points = self.problem.make_randint(
                (num_pairings, num_points), n=(solution_length + 1), device=device
            )

            # From `crossover_points`, extract each cutting point for each solution.
            cutting_points = [crossover_points[:, i].reshape(-1, 1) for i in range(num_points)]

            # Initialize `crossover_mask` as a tensor filled with False.
            crossover_mask = torch.zeros((num_pairings, solution_length), dtype=torch.bool, device=device)

            # For each cutting point p, toggle the boolean values of `crossover_mask`
            # for indices bigger than the index pointed to by p
            for p in cutting_points:
                crossover_mask ^= gene_indices >= p

        # Using the mask, generate two children.
        children1 = torch.where(crossover_mask, parents1, parents2)
        children2 = torch.where(crossover_mask, parents2, parents1)

        # Combine the children tensors in one big tensor
        children = torch.cat([children1, children2], dim=0)

        # Write the children solutions into a new SolutionBatch, and return the new batch
        result = self._make_children_batch(children)
        return result
__init__(self, problem, *, tournament_size, obj_index=None, num_points=None, num_children=None, cross_over_rate=None) special

__init__(...): Initialize the MultiPointCrossOver.

Parameters:

Name Type Description Default
problem Problem

The problem object to work on.

required
tournament_size int

What is the size (or length) of a tournament when selecting a parent candidate from a population

required
obj_index Optional[int]

Objective index according to which the selection will be done.

None
num_points Optional[int]

Number of cutting points for the cross-over operator.

None
num_children Optional[int]

Optionally a number of children to produce by the cross-over operation. Not to be used together with cross_over_rate. If num_children and cross_over_rate are both None, then the number of children is equal to the number of solutions received.

None
cross_over_rate Optional[float]

Optionally expected as a real number between 0.0 and 1.0. Specifies the number of cross-over operations to perform. 1.0 means 1.0 * len(solution_batch) amount of cross overs will be performed, resulting in 2.0 * len(solution_batch) amount of children. Not to be used together with num_children. If num_children and cross_over_rate are both None, then the number of children is equal to the number of solutions received.

None
Source code in evotorch/operators/real.py
def __init__(
    self,
    problem: Problem,
    *,
    tournament_size: int,
    obj_index: Optional[int] = None,
    num_points: Optional[int] = None,
    num_children: Optional[int] = None,
    cross_over_rate: Optional[float] = None,
):
    """
    `__init__(...)`: Initialize the MultiPointCrossOver.

    Args:
        problem: The problem object to work on.
        tournament_size: What is the size (or length) of a tournament
            when selecting a parent candidate from a population
        obj_index: Objective index according to which the selection
            will be done.
        num_points: Number of cutting points for the cross-over operator.
        num_children: Optionally a number of children to produce by the
            cross-over operation.
            Not to be used together with `cross_over_rate`.
            If `num_children` and `cross_over_rate` are both None,
            then the number of children is equal to the number
            of solutions received.
        cross_over_rate: Optionally expected as a real number between
            0.0 and 1.0. Specifies the number of cross-over operations
            to perform. 1.0 means `1.0 * len(solution_batch)` amount of
            cross overs will be performed, resulting in
            `2.0 * len(solution_batch)` amount of children.
            Not to be used together with `num_children`.
            If `num_children` and `cross_over_rate` are both None,
            then the number of children is equal to the number
            of solutions received.
    """

    super().__init__(
        problem,
        tournament_size=tournament_size,
        obj_index=obj_index,
        num_children=num_children,
        cross_over_rate=cross_over_rate,
    )

    self._num_points = int(num_points)
    if self._num_points < 1:
        raise ValueError(
            f"Invalid `num_points`: {self._num_points}."
            f" Please provide a `num_points` which is greater than or equal to 1"
        )

OnePointCrossOver (MultiPointCrossOver)

Representation of a one-point cross-over operator.

When this operator is applied on a SolutionBatch, a tournament selection technique is used for selecting parent solutions from the batch, and then those parent solutions are mated via cutting from a random position and recombining. The result of these recombination operations is a new SolutionBatch, containing the children solutions. The original SolutionBatch stays unmodified.

Let us assume that the two of the parent solutions that were selected for the cross-over operation are as follows:

a: [ a0 , a1 , a2 , a3 , a4 , a5 ]
b: [ b0 , b1 , b2 , b3 , b4 , b5 ]

For recombining parents a and b, a cutting point is first randomly selected. In the case of this example, let us assume that the cutting point was chosen as the point between the items with indices 2 and 3:

a: [ a0 , a1 , a2 | a3 , a4 , a5 ]
b: [ b0 , b1 , b2 | b3 , b4 , b5 ]
                  |
                  ^
       Selected cutting point

Considering this selected cutting point, the two children c and d will be constructed from a and b like this:

c: [ a0 , a1 , a2 | b3 , b4 , b5 ]
d: [ b0 , b1 , b2 | a3 , a4 , a5 ]

Note that the recombination procedure explained above is be done on all of the parents chosen from the given SolutionBatch, in a vectorized manner. For each chosen pair of parents, the cutting points will be sampled differently.

Source code in evotorch/operators/real.py
class OnePointCrossOver(MultiPointCrossOver):
    """
    Representation of a one-point cross-over operator.

    When this operator is applied on a SolutionBatch, a tournament selection
    technique is used for selecting parent solutions from the batch, and then
    those parent solutions are mated via cutting from a random position and
    recombining. The result of these recombination operations is a new
    SolutionBatch, containing the children solutions. The original
    SolutionBatch stays unmodified.

    Let us assume that the two of the parent solutions that were selected for
    the cross-over operation are as follows:

    ```
    a: [ a0 , a1 , a2 , a3 , a4 , a5 ]
    b: [ b0 , b1 , b2 , b3 , b4 , b5 ]
    ```

    For recombining parents `a` and `b`, a cutting point is first randomly
    selected. In the case of this example, let us assume that the cutting
    point was chosen as the point between the items with indices 2 and 3:

    ```
    a: [ a0 , a1 , a2 | a3 , a4 , a5 ]
    b: [ b0 , b1 , b2 | b3 , b4 , b5 ]
                      |
                      ^
           Selected cutting point
    ```

    Considering this selected cutting point, the two children `c` and `d`
    will be constructed from `a` and `b` like this:

    ```
    c: [ a0 , a1 , a2 | b3 , b4 , b5 ]
    d: [ b0 , b1 , b2 | a3 , a4 , a5 ]
    ```

    Note that the recombination procedure explained above is be done on all
    of the parents chosen from the given SolutionBatch, in a vectorized manner.
    For each chosen pair of parents, the cutting points will be sampled
    differently.
    """

    def __init__(
        self,
        problem: Problem,
        *,
        tournament_size: int,
        obj_index: Optional[int] = None,
        num_children: Optional[int] = None,
        cross_over_rate: Optional[float] = None,
    ):
        """
        `__init__(...)`: Initialize the OnePointCrossOver.

        Args:
            problem: The problem object to work on.
            tournament_size: What is the size (or length) of a tournament
                when selecting a parent candidate from a population
            obj_index: Objective index according to which the selection
                will be done.
            num_children: Optionally a number of children to produce by the
                cross-over operation.
                Not to be used together with `cross_over_rate`.
                If `num_children` and `cross_over_rate` are both None,
                then the number of children is equal to the number
                of solutions received.
            cross_over_rate: Optionally expected as a real number between
                0.0 and 1.0. Specifies the number of cross-over operations
                to perform. 1.0 means `1.0 * len(solution_batch)` amount of
                cross overs will be performed, resulting in
                `2.0 * len(solution_batch)` amount of children.
                Not to be used together with `num_children`.
                If `num_children` and `cross_over_rate` are both None,
                then the number of children is equal to the number
                of solutions received.
        """
        super().__init__(
            problem,
            tournament_size=tournament_size,
            obj_index=obj_index,
            num_points=1,
            num_children=num_children,
            cross_over_rate=cross_over_rate,
        )
__init__(self, problem, *, tournament_size, obj_index=None, num_children=None, cross_over_rate=None) special

__init__(...): Initialize the OnePointCrossOver.

Parameters:

Name Type Description Default
problem Problem

The problem object to work on.

required
tournament_size int

What is the size (or length) of a tournament when selecting a parent candidate from a population

required
obj_index Optional[int]

Objective index according to which the selection will be done.

None
num_children Optional[int]

Optionally a number of children to produce by the cross-over operation. Not to be used together with cross_over_rate. If num_children and cross_over_rate are both None, then the number of children is equal to the number of solutions received.

None
cross_over_rate Optional[float]

Optionally expected as a real number between 0.0 and 1.0. Specifies the number of cross-over operations to perform. 1.0 means 1.0 * len(solution_batch) amount of cross overs will be performed, resulting in 2.0 * len(solution_batch) amount of children. Not to be used together with num_children. If num_children and cross_over_rate are both None, then the number of children is equal to the number of solutions received.

None
Source code in evotorch/operators/real.py
def __init__(
    self,
    problem: Problem,
    *,
    tournament_size: int,
    obj_index: Optional[int] = None,
    num_children: Optional[int] = None,
    cross_over_rate: Optional[float] = None,
):
    """
    `__init__(...)`: Initialize the OnePointCrossOver.

    Args:
        problem: The problem object to work on.
        tournament_size: What is the size (or length) of a tournament
            when selecting a parent candidate from a population
        obj_index: Objective index according to which the selection
            will be done.
        num_children: Optionally a number of children to produce by the
            cross-over operation.
            Not to be used together with `cross_over_rate`.
            If `num_children` and `cross_over_rate` are both None,
            then the number of children is equal to the number
            of solutions received.
        cross_over_rate: Optionally expected as a real number between
            0.0 and 1.0. Specifies the number of cross-over operations
            to perform. 1.0 means `1.0 * len(solution_batch)` amount of
            cross overs will be performed, resulting in
            `2.0 * len(solution_batch)` amount of children.
            Not to be used together with `num_children`.
            If `num_children` and `cross_over_rate` are both None,
            then the number of children is equal to the number
            of solutions received.
    """
    super().__init__(
        problem,
        tournament_size=tournament_size,
        obj_index=obj_index,
        num_points=1,
        num_children=num_children,
        cross_over_rate=cross_over_rate,
    )

PolynomialMutation (CopyingOperator)

Representation of the polynomial mutation operator.

Follows the algorithm description in:

Kalyanmoy Deb, Santosh Tiwari (2008).
Omni-optimizer: A generic evolutionary algorithm for single
and multi-objective optimization

The operator ensures a non-zero probability of generating offspring in the entire search space by dividing the space into two regions and using independent probability distributions associated with each region. In contrast, the original polynomial mutation formulation may render the mutation ineffective when the decision variable approaches its boundary.

Source code in evotorch/operators/real.py
class PolynomialMutation(CopyingOperator):
    """
    Representation of the polynomial mutation operator.

    Follows the algorithm description in:

        Kalyanmoy Deb, Santosh Tiwari (2008).
        Omni-optimizer: A generic evolutionary algorithm for single
        and multi-objective optimization

    The operator ensures a non-zero probability of generating offspring in
    the entire search space by dividing the space into two regions and using
    independent probability distributions associated with each region.
    In contrast, the original polynomial mutation formulation may render the
    mutation ineffective when the decision variable approaches its boundary.
    """

    def __init__(
        self,
        problem: Problem,
        *,
        eta: Optional[float] = None,
        mutation_probability: Optional[float] = None,
    ):
        """
        `__init__(...)`: Initialize the PolynomialMutation.

        Args:
            problem: The problem object to work with.
            eta: The index for polynomial mutation; a large value gives a higher
                probability for creating near-parent solutions, whereas a small
                value allows distant solutions to be created.
                If not specified, `eta` will be assumed as 20.0.
            mutation_probability: The probability of mutation, for each decision
                variable. If not specified, all variables will be mutated.
        """

        super().__init__(problem)

        if "float" not in str(problem.dtype):
            raise ValueError(
                f"This operator can be used only when `dtype` of the problem is float type"
                f" (like, e.g. torch.float32, torch.float64, etc.)"
                f" The dtype of the problem is {problem.dtype}."
            )

        if (self.problem.lower_bounds is None) or (self.problem.upper_bounds is None):
            raise ValueError(
                "The polynomial mutation operator can be used only when the problem object has"
                " `lower_bounds` and `upper_bounds`."
                " In the given problem object, at least one of them appears to be missing."
            )

        if torch.any(self.problem.lower_bounds > self.problem.upper_bounds):
            raise ValueError("Some of the `lower_bounds` appear greater than their `upper_bounds`")

        self._prob = None if mutation_probability is None else float(mutation_probability)
        self._eta = 20.0 if eta is None else float(eta)
        self._lb = self.problem.lower_bounds
        self._ub = self.problem.upper_bounds

    @torch.no_grad()
    def _do(self, batch: SolutionBatch) -> SolutionBatch:
        # Take a copy of the original batch. Modifications will be done on this copy.
        result = deepcopy(batch)

        # Take the decision values tensor from within the newly made copy of the batch (`result`).
        # Any modification done on this tensor will affect the `result` batch.
        data = result.access_values()

        # Take the population size
        pop_size, solution_length = data.size()

        if self._prob is None:
            # If a probability of mutation is not given, then we prepare our mutation mask (`to_mutate`) as a tensor
            # consisting only of `True`s.
            to_mutate = torch.ones(data.shape, dtype=torch.bool, device=data.device)
        else:
            # If a probability of mutation is given, then we produce a boolean mask that probabilistically marks which
            # decision variables will be affected by this mutation operation.
            to_mutate = self.problem.make_uniform_shaped_like(data) < self._prob

        # Obtain a flattened (1-dimensional) tensor which addresses only the variables that are subject to mutation
        # (i.e. variables that are not subject to mutation are filtered out).
        selected = data[to_mutate]

        # Obtain flattened (1-dimensional) lower and upper bound tensors such that `lb[i]` and `ub[i]` specify the
        # bounds for `selected[i]`.
        lb = self._lb.expand(pop_size, solution_length)[to_mutate]
        ub = self._ub.expand(pop_size, solution_length)[to_mutate]

        # Apply the mutation procedure explained by Deb & Tiwari (2008).
        delta_1 = (selected - lb) / (ub - lb)
        delta_2 = (ub - selected) / (ub - lb)

        r = self.problem.make_uniform(selected.size())
        mask = r < 0.5
        mask_not = torch.logical_not(mask)

        mut_str = 1.0 / (self._eta + 1.0)
        delta_q = torch.zeros_like(selected)

        v = 2.0 * r + (1.0 - 2.0 * r) * (1.0 - delta_1).pow(self._eta + 1.0)
        d = v.pow(mut_str) - 1.0
        delta_q[mask] = d[mask]

        v = 2.0 * (1.0 - r) + 2.0 * (r - 0.5) * (1.0 - delta_2).pow(self._eta + 1.0)
        d = 1.0 - v.pow(mut_str)
        delta_q[mask_not] = d[mask_not]

        mutated = selected + delta_q * (ub - lb)

        # Put the mutated decision values into the decision variables tensor stored within the `result` batch.
        data[to_mutate] = mutated

        # Prevent violations that could happen because of numerical errors.
        data[:] = self._respect_bounds(data)

        # Return the `result` batch.
        return result
__init__(self, problem, *, eta=None, mutation_probability=None) special

__init__(...): Initialize the PolynomialMutation.

Parameters:

Name Type Description Default
problem Problem

The problem object to work with.

required
eta Optional[float]

The index for polynomial mutation; a large value gives a higher probability for creating near-parent solutions, whereas a small value allows distant solutions to be created. If not specified, eta will be assumed as 20.0.

None
mutation_probability Optional[float]

The probability of mutation, for each decision variable. If not specified, all variables will be mutated.

None
Source code in evotorch/operators/real.py
def __init__(
    self,
    problem: Problem,
    *,
    eta: Optional[float] = None,
    mutation_probability: Optional[float] = None,
):
    """
    `__init__(...)`: Initialize the PolynomialMutation.

    Args:
        problem: The problem object to work with.
        eta: The index for polynomial mutation; a large value gives a higher
            probability for creating near-parent solutions, whereas a small
            value allows distant solutions to be created.
            If not specified, `eta` will be assumed as 20.0.
        mutation_probability: The probability of mutation, for each decision
            variable. If not specified, all variables will be mutated.
    """

    super().__init__(problem)

    if "float" not in str(problem.dtype):
        raise ValueError(
            f"This operator can be used only when `dtype` of the problem is float type"
            f" (like, e.g. torch.float32, torch.float64, etc.)"
            f" The dtype of the problem is {problem.dtype}."
        )

    if (self.problem.lower_bounds is None) or (self.problem.upper_bounds is None):
        raise ValueError(
            "The polynomial mutation operator can be used only when the problem object has"
            " `lower_bounds` and `upper_bounds`."
            " In the given problem object, at least one of them appears to be missing."
        )

    if torch.any(self.problem.lower_bounds > self.problem.upper_bounds):
        raise ValueError("Some of the `lower_bounds` appear greater than their `upper_bounds`")

    self._prob = None if mutation_probability is None else float(mutation_probability)
    self._eta = 20.0 if eta is None else float(eta)
    self._lb = self.problem.lower_bounds
    self._ub = self.problem.upper_bounds

SimulatedBinaryCrossOver (CrossOver)

Representation of a simulated binary cross-over (SBX).

When this operator is applied on a SolutionBatch, a tournament selection technique is used for selecting parent solutions from the batch, and then those parent solutions are mated via SBX. The generated children solutions are given in a new SolutionBatch. The original SolutionBatch stays unmodified.

Reference:

Kalyanmoy Deb, Hans-Georg Beyer (2001).
Self-Adaptive Genetic Algorithms with Simulated Binary Crossover.
Source code in evotorch/operators/real.py
class SimulatedBinaryCrossOver(CrossOver):
    """
    Representation of a simulated binary cross-over (SBX).

    When this operator is applied on a SolutionBatch,
    a tournament selection technique is used for selecting
    parent solutions from the batch, and then those parent
    solutions are mated via SBX. The generated children
    solutions are given in a new SolutionBatch.
    The original SolutionBatch stays unmodified.

    Reference:

        Kalyanmoy Deb, Hans-Georg Beyer (2001).
        Self-Adaptive Genetic Algorithms with Simulated Binary Crossover.
    """

    def __init__(
        self,
        problem: Problem,
        *,
        tournament_size: int,
        eta: float,
        obj_index: Optional[int] = None,
        num_children: Optional[int] = None,
        cross_over_rate: Optional[float] = None,
    ):
        """
        `__init__(...)`: Initialize the SimulatedBinaryCrossOver.

        Args:
            problem: Problem object to work with.
            tournament_size: What is the size (or length) of a tournament
                when selecting a parent candidate from a population.
            eta: The crowding index, expected as a float.
                Bigger eta values result in children closer
                to their parents.
            obj_index: Objective index according to which the selection
                will be done.
            num_children: Optionally a number of children to produce by the
                cross-over operation.
                Not to be used together with `cross_over_rate`.
                If `num_children` and `cross_over_rate` are both None,
                then the number of children is equal to the number
                of solutions received.
            cross_over_rate: Optionally expected as a real number between
                0.0 and 1.0. Specifies the number of cross-over operations
                to perform. 1.0 means `1.0 * len(solution_batch)` amount of
                cross overs will be performed, resulting in
                `2.0 * len(solution_batch)` amount of children.
                Not to be used together with `num_children`.
                If `num_children` and `cross_over_rate` are both None,
                then the number of children is equal to the number
                of solutions received.
        """

        super().__init__(
            problem,
            tournament_size=int(tournament_size),
            obj_index=obj_index,
            num_children=num_children,
            cross_over_rate=cross_over_rate,
        )
        self._eta = float(eta)

    def _do_cross_over(self, parents1: torch.Tensor, parents2: torch.Tensor) -> SolutionBatch:
        # Generate u_i values which determine the spread
        u = self.problem.make_uniform_shaped_like(parents1)

        # Compute beta_i values from u_i values as the actual spread per dimension
        betas = (2 * u).pow(1.0 / (self._eta + 1.0))  # Compute all values for u_i < 0.5 first
        betas[u > 0.5] = (1.0 / (2 * (1.0 - u[u > 0.5]))).pow(
            1.0 / (self._eta + 1.0)
        )  # Replace the values for u_i >= 0.5
        children1 = 0.5 * (
            (1 + betas) * parents1 + (1 - betas) * parents2
        )  # Create the first set of children from the beta values
        children2 = 0.5 * (
            (1 + betas) * parents2 + (1 - betas) * parents1
        )  # Create the second set of children as a mirror of the first set of children

        # Combine the children tensors in one big tensor
        children = torch.cat([children1, children2], dim=0)

        # Respect the lower and upper bounds defined by the problem object
        children = self._respect_bounds(children)

        # Write the children solutions into a new SolutionBatch, and return the new batch
        result = self._make_children_batch(children)

        return result
__init__(self, problem, *, tournament_size, eta, obj_index=None, num_children=None, cross_over_rate=None) special

__init__(...): Initialize the SimulatedBinaryCrossOver.

Parameters:

Name Type Description Default
problem Problem

Problem object to work with.

required
tournament_size int

What is the size (or length) of a tournament when selecting a parent candidate from a population.

required
eta float

The crowding index, expected as a float. Bigger eta values result in children closer to their parents.

required
obj_index Optional[int]

Objective index according to which the selection will be done.

None
num_children Optional[int]

Optionally a number of children to produce by the cross-over operation. Not to be used together with cross_over_rate. If num_children and cross_over_rate are both None, then the number of children is equal to the number of solutions received.

None
cross_over_rate Optional[float]

Optionally expected as a real number between 0.0 and 1.0. Specifies the number of cross-over operations to perform. 1.0 means 1.0 * len(solution_batch) amount of cross overs will be performed, resulting in 2.0 * len(solution_batch) amount of children. Not to be used together with num_children. If num_children and cross_over_rate are both None, then the number of children is equal to the number of solutions received.

None
Source code in evotorch/operators/real.py
def __init__(
    self,
    problem: Problem,
    *,
    tournament_size: int,
    eta: float,
    obj_index: Optional[int] = None,
    num_children: Optional[int] = None,
    cross_over_rate: Optional[float] = None,
):
    """
    `__init__(...)`: Initialize the SimulatedBinaryCrossOver.

    Args:
        problem: Problem object to work with.
        tournament_size: What is the size (or length) of a tournament
            when selecting a parent candidate from a population.
        eta: The crowding index, expected as a float.
            Bigger eta values result in children closer
            to their parents.
        obj_index: Objective index according to which the selection
            will be done.
        num_children: Optionally a number of children to produce by the
            cross-over operation.
            Not to be used together with `cross_over_rate`.
            If `num_children` and `cross_over_rate` are both None,
            then the number of children is equal to the number
            of solutions received.
        cross_over_rate: Optionally expected as a real number between
            0.0 and 1.0. Specifies the number of cross-over operations
            to perform. 1.0 means `1.0 * len(solution_batch)` amount of
            cross overs will be performed, resulting in
            `2.0 * len(solution_batch)` amount of children.
            Not to be used together with `num_children`.
            If `num_children` and `cross_over_rate` are both None,
            then the number of children is equal to the number
            of solutions received.
    """

    super().__init__(
        problem,
        tournament_size=int(tournament_size),
        obj_index=obj_index,
        num_children=num_children,
        cross_over_rate=cross_over_rate,
    )
    self._eta = float(eta)

TwoPointCrossOver (MultiPointCrossOver)

Representation of a two-point cross-over operator.

When this operator is applied on a SolutionBatch, a tournament selection technique is used for selecting parent solutions from the batch, and then those parent solutions are mated via cutting from a random position and recombining. The result of these recombination operations is a new SolutionBatch, containing the children solutions. The original SolutionBatch stays unmodified.

Let us assume that the two of the parent solutions that were selected for the cross-over operation are as follows:

a: [ a0 , a1 , a2 , a3 , a4 , a5 ]
b: [ b0 , b1 , b2 , b3 , b4 , b5 ]

For recombining parents a and b, two cutting points are first randomly selected. In the case of this example, let us assume that the cutting point were chosen as the point between the items with indices 1 and 2, and between 3 and 4:

a: [ a0 , a1 | a2 , a3 | a4 , a5 ]
b: [ b0 , b1 | b2 , b3 | b4 , b5 ]
             |         |
             ^         ^
           First     Second
          cutting    cutting
           point     point

Given these two cutting points, the two children c and d will be constructed from a and b like this:

c: [ a0 , a1 | b2 , b3 | a4 , a5 ]
d: [ b0 , b1 | a2 , a3 | b4 , b5 ]

Note that the recombination procedure explained above is be done on all of the parents chosen from the given SolutionBatch, in a vectorized manner. For each chosen pair of parents, the cutting points will be sampled differently.

Source code in evotorch/operators/real.py
class TwoPointCrossOver(MultiPointCrossOver):
    """
    Representation of a two-point cross-over operator.

    When this operator is applied on a SolutionBatch, a tournament selection
    technique is used for selecting parent solutions from the batch, and then
    those parent solutions are mated via cutting from a random position and
    recombining. The result of these recombination operations is a new
    SolutionBatch, containing the children solutions. The original
    SolutionBatch stays unmodified.

    Let us assume that the two of the parent solutions that were selected for
    the cross-over operation are as follows:

    ```
    a: [ a0 , a1 , a2 , a3 , a4 , a5 ]
    b: [ b0 , b1 , b2 , b3 , b4 , b5 ]
    ```

    For recombining parents `a` and `b`, two cutting points are first randomly
    selected. In the case of this example, let us assume that the cutting
    point were chosen as the point between the items with indices 1 and 2,
    and between 3 and 4:

    ```
    a: [ a0 , a1 | a2 , a3 | a4 , a5 ]
    b: [ b0 , b1 | b2 , b3 | b4 , b5 ]
                 |         |
                 ^         ^
               First     Second
              cutting    cutting
               point     point
    ```

    Given these two cutting points, the two children `c` and `d` will be
    constructed from `a` and `b` like this:

    ```
    c: [ a0 , a1 | b2 , b3 | a4 , a5 ]
    d: [ b0 , b1 | a2 , a3 | b4 , b5 ]
    ```

    Note that the recombination procedure explained above is be done on all
    of the parents chosen from the given SolutionBatch, in a vectorized manner.
    For each chosen pair of parents, the cutting points will be sampled
    differently.
    """

    def __init__(
        self,
        problem: Problem,
        *,
        tournament_size: int,
        obj_index: Optional[int] = None,
        num_children: Optional[int] = None,
        cross_over_rate: Optional[float] = None,
    ):
        """
        `__init__(...)`: Initialize the TwoPointCrossOver.

        Args:
            problem: The problem object to work on.
            tournament_size: What is the size (or length) of a tournament
                when selecting a parent candidate from a population
            obj_index: Objective index according to which the selection
                will be done.
            num_children: Optionally a number of children to produce by the
                cross-over operation.
                Not to be used together with `cross_over_rate`.
                If `num_children` and `cross_over_rate` are both None,
                then the number of children is equal to the number
                of solutions received.
            cross_over_rate: Optionally expected as a real number between
                0.0 and 1.0. Specifies the number of cross-over operations
                to perform. 1.0 means `1.0 * len(solution_batch)` amount of
                cross overs will be performed, resulting in
                `2.0 * len(solution_batch)` amount of children.
                Not to be used together with `num_children`.
                If `num_children` and `cross_over_rate` are both None,
                then the number of children is equal to the number
                of solutions received.
        """
        super().__init__(
            problem,
            tournament_size=tournament_size,
            obj_index=obj_index,
            num_points=2,
            num_children=num_children,
            cross_over_rate=cross_over_rate,
        )
__init__(self, problem, *, tournament_size, obj_index=None, num_children=None, cross_over_rate=None) special

__init__(...): Initialize the TwoPointCrossOver.

Parameters:

Name Type Description Default
problem Problem

The problem object to work on.

required
tournament_size int

What is the size (or length) of a tournament when selecting a parent candidate from a population

required
obj_index Optional[int]

Objective index according to which the selection will be done.

None
num_children Optional[int]

Optionally a number of children to produce by the cross-over operation. Not to be used together with cross_over_rate. If num_children and cross_over_rate are both None, then the number of children is equal to the number of solutions received.

None
cross_over_rate Optional[float]

Optionally expected as a real number between 0.0 and 1.0. Specifies the number of cross-over operations to perform. 1.0 means 1.0 * len(solution_batch) amount of cross overs will be performed, resulting in 2.0 * len(solution_batch) amount of children. Not to be used together with num_children. If num_children and cross_over_rate are both None, then the number of children is equal to the number of solutions received.

None
Source code in evotorch/operators/real.py
def __init__(
    self,
    problem: Problem,
    *,
    tournament_size: int,
    obj_index: Optional[int] = None,
    num_children: Optional[int] = None,
    cross_over_rate: Optional[float] = None,
):
    """
    `__init__(...)`: Initialize the TwoPointCrossOver.

    Args:
        problem: The problem object to work on.
        tournament_size: What is the size (or length) of a tournament
            when selecting a parent candidate from a population
        obj_index: Objective index according to which the selection
            will be done.
        num_children: Optionally a number of children to produce by the
            cross-over operation.
            Not to be used together with `cross_over_rate`.
            If `num_children` and `cross_over_rate` are both None,
            then the number of children is equal to the number
            of solutions received.
        cross_over_rate: Optionally expected as a real number between
            0.0 and 1.0. Specifies the number of cross-over operations
            to perform. 1.0 means `1.0 * len(solution_batch)` amount of
            cross overs will be performed, resulting in
            `2.0 * len(solution_batch)` amount of children.
            Not to be used together with `num_children`.
            If `num_children` and `cross_over_rate` are both None,
            then the number of children is equal to the number
            of solutions received.
    """
    super().__init__(
        problem,
        tournament_size=tournament_size,
        obj_index=obj_index,
        num_points=2,
        num_children=num_children,
        cross_over_rate=cross_over_rate,
    )

sequence

This module contains operators for problems whose solutions contain variable-length sequences (list-like objects).

CutAndSplice (CrossOver)

Cut & Splice operator for variable-length solutions.

This class serves as a cross-over operator to be used on problems with their dtypes set as object, and with their solutions initialized to contain variable-length sequences (list-like objects).

Reference:

David E. Goldberg, Bradley Korb, Kalyanmoy Deb (1989).
Messy Genetic Algorithms: Motivation, Analysis, and First Results.
Complex Systems 3, 493-530.
Source code in evotorch/operators/sequence.py
class CutAndSplice(CrossOver):
    """Cut & Splice operator for variable-length solutions.

    This class serves as a cross-over operator to be used on problems
    with their `dtype`s set as `object`, and with their solutions
    initialized to contain variable-length sequences (list-like objects).

    Reference:

        David E. Goldberg, Bradley Korb, Kalyanmoy Deb (1989).
        Messy Genetic Algorithms: Motivation, Analysis, and First Results.
        Complex Systems 3, 493-530.
    """

    def _cut_and_splice(
        self,
        parents1: ObjectArray,
        parents2: ObjectArray,
        children1: SolutionBatch,
        children2: SolutionBatch,
        row_index: int,
    ):
        parvals1 = parents1[row_index]
        parvals2 = parents2[row_index]

        length1 = len(parvals1)
        length2 = len(parvals2)

        cutpoint1 = int(self.problem.make_randint(tuple(), n=length1))
        cutpoint2 = int(self.problem.make_randint(tuple(), n=length2))

        childvals1 = parvals1[:cutpoint1]
        childvals1.extend(parvals2[cutpoint2:])

        childvals2 = parvals2[:cutpoint2]
        childvals2.extend(parvals1[cutpoint1:])

        children1.access_values(keep_evals=True)[row_index] = childvals1
        children2.access_values(keep_evals=True)[row_index] = childvals2

    def _do_cross_over(self, parents1: ObjectArray, parents2: ObjectArray) -> SolutionBatch:
        n = len(parents1)

        children1 = SolutionBatch(self.problem, popsize=n, empty=True)
        children2 = SolutionBatch(self.problem, popsize=n, empty=True)

        for i in range(n):
            self._cut_and_splice(parents1, parents2, children1, children2, i)

        return children1.concat(children2)

optimizers

Optimizers (like Adam or ClipUp) to be used with distribution-based search algorithms.

Adam (TorchOptimizer)

The Adam optimizer.

Reference:

Kingma, D. P. and J. Ba (2015).
Adam: A method for stochastic optimization.
In Proceedings of 3rd International Conference on Learning Representations.
Source code in evotorch/optimizers.py
class Adam(TorchOptimizer):
    """
    The Adam optimizer.

    Reference:

        Kingma, D. P. and J. Ba (2015).
        Adam: A method for stochastic optimization.
        In Proceedings of 3rd International Conference on Learning Representations.
    """

    def __init__(
        self,
        *,
        solution_length: int,
        dtype: DType,
        device: Device = "cpu",
        stepsize: Optional[float] = None,
        beta1: Optional[float] = None,
        beta2: Optional[float] = None,
        epsilon: Optional[float] = None,
        amsgrad: Optional[bool] = None,
    ):
        """
        `__init__(...)`: Initialize the Adam optimizer.

        Args:
            solution_length: Length of a solution of the problem which is
                being worked on.
            dtype: The dtype of the problem which is being worked on.
            device: The device on which the solutions are kept.
            stepsize: The step size (i.e. the learning rate) employed
                by the optimizer.
            beta1: The beta1 hyperparameter. None means the default.
            beta2: The beta2 hyperparameter. None means the default.
            epsilon: The epsilon hyperparameters. None means the default.
            amsgrad: Whether or not to use the amsgrad behavior.
                None means the default behavior.
                See `torch.optim.Adam` for details.
        """

        config = {}

        if stepsize is not None:
            config["lr"] = float(stepsize)

        if beta1 is None and beta2 is None:
            pass  # nothing to do
        elif beta1 is not None and beta2 is not None:
            config["betas"] = (float(beta1), float(beta2))
        else:
            raise ValueError(
                "The arguments beta1 and beta2 were expected"
                " as both None, or as both real numbers."
                " However, one of them was encountered as None and"
                " the other was encountered as something other than None."
            )

        if epsilon is not None:
            config["eps"] = float(epsilon)

        if amsgrad is not None:
            config["amsgrad"] = bool(amsgrad)

        super().__init__(torch.optim.Adam, solution_length=solution_length, dtype=dtype, device=device, config=config)

__init__(self, *, solution_length, dtype, device='cpu', stepsize=None, beta1=None, beta2=None, epsilon=None, amsgrad=None) special

__init__(...): Initialize the Adam optimizer.

Parameters:

Name Type Description Default
solution_length int

Length of a solution of the problem which is being worked on.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

The dtype of the problem which is being worked on.

required
device Union[str, torch.device]

The device on which the solutions are kept.

'cpu'
stepsize Optional[float]

The step size (i.e. the learning rate) employed by the optimizer.

None
beta1 Optional[float]

The beta1 hyperparameter. None means the default.

None
beta2 Optional[float]

The beta2 hyperparameter. None means the default.

None
epsilon Optional[float]

The epsilon hyperparameters. None means the default.

None
amsgrad Optional[bool]

Whether or not to use the amsgrad behavior. None means the default behavior. See torch.optim.Adam for details.

None
Source code in evotorch/optimizers.py
def __init__(
    self,
    *,
    solution_length: int,
    dtype: DType,
    device: Device = "cpu",
    stepsize: Optional[float] = None,
    beta1: Optional[float] = None,
    beta2: Optional[float] = None,
    epsilon: Optional[float] = None,
    amsgrad: Optional[bool] = None,
):
    """
    `__init__(...)`: Initialize the Adam optimizer.

    Args:
        solution_length: Length of a solution of the problem which is
            being worked on.
        dtype: The dtype of the problem which is being worked on.
        device: The device on which the solutions are kept.
        stepsize: The step size (i.e. the learning rate) employed
            by the optimizer.
        beta1: The beta1 hyperparameter. None means the default.
        beta2: The beta2 hyperparameter. None means the default.
        epsilon: The epsilon hyperparameters. None means the default.
        amsgrad: Whether or not to use the amsgrad behavior.
            None means the default behavior.
            See `torch.optim.Adam` for details.
    """

    config = {}

    if stepsize is not None:
        config["lr"] = float(stepsize)

    if beta1 is None and beta2 is None:
        pass  # nothing to do
    elif beta1 is not None and beta2 is not None:
        config["betas"] = (float(beta1), float(beta2))
    else:
        raise ValueError(
            "The arguments beta1 and beta2 were expected"
            " as both None, or as both real numbers."
            " However, one of them was encountered as None and"
            " the other was encountered as something other than None."
        )

    if epsilon is not None:
        config["eps"] = float(epsilon)

    if amsgrad is not None:
        config["amsgrad"] = bool(amsgrad)

    super().__init__(torch.optim.Adam, solution_length=solution_length, dtype=dtype, device=device, config=config)

ClipUp

The ClipUp optimizer.

Reference:

Toklu, N. E., Liskowski, P., & Srivastava, R. K. (2020, September).
ClipUp: A Simple and Powerful Optimizer for Distribution-Based Policy Evolution.
In International Conference on Parallel Problem Solving from Nature (pp. 515-527).
Springer, Cham.
Source code in evotorch/optimizers.py
class ClipUp:
    """
    The ClipUp optimizer.

    Reference:

        Toklu, N. E., Liskowski, P., & Srivastava, R. K. (2020, September).
        ClipUp: A Simple and Powerful Optimizer for Distribution-Based Policy Evolution.
        In International Conference on Parallel Problem Solving from Nature (pp. 515-527).
        Springer, Cham.
    """

    _param_group_items = {"lr": "_stepsize", "max_speed": "_max_speed", "momentum": "_momentum"}
    _param_group_item_lb = {"lr": 0.0, "max_speed": 0.0, "momentum": 0.0}
    _param_group_item_ub = {"momentum": 1.0}

    def __init__(
        self,
        *,
        solution_length: int,
        dtype: DType,
        stepsize: float,
        momentum: float = 0.9,
        max_speed: Optional[float] = None,
        device: Device = "cpu",
    ):
        """
        `__init__(...)`: Initialize the ClipUp optimizer.

        Args:
            solution_length: Length of a solution of the problem which is
                being worked on.
            dtype: The dtype of the problem which is being worked on.
            stepsize: The step size (i.e. the learning rate) employed
                by the optimizer.
            momentum: The momentum coefficient. None means the default.
            max_speed: The maximum speed. If given as None, the
                `max_speed` will be taken as two times the stepsize.
            device: The device on which the solutions are kept.
        """

        stepsize = float(stepsize)
        momentum = float(momentum)
        if max_speed is None:
            max_speed = stepsize * 2.0
            _evolog.info(
                message_from(
                    self,
                    (
                        f"The maximum speed for the ClipUp optimizer is set as {max_speed}"
                        f" which is two times the given step size."
                    ),
                )
            )
        else:
            max_speed = float(max_speed)
        solution_length = int(solution_length)

        if stepsize < 0.0:
            raise ValueError(f"Invalid stepsize: {stepsize}")
        if momentum < 0.0 or momentum > 1.0:
            raise ValueError(f"Invalid momentum: {momentum}")
        if max_speed < 0.0:
            raise ValueError(f"Invalid max_speed: {max_speed}")

        self._stepsize = stepsize
        self._momentum = momentum
        self._max_speed = max_speed

        self._param_groups = (ClipUpParameterGroup(self),)

        self._velocity: Optional[torch.Tensor] = torch.zeros(
            solution_length, dtype=to_torch_dtype(dtype), device=device
        )

        self._dtype = to_torch_dtype(dtype)
        self._device = device

    @staticmethod
    def _clip(x: torch.Tensor, limit: float) -> torch.Tensor:
        with torch.no_grad():
            normx = torch.norm(x)
            if normx > limit:
                ratio = limit / normx
                return x * ratio
            else:
                return x

    @torch.no_grad()
    def ascent(self, globalg: RealOrVector, *, cloned_result: bool = True) -> torch.Tensor:
        """
        Compute the ascent, i.e. the step to follow.

        Args:
            globalg: The estimated gradient.
            cloned_result: If `cloned_result` is True, then the result is a
                copy, guaranteed not to be the view of any other tensor
                internal to the TorchOptimizer class.
                If `cloned_result` is False, then the result is not a copy.
                Use `cloned_result=False` only when you are sure that your
                algorithm will never do direct modification on the ascent
                vector it receives.
                Important: if you set `cloned_result=False`, and do in-place
                modifications on the returned result of `ascent(...)`, then
                the internal velocity of ClipUp will be corrupted!
        Returns:
            The ascent vector, representing the step to follow.
        """

        globalg = ensure_tensor_length_and_dtype(
            globalg,
            len(self._velocity),
            dtype=self._dtype,
            device=self._device,
            about=f"{type(self).__name__}.ascent",
        )

        grad = (globalg / torch.norm(globalg)) * self._stepsize

        self._velocity = self._clip((self._momentum * self._velocity) + grad, self._max_speed)

        result = self._velocity

        if cloned_result:
            result = result.clone()

        return result

    @property
    def contained_optimizer(self) -> "ClipUp":
        """
        Get this `ClipUp` instance itself.
        """
        return self

    @property
    def param_groups(self) -> tuple:
        """
        Returns a single-element tuple representing a parameter group.

        The tuple contains a dictionary-like object in which the keys are the
        hyperparameter names, and the values are the values of those
        hyperparameters. The hyperparameter names are `lr` (the step size, or
        the learning rate), `max_speed` (the maximum speed), and `momentum`
        (the momentum coefficient). The values of these hyperparameters can be
        read and also be written if one wishes to adjust the behavior of ClipUp
        during the optimization.
        """
        return self._param_groups

contained_optimizer: ClipUp property readonly

Get this ClipUp instance itself.

param_groups: tuple property readonly

Returns a single-element tuple representing a parameter group.

The tuple contains a dictionary-like object in which the keys are the hyperparameter names, and the values are the values of those hyperparameters. The hyperparameter names are lr (the step size, or the learning rate), max_speed (the maximum speed), and momentum (the momentum coefficient). The values of these hyperparameters can be read and also be written if one wishes to adjust the behavior of ClipUp during the optimization.

__init__(self, *, solution_length, dtype, stepsize, momentum=0.9, max_speed=None, device='cpu') special

__init__(...): Initialize the ClipUp optimizer.

Parameters:

Name Type Description Default
solution_length int

Length of a solution of the problem which is being worked on.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

The dtype of the problem which is being worked on.

required
stepsize float

The step size (i.e. the learning rate) employed by the optimizer.

required
momentum float

The momentum coefficient. None means the default.

0.9
max_speed Optional[float]

The maximum speed. If given as None, the max_speed will be taken as two times the stepsize.

None
device Union[str, torch.device]

The device on which the solutions are kept.

'cpu'
Source code in evotorch/optimizers.py
def __init__(
    self,
    *,
    solution_length: int,
    dtype: DType,
    stepsize: float,
    momentum: float = 0.9,
    max_speed: Optional[float] = None,
    device: Device = "cpu",
):
    """
    `__init__(...)`: Initialize the ClipUp optimizer.

    Args:
        solution_length: Length of a solution of the problem which is
            being worked on.
        dtype: The dtype of the problem which is being worked on.
        stepsize: The step size (i.e. the learning rate) employed
            by the optimizer.
        momentum: The momentum coefficient. None means the default.
        max_speed: The maximum speed. If given as None, the
            `max_speed` will be taken as two times the stepsize.
        device: The device on which the solutions are kept.
    """

    stepsize = float(stepsize)
    momentum = float(momentum)
    if max_speed is None:
        max_speed = stepsize * 2.0
        _evolog.info(
            message_from(
                self,
                (
                    f"The maximum speed for the ClipUp optimizer is set as {max_speed}"
                    f" which is two times the given step size."
                ),
            )
        )
    else:
        max_speed = float(max_speed)
    solution_length = int(solution_length)

    if stepsize < 0.0:
        raise ValueError(f"Invalid stepsize: {stepsize}")
    if momentum < 0.0 or momentum > 1.0:
        raise ValueError(f"Invalid momentum: {momentum}")
    if max_speed < 0.0:
        raise ValueError(f"Invalid max_speed: {max_speed}")

    self._stepsize = stepsize
    self._momentum = momentum
    self._max_speed = max_speed

    self._param_groups = (ClipUpParameterGroup(self),)

    self._velocity: Optional[torch.Tensor] = torch.zeros(
        solution_length, dtype=to_torch_dtype(dtype), device=device
    )

    self._dtype = to_torch_dtype(dtype)
    self._device = device

ascent(self, globalg, *, cloned_result=True)

Compute the ascent, i.e. the step to follow.

Parameters:

Name Type Description Default
globalg Union[float, Iterable[float], torch.Tensor]

The estimated gradient.

required
cloned_result bool

If cloned_result is True, then the result is a copy, guaranteed not to be the view of any other tensor internal to the TorchOptimizer class. If cloned_result is False, then the result is not a copy. Use cloned_result=False only when you are sure that your algorithm will never do direct modification on the ascent vector it receives. Important: if you set cloned_result=False, and do in-place modifications on the returned result of ascent(...), then the internal velocity of ClipUp will be corrupted!

True

Returns:

Type Description
Tensor

The ascent vector, representing the step to follow.

Source code in evotorch/optimizers.py
@torch.no_grad()
def ascent(self, globalg: RealOrVector, *, cloned_result: bool = True) -> torch.Tensor:
    """
    Compute the ascent, i.e. the step to follow.

    Args:
        globalg: The estimated gradient.
        cloned_result: If `cloned_result` is True, then the result is a
            copy, guaranteed not to be the view of any other tensor
            internal to the TorchOptimizer class.
            If `cloned_result` is False, then the result is not a copy.
            Use `cloned_result=False` only when you are sure that your
            algorithm will never do direct modification on the ascent
            vector it receives.
            Important: if you set `cloned_result=False`, and do in-place
            modifications on the returned result of `ascent(...)`, then
            the internal velocity of ClipUp will be corrupted!
    Returns:
        The ascent vector, representing the step to follow.
    """

    globalg = ensure_tensor_length_and_dtype(
        globalg,
        len(self._velocity),
        dtype=self._dtype,
        device=self._device,
        about=f"{type(self).__name__}.ascent",
    )

    grad = (globalg / torch.norm(globalg)) * self._stepsize

    self._velocity = self._clip((self._momentum * self._velocity) + grad, self._max_speed)

    result = self._velocity

    if cloned_result:
        result = result.clone()

    return result

ClipUpParameterGroup (Mapping)

A dictionary-like object storing the hyperparameters of a ClipUp instance.

The values of the hyperparameters within this container can be read and can also be written if one wishes to adjust the behavior of ClipUp during the optimization.

Source code in evotorch/optimizers.py
class ClipUpParameterGroup(Mapping):
    """
    A dictionary-like object storing the hyperparameters of a ClipUp instance.

    The values of the hyperparameters within this container can be read and
    can also be written if one wishes to adjust the behavior of ClipUp during
    the optimization.
    """

    def __init__(self, clipup: "ClipUp"):
        self.clipup = clipup

    def __getitem__(self, key: str) -> float:
        attrname = ClipUp._param_group_items[key]
        return getattr(self.clipup, attrname)

    def __setitem__(self, key: str, value: float):
        attrname = ClipUp._param_group_items[key]
        value = float(value)
        if attrname in ClipUp._param_group_item_lb:
            lb = ClipUp._param_group_item_lb[key]
            if value < lb:
                raise ValueError(f"Invalid value for {repr(key)}: {value}")
        if attrname in ClipUp._param_group_item_ub:
            ub = ClipUp._param_group_item_ub[key]
            if value > ub:
                raise ValueError(f"Invalid value for {repr(key)}: {value}")
        setattr(self.clipup, attrname, value)

    def __iter__(self):
        return ClipUp._param_group_items.__iter__()

    def __len__(self) -> int:
        return len(ClipUp._param_group_items)

    def __repr__(self) -> str:
        return f"<{type(self).__name__}: {dict(self)}>"

SGD (TorchOptimizer)

The SGD optimizer.

Reference regarding the momentum behavior:

Polyak, B. T. (1964).
Some methods of speeding up the convergence of iteration methods.
USSR Computational Mathematics and Mathematical Physics, 4(5):1–17.

Reference regarding the Nesterov behavior:

Yurii Nesterov (1983).
A method for unconstrained convex minimization problem with the rate ofconvergence o(1/k2).
Doklady ANSSSR (translated as Soviet.Math.Docl.), 269:543–547.
Source code in evotorch/optimizers.py
class SGD(TorchOptimizer):
    """
    The SGD optimizer.

    Reference regarding the momentum behavior:

        Polyak, B. T. (1964).
        Some methods of speeding up the convergence of iteration methods.
        USSR Computational Mathematics and Mathematical Physics, 4(5):1–17.

    Reference regarding the Nesterov behavior:

        Yurii Nesterov (1983).
        A method for unconstrained convex minimization problem with the rate ofconvergence o(1/k2).
        Doklady ANSSSR (translated as Soviet.Math.Docl.), 269:543–547.
    """

    def __init__(
        self,
        *,
        solution_length: int,
        dtype: DType,
        stepsize: float,
        device: Device = "cpu",
        momentum: Optional[float] = None,
        dampening: Optional[bool] = None,
        nesterov: Optional[bool] = None,
    ):
        """
        `__init__(...)`: Initialize the SGD optimizer.

        Args:
            solution_length: Length of a solution of the problem which is
                being worked on.
            dtype: The dtype of the problem which is being worked on.
            stepsize: The step size (i.e. the learning rate) employed
                by the optimizer.
            device: The device on which the solutions are kept.
            momentum: The momentum coefficient. None means the default.
            dampening: Whether or not to activate the dampening behavior.
                None means the default.
                See `torch.optim.SGD` for details.
            nesterov: Whether or not to activate the nesterov behavior.
                None means the default.
                See `torch.optim.SGD` for details.
        """

        config = {}

        config["lr"] = float(stepsize)

        if momentum is not None:
            config["momentum"] = float(momentum)

        if dampening is not None:
            config["dampening"] = float(dampening)

        if nesterov is not None:
            config["nesterov"] = bool(nesterov)

        super().__init__(torch.optim.SGD, solution_length=solution_length, dtype=dtype, device=device, config=config)

__init__(self, *, solution_length, dtype, stepsize, device='cpu', momentum=None, dampening=None, nesterov=None) special

__init__(...): Initialize the SGD optimizer.

Parameters:

Name Type Description Default
solution_length int

Length of a solution of the problem which is being worked on.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

The dtype of the problem which is being worked on.

required
stepsize float

The step size (i.e. the learning rate) employed by the optimizer.

required
device Union[str, torch.device]

The device on which the solutions are kept.

'cpu'
momentum Optional[float]

The momentum coefficient. None means the default.

None
dampening Optional[bool]

Whether or not to activate the dampening behavior. None means the default. See torch.optim.SGD for details.

None
nesterov Optional[bool]

Whether or not to activate the nesterov behavior. None means the default. See torch.optim.SGD for details.

None
Source code in evotorch/optimizers.py
def __init__(
    self,
    *,
    solution_length: int,
    dtype: DType,
    stepsize: float,
    device: Device = "cpu",
    momentum: Optional[float] = None,
    dampening: Optional[bool] = None,
    nesterov: Optional[bool] = None,
):
    """
    `__init__(...)`: Initialize the SGD optimizer.

    Args:
        solution_length: Length of a solution of the problem which is
            being worked on.
        dtype: The dtype of the problem which is being worked on.
        stepsize: The step size (i.e. the learning rate) employed
            by the optimizer.
        device: The device on which the solutions are kept.
        momentum: The momentum coefficient. None means the default.
        dampening: Whether or not to activate the dampening behavior.
            None means the default.
            See `torch.optim.SGD` for details.
        nesterov: Whether or not to activate the nesterov behavior.
            None means the default.
            See `torch.optim.SGD` for details.
    """

    config = {}

    config["lr"] = float(stepsize)

    if momentum is not None:
        config["momentum"] = float(momentum)

    if dampening is not None:
        config["dampening"] = float(dampening)

    if nesterov is not None:
        config["nesterov"] = bool(nesterov)

    super().__init__(torch.optim.SGD, solution_length=solution_length, dtype=dtype, device=device, config=config)

TorchOptimizer

Base class for using a PyTorch optimizer

Source code in evotorch/optimizers.py
class TorchOptimizer:
    """
    Base class for using a PyTorch optimizer
    """

    def __init__(
        self,
        torch_optimizer: Type,
        *,
        config: dict,
        solution_length: int,
        dtype: DType,
        device: Device = "cpu",
    ):
        """
        `__init__(...)`: Initialize the TorchOptimizer.

        Args:
            torch_optimizer: The class which represents a PyTorch optimizer.
            config: The configuration dictionary to be passed to the optimizer
                as keyword arguments.
            solution_length: Length of a solution of the problem on which the
                optimizer will work.
            dtype: The dtype of the problem.
            device: The device on which the solutions are kept.
        """
        self._data = torch.empty(int(solution_length), dtype=to_torch_dtype(dtype), device=device)
        self._optim = torch_optimizer([self._data], **config)

    @torch.no_grad()
    def ascent(self, globalg: RealOrVector, *, cloned_result: bool = True) -> torch.Tensor:
        """
        Compute the ascent, i.e. the step to follow.

        Args:
            globalg: The estimated gradient.
            cloned_result: If `cloned_result` is True, then the result is a
                copy, guaranteed not to be the view of any other tensor
                internal to the TorchOptimizer class.
                If `cloned_result` is False, then the result is not a copy.
                Use `cloned_result=False` only when you are sure that your
                algorithm will never do direct modification on the ascent
                vector it receives.
        Returns:
            The ascent vector, representing the step to follow.
        """

        globalg = ensure_tensor_length_and_dtype(
            globalg,
            len(self._data),
            dtype=self._data.dtype,
            device=self._data.device,
            about=f"{type(self).__name__}.ascent",
        )

        self._data.zero_()
        self._data.grad = globalg
        self._optim.step()
        result = -1.0 * self._data

        return result

    @property
    def contained_optimizer(self) -> torch.optim.Optimizer:
        """
        Get the PyTorch optimizer contained by this wrapper
        """
        return self._optim

contained_optimizer: Optimizer property readonly

Get the PyTorch optimizer contained by this wrapper

__init__(self, torch_optimizer, *, config, solution_length, dtype, device='cpu') special

__init__(...): Initialize the TorchOptimizer.

Parameters:

Name Type Description Default
torch_optimizer Type

The class which represents a PyTorch optimizer.

required
config dict

The configuration dictionary to be passed to the optimizer as keyword arguments.

required
solution_length int

Length of a solution of the problem on which the optimizer will work.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

The dtype of the problem.

required
device Union[str, torch.device]

The device on which the solutions are kept.

'cpu'
Source code in evotorch/optimizers.py
def __init__(
    self,
    torch_optimizer: Type,
    *,
    config: dict,
    solution_length: int,
    dtype: DType,
    device: Device = "cpu",
):
    """
    `__init__(...)`: Initialize the TorchOptimizer.

    Args:
        torch_optimizer: The class which represents a PyTorch optimizer.
        config: The configuration dictionary to be passed to the optimizer
            as keyword arguments.
        solution_length: Length of a solution of the problem on which the
            optimizer will work.
        dtype: The dtype of the problem.
        device: The device on which the solutions are kept.
    """
    self._data = torch.empty(int(solution_length), dtype=to_torch_dtype(dtype), device=device)
    self._optim = torch_optimizer([self._data], **config)

ascent(self, globalg, *, cloned_result=True)

Compute the ascent, i.e. the step to follow.

Parameters:

Name Type Description Default
globalg Union[float, Iterable[float], torch.Tensor]

The estimated gradient.

required
cloned_result bool

If cloned_result is True, then the result is a copy, guaranteed not to be the view of any other tensor internal to the TorchOptimizer class. If cloned_result is False, then the result is not a copy. Use cloned_result=False only when you are sure that your algorithm will never do direct modification on the ascent vector it receives.

True

Returns:

Type Description
Tensor

The ascent vector, representing the step to follow.

Source code in evotorch/optimizers.py
@torch.no_grad()
def ascent(self, globalg: RealOrVector, *, cloned_result: bool = True) -> torch.Tensor:
    """
    Compute the ascent, i.e. the step to follow.

    Args:
        globalg: The estimated gradient.
        cloned_result: If `cloned_result` is True, then the result is a
            copy, guaranteed not to be the view of any other tensor
            internal to the TorchOptimizer class.
            If `cloned_result` is False, then the result is not a copy.
            Use `cloned_result=False` only when you are sure that your
            algorithm will never do direct modification on the ascent
            vector it receives.
    Returns:
        The ascent vector, representing the step to follow.
    """

    globalg = ensure_tensor_length_and_dtype(
        globalg,
        len(self._data),
        dtype=self._data.dtype,
        device=self._data.device,
        about=f"{type(self).__name__}.ascent",
    )

    self._data.zero_()
    self._data.grad = globalg
    self._optim.step()
    result = -1.0 * self._data

    return result

get_optimizer_class(s, optimizer_config=None)

Get the optimizer class from the given string.

Parameters:

Name Type Description Default
s str

A string, referring to the optimizer class. "clipsgd", "clipsga", "clipup" refers to ClipUp. "adam" refers to Adam. "sgd" or "sga" refers to SGD.

required
optimizer_config Optional[dict]

A dictionary containing the configurations to be passed to the optimizer. If this argument is not None, then, instead of the class being referred to, a dynamically generated factory function will be returned, which will pass these configurations to the actual class upon being called.

None

Returns:

Type Description
Callable

The class, or a factory function instantiating that class.

Source code in evotorch/optimizers.py
def get_optimizer_class(s: str, optimizer_config: Optional[dict] = None) -> Callable:
    """
    Get the optimizer class from the given string.

    Args:
        s: A string, referring to the optimizer class.
            "clipsgd", "clipsga", "clipup" refers to ClipUp.
            "adam" refers to Adam.
            "sgd" or "sga" refers to SGD.
        optimizer_config: A dictionary containing the configurations to be
            passed to the optimizer. If this argument is not None,
            then, instead of the class being referred to, a dynamically
            generated factory function will be returned, which will pass
            these configurations to the actual class upon being called.
    Returns:
        The class, or a factory function instantiating that class.
    """
    if s in ("clipsgd", "clipsga", "clipup"):
        cls = ClipUp
    elif s == "adam":
        cls = Adam
    elif s in ("sgd", "sga"):
        cls = SGD
    else:
        raise ValueError(f"Unknown optimizer: {repr(s)}")

    if optimizer_config is None:
        return cls
    else:

        def f(*args, **kwargs):
            nonlocal cls, optimizer_config
            conf = {}
            conf.update(optimizer_config)
            conf.update(kwargs)
            return cls(*args, **conf)

        return f

testing

Utility functions for evotorch-related unit testing.

TestingError (Exception)

An Exception type to be raised when a unit testing function encounters illegal arguments.

Source code in evotorch/testing.py
class TestingError(Exception):
    """
    An Exception type to be raised when a unit testing function
    encounters illegal arguments.
    """

    pass

assert_allclose(actual, desired, *, rtol=None, atol=None, equal_nan=True)

This function is similar to numpy.testing.assert_allclose(...) except that atol and rtol are keyword-only arguments (which encourages one to be more explicit when writing tests) and that the default dtype is "float32" when the provided arguments are neither numpy arrays nor torch tensors. Having "float32" as the default target dtype is a behavior that is compatible with PyTorch.

This function first casts actual into the dtype of desired, then uses numpy's assert_allclose(...) for testing the closeness of the values.

Parameters:

Name Type Description Default
actual Iterable

An iterable of numbers.

required
desired Iterable

An iterable of numbers. These numbers represent the values that we expect the actual to contain. If the numbers contained by actual are significantly different than desired, the assertion will fail.

required
rtol Optional[float]

Relative tolerance. Can be left as None if only atol is to be used. See the documentation of numpy.testing.assert_allclose(...) for details about how rtol affects the tolerance.

None
atol Optional[float]

Absolute tolerance. Can be left as None if only rtol is to be used. See the documentation of numpy.testing.assert_allclose(...) for details about how atol affects the tolerance.

None
equal_nan bool

If True, nan values will be counted as equal.

True

Exceptions:

Type Description
AssertionError

if the numerical difference between actual and desired are beyond the tolerance expressed by atol and rtol.

TestingError

If both rtol and atol are given as None.

Source code in evotorch/testing.py
def assert_allclose(
    actual: Iterable,
    desired: Iterable,
    *,
    rtol: Optional[float] = None,
    atol: Optional[float] = None,
    equal_nan: bool = True,
):
    """
    This function is similar to `numpy.testing.assert_allclose(...)` except
    that `atol` and `rtol` are keyword-only arguments (which encourages
    one to be more explicit when writing tests) and that the default dtype
    is "float32" when the provided arguments are neither numpy arrays nor
    torch tensors. Having "float32" as the default target dtype is a behavior
    that is compatible with PyTorch.

    This function first casts `actual` into the dtype of `desired`, then
    uses numpy's `assert_allclose(...)` for testing the closeness of the
    values.

    Args:
        actual: An iterable of numbers.
        desired: An iterable of numbers. These numbers represent the values
            that we expect the `actual` to contain. If the numbers contained
            by `actual` are significantly different than `desired`, the
            assertion will fail.
        rtol: Relative tolerance.
            Can be left as None if only `atol` is to be used.
            See the documentation of `numpy.testing.assert_allclose(...)`
            for details about how `rtol` affects the tolerance.
        atol: Absolute tolerance.
            Can be left as None if only `rtol` is to be used.
            See the documentation of `numpy.testing.assert_allclose(...)`
            for details about how `atol` affects the tolerance.
        equal_nan: If True, `nan` values will be counted as equal.
    Raises:
        AssertionError: if the numerical difference between `actual`
            and `desired` are beyond the tolerance expressed by `atol`
            and `rtol`.
        TestingError: If both `rtol` and `atol` are given as None.
    """

    if rtol is None and atol is None:
        raise TestingError(
            "Both `rtol` and `atol` were found to be None. Please either specify `rtol`, `atol`, or both."
        )
    elif rtol is None:
        rtol = 0.0
    elif atol is None:
        atol = 0.0

    desired = _to_numpy(desired)
    actual = _to_numpy(actual, dtype=desired.dtype)

    np.testing.assert_allclose(actual, desired, rtol=rtol, atol=atol, equal_nan=bool(equal_nan))

assert_almost_between(x, lb, ub, *, atol=None)

Assert that the given Iterable has its values between the desired bounds.

Parameters:

Name Type Description Default
x Iterable

An Iterable containing numeric (float) values.

required
lb Union[float, Iterable]

Lower bound for the desired interval. Can be a scalar or an iterable of values.

required
ub Union[float, Iterable]

Upper bound for the desired interval. Can be a scalar or an iterable of values.

required
atol Optional[float]

Absolute tolerance. If given, then the effective interval will be [lb-atol; ub+atol] instead of [lb; ub].

None

Exceptions:

Type Description
AssertionError

if any element of x violates the boundaries.

Source code in evotorch/testing.py
def assert_almost_between(
    x: Iterable, lb: Union[float, Iterable], ub: Union[float, Iterable], *, atol: Optional[float] = None
):
    """
    Assert that the given Iterable has its values between the desired bounds.

    Args:
        x: An Iterable containing numeric (float) values.
        lb: Lower bound for the desired interval.
            Can be a scalar or an iterable of values.
        ub: Upper bound for the desired interval.
            Can be a scalar or an iterable of values.
        atol: Absolute tolerance. If given, then the effective interval will
            be `[lb-atol; ub+atol]` instead of `[lb; ub]`.
    Raises:
        AssertionError: if any element of `x` violates the boundaries.
    """

    x = _to_numpy(x)
    lb = _to_numpy(lb)
    ub = _to_numpy(ub)

    if lb.shape != x.shape:
        lb = np.broadcast_to(lb, x.shape)
    if ub.shape != x.shape:
        ub = np.broadcast_to(ub, x.shape)

    lb = np.asarray(lb, dtype=x.dtype)
    ub = np.asarray(ub, dtype=x.dtype)

    if atol is not None:
        atol = float(atol)
        tolerant_lb = lb - atol
        tolerant_ub = ub + atol
    else:
        tolerant_lb = lb
        tolerant_ub = ub

    assert np.all((x >= tolerant_lb) & (x <= tolerant_ub)), (
        f"The provided array is not within the desired boundaries."
        f"Provided array: {x}. Lower bound: {lb}. Upper bound: {ub}. Absolute tolerance: {atol}."
    )

assert_dtype_matches(x, dtype)

Assert that the dtype of x is compatible with the given dtype.

Parameters:

Name Type Description Default
x Iterable

An object with dtype attribute (e.g. can be numpy array, a torch tensor, an ObjectArray, a Solution, etc.)

required
dtype Union[str, Type, numpy.dtype, torch.dtype]

The dtype which x is expected to have. Can be given as a string, as a numpy dtype, as a torch dtype, or as a native type (e.g. int, float, bool, object).

required

Exceptions:

Type Description
AssertionError

if x has a different dtype.

Source code in evotorch/testing.py
def assert_dtype_matches(x: Iterable, dtype: Union[str, Type, np.dtype, torch.dtype]):
    """
    Assert that the dtype of `x` is compatible with the given `dtype`.

    Args:
        x: An object with `dtype` attribute (e.g. can be numpy array,
            a torch tensor, an ObjectArray, a Solution, etc.)
        dtype: The dtype which `x` is expected to have.
            Can be given as a string, as a numpy dtype, as a torch dtype,
            or as a native type (e.g. int, float, bool, object).
    Raises:
        AssertionError: if `x` has a different dtype.
    """
    actual_dtype = x.dtype

    if isinstance(actual_dtype, torch.dtype):
        actual_dtype = torch.tensor([], dtype=actual_dtype).numpy().dtype
    else:
        actual_dtype = np.dtype(actual_dtype)

    if dtype == "Any" or dtype is Any:
        dtype = np.dtype(object)
    elif isinstance(dtype, torch.dtype):
        dtype = torch.tensor([], dtype=dtype).numpy().dtype
    else:
        dtype = np.dtype(dtype)

    assert dtype == actual_dtype, f"dtype mismatch. Encountered dtype: {actual_dtype}, expected dtype: {dtype}"

assert_eachclose(x, value, *, rtol=None, atol=None)

Assert that the given tensor or array consists of a single value.

Parameters:

Name Type Description Default
x Iterable

The tensor in which each value will be compared against value

required
value Any

A scalar

required

Exceptions:

Type Description
AssertionError

if at least one value is different enough

Source code in evotorch/testing.py
def assert_eachclose(x: Iterable, value: Any, *, rtol: Optional[float] = None, atol: Optional[float] = None):
    """
    Assert that the given tensor or array consists of a single value.

    Args:
        x: The tensor in which each value will be compared against `value`
        value: A scalar
    Raises:
        AssertionError: if at least one value is different enough
    """

    # If the given scalar is not a Real, then try to cast it to float
    if not isinstance(value, Real):
        value = float(value)

    x = _to_numpy(x)
    desired = np.empty_like(x)
    desired[:] = value

    assert_allclose(x, desired, rtol=rtol, atol=atol)

assert_shape_matches(x, shape)

Assert that the dtype of x matches the given shape

Parameters:

Name Type Description Default
x Iterable

An object which can be converted to a PyTorch tensor.

required
shape Union[tuple, int]

A tuple, or a torch.Size, or an integer.

required

Exceptions:

Type Description
AssertionError

if there is a shape mismatch.

Source code in evotorch/testing.py
def assert_shape_matches(x: Iterable, shape: Union[tuple, int]):
    """
    Assert that the dtype of `x` matches the given shape

    Args:
        x: An object which can be converted to a PyTorch tensor.
        shape: A tuple, or a torch.Size, or an integer.
    Raises:
        AssertionError: if there is a shape mismatch.
    """
    if isinstance(x, torch.Tensor):
        pass  # nothing to do
    elif isinstance(x, np.ndarray):
        x = torch.from_numpy(x)
    else:
        x = torch.tensor(x)

    if not isinstance(shape, Iterable):
        shape = (int(shape),)

    assert x.shape == shape, f"Encountered a shape mismatch. Shape of the tensor: {x.shape}. Expected shape: {shape}"

tools special

This namespace contains various utility functions, classes, and type aliases.

cloning

Clonable

A base class allowing inheriting classes define how they should be cloned.

Any class inheriting from Clonable gains these behaviors: (i) A new method named .clone() becomes available; (ii) __deepcopy__ and __copy__ work as aliases for .clone(); (iii) A new method, _get_cloned_state(self, *, memo: dict) is now defined and needs to be implemented by the inheriting class.

The method _get_cloned_state(...) expects a dictionary named memo, which maps from the ids of already cloned objects to their clones. If _get_cloned_state(...) is to use deep_clone(...) or deepcopy(...) within itself, this memo dictionary can be passed to these functions. The return value of _get_cloned_state(...) is a dictionary, which will be used as the __dict__ of the newly made clone.

Source code in evotorch/tools/cloning.py
class Clonable:
    """
    A base class allowing inheriting classes define how they should be cloned.

    Any class inheriting from Clonable gains these behaviors:
    (i) A new method named `.clone()` becomes available;
    (ii) `__deepcopy__` and `__copy__` work as aliases for `.clone()`;
    (iii) A new method, `_get_cloned_state(self, *, memo: dict)` is now
    defined and needs to be implemented by the inheriting class.

    The method `_get_cloned_state(...)` expects a dictionary named `memo`,
    which maps from the ids of already cloned objects to their clones.
    If `_get_cloned_state(...)` is to use `deep_clone(...)` or `deepcopy(...)`
    within itself, this `memo` dictionary can be passed to these functions.
    The return value of `_get_cloned_state(...)` is a dictionary, which will
    be used as the `__dict__` of the newly made clone.
    """

    def _get_cloned_state(self, *, memo: dict) -> dict:
        raise NotImplementedError

    def clone(self, *, memo: Optional[dict] = None) -> "Clonable":
        """
        Get a clone of this object.

        Args:
            memo: Optionally a dictionary which maps from the ids of the
                already cloned objects to their clones. In most scenarios,
                when this method is called from outside, this can be left
                as None.
        Returns:
            The clone of the object.
        """
        if memo is None:
            memo = {}

        self_id = id(self)
        if self_id in memo:
            return memo[self_id]

        new_object = object.__new__(type(self))
        memo[id(self)] = new_object

        new_object.__dict__.update(self._get_cloned_state(memo=memo))
        return new_object

    def __copy__(self) -> "Clonable":
        return self.clone()

    def __deepcopy__(self, memo: Optional[dict]):
        if memo is None:
            memo = {}
        return self.clone(memo=memo)
clone(self, *, memo=None)

Get a clone of this object.

Parameters:

Name Type Description Default
memo Optional[dict]

Optionally a dictionary which maps from the ids of the already cloned objects to their clones. In most scenarios, when this method is called from outside, this can be left as None.

None

Returns:

Type Description
Clonable

The clone of the object.

Source code in evotorch/tools/cloning.py
def clone(self, *, memo: Optional[dict] = None) -> "Clonable":
    """
    Get a clone of this object.

    Args:
        memo: Optionally a dictionary which maps from the ids of the
            already cloned objects to their clones. In most scenarios,
            when this method is called from outside, this can be left
            as None.
    Returns:
        The clone of the object.
    """
    if memo is None:
        memo = {}

    self_id = id(self)
    if self_id in memo:
        return memo[self_id]

    new_object = object.__new__(type(self))
    memo[id(self)] = new_object

    new_object.__dict__.update(self._get_cloned_state(memo=memo))
    return new_object

ReadOnlyClonable (Clonable)

Clonability base class for read-only and/or immutable objects.

This is a base class specialized for the immutable containers of EvoTorch. These immutable containers have two behaviors for cloning: one where the read-only attribute is preserved and one where a mutable clone is created.

Upon being copied or deep-copied (using the standard Python functions), the newly made clones are also read-only. However, when copied using the clone(...) method, the newly made clone is mutable by default (unless the clone(...) method was used with preserve_read_only=True). This default behavior of the clone(...) method was inspired by the copy() method of numpy arrays (the inspiration being that the .copy() of a read-only numpy array will not be read-only anymore).

Subclasses of evotorch.immutable.ImmutableContainer inherit from ReadOnlyClonable.

Source code in evotorch/tools/cloning.py
class ReadOnlyClonable(Clonable):
    """
    Clonability base class for read-only and/or immutable objects.

    This is a base class specialized for the immutable containers of EvoTorch.
    These immutable containers have two behaviors for cloning:
    one where the read-only attribute is preserved and one where a mutable
    clone is created.

    Upon being copied or deep-copied (using the standard Python functions),
    the newly made clones are also read-only. However, when copied using the
    `clone(...)` method, the newly made clone is mutable by default
    (unless the `clone(...)` method was used with `preserve_read_only=True`).
    This default behavior of the `clone(...)` method was inspired by the
    `copy()` method of numpy arrays (the inspiration being that the `.copy()`
    of a read-only numpy array will not be read-only anymore).

    Subclasses of `evotorch.immutable.ImmutableContainer` inherit from
    `ReadOnlyClonable`.
    """

    def _get_mutable_clone(self, *, memo: dict) -> Any:
        raise NotImplementedError

    def clone(self, *, memo: Optional[dict] = None, preserve_read_only: bool = False) -> Any:
        """
        Get a clone of this read-only object.

        Args:
            memo: Optionally a dictionary which maps from the ids of the
                already cloned objects to their clones. In most scenarios,
                when this method is called from outside, this can be left
                as None.
            preserve_read_only: Whether or not to preserve the read-only
                behavior in the clone.
        Returns:
            The clone of the object.
        """
        if memo is None:
            memo = {}
        if preserve_read_only:
            return super().clone(memo=memo)
        else:
            return self._get_mutable_clone(memo=memo)

    def __copy__(self) -> Any:
        return self.clone(preserve_read_only=True)

    def __deepcopy__(self, memo: Optional[dict]) -> Any:
        if memo is None:
            memo = {}
        return self.clone(memo=memo, preserve_read_only=True)
clone(self, *, memo=None, preserve_read_only=False)

Get a clone of this read-only object.

Parameters:

Name Type Description Default
memo Optional[dict]

Optionally a dictionary which maps from the ids of the already cloned objects to their clones. In most scenarios, when this method is called from outside, this can be left as None.

None
preserve_read_only bool

Whether or not to preserve the read-only behavior in the clone.

False

Returns:

Type Description
Any

The clone of the object.

Source code in evotorch/tools/cloning.py
def clone(self, *, memo: Optional[dict] = None, preserve_read_only: bool = False) -> Any:
    """
    Get a clone of this read-only object.

    Args:
        memo: Optionally a dictionary which maps from the ids of the
            already cloned objects to their clones. In most scenarios,
            when this method is called from outside, this can be left
            as None.
        preserve_read_only: Whether or not to preserve the read-only
            behavior in the clone.
    Returns:
        The clone of the object.
    """
    if memo is None:
        memo = {}
    if preserve_read_only:
        return super().clone(memo=memo)
    else:
        return self._get_mutable_clone(memo=memo)

Serializable (Clonable)

Base class allowing the inheriting classes become Clonable and picklable.

Any class inheriting from Serializable becomes Clonable (since Serializable is a subclass of Clonable) and therefore is expected to define its own _get_cloned_state(...) (see the documentation of the class Clonable for details).

A Serializable class gains a behavior for its __getstate__. In this already defined and implemented __getstate__ method, the resulting dictionary of _get_cloned_state(...) is used as the state dictionary. Therefore, for Serializable objects, the behavior defined in their _get_cloned_state(...) methods affect how they are pickled.

Classes inheriting from Serializable are evotorch.Problem, evotorch.Solution, evotorch.SolutionBatch, and evotorch.distributions.Distribution. In their _get_cloned_state(...) implementations, these classes use deep_clone(...) on themselves to make sure that their contained PyTorch tensors are copied using the .clone() method, ensuring that those tensors are detached from their old storages during the cloning operation. Thanks to being Serializable, their contained tensors are detached from their old storages both at the moment of copying/cloning AND at the moment of pickling.

Source code in evotorch/tools/cloning.py
class Serializable(Clonable):
    """
    Base class allowing the inheriting classes become Clonable and picklable.

    Any class inheriting from `Serializable` becomes `Clonable` (since
    `Serializable` is a subclass of `Clonable`) and therefore is expected to
    define its own `_get_cloned_state(...)` (see the documentation of the
    class `Clonable` for details).

    A `Serializable` class gains a behavior for its `__getstate__`. In this
    already defined and implemented `__getstate__` method, the resulting
    dictionary of `_get_cloned_state(...)` is used as the state dictionary.
    Therefore, for `Serializable` objects, the behavior defined in their
    `_get_cloned_state(...)` methods affect how they are pickled.

    Classes inheriting from `Serializable` are `evotorch.Problem`,
    `evotorch.Solution`, `evotorch.SolutionBatch`, and
    `evotorch.distributions.Distribution`. In their `_get_cloned_state(...)`
    implementations, these classes use `deep_clone(...)` on themselves to make
    sure that their contained PyTorch tensors are copied using the `.clone()`
    method, ensuring that those tensors are detached from their old storages
    during the cloning operation. Thanks to being `Serializable`, their
    contained tensors are detached from their old storages both at the moment
    of copying/cloning AND at the moment of pickling.
    """

    def __getstate__(self) -> dict:
        memo = {id(self): self}
        return self._get_cloned_state(memo=memo)

deep_clone(x, *, otherwise_deepcopy=False, otherwise_return=False, otherwise_fail=False, memo=None)

A recursive cloning function similar to the standard deepcopy.

The difference between deep_clone(...) and deepcopy(...) is that deep_clone(...), while recursively traversing, will run the .clone() method on the PyTorch tensors it encounters, so that the cloned tensors are forcefully detached from their storages (instead of cloning those storages as well).

At the moment of writing this documentation, the current behavior of PyTorch tensors upon being deep-copied is to clone themselves AND their storages. Therefore, if a PyTorch tensor is a slice of a large tensor (which has a large storage), then the large storage will also be deep-copied, and the newly made clone of the tensor will point to a newly made large storage. One might instead prefer to clone tensors in such a way that the newly made tensor points to a newly made storage that contains just enough data for the tensor (with the unused data being dropped). When such a behavior is desired, one can use this deep_clone(...) function.

Upon encountering a read-only and/or immutable data, this function will NOT modify the read-only behavior. For example, the deep-clone of a ReadOnlyTensor is still a ReadOnlyTensor, and the deep-clone of a read-only numpy array is still a read-only numpy array. Note that this behavior is different than the clone() method of a ReadOnlyTensor and the copy() method of a numpy array. The reason for this protective behavior is that since this is a deep-cloning operation, the encountered tensors and/or arrays might be the components of the root object, and changing their read-only attributes might affect the integrity of this root object.

The deep_clone(...) function needs to know what to do when an object of unrecognized type is encountered. Therefore, the user is expected to set one of these arguments as True (and leave the others as False): otherwise_deepcopy, otherwise_return, otherwise_fail.

Parameters:

Name Type Description Default
x Any

The object which will be deep-cloned. This object can be a standard Python container (i.e. list, tuple, dict, set), an instance of Problem, Solution, SolutionBatch, ObjectArray, ImmutableContainer, Clonable, and also any other type of object if either the argument otherwise_deepcopy or the argument otherwise_return is set as True.

required
otherwise_deepcopy bool

Setting this as True means that, when an unrecognized object is encountered, that object will be deep-copied. To handle shared and cyclic-referencing objects, the deep_clone(...) function stores its own memo dictionary. When the control is given to the standard deepcopy(...) function, the memo dictionary of deep_clone(...) will be passed to deepcopy.

False
otherwise_return bool

Setting this as True means that, when an unrecognized object is encountered, that object itself will be returned (i.e. will be a part of the created clone).

False
otherwise_fail bool

Setting this as True means that, when an unrecognized object is encountered, a TypeError will be raised.

False
memo Optional[dict]

Optionally a dictionary. In most scenarios, when this function is called from outside, this is expected to be left as None.

None

Returns:

Type Description
Any

The newly made clone of the original object.

Source code in evotorch/tools/cloning.py
def deep_clone(  # noqa: C901
    x: Any,
    *,
    otherwise_deepcopy: bool = False,
    otherwise_return: bool = False,
    otherwise_fail: bool = False,
    memo: Optional[dict] = None,
) -> Any:
    """
    A recursive cloning function similar to the standard `deepcopy`.

    The difference between `deep_clone(...)` and `deepcopy(...)` is that
    `deep_clone(...)`, while recursively traversing, will run the `.clone()`
    method on the PyTorch tensors it encounters, so that the cloned tensors
    are forcefully detached from their storages (instead of cloning those
    storages as well).

    At the moment of writing this documentation, the current behavior of
    PyTorch tensors upon being deep-copied is to clone themselves AND their
    storages. Therefore, if a PyTorch tensor is a slice of a large tensor
    (which has a large storage), then the large storage will also be
    deep-copied, and the newly made clone of the tensor will point to a newly
    made large storage. One might instead prefer to clone tensors in such a
    way that the newly made tensor points to a newly made storage that
    contains just enough data for the tensor (with the unused data being
    dropped). When such a behavior is desired, one can use this
    `deep_clone(...)` function.

    Upon encountering a read-only and/or immutable data, this function will
    NOT modify the read-only behavior. For example, the deep-clone of a
    ReadOnlyTensor is still a ReadOnlyTensor, and the deep-clone of a
    read-only numpy array is still a read-only numpy array. Note that this
    behavior is different than the `clone()` method of a ReadOnlyTensor
    and the `copy()` method of a numpy array. The reason for this
    protective behavior is that since this is a deep-cloning operation,
    the encountered tensors and/or arrays might be the components of the root
    object, and changing their read-only attributes might affect the integrity
    of this root object.

    The `deep_clone(...)` function needs to know what to do when an object
    of unrecognized type is encountered. Therefore, the user is expected to
    set one of these arguments as True (and leave the others as False):
    `otherwise_deepcopy`, `otherwise_return`, `otherwise_fail`.

    Args:
        x: The object which will be deep-cloned. This object can be a standard
            Python container (i.e. list, tuple, dict, set), an instance of
            Problem, Solution, SolutionBatch, ObjectArray, ImmutableContainer,
            Clonable, and also any other type of object if either the argument
            `otherwise_deepcopy` or the argument `otherwise_return` is set as
            True.
        otherwise_deepcopy: Setting this as True means that, when an
            unrecognized object is encountered, that object will be
            deep-copied. To handle shared and cyclic-referencing objects,
            the `deep_clone(...)` function stores its own memo dictionary.
            When the control is given to the standard `deepcopy(...)`
            function, the memo dictionary of `deep_clone(...)` will be passed
            to `deepcopy`.
        otherwise_return: Setting this as True means that, when an
            unrecognized object is encountered, that object itself will be
            returned (i.e. will be a part of the created clone).
        otherwise_fail: Setting this as True means that, when an unrecognized
            object is encountered, a TypeError will be raised.
        memo: Optionally a dictionary. In most scenarios, when this function
            is called from outside, this is expected to be left as None.
    Returns:
        The newly made clone of the original object.
    """
    from .objectarray import ObjectArray
    from .readonlytensor import ReadOnlyTensor

    if memo is None:
        # If a memo dictionary was not given, make a new one now.
        memo = {}

    # Get the id of the object being cloned.
    x_id = id(x)

    if x_id in memo:
        # If the id of the object being cloned is already in the memo dictionary, then this object was previously
        # cloned. We just return that clone.
        return memo[x_id]

    # Count how many of the arguments `otherwise_deepcopy`, `otherwise_return`, and `otherwise_fail` was set as True.
    # In this context, we call these arguments as fallback behaviors.
    fallback_behaviors = (otherwise_deepcopy, otherwise_return, otherwise_fail)
    enabled_behavior_count = sum(1 for behavior in fallback_behaviors if behavior)

    if enabled_behavior_count == 0:
        # If none of the fallback behaviors was enabled, then we raise an error.
        raise ValueError(
            "The action to take with objects of unrecognized types is not known because"
            " none of these arguments was set as True: `otherwise_deepcopy`, `otherwise_return`, `otherwise_fail`."
            " Please set one of these arguments as True."
        )
    elif enabled_behavior_count == 1:
        # If one of the fallback behaviors was enabled, then we received our expected input. We do nothing here.
        pass
    else:
        # If the number of enabled fallback behaviors is an unexpected value. then we raise an error.
        raise ValueError(
            f"The following arguments were received, which is conflicting: otherwise_deepcopy={otherwise_deepcopy},"
            f" otherwise_return={otherwise_return}, otherwise_fail={otherwise_fail}."
            f" Please set exactly one of these arguments as True and leave the others as False."
        )

    # This inner function specifies how the deep_clone function should call itself.
    def call_self(obj: Any) -> Any:
        return deep_clone(
            obj,
            otherwise_deepcopy=otherwise_deepcopy,
            otherwise_return=otherwise_return,
            otherwise_fail=otherwise_fail,
            memo=memo,
        )

    # Below, we handle the cloning behaviors case by case.
    if (x is None) or (x is NotImplemented) or (x is Ellipsis):
        result = deepcopy(x)
    elif isinstance(x, (Number, str, bytes, bytearray)):
        result = deepcopy(x, memo=memo)
    elif isinstance(x, np.ndarray):
        result = x.copy()
        result.flags["WRITEABLE"] = x.flags["WRITEABLE"]
    elif isinstance(x, (ObjectArray, ReadOnlyClonable)):
        result = x.clone(preserve_read_only=True, memo=memo)
    elif isinstance(x, ReadOnlyTensor):
        result = x.clone(preserve_read_only=True)
    elif isinstance(x, torch.Tensor):
        result = x.clone()
    elif isinstance(x, Clonable):
        result = x.clone(memo=memo)
    elif isinstance(x, (dict, OrderedDict)):
        result = type(x)()
        memo[x_id] = result
        for k, v in x.items():
            result[call_self(k)] = call_self(v)
    elif isinstance(x, list):
        result = type(x)()
        memo[x_id] = result
        for item in x:
            result.append(call_self(item))
    elif isinstance(x, set):
        result = type(x)()
        memo[x_id] = result
        for item in x:
            result.add(call_self(item))
    elif isinstance(x, tuple):
        result = []
        memo[x_id] = result
        for item in x:
            result.append(call_self(item))
        if hasattr(x, "_fields"):
            result = type(x)(*result)
        else:
            result = type(x)(result)
        memo[x_id] = result
    else:
        # If the object is not recognized, we use the fallback behavior.
        if otherwise_deepcopy:
            result = deepcopy(x, memo=memo)
        elif otherwise_return:
            result = x
        elif otherwise_fail:
            raise TypeError(f"Do not know how to clone {repr(x)} (of type {type(x)}).")
        else:
            raise RuntimeError("The function `deep_clone` reached an unexpected state. This might be a bug.")

    if (x_id not in memo) and (result is not x):
        # If the newly made clone is still not in the memo dictionary AND the "clone" is not just a reference to the
        # original object, we make sure that it is in the memo dictionary.
        memo[x_id] = result

    # Finally, the result is returned.
    return result

hook

This module contains the Hook class, which is used for event handling, and for defining additional behaviors to the class instances which own the Hook.

Hook (MutableSequence)

A Hook stores a list of callable objects to be called for handling certain events. A Hook itself is callable, which invokes the callables stored in its list. If the callables stored by the Hook return list-like objects or dict-like objects, their returned results are accumulated, and then those accumulated results are finally returned by the Hook.

Source code in evotorch/tools/hook.py
class Hook(MutableSequence):
    """
    A Hook stores a list of callable objects to be called for handling
    certain events. A Hook itself is callable, which invokes the callables
    stored in its list. If the callables stored by the Hook return list-like
    objects or dict-like objects, their returned results are accumulated,
    and then those accumulated results are finally returned by the Hook.
    """

    def __init__(
        self,
        callables: Optional[Iterable[Callable]] = None,
        *,
        args: Optional[Iterable] = None,
        kwargs: Optional[Mapping] = None,
    ):
        """
        Initialize the Hook.

        Args:
            callables: A sequence of callables to be stored by the Hook.
            args: Positional arguments which, when the Hook is called,
                are to be passed to every callable stored by the Hook.
                Please note that these positional arguments will be passed
                as the leftmost arguments, and, the other positional
                arguments passed via the `__call__(...)` method of the
                Hook will be added to the right of these arguments.
            kwargs: Keyword arguments which, when the Hook is called,
                are to be passed to every callable stored by the Hook.
                Please note that these keyword arguments could be overriden
                by the keyword arguments passed via the `__call__(...)`
                method of the Hook.
        """
        self._funcs: list = [] if callables is None else list(callables)
        self._args: list = [] if args is None else list(args)
        self._kwargs: dict = {} if kwargs is None else dict(kwargs)

    def __call__(self, *args: Any, **kwargs: Any) -> Optional[Union[dict, list]]:
        """
        Call every callable object stored by the Hook.
        The results of the stored callable objects (which can be dict-like
        or list-like objects) are accumulated and finally returned.

        Args:
            args: Additional positional arguments to be passed to the stored
                callables.
            kwargs: Additional keyword arguments to be passed to the stored
                keyword arguments.
        """

        all_args = []
        all_args.extend(self._args)
        all_args.extend(args)

        all_kwargs = {}
        all_kwargs.update(self._kwargs)
        all_kwargs.update(kwargs)

        result: Optional[Union[dict, list]] = None

        for f in self._funcs:
            tmp = f(*all_args, **all_kwargs)
            if tmp is not None:
                if isinstance(tmp, Mapping):
                    if result is None:
                        result = dict(tmp)
                    elif isinstance(result, list):
                        raise TypeError(
                            f"The function {f} returned a dict-like object."
                            f" However, previous function(s) in this hook had returned list-like object(s)."
                            f" Such incompatible results cannot be accumulated."
                        )
                    elif isinstance(result, dict):
                        result.update(tmp)
                    else:
                        raise RuntimeError
                elif isinstance(tmp, Iterable):
                    if result is None:
                        result = list(tmp)
                    elif isinstance(result, list):
                        result.extend(tmp)
                    elif isinstance(result, dict):
                        raise TypeError(
                            f"The function {f} returned a list-like object."
                            f" However, previous function(s) in this hook had returned dict-like object(s)."
                            f" Such incompatible results cannot be accumulated."
                        )
                    else:
                        raise RuntimeError
                else:
                    raise TypeError(
                        f"Expected the function {f} to return None, or a dict-like object, or a list-like object."
                        f" However, the function returned an object of type {repr(type(tmp))}."
                    )

        return result

    def accumulate_dict(self, *args: Any, **kwargs: Any) -> Optional[Union[dict, list]]:
        result = self(*args, **kwargs)
        if result is None:
            return {}
        elif isinstance(result, Mapping):
            return result
        else:
            raise TypeError(
                f"Expected the functions in this hook to accumulate"
                f" dictionary-like objects. Instead, accumulated"
                f" an object of type {type(result)}."
                f" Hint: are the functions registered in this hook"
                f" returning non-dictionary iterables?"
            )

    def accumulate_sequence(self, *args: Any, **kwargs: Any) -> Optional[Union[dict, list]]:
        result = self(*args, **kwargs)
        if result is None:
            return []
        elif isinstance(result, Mapping):
            raise TypeError(
                f"Expected the functions in this hook to accumulate"
                f" sequences (that are NOT dictionaries). Instead, accumulated"
                f" a dict-like object of type {type(result)}."
                f" Hint: are the functions registered in this hook"
                f" returning objects with Mapping interface?"
            )
        else:
            return result

    def _to_string(self) -> str:
        init_args = [repr(self._funcs)]

        if len(self._args) > 0:
            init_args.append(f"args={self._args}")

        if len(self._kwargs) > 0:
            init_args.append(f"kwargs={self._kwargs}")

        s_init_args = ", ".join(init_args)

        return f"{type(self).__name__}({s_init_args})"

    def __repr__(self) -> str:
        return self._to_string()

    def __str__(self) -> str:
        return self._to_string()

    def __getitem__(self, i: Union[int, slice]) -> Union[Callable, "Hook"]:
        if isinstance(i, slice):
            return Hook(self._funcs[i], args=self._args, kwargs=self._kwargs)
        else:
            return self._funcs[i]

    def __setitem__(self, i: Union[int, slice], x: Iterable[Callable]):
        self._funcs[i] = x

    def __delitem__(self, i: Union[int, slice]):
        del self._funcs[i]

    def insert(self, i: int, x: Callable):
        self._funcs.insert(i, x)

    def __len__(self) -> int:
        return len(self._funcs)

    @property
    def args(self) -> list:
        """Positional arguments that will be passed to the stored callables"""
        return self._args

    @property
    def kwargs(self) -> dict:
        """Keyword arguments that will be passed to the stored callables"""
        return self._kwargs
args: list property readonly

Positional arguments that will be passed to the stored callables

kwargs: dict property readonly

Keyword arguments that will be passed to the stored callables

__call__(self, *args, **kwargs) special

Call every callable object stored by the Hook. The results of the stored callable objects (which can be dict-like or list-like objects) are accumulated and finally returned.

Parameters:

Name Type Description Default
args Any

Additional positional arguments to be passed to the stored callables.

()
kwargs Any

Additional keyword arguments to be passed to the stored keyword arguments.

{}
Source code in evotorch/tools/hook.py
def __call__(self, *args: Any, **kwargs: Any) -> Optional[Union[dict, list]]:
    """
    Call every callable object stored by the Hook.
    The results of the stored callable objects (which can be dict-like
    or list-like objects) are accumulated and finally returned.

    Args:
        args: Additional positional arguments to be passed to the stored
            callables.
        kwargs: Additional keyword arguments to be passed to the stored
            keyword arguments.
    """

    all_args = []
    all_args.extend(self._args)
    all_args.extend(args)

    all_kwargs = {}
    all_kwargs.update(self._kwargs)
    all_kwargs.update(kwargs)

    result: Optional[Union[dict, list]] = None

    for f in self._funcs:
        tmp = f(*all_args, **all_kwargs)
        if tmp is not None:
            if isinstance(tmp, Mapping):
                if result is None:
                    result = dict(tmp)
                elif isinstance(result, list):
                    raise TypeError(
                        f"The function {f} returned a dict-like object."
                        f" However, previous function(s) in this hook had returned list-like object(s)."
                        f" Such incompatible results cannot be accumulated."
                    )
                elif isinstance(result, dict):
                    result.update(tmp)
                else:
                    raise RuntimeError
            elif isinstance(tmp, Iterable):
                if result is None:
                    result = list(tmp)
                elif isinstance(result, list):
                    result.extend(tmp)
                elif isinstance(result, dict):
                    raise TypeError(
                        f"The function {f} returned a list-like object."
                        f" However, previous function(s) in this hook had returned dict-like object(s)."
                        f" Such incompatible results cannot be accumulated."
                    )
                else:
                    raise RuntimeError
            else:
                raise TypeError(
                    f"Expected the function {f} to return None, or a dict-like object, or a list-like object."
                    f" However, the function returned an object of type {repr(type(tmp))}."
                )

    return result
__init__(self, callables=None, *, args=None, kwargs=None) special

Initialize the Hook.

Parameters:

Name Type Description Default
callables Optional[Iterable[Callable]]

A sequence of callables to be stored by the Hook.

None
args Optional[Iterable]

Positional arguments which, when the Hook is called, are to be passed to every callable stored by the Hook. Please note that these positional arguments will be passed as the leftmost arguments, and, the other positional arguments passed via the __call__(...) method of the Hook will be added to the right of these arguments.

None
kwargs Optional[collections.abc.Mapping]

Keyword arguments which, when the Hook is called, are to be passed to every callable stored by the Hook. Please note that these keyword arguments could be overriden by the keyword arguments passed via the __call__(...) method of the Hook.

None
Source code in evotorch/tools/hook.py
def __init__(
    self,
    callables: Optional[Iterable[Callable]] = None,
    *,
    args: Optional[Iterable] = None,
    kwargs: Optional[Mapping] = None,
):
    """
    Initialize the Hook.

    Args:
        callables: A sequence of callables to be stored by the Hook.
        args: Positional arguments which, when the Hook is called,
            are to be passed to every callable stored by the Hook.
            Please note that these positional arguments will be passed
            as the leftmost arguments, and, the other positional
            arguments passed via the `__call__(...)` method of the
            Hook will be added to the right of these arguments.
        kwargs: Keyword arguments which, when the Hook is called,
            are to be passed to every callable stored by the Hook.
            Please note that these keyword arguments could be overriden
            by the keyword arguments passed via the `__call__(...)`
            method of the Hook.
    """
    self._funcs: list = [] if callables is None else list(callables)
    self._args: list = [] if args is None else list(args)
    self._kwargs: dict = {} if kwargs is None else dict(kwargs)
insert(self, i, x)

S.insert(index, value) -- insert value before index

Source code in evotorch/tools/hook.py
def insert(self, i: int, x: Callable):
    self._funcs.insert(i, x)

misc

Miscellaneous utility functions

DTypeAndDevice (tuple)

DTypeAndDevice(dtype, device)

__getnewargs__(self) special

Return self as a plain tuple. Used by copy and pickle.

Source code in evotorch/tools/misc.py
def __getnewargs__(self):
    'Return self as a plain tuple.  Used by copy and pickle.'
    return _tuple(self)
__new__(_cls, dtype, device) special staticmethod

Create new instance of DTypeAndDevice(dtype, device)

__repr__(self) special

Return a nicely formatted representation string

Source code in evotorch/tools/misc.py
def __repr__(self):
    'Return a nicely formatted representation string'
    return self.__class__.__name__ + repr_fmt % self

ErroneousResult

Representation of a caught error being returned as a result.

Source code in evotorch/tools/misc.py
class ErroneousResult:
    """
    Representation of a caught error being returned as a result.
    """

    def __init__(self, error: Exception):
        self.error = error

    def _to_string(self) -> str:
        return f"<{type(self).__name__}, error: {self.error}>"

    def __str__(self) -> str:
        return self._to_string()

    def __repr__(self) -> str:
        return self._to_string()

    def __bool__(self) -> bool:
        return False

    @staticmethod
    def call(f, *args, **kwargs) -> Any:
        """
        Call a function with the given arguments.
        If the function raises an error, wrap the error in an ErroneousResult
        object, and return that ErroneousResult object instead.

        Returns:
            The result of the function if there was no error,
            or an ErroneousResult if there was an error.
        """
        try:
            result = f(*args, **kwargs)
        except Exception as ex:
            result = ErroneousResult(ex)
        return result
call(f, *args, **kwargs) staticmethod

Call a function with the given arguments. If the function raises an error, wrap the error in an ErroneousResult object, and return that ErroneousResult object instead.

Returns:

Type Description
Any

The result of the function if there was no error, or an ErroneousResult if there was an error.

Source code in evotorch/tools/misc.py
@staticmethod
def call(f, *args, **kwargs) -> Any:
    """
    Call a function with the given arguments.
    If the function raises an error, wrap the error in an ErroneousResult
    object, and return that ErroneousResult object instead.

    Returns:
        The result of the function if there was no error,
        or an ErroneousResult if there was an error.
    """
    try:
        result = f(*args, **kwargs)
    except Exception as ex:
        result = ErroneousResult(ex)
    return result

as_tensor(x, *, dtype=None, device=None)

Get the tensor counterpart of the given object x.

This function can be used to convert native Python objects to tensors:

my_tensor = as_tensor([1.0, 2.0, 3.0], dtype="float32")

One can also use this function to convert an existing tensor to another dtype:

my_new_tensor = as_tensor(my_tensor, dtype="float16")

This function can also be used for moving a tensor from one device to another:

my_gpu_tensor = as_tensor(my_tensor, device="cuda:0")

This function can also create ObjectArray instances when dtype is given as object or Any or "object" or "O".

my_objects = as_tensor([1, {"a": 3}], dtype=object)

Parameters:

Name Type Description Default
x Any

Any object to be converted to a tensor.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32) or, for creating an ObjectArray, "object" (as string) or object or Any. If dtype is not specified, the default behavior of torch.as_tensor(...) will be used, that is, dtype will be inferred from x.

None
device Union[str, torch.device]

The device in which the resulting tensor will be stored.

None

Returns:

Type Description
Iterable

The tensor counterpart of the given object x.

Source code in evotorch/tools/misc.py
def as_tensor(x: Any, *, dtype: Optional[DType] = None, device: Optional[Device] = None) -> Iterable:
    """
    Get the tensor counterpart of the given object `x`.

    This function can be used to convert native Python objects to tensors:

        my_tensor = as_tensor([1.0, 2.0, 3.0], dtype="float32")

    One can also use this function to convert an existing tensor to another
    dtype:

        my_new_tensor = as_tensor(my_tensor, dtype="float16")

    This function can also be used for moving a tensor from one device to
    another:

        my_gpu_tensor = as_tensor(my_tensor, device="cuda:0")

    This function can also create ObjectArray instances when dtype is
    given as `object` or `Any` or "object" or "O".

        my_objects = as_tensor([1, {"a": 3}], dtype=object)

    Args:
        x: Any object to be converted to a tensor.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32) or, for creating an `ObjectArray`,
            "object" (as string) or `object` or `Any`.
            If `dtype` is not specified, the default behavior of
            `torch.as_tensor(...)` will be used, that is, dtype will be
            inferred from `x`.
        device: The device in which the resulting tensor will be stored.
    Returns:
        The tensor counterpart of the given object `x`.
    """
    from .objectarray import ObjectArray

    if (dtype is None) and isinstance(x, (torch.Tensor, ObjectArray)):
        if (device is None) or (str(device) == "cpu"):
            return x
        else:
            raise ValueError(
                f"An ObjectArray cannot be moved into a device other than 'cpu'." f" The received device is: {device}."
            )
    elif is_dtype_object(dtype):
        if (device is None) or (str(device) == "cpu"):
            raise ValueError(
                f"An ObjectArray cannot be created on a device other than 'cpu'." f" The received device is: {device}."
            )
        if isinstance(x, ObjectArray):
            return x
        else:
            x = list(x)
            n = len(x)
            result = ObjectArray(n)
            result[:] = x
            return result
    else:
        dtype = to_torch_dtype(dtype)
        return torch.as_tensor(x, dtype=dtype, device=device)

cast_tensors_in_container(container, *, dtype=None, device=None, memo=None)

Cast and/or transfer all the tensors in a Python container.

Parameters:

Name Type Description Default
dtype Union[str, torch.dtype, numpy.dtype, Type]

If given as a dtype and not as None, then all the PyTorch tensors in the container will be cast to this dtype.

None
device Union[str, torch.device]

If given as a device and not as None, then all the PyTorch tensors in the container will be copied to this device.

None
memo Optional[dict]

Optionally a memo dictionary to handle shared objects and circular references. In most scenarios, when calling this function from outside, this is expected as None.

None

Returns:

Type Description
Any

A new copy of the original container in which the tensors have the desired dtype and/or device.

Source code in evotorch/tools/misc.py
def cast_tensors_in_container(
    container: Any,
    *,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    memo: Optional[dict] = None,
) -> Any:
    """
    Cast and/or transfer all the tensors in a Python container.

    Args:
        dtype: If given as a dtype and not as None, then all the PyTorch
            tensors in the container will be cast to this dtype.
        device: If given as a device and not as None, then all the PyTorch
            tensors in the container will be copied to this device.
        memo: Optionally a memo dictionary to handle shared objects and
            circular references. In most scenarios, when calling this
            function from outside, this is expected as None.
    Returns:
        A new copy of the original container in which the tensors have the
        desired dtype and/or device.
    """

    if memo is None:
        memo = {}

    container_id = id(container)
    if container_id in memo:
        return memo[container_id]

    cast_kwargs = {}
    if dtype is not None:
        cast_kwargs["dtype"] = to_torch_dtype(dtype)
    if device is not None:
        cast_kwargs["device"] = device

    def call_self(sub_container: Any) -> Any:
        return cast_tensors_in_container(sub_container, dtype=dtype, device=device, memo=memo)

    if isinstance(container, torch.Tensor):
        result = torch.as_tensor(container, **cast_kwargs)
        memo[container_id] = result
    elif (container is None) or isinstance(container, (Number, str, bytes, bool)):
        result = container
    elif isinstance(container, set):
        result = set()
        memo[container_id] = result
        for x in container:
            result.add(call_self(x))
    elif isinstance(container, Mapping):
        result = {}
        memo[container_id] = result
        for k, v in container.items():
            result[k] = call_self(v)
    elif isinstance(container, tuple):
        result = []
        memo[container_id] = result
        for x in container:
            result.append(call_self(x))
        if hasattr(container, "_fields"):
            result = type(container)(*result)
        else:
            result = type(container)(result)
        memo[container_id] = result
    elif isinstance(container, Iterable):
        result = []
        memo[container_id] = result
        for x in container:
            result.append(call_self(x))
    else:
        raise TypeError(f"Encountered an object of unrecognized type: {type(container)}")

    return result

clip_tensor(x, lb=None, ub=None, ensure_copy=True)

Clip the values of a tensor with respect to the given bounds.

Parameters:

Name Type Description Default
x Tensor

The PyTorch tensor whose values will be clipped.

required
lb Union[float, Iterable]

Lower bounds, as a PyTorch tensor. Can be None if there are no lower bounds.

None
ub Union[float, Iterable]

Upper bounds, as a PyTorch tensor. Can be None if there are no upper bonuds.

None
ensure_copy bool

If ensure_copy is True, the result will be a clipped copy of the original tensor. If ensure_copy is False, and both lb and ub are None, then there is nothing to do, so, the result will be the original tensor itself, not a copy of it.

True

Returns:

Type Description
Tensor

The clipped tensor.

Source code in evotorch/tools/misc.py
@torch.no_grad()
def clip_tensor(
    x: torch.Tensor,
    lb: Optional[Union[float, Iterable]] = None,
    ub: Optional[Union[float, Iterable]] = None,
    ensure_copy: bool = True,
) -> torch.Tensor:
    """
    Clip the values of a tensor with respect to the given bounds.

    Args:
        x: The PyTorch tensor whose values will be clipped.
        lb: Lower bounds, as a PyTorch tensor.
            Can be None if there are no lower bounds.
        ub: Upper bounds, as a PyTorch tensor.
            Can be None if there are no upper bonuds.
        ensure_copy: If `ensure_copy` is True, the result will be
            a clipped copy of the original tensor.
            If `ensure_copy` is False, and both `lb` and `ub`
            are None, then there is nothing to do, so, the result
            will be the original tensor itself, not a copy of it.
    Returns:
        The clipped tensor.
    """
    result = x
    if lb is not None:
        lb = torch.as_tensor(lb, dtype=x.dtype, device=x.device)
        result = torch.max(result, lb)
    if ub is not None:
        ub = torch.as_tensor(ub, dtype=x.dtype, device=x.device)
        result = torch.min(result, ub)
    if ensure_copy and result is x:
        result = x.clone()
    return result

clone(x, *, memo=None)

Get a deep copy of the given object.

The cloning is done in no_grad mode.

When this function is used on read-only containers (e.g. ReadOnlyTensor, ImmutableContainer, etc.), the created clones preserve their read-only behaviors. For creating a mutable clone of an immutable object, use their clone() method instead.

Returns:

Type Description
Any

The deep copy of the given object.

Source code in evotorch/tools/misc.py
@torch.no_grad()
def clone(x: Any, *, memo: Optional[dict] = None) -> Any:
    """
    Get a deep copy of the given object.

    The cloning is done in no_grad mode.

    When this function is used on read-only containers (e.g. ReadOnlyTensor,
    ImmutableContainer, etc.), the created clones preserve their read-only
    behaviors. For creating a mutable clone of an immutable object,
    use their `clone()` method instead.

    Returns:
        The deep copy of the given object.
    """
    from .cloning import deep_clone

    if memo is None:
        memo = {}
    return deep_clone(x, otherwise_deepcopy=True, memo=memo)

device_of(x)

Get the device of the given object.

Parameters:

Name Type Description Default
x Any

The object whose device is being queried. The object can be a PyTorch tensor, or a PyTorch module (in which case the device of the first parameter tensor will be returned), or an ObjectArray (in which case the returned device will be the cpu device), or any object with the attribute device.

required

Returns:

Type Description
Union[str, torch.device]

The device of the given object.

Source code in evotorch/tools/misc.py
def device_of(x: Any) -> Device:
    """
    Get the device of the given object.

    Args:
        x: The object whose device is being queried.
            The object can be a PyTorch tensor, or a PyTorch module
            (in which case the device of the first parameter tensor
            will be returned), or an ObjectArray (in which case
            the returned device will be the cpu device), or any object
            with the attribute `device`.
    Returns:
        The device of the given object.
    """
    if isinstance(x, nn.Module):
        result = None
        for param in x.parameters():
            result = param.device
            break
        if result is None:
            raise ValueError(f"Cannot determine the device of the module {x}")
        return result
    else:
        return x.device

device_of_container(container, *, visited=None, visiting=None)

Get the device of the given container.

It is assumed that the given container stores PyTorch tensors from which the device information will be extracted. If the container contains only basic types like int, float, string, bool, or None, or if the container is empty, then the returned device will be None. If the container contains unrecognized objects, an error will be raised.

Parameters:

Name Type Description Default
container Any

A sequence or a dictionary of objects from which the device information will be extracted.

required
visited Optional[dict]

Optionally a dictionary which stores the (sub)containers which are already visited. In most cases, when this function is called from outside, this is expected as None.

None
visiting Optional[str]

Optionally a set which stores the (sub)containers which are being visited. This set is used to prevent recursion errors while handling circular references. In most cases, when this function is called from outside, this argument is expected as None.

None

Returns:

Type Description
Optional[torch.device]

The device if available, None otherwise.

Source code in evotorch/tools/misc.py
def device_of_container(
    container: Any, *, visited: Optional[dict] = None, visiting: Optional[str] = None
) -> Optional[torch.device]:
    """
    Get the device of the given container.

    It is assumed that the given container stores PyTorch tensors from
    which the device information will be extracted.
    If the container contains only basic types like int, float, string,
    bool, or None, or if the container is empty, then the returned device
    will be None.
    If the container contains unrecognized objects, an error will be
    raised.

    Args:
        container: A sequence or a dictionary of objects from which the
            device information will be extracted.
        visited: Optionally a dictionary which stores the (sub)containers
            which are already visited. In most cases, when this function
            is called from outside, this is expected as None.
        visiting: Optionally a set which stores the (sub)containers
            which are being visited. This set is used to prevent recursion
            errors while handling circular references. In most cases,
            when this function is called from outside, this argument is
            expected as None.
    Returns:
        The device if available, None otherwise.
    """
    container_id = id(container)

    if visited is None:
        visited = {}

    if container_id in visited:
        return visited[container_id]

    if visiting is None:
        visiting = set()

    if container_id in visiting:
        return None

    class result:
        device: Optional[torch.device] = None

        @classmethod
        def update(cls, new_device: Optional[torch.device]):
            if new_device is not None:
                if cls.device is None:
                    cls.device = new_device
                else:
                    if new_device != cls.device:
                        raise ValueError(f"Encountered tensors whose `device`s mismatch: {new_device}, {cls.device}")

    def call_self(sub_container):
        return device_of_container(sub_container, visited=visited, visiting=visiting)

    if isinstance(container, torch.Tensor):
        result.update(container.device)
    elif (container is None) or isinstance(container, (Number, str, bytes, bool)):
        pass
    elif isinstance(container, Mapping):
        visiting.add(container_id)
        try:
            for _, v in container.items():
                result.update(call_self(v))
        finally:
            visiting.remove(container_id)
    elif isinstance(container, Iterable):
        visiting.add(container_id)
        try:
            for v in container:
                result.update(call_self(v))
        finally:
            visiting.remove(container_id)
    else:
        raise TypeError(f"Encountered an object of unrecognized type: {type(container)}")

    visited[container_id] = result.device
    return result.device

dtype_of(x)

Get the dtype of the given object.

Parameters:

Name Type Description Default
x Any

The object whose dtype is being queried. The object can be a PyTorch tensor, or a PyTorch module (in which case the dtype of the first parameter tensor will be returned), or an ObjectArray (in which case the returned dtype will be object), or any object with the attribute dtype.

required

Returns:

Type Description
Union[str, torch.dtype, numpy.dtype, Type]

The dtype of the given object.

Source code in evotorch/tools/misc.py
def dtype_of(x: Any) -> DType:
    """
    Get the dtype of the given object.

    Args:
        x: The object whose dtype is being queried.
            The object can be a PyTorch tensor, or a PyTorch module
            (in which case the dtype of the first parameter tensor
            will be returned), or an ObjectArray (in which case
            the returned dtype will be `object`), or any object with
            the attribute `dtype`.
    Returns:
        The dtype of the given object.
    """
    if isinstance(x, nn.Module):
        result = None
        for param in x.parameters():
            result = param.dtype
            break
        if result is None:
            raise ValueError(f"Cannot determine the dtype of the module {x}")
        return result
    else:
        return x.dtype

dtype_of_container(container, *, visited=None, visiting=None)

Get the dtype of the given container.

It is assumed that the given container stores PyTorch tensors from which the dtype information will be extracted. If the container contains only basic types like int, float, string, bool, or None, or if the container is empty, then the returned dtype will be None. If the container contains unrecognized objects, an error will be raised.

Parameters:

Name Type Description Default
container Any

A sequence or a dictionary of objects from which the dtype information will be extracted.

required
visited Optional[dict]

Optionally a dictionary which stores the (sub)containers which are already visited. In most cases, when this function is called from outside, this is expected as None.

None
visiting Optional[str]

Optionally a set which stores the (sub)containers which are being visited. This set is used to prevent recursion errors while handling circular references. In most cases, when this function is called from outside, this argument is expected as None.

None

Returns:

Type Description
Optional[torch.dtype]

The dtype if available, None otherwise.

Source code in evotorch/tools/misc.py
def dtype_of_container(
    container: Any, *, visited: Optional[dict] = None, visiting: Optional[str] = None
) -> Optional[torch.dtype]:
    """
    Get the dtype of the given container.

    It is assumed that the given container stores PyTorch tensors from
    which the dtype information will be extracted.
    If the container contains only basic types like int, float, string,
    bool, or None, or if the container is empty, then the returned dtype
    will be None.
    If the container contains unrecognized objects, an error will be
    raised.

    Args:
        container: A sequence or a dictionary of objects from which the
            dtype information will be extracted.
        visited: Optionally a dictionary which stores the (sub)containers
            which are already visited. In most cases, when this function
            is called from outside, this is expected as None.
        visiting: Optionally a set which stores the (sub)containers
            which are being visited. This set is used to prevent recursion
            errors while handling circular references. In most cases,
            when this function is called from outside, this argument is
            expected as None.
    Returns:
        The dtype if available, None otherwise.
    """

    container_id = id(container)

    if visited is None:
        visited = {}

    if container_id in visited:
        return visited[container_id]

    if visiting is None:
        visiting = set()

    if container_id in visiting:
        return None

    class result:
        dtype: Optional[torch.dtype] = None

        @classmethod
        def update(cls, new_dtype: Optional[torch.dtype]):
            if new_dtype is not None:
                if cls.dtype is None:
                    cls.dtype = new_dtype
                else:
                    if new_dtype != cls.dtype:
                        raise ValueError(f"Encountered tensors whose `dtype`s mismatch: {new_dtype}, {cls.dtype}")

    def call_self(sub_container):
        return dtype_of_container(sub_container, visited=visited, visiting=visiting)

    if isinstance(container, torch.Tensor):
        result.update(container.dtype)
    elif (container is None) or isinstance(container, (Number, str, bytes, bool)):
        pass
    elif isinstance(container, Mapping):
        visiting.add(container_id)
        try:
            for _, v in container.items():
                result.update(call_self(v))
        finally:
            visiting.remove(container_id)
    elif isinstance(container, Iterable):
        visiting.add(container_id)
        try:
            for v in container:
                result.update(call_self(v))
        finally:
            visiting.remove(container_id)
    else:
        raise TypeError(f"Encountered an object of unrecognized type: {type(container)}")

    visited[container_id] = result.dtype
    return result.dtype

empty_tensor_like(source, *, shape=None, length=None, dtype=None, device=None)

Make an empty tensor with attributes taken from a source tensor.

The source tensor can be a PyTorch tensor, or an ObjectArray.

Unlike torch.empty_like(...), this function allows one to redefine the shape and/or length of the new empty tensor.

Parameters:

Name Type Description Default
source Any

The source tensor whose shape, dtype, and device will be used by default for the new empty tensor.

required
shape Union[tuple, int]

If given as None (which is the default), then the shape of the source tensor will be used for the new empty tensor. If given as a tuple or a torch.Size instance, then the new empty tensor will be in this given shape instead. This argument cannot be used together with length.

None
length Optional[int]

If given as None (which is the default), then the length of the new empty tensor will be equal to the length of the source tensor (where length here means the size of the outermost dimension, i.e., what is returned by len(...)). If given as an integer, the length of the empty tensor will be this given length instead. This argument cannot be used together with shape.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

If given as None, the dtype of the new empty tensor will be the dtype of the source tensor. If given as a torch.dtype instance, then the dtype of the tensor will be this given dtype instead.

None
device Union[str, torch.device]

If given as None, the device of the new empty tensor will be the device of the source tensor. If given as a torch.device instance, then the device of the tensor will be this given device instead.

None

Returns:

Type Description
Any

The new empty tensor.

Source code in evotorch/tools/misc.py
def empty_tensor_like(
    source: Any,
    *,
    shape: Optional[Union[tuple, int]] = None,
    length: Optional[int] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
) -> Any:
    """
    Make an empty tensor with attributes taken from a source tensor.

    The source tensor can be a PyTorch tensor, or an ObjectArray.

    Unlike `torch.empty_like(...)`, this function allows one to redefine the
    shape and/or length of the new empty tensor.

    Args:
        source: The source tensor whose shape, dtype, and device will be used
            by default for the new empty tensor.
        shape: If given as None (which is the default), then the shape of the
            source tensor will be used for the new empty tensor.
            If given as a tuple or a `torch.Size` instance, then the new empty
            tensor will be in this given shape instead.
            This argument cannot be used together with `length`.
        length: If given as None (which is the default), then the length of
            the new empty tensor will be equal to the length of the source
            tensor (where length here means the size of the outermost
            dimension, i.e., what is returned by `len(...)`).
            If given as an integer, the length of the empty tensor will be
            this given length instead.
            This argument cannot be used together with `shape`.
        dtype: If given as None, the dtype of the new empty tensor will be
            the dtype of the source tensor.
            If given as a `torch.dtype` instance, then the dtype of the
            tensor will be this given dtype instead.
        device: If given as None, the device of the new empty tensor will be
            the device of the source tensor.
            If given as a `torch.device` instance, then the device of the
            tensor will be this given device instead.
    Returns:
        The new empty tensor.
    """
    from .objectarray import ObjectArray

    if isinstance(source, ObjectArray):
        if length is not None and shape is None:
            n = int(length)
        elif shape is not None and length is None:
            if isinstance(shape, Iterable):
                if len(shape) != 1:
                    raise ValueError(
                        f"An ObjectArray must always be 1-dimensional."
                        f" Therefore, this given shape is incompatible: {shape}"
                    )
                n = int(shape[0])
        elif length is None and shape is None:
            n = len(source)
        else:
            raise ValueError("`length` and `shape` cannot be used together")

        if device is not None:
            if str(device) != "cpu":
                raise ValueError(
                    f"An ObjectArray can only be allocated on cpu. However, the specified `device` is: {device}."
                )

        if dtype is not None:
            if not is_dtype_object(dtype):
                raise ValueError(
                    f"The dtype of an ObjectArray can only be `object`. However, the specified `dtype` is: {dtype}."
                )

        return ObjectArray(n)
    elif isinstance(source, torch.Tensor):
        if length is not None:
            if shape is not None:
                raise ValueError("`length` and `shape` cannot be used together")
            if source.ndim == 0:
                raise ValueError("`length` can only be used when the source tensor is at least 1-dimensional")
            newshape = [int(length)]
            newshape.extend(source.shape[1:])
            shape = tuple(newshape)

        if not ((dtype is None) or isinstance(dtype, torch.dtype)):
            dtype = to_torch_dtype(dtype)

        return torch.empty(
            source.shape if shape is None else shape,
            dtype=(source.dtype if dtype is None else dtype),
            device=(source.device if device is None else device),
        )
    else:
        raise TypeError(f"The source tensor is of an unrecognized type: {type(source)}")

ensure_ray()

Ensure that the ray parallelization engine is initialized. If ray is already initialized, this function does nothing.

Source code in evotorch/tools/misc.py
def ensure_ray():
    """
    Ensure that the ray parallelization engine is initialized.
    If ray is already initialized, this function does nothing.
    """
    import ray

    if not ray.is_initialized():
        ray.init()

ensure_tensor_length_and_dtype(t, length, dtype, about=None, *, allow_scalar=False, device=None)

Return the given sequence as a tensor while also confirming its length, dtype, and device. If the given object is already a tensor conforming to the desired length, dtype, and device, the object will be returned as it is (there will be no copying).

Parameters:

Name Type Description Default
t Any

The tensor, or a sequence which is convertible to a tensor.

required
length int

The length to which the tensor is expected to conform.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

The dtype to which the tensor is expected to conform.

required
about Optional[str]

The prefix for the error message. Can be left as None.

None
allow_scalar bool

Whether or not to accept scalars in addition to vector of the desired length. If allow_scalar is False, then scalars will be converted to sequences of the desired length. The sequence will contain the same scalar, repeated. If allow_scalar is True, then the scalar itself will be converted to a PyTorch scalar, and then will be returned.

False
device Union[str, torch.device]

The device in which the sequence is to be stored. If the given sequence is on a different device than the desired device, a copy on the correct device will be made. If device is None, the default behavior of torch.tensor(...) will be used, that is: if t is already a tensor, the result will be on the same device, otherwise, the result will be on the cpu.

None

Returns:

Type Description
Any

The sequence whose correctness in terms of length, dtype, and device is ensured.

Exceptions:

Type Description
ValueError

if there is a length mismatch.

Source code in evotorch/tools/misc.py
@torch.no_grad()
def ensure_tensor_length_and_dtype(
    t: Any,
    length: int,
    dtype: DType,
    about: Optional[str] = None,
    *,
    allow_scalar: bool = False,
    device: Optional[Device] = None,
) -> Any:
    """
    Return the given sequence as a tensor while also confirming its
    length, dtype, and device.
    If the given object is already a tensor conforming to the desired
    length, dtype, and device, the object will be returned as it is
    (there will be no copying).

    Args:
        t: The tensor, or a sequence which is convertible to a tensor.
        length: The length to which the tensor is expected to conform.
        dtype: The dtype to which the tensor is expected to conform.
        about: The prefix for the error message. Can be left as None.
        allow_scalar: Whether or not to accept scalars in addition
            to vector of the desired length.
            If `allow_scalar` is False, then scalars will be converted
            to sequences of the desired length. The sequence will contain
            the same scalar, repeated.
            If `allow_scalar` is True, then the scalar itself will be
            converted to a PyTorch scalar, and then will be returned.
        device: The device in which the sequence is to be stored.
            If the given sequence is on a different device than the
            desired device, a copy on the correct device will be made.
            If device is None, the default behavior of `torch.tensor(...)`
            will be used, that is: if `t` is already a tensor, the result
            will be on the same device, otherwise, the result will be on
            the cpu.
    Returns:
        The sequence whose correctness in terms of length, dtype, and
        device is ensured.
    Raises:
        ValueError: if there is a length mismatch.
    """
    device_args = {}
    if device is not None:
        device_args["device"] = device

    t = as_tensor(t, dtype=dtype, **device_args)

    if t.ndim == 0:
        if allow_scalar:
            return t
        else:
            return t.repeat(length)
    else:
        if t.ndim != 1 or len(t) != length:
            if about is not None:
                err_prefix = about + ": "
            else:
                err_prefix = ""
            raise ValueError(
                f"{err_prefix}Expected a 1-dimensional tensor of length {length}, but got a tensor with shape: {t.shape}"
            )
        return t

expect_none(msg_prefix, **kwargs)

Expect the values associated with the given keyword arguments to be None. If not, raise error.

Parameters:

Name Type Description Default
msg_prefix str

Prefix of the error message.

required
kwargs

Keyword arguments whose values are expected to be None.

{}

Exceptions:

Type Description
ValueError

if at least one of the keyword arguments has a value other than None.

Source code in evotorch/tools/misc.py
def expect_none(msg_prefix: str, **kwargs):
    """
    Expect the values associated with the given keyword arguments
    to be None. If not, raise error.

    Args:
        msg_prefix: Prefix of the error message.
        kwargs: Keyword arguments whose values are expected to be None.
    Raises:
        ValueError: if at least one of the keyword arguments has a value
            other than None.
    """
    for k, v in kwargs.items():
        if v is not None:
            raise ValueError(f"{msg_prefix}: expected `{k}` as None, however, it was found to be {repr(v)}")

is_bool(x)

Return True if x represents a bool.

Parameters:

Name Type Description Default
x Any

An object whose type is being queried.

required

Returns:

Type Description
bool

True if x is a bool; False otherwise.

Source code in evotorch/tools/misc.py
def is_bool(x: Any) -> bool:
    """
    Return True if `x` represents a bool.

    Args:
        x: An object whose type is being queried.
    Returns:
        True if `x` is a bool; False otherwise.
    """
    if isinstance(x, (bool, np.bool_)):
        return True
    elif isinstance(x, (torch.Tensor, np.ndarray)):
        if x.ndim > 0:
            return False
        else:
            return is_dtype_bool(x.dtype)
    else:
        return False

is_bool_vector(x)

Return True if x is a vector consisting of bools.

Parameters:

Name Type Description Default
x Any

An object whose elements' types are to be queried.

required

Returns:

Type Description
bool

True if the elements of x are bools; False otherwise.

Source code in evotorch/tools/misc.py
def is_bool_vector(x: Any) -> bool:
    """
    Return True if `x` is a vector consisting of bools.

    Args:
        x: An object whose elements' types are to be queried.
    Returns:
        True if the elements of `x` are bools; False otherwise.
    """
    if isinstance(x, (torch.Tensor, np.ndarray)):
        if x.ndim != 1:
            return False
        else:
            return is_dtype_bool(x.dtype)
    elif isinstance(x, Iterable):
        for item in x:
            if not is_bool(item):
                return False
        return True
    else:
        return False

is_dtype_bool(t)

Return True if the given dtype is an bool type.

Parameters:

Name Type Description Default
t Union[str, torch.dtype, numpy.dtype, Type]

The dtype, which can be a dtype string, a numpy dtype, or a PyTorch dtype.

required

Returns:

Type Description
bool

True if t is a bool type; False otherwise.

Source code in evotorch/tools/misc.py
def is_dtype_bool(t: DType) -> bool:
    """
    Return True if the given dtype is an bool type.

    Args:
        t: The dtype, which can be a dtype string, a numpy dtype,
            or a PyTorch dtype.
    Returns:
        True if t is a bool type; False otherwise.
    """
    t: np.dtype = to_numpy_dtype(t)
    return t.kind.startswith("b")

is_dtype_float(t)

Return True if the given dtype is an float type.

Parameters:

Name Type Description Default
t Union[str, torch.dtype, numpy.dtype, Type]

The dtype, which can be a dtype string, a numpy dtype, or a PyTorch dtype.

required

Returns:

Type Description
bool

True if t is an float type; False otherwise.

Source code in evotorch/tools/misc.py
def is_dtype_float(t: DType) -> bool:
    """
    Return True if the given dtype is an float type.

    Args:
        t: The dtype, which can be a dtype string, a numpy dtype,
            or a PyTorch dtype.
    Returns:
        True if t is an float type; False otherwise.
    """
    t: np.dtype = to_numpy_dtype(t)
    return t.kind.startswith("f")

is_dtype_integer(t)

Return True if the given dtype is an integer type.

Parameters:

Name Type Description Default
t Union[str, torch.dtype, numpy.dtype, Type]

The dtype, which can be a dtype string, a numpy dtype, or a PyTorch dtype.

required

Returns:

Type Description
bool

True if t is an integer type; False otherwise.

Source code in evotorch/tools/misc.py
def is_dtype_integer(t: DType) -> bool:
    """
    Return True if the given dtype is an integer type.

    Args:
        t: The dtype, which can be a dtype string, a numpy dtype,
            or a PyTorch dtype.
    Returns:
        True if t is an integer type; False otherwise.
    """
    t: np.dtype = to_numpy_dtype(t)
    return t.kind.startswith("u") or t.kind.startswith("i")

is_dtype_object(dtype)

Return True if the given dtype is object or Any.

Returns:

Type Description
bool

True if the given dtype is object or Any; False otherwise.

Source code in evotorch/tools/misc.py
def is_dtype_object(dtype: DType) -> bool:
    """
    Return True if the given dtype is `object` or `Any`.

    Returns:
        True if the given dtype is `object` or `Any`; False otherwise.
    """
    if isinstance(dtype, str):
        return dtype in ("object", "Any", "O")
    elif dtype is object or dtype is Any:
        return True
    else:
        return False

is_dtype_real(t)

Return True if the given dtype represents real numbers (i.e. if dtype is an integer type or is a float type).

Parameters:

Name Type Description Default
t Union[str, torch.dtype, numpy.dtype, Type]

The dtype, which can be a dtype string, a numpy dtype, or a PyTorch dtype.

required

Returns:

Type Description
bool

True if t represents a real numbers type; False otherwise.

Source code in evotorch/tools/misc.py
def is_dtype_real(t: DType) -> bool:
    """
    Return True if the given dtype represents real numbers
    (i.e. if dtype is an integer type or is a float type).

    Args:
        t: The dtype, which can be a dtype string, a numpy dtype,
            or a PyTorch dtype.
    Returns:
        True if t represents a real numbers type; False otherwise.
    """
    return is_dtype_float(t) or is_dtype_integer(t)

is_integer(x)

Return True if x is an integer.

Note that this function does NOT consider booleans as integers.

Parameters:

Name Type Description Default
x Any

An object whose type is being queried.

required

Returns:

Type Description
bool

True if x is an integer; False otherwise.

Source code in evotorch/tools/misc.py
def is_integer(x: Any) -> bool:
    """
    Return True if `x` is an integer.

    Note that this function does NOT consider booleans as integers.

    Args:
        x: An object whose type is being queried.
    Returns:
        True if `x` is an integer; False otherwise.
    """
    if is_bool(x):
        return False
    elif isinstance(x, Integral):
        return True
    elif isinstance(x, (torch.Tensor, np.ndarray)):
        if x.ndim > 0:
            return False
        else:
            return is_dtype_integer(x.dtype)
    else:
        return False

is_integer_vector(x)

Return True if x is a vector consisting of integers.

Parameters:

Name Type Description Default
x Any

An object whose elements' types are to be queried.

required

Returns:

Type Description
bool

True if the elements of x are integers; False otherwise.

Source code in evotorch/tools/misc.py
def is_integer_vector(x: Any) -> bool:
    """
    Return True if `x` is a vector consisting of integers.

    Args:
        x: An object whose elements' types are to be queried.
    Returns:
        True if the elements of `x` are integers; False otherwise.
    """
    if isinstance(x, (torch.Tensor, np.ndarray)):
        if x.ndim != 1:
            return False
        else:
            return is_dtype_integer(x.dtype)
    elif isinstance(x, Iterable):
        for item in x:
            if not is_integer(item):
                return False
        return True
    else:
        return False

is_real(x)

Return True if x is a real number.

Note that this function does NOT consider booleans as real numbers.

Parameters:

Name Type Description Default
x Any

An object whose type is being queried.

required

Returns:

Type Description
bool

True if x is a real number; False otherwise.

Source code in evotorch/tools/misc.py
def is_real(x: Any) -> bool:
    """
    Return True if `x` is a real number.

    Note that this function does NOT consider booleans as real numbers.

    Args:
        x: An object whose type is being queried.
    Returns:
        True if `x` is a real number; False otherwise.
    """
    if is_bool(x):
        return False
    elif isinstance(x, Real):
        return True
    elif isinstance(x, (torch.Tensor, np.ndarray)):
        if x.ndim > 0:
            return False
        else:
            return is_dtype_real(x.dtype)
    else:
        return False

is_real_vector(x)

Return True if x is a vector consisting of real numbers.

Parameters:

Name Type Description Default
x Any

An object whose elements' types are to be queried.

required

Returns:

Type Description
bool

True if the elements of x are real numbers; False otherwise.

Source code in evotorch/tools/misc.py
def is_real_vector(x: Any) -> bool:
    """
    Return True if `x` is a vector consisting of real numbers.

    Args:
        x: An object whose elements' types are to be queried.
    Returns:
        True if the elements of `x` are real numbers; False otherwise.
    """
    if isinstance(x, (torch.Tensor, np.ndarray)):
        if x.ndim != 1:
            return False
        else:
            return is_dtype_real(x.dtype)
    elif isinstance(x, Iterable):
        for item in x:
            if not is_real(item):
                return False
        return True
    else:
        return False

is_sequence(x)

Return True if x is a sequence. Note that this function considers str and bytes as scalars, not as sequences.

Parameters:

Name Type Description Default
x Any

The object whose sequential nature is being queried.

required

Returns:

Type Description
bool

True if x is a sequence; False otherwise.

Source code in evotorch/tools/misc.py
def is_sequence(x: Any) -> bool:
    """
    Return True if `x` is a sequence.
    Note that this function considers `str` and `bytes` as scalars,
    not as sequences.

    Args:
        x: The object whose sequential nature is being queried.
    Returns:
        True if `x` is a sequence; False otherwise.
    """
    if isinstance(x, (str, bytes)):
        return False
    elif isinstance(x, (np.ndarray, torch.Tensor)):
        return x.ndim > 0
    elif isinstance(x, Iterable):
        return True
    else:
        return False

is_tensor_on_cpu(tensor)

Return True of the given PyTorch tensor or ObjectArray is on cpu.

Source code in evotorch/tools/misc.py
def is_tensor_on_cpu(tensor) -> bool:
    """
    Return True of the given PyTorch tensor or ObjectArray is on cpu.
    """
    return str(tensor.device) == "cpu"

make_I(size=None, *, out=None, dtype=None, device=None)

Make a new identity matrix (I), or change an existing tensor into one.

The following example creates a 3x3 identity matrix:

identity_matrix = make_I(3, dtype="float32")

The following example changes an already existing square matrix such that its values will store an identity matrix:

make_I(out=existing_tensor)

Parameters:

Name Type Description Default
size Optional[int]

A single integer or a tuple containing a single integer, where the integer specifies the length of the target square matrix. In this context, "length" means both rowwise length and columnwise length, since the target is a square matrix. Note that, if the user wishes to fill an existing tensor with identity values, then size is expected to be left as None.

None
out Optional[torch.Tensor]

Optionally, the existing tensor whose values will be changed so that they represent an identity matrix. If an out tensor is given, then size is expected as None.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified, the default choice of torch.empty(...) is used, that is, torch.float32. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new tensor will be stored. If not specified, "cpu" will be used. If an out tensor is specified, then device is expected as None.

None

Returns:

Type Description
Tensor

The created or modified tensor after placing the I matrix values

Source code in evotorch/tools/misc.py
def make_I(
    size: Optional[int] = None,
    *,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
) -> torch.Tensor:
    """
    Make a new identity matrix (I), or change an existing tensor into one.

    The following example creates a 3x3 identity matrix:

        identity_matrix = make_I(3, dtype="float32")

    The following example changes an already existing square matrix such that
    its values will store an identity matrix:

        make_I(out=existing_tensor)

    Args:
        size: A single integer or a tuple containing a single integer,
            where the integer specifies the length of the target square
            matrix. In this context, "length" means both rowwise length
            and columnwise length, since the target is a square matrix.
            Note that, if the user wishes to fill an existing tensor with
            identity values, then `size` is expected to be left as None.
        out: Optionally, the existing tensor whose values will be changed
            so that they represent an identity matrix.
            If an `out` tensor is given, then `size` is expected as None.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified, the default choice of
            `torch.empty(...)` is used, that is, `torch.float32`.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new tensor will be stored.
            If not specified, "cpu" will be used.
            If an `out` tensor is specified, then `device` is expected
            as None.
    Returns:
        The created or modified tensor after placing the I matrix values
    """
    if size is None:
        if out is None:
            raise ValueError(
                "When the `size` argument is missing, the function `make_I(...)` expects an `out` tensor."
                " However, the `out` argument was received as None."
            )
        size = tuple()
    else:
        if isinstance(size, tuple):
            if len(size) == 1:
                size = size[0]
            else:
                raise ValueError(
                    f"When the `size` argument is given as a tuple,"
                    f" the function `make_I(...)` expects this tuple to contain exactly one element."
                    f" The received tuple is {size}."
                )
        n = int(size)
        size = (n, n)
    out = _out_tensor(*size, out=out, dtype=dtype, device=device)
    out.zero_()
    out.fill_diagonal_(1)
    return out

make_empty(*size, *, dtype=None, device=None)

Make an empty tensor.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Shape of the empty tensor to be created. expected as multiple positional arguments of integers, or as a single positional argument containing a tuple of integers. Note that when the user wishes to create an ObjectArray (i.e. when dtype is given as object), then the size is expected as a single integer, or as a single-element tuple containing an integer (because ObjectArray can only be one-dimensional).

()
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32) or, for creating an ObjectArray, "object" (as string) or object or Any. If dtype is not specified, the default choice of torch.empty(...) is used, that is, torch.float32.

None
device Union[str, torch.device]

The device in which the new empty tensor will be stored. If not specified, "cpu" will be used.

None

Returns:

Type Description
Iterable

The new empty tensor, which can be a PyTorch tensor or an ObjectArray.

Source code in evotorch/tools/misc.py
def make_empty(
    *size: Size,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
) -> Iterable:
    """
    Make an empty tensor.

    Args:
        size: Shape of the empty tensor to be created.
            expected as multiple positional arguments of integers,
            or as a single positional argument containing a tuple of
            integers.
            Note that when the user wishes to create an `ObjectArray`
            (i.e. when `dtype` is given as `object`), then the size
            is expected as a single integer, or as a single-element
            tuple containing an integer (because `ObjectArray` can only
            be one-dimensional).
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32) or, for creating an `ObjectArray`,
            "object" (as string) or `object` or `Any`.
            If `dtype` is not specified, the default choice of
            `torch.empty(...)` is used, that is, `torch.float32`.
        device: The device in which the new empty tensor will be stored.
            If not specified, "cpu" will be used.
    Returns:
        The new empty tensor, which can be a PyTorch tensor or an
        `ObjectArray`.
    """
    from .objectarray import ObjectArray

    if (dtype is not None) and is_dtype_object(dtype):
        if (device is None) or (str(device) == "cpu"):
            if len(size) == 1:
                size = size[0]
            return ObjectArray(size)
        else:
            return ValueError(
                f"Invalid device for ObjectArray: {repr(device)}. Note: an ObjectArray can only be stored on 'cpu'."
            )
    else:
        kwargs = {}
        if dtype is not None:
            kwargs["dtype"] = to_torch_dtype(dtype)
        if device is not None:
            kwargs["device"] = device
        return torch.empty(*size, **kwargs)

make_gaussian(*size, *, center=None, stdev=None, symmetric=False, out=None, dtype=None, device=None, generator=None)

Make a new or existing tensor filled by Gaussian distributed values. This function can work only with float dtypes.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with Gaussian distributed values. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor instead, then no positional argument is expected.

()
center Union[float, Iterable[float], torch.Tensor]

Center point (i.e. mean) of the Gaussian distribution. Can be a scalar, or a tensor. If not specified, the center point will be taken as 0. Note that, if one specifies center, then stdev is also expected to be explicitly specified.

None
stdev Union[float, Iterable[float], torch.Tensor]

Standard deviation for the Gaussian distributed values. Can be a scalar, or a tensor. If not specified, the standard deviation will be taken as 1. Note that, if one specifies stdev, then center is also expected to be explicitly specified.

None
symmetric bool

Whether or not the values should be sampled in a symmetric (i.e. antithetic) manner. The default is False.

False
out Optional[torch.Tensor]

Optionally, the tensor to be filled by Gaussian distributed values. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified, the default choice of torch.empty(...) is used, that is, torch.float32. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new tensor will be stored. If not specified, "cpu" will be used. If an out tensor is specified, then device is expected as None.

None
generator Any

Pseudo-random number generator to be used when sampling the values. Can be a torch.Generator, or an object with a generator attribute (such as Problem). If left as None, the global generator of PyTorch will be used.

None

Returns:

Type Description
Tensor

The created or modified tensor after placing the Gaussian distributed values.

Source code in evotorch/tools/misc.py
def make_gaussian(
    *size: Size,
    center: Optional[RealOrVector] = None,
    stdev: Optional[RealOrVector] = None,
    symmetric: bool = False,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    generator: Any = None,
) -> torch.Tensor:
    """
    Make a new or existing tensor filled by Gaussian distributed values.
    This function can work only with float dtypes.

    Args:
        size: Size of the new tensor to be filled with Gaussian distributed
            values. This can be given as multiple positional arguments, each
            such positional argument being an integer, or as a single
            positional argument of a tuple, the tuple containing multiple
            integers. Note that, if the user wishes to fill an existing
            tensor instead, then no positional argument is expected.
        center: Center point (i.e. mean) of the Gaussian distribution.
            Can be a scalar, or a tensor.
            If not specified, the center point will be taken as 0.
            Note that, if one specifies `center`, then `stdev` is also
            expected to be explicitly specified.
        stdev: Standard deviation for the Gaussian distributed values.
            Can be a scalar, or a tensor.
            If not specified, the standard deviation will be taken as 1.
            Note that, if one specifies `stdev`, then `center` is also
            expected to be explicitly specified.
        symmetric: Whether or not the values should be sampled in a
            symmetric (i.e. antithetic) manner.
            The default is False.
        out: Optionally, the tensor to be filled by Gaussian distributed
            values. If an `out` tensor is given, then no `size` argument is
            expected.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified, the default choice of
            `torch.empty(...)` is used, that is, `torch.float32`.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new tensor will be stored.
            If not specified, "cpu" will be used.
            If an `out` tensor is specified, then `device` is expected
            as None.
        generator: Pseudo-random number generator to be used when sampling
            the values. Can be a `torch.Generator`, or an object with
            a `generator` attribute (such as `Problem`).
            If left as None, the global generator of PyTorch will be used.
    Returns:
        The created or modified tensor after placing the Gaussian
        distributed values.
    """
    scalar_requested = _scalar_requested(*size)
    if scalar_requested:
        size = (1,)

    out = _out_tensor(*size, out=out, dtype=dtype, device=device)
    gen_kwargs = _generator_kwargs(generator)

    if symmetric:
        leftmost_dim = out.shape[0]
        if (leftmost_dim % 2) != 0:
            raise ValueError(
                f"Symmetric sampling cannot be done if the leftmost dimension of the target tensor is odd."
                f" The shape of the target tensor is: {repr(out.shape)}."
            )
        out[0::2, ...].normal_(**gen_kwargs)
        out[1::2, ...] = out[0::2, ...]
        out[1::2, ...] *= -1
    else:
        out.normal_(**gen_kwargs)

    if (center is None) and (stdev is None):
        pass  # do nothing
    elif (center is not None) and (stdev is not None):
        stdev = torch.as_tensor(stdev, dtype=out.dtype, device=out.device)
        out *= stdev
        center = torch.as_tensor(center, dtype=out.dtype, device=out.device)
        out += center
    else:
        raise ValueError(
            f"Please either specify none of `stdev` and `center`, or both of them."
            f" Currently, `center` is {center}"
            f" and `stdev` is {stdev}."
        )

    if scalar_requested:
        out = out[0]

    return out

make_nan(*size, *, out=None, dtype=None, device=None)

Make a new tensor filled with NaN, or fill an existing tensor with NaN.

The following example creates a float32 tensor filled with NaN values, of shape (3, 5):

nan_values = make_nan(3, 5, dtype="float32")

The following example fills an existing tensor with NaNs.

make_nan(out=existing_tensor)

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with NaNs. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor with NaN values, then no positional argument is expected.

()
out Optional[torch.Tensor]

Optionally, the tensor to be filled by NaN values. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified, the default choice of torch.empty(...) is used, that is, torch.float32. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new tensor will be stored. If not specified, "cpu" will be used. If an out tensor is specified, then device is expected as None.

None

Returns:

Type Description
Tensor

The created or modified tensor after placing NaN values.

Source code in evotorch/tools/misc.py
def make_nan(
    *size: Size,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
) -> torch.Tensor:
    """
    Make a new tensor filled with NaN, or fill an existing tensor with NaN.

    The following example creates a float32 tensor filled with NaN values,
    of shape (3, 5):

        nan_values = make_nan(3, 5, dtype="float32")

    The following example fills an existing tensor with NaNs.

        make_nan(out=existing_tensor)

    Args:
        size: Size of the new tensor to be filled with NaNs.
            This can be given as multiple positional arguments, each such
            positional argument being an integer, or as a single positional
            argument of a tuple, the tuple containing multiple integers.
            Note that, if the user wishes to fill an existing tensor with
            NaN values, then no positional argument is expected.
        out: Optionally, the tensor to be filled by NaN values.
            If an `out` tensor is given, then no `size` argument is expected.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified, the default choice of
            `torch.empty(...)` is used, that is, `torch.float32`.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new tensor will be stored.
            If not specified, "cpu" will be used.
            If an `out` tensor is specified, then `device` is expected
            as None.
    Returns:
        The created or modified tensor after placing NaN values.
    """
    if _scalar_requested(*size):
        return _scalar_tensor(float("nan"), out=out, dtype=dtype, device=device)
    else:
        out = _out_tensor(*size, out=out, dtype=dtype, device=device)
        out[:] = float("nan")
        return out

make_ones(*size, *, out=None, dtype=None, device=None)

Make a new tensor filled with 1, or fill an existing tensor with 1.

The following example creates a float32 tensor filled with 1 values, of shape (3, 5):

zero_values = make_ones(3, 5, dtype="float32")

The following example fills an existing tensor with 1s:

make_ones(out=existing_tensor)

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with 1. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor with 1 values, then no positional argument is expected.

()
out Optional[torch.Tensor]

Optionally, the tensor to be filled by 1 values. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified, the default choice of torch.empty(...) is used, that is, torch.float32. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new tensor will be stored. If not specified, "cpu" will be used. If an out tensor is specified, then device is expected as None.

None

Returns:

Type Description
Tensor

The created or modified tensor after placing 1 values.

Source code in evotorch/tools/misc.py
def make_ones(
    *size: Size,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
) -> torch.Tensor:
    """
    Make a new tensor filled with 1, or fill an existing tensor with 1.

    The following example creates a float32 tensor filled with 1 values,
    of shape (3, 5):

        zero_values = make_ones(3, 5, dtype="float32")

    The following example fills an existing tensor with 1s:

        make_ones(out=existing_tensor)

    Args:
        size: Size of the new tensor to be filled with 1.
            This can be given as multiple positional arguments, each such
            positional argument being an integer, or as a single positional
            argument of a tuple, the tuple containing multiple integers.
            Note that, if the user wishes to fill an existing tensor with
            1 values, then no positional argument is expected.
        out: Optionally, the tensor to be filled by 1 values.
            If an `out` tensor is given, then no `size` argument is expected.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified, the default choice of
            `torch.empty(...)` is used, that is, `torch.float32`.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new tensor will be stored.
            If not specified, "cpu" will be used.
            If an `out` tensor is specified, then `device` is expected
            as None.
    Returns:
        The created or modified tensor after placing 1 values.
    """
    if _scalar_requested(*size):
        return _scalar_tensor(1, out=out, dtype=dtype, device=device)
    else:
        out = _out_tensor(*size, out=out, dtype=dtype, device=device)
        out[:] = 1
        return out

make_randint(*size, *, n, out=None, dtype=None, device=None, generator=None)

Make a new or existing tensor filled by random integers. The integers are uniformly distributed within [0 ... n-1]. This function can be used with integer or float dtypes.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with uniformly distributed values. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor instead, then no positional argument is expected.

()
n Union[int, float, torch.Tensor]

Number of choice(s) for integer sampling. The lowest possible value will be 0, and the highest possible value will be n - 1. n can be a scalar, or a tensor.

required
out Optional[torch.Tensor]

Optionally, the tensor to be filled by the random integers. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "int64") or a PyTorch dtype (e.g. torch.int64). If dtype is not specified, torch.int64 will be used.

None
device Union[str, torch.device]

The device in which the new tensor will be stored. If not specified, "cpu" will be used. If an out tensor is specified, then device is expected as None.

None
generator Any

Pseudo-random number generator to be used when sampling the values. Can be a torch.Generator, or an object with a generator attribute (such as Problem). If left as None, the global generator of PyTorch will be used.

None

Returns:

Type Description
Tensor

The created or modified tensor after placing the uniformly distributed values.

Source code in evotorch/tools/misc.py
def make_randint(
    *size: Size,
    n: Union[int, float, torch.Tensor],
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    generator: Any = None,
) -> torch.Tensor:
    """
    Make a new or existing tensor filled by random integers.
    The integers are uniformly distributed within `[0 ... n-1]`.
    This function can be used with integer or float dtypes.

    Args:
        size: Size of the new tensor to be filled with uniformly distributed
            values. This can be given as multiple positional arguments, each
            such positional argument being an integer, or as a single
            positional argument of a tuple, the tuple containing multiple
            integers. Note that, if the user wishes to fill an existing
            tensor instead, then no positional argument is expected.
        n: Number of choice(s) for integer sampling.
            The lowest possible value will be 0, and the highest possible
            value will be n - 1.
            `n` can be a scalar, or a tensor.
        out: Optionally, the tensor to be filled by the random integers.
            If an `out` tensor is given, then no `size` argument is
            expected.
        dtype: Optionally a string (e.g. "int64") or a PyTorch dtype
            (e.g. torch.int64).
            If `dtype` is not specified, torch.int64 will be used.
        device: The device in which the new tensor will be stored.
            If not specified, "cpu" will be used.
            If an `out` tensor is specified, then `device` is expected
            as None.
        generator: Pseudo-random number generator to be used when sampling
            the values. Can be a `torch.Generator`, or an object with
            a `generator` attribute (such as `Problem`).
            If left as None, the global generator of PyTorch will be used.
    Returns:
            The created or modified tensor after placing the uniformly
            distributed values.
    """
    scalar_requested = _scalar_requested(*size)
    if scalar_requested:
        size = (1,)

    if (dtype is None) and (out is None):
        dtype = torch.int64
    out = _out_tensor(*size, out=out, dtype=dtype, device=device)
    gen_kwargs = _generator_kwargs(generator)
    out.random_(**gen_kwargs)
    out %= n

    if scalar_requested:
        out = out[0]

    return out

make_tensor(data, *, dtype=None, device=None, read_only=False)

Make a new tensor.

This function can be used to create PyTorch tensors, or ObjectArray instances with or without read-only behavior.

The following example creates a 2-dimensional PyTorch tensor:

my_tensor = make_tensor(
    [[1, 2], [3, 4]],
    dtype="float32",    # alternatively, torch.float32
    device="cpu",
)

The following example creates an ObjectArray from a list that contains arbitrary data:

my_obj_tensor = make_tensor(["a_string", (1, 2)], dtype=object)

Parameters:

Name Type Description Default
data Any

The data to be converted to a tensor. If one wishes to create a PyTorch tensor, this can be anything that can be stored by a PyTorch tensor. If one wishes to create an ObjectArray and therefore passes dtype=object, then the provided data is expected as an Iterable.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32"), or a PyTorch dtype (e.g. torch.float32), or object or "object" (as a string) or Any if one wishes to create an ObjectArray. If dtype is not specified, it will be assumed that the user wishes to create a PyTorch tensor (not an ObjectArray) and then dtype will be inferred from the provided data (according to the default behavior of PyTorch).

None
device Union[str, torch.device]

The device in which the tensor will be stored. If device is not specified, it will be understood from the given data (according to the default behavior of PyTorch).

None
read_only bool

Whether or not the created tensor will be read-only. By default, this is False.

False

Returns:

Type Description
Iterable

A PyTorch tensor or an ObjectArray.

Source code in evotorch/tools/misc.py
def make_tensor(
    data: Any,
    *,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    read_only: bool = False,
) -> Iterable:
    """
    Make a new tensor.

    This function can be used to create PyTorch tensors, or ObjectArray
    instances with or without read-only behavior.

    The following example creates a 2-dimensional PyTorch tensor:

        my_tensor = make_tensor(
            [[1, 2], [3, 4]],
            dtype="float32",    # alternatively, torch.float32
            device="cpu",
        )

    The following example creates an ObjectArray from a list that contains
    arbitrary data:

        my_obj_tensor = make_tensor(["a_string", (1, 2)], dtype=object)

    Args:
        data: The data to be converted to a tensor.
            If one wishes to create a PyTorch tensor, this can be anything
            that can be stored by a PyTorch tensor.
            If one wishes to create an `ObjectArray` and therefore passes
            `dtype=object`, then the provided `data` is expected as an
            `Iterable`.
        dtype: Optionally a string (e.g. "float32"), or a PyTorch dtype
            (e.g. torch.float32), or `object` or "object" (as a string)
            or `Any` if one wishes to create an `ObjectArray`.
            If `dtype` is not specified, it will be assumed that the user
            wishes to create a PyTorch tensor (not an `ObjectArray`) and
            then `dtype` will be inferred from the provided `data`
            (according to the default behavior of PyTorch).
        device: The device in which the tensor will be stored.
            If `device` is not specified, it will be understood from the
            given `data` (according to the default behavior of PyTorch).
        read_only: Whether or not the created tensor will be read-only.
            By default, this is False.
    Returns:
        A PyTorch tensor or an ObjectArray.
    """
    from .objectarray import ObjectArray
    from .readonlytensor import as_read_only_tensor

    if (dtype is not None) and is_dtype_object(dtype):
        if not hasattr(data, "__len__"):
            data = list(data)
        n = len(data)
        result = ObjectArray(n)
        result[:] = data
    else:
        kwargs = {}
        if dtype is not None:
            kwargs["dtype"] = to_torch_dtype(dtype)
        if device is not None:
            kwargs["device"] = device
        result = torch.tensor(data, **kwargs)

    if read_only:
        result = as_read_only_tensor(result)

    return result

make_uniform(*size, *, lb=None, ub=None, out=None, dtype=None, device=None, generator=None)

Make a new or existing tensor filled by uniformly distributed values. Both lower and upper bounds are inclusive. This function can work with both float and int dtypes.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with uniformly distributed values. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor instead, then no positional argument is expected.

()
lb Union[float, Iterable[float], torch.Tensor]

Lower bound for the uniformly distributed values. Can be a scalar, or a tensor. If not specified, the lower bound will be taken as 0. Note that, if one specifies lb, then ub is also expected to be explicitly specified.

None
ub Union[float, Iterable[float], torch.Tensor]

Upper bound for the uniformly distributed values. Can be a scalar, or a tensor. If not specified, the upper bound will be taken as 1. Note that, if one specifies ub, then lb is also expected to be explicitly specified.

None
out Optional[torch.Tensor]

Optionally, the tensor to be filled by uniformly distributed values. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified, the default choice of torch.empty(...) is used, that is, torch.float32. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new tensor will be stored. If not specified, "cpu" will be used. If an out tensor is specified, then device is expected as None.

None
generator Any

Pseudo-random number generator to be used when sampling the values. Can be a torch.Generator, or an object with a generator attribute (such as Problem). If left as None, the global generator of PyTorch will be used.

None

Returns:

Type Description
Tensor

The created or modified tensor after placing the uniformly distributed values.

Source code in evotorch/tools/misc.py
def make_uniform(
    *size: Size,
    lb: Optional[RealOrVector] = None,
    ub: Optional[RealOrVector] = None,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    generator: Any = None,
) -> torch.Tensor:
    """
    Make a new or existing tensor filled by uniformly distributed values.
    Both lower and upper bounds are inclusive.
    This function can work with both float and int dtypes.

    Args:
        size: Size of the new tensor to be filled with uniformly distributed
            values. This can be given as multiple positional arguments, each
            such positional argument being an integer, or as a single
            positional argument of a tuple, the tuple containing multiple
            integers. Note that, if the user wishes to fill an existing
            tensor instead, then no positional argument is expected.
        lb: Lower bound for the uniformly distributed values.
            Can be a scalar, or a tensor.
            If not specified, the lower bound will be taken as 0.
            Note that, if one specifies `lb`, then `ub` is also expected to
            be explicitly specified.
        ub: Upper bound for the uniformly distributed values.
            Can be a scalar, or a tensor.
            If not specified, the upper bound will be taken as 1.
            Note that, if one specifies `ub`, then `lb` is also expected to
            be explicitly specified.
        out: Optionally, the tensor to be filled by uniformly distributed
            values. If an `out` tensor is given, then no `size` argument is
            expected.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified, the default choice of
            `torch.empty(...)` is used, that is, `torch.float32`.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new tensor will be stored.
            If not specified, "cpu" will be used.
            If an `out` tensor is specified, then `device` is expected
            as None.
        generator: Pseudo-random number generator to be used when sampling
            the values. Can be a `torch.Generator`, or an object with
            a `generator` attribute (such as `Problem`).
            If left as None, the global generator of PyTorch will be used.
    Returns:
        The created or modified tensor after placing the uniformly
        distributed values.
    """

    scalar_requested = _scalar_requested(*size)
    if scalar_requested:
        size = (1,)

    def _invalid_bound_args():
        raise ValueError(
            f"Expected both `lb` and `ub` as None, or both `lb` and `ub` as not None."
            f" It appears that one of them is None, while the other is not."
            f" lb: {repr(lb)}."
            f" ub: {repr(ub)}."
        )

    out = _out_tensor(*size, out=out, dtype=dtype, device=device)
    gen_kwargs = _generator_kwargs(generator)

    def _cast_bounds():
        nonlocal lb, ub
        lb = torch.as_tensor(lb, dtype=out.dtype, device=out.device)
        ub = torch.as_tensor(ub, dtype=out.dtype, device=out.device)

    if out.dtype == torch.bool:
        out.random_(**gen_kwargs)
        if (lb is None) and (ub is None):
            pass  # nothing to do
        elif (lb is not None) and (ub is not None):
            _cast_bounds()
            lb_shape_matches = lb.shape == out.shape
            ub_shape_matches = ub.shape == out.shape
            if (not lb_shape_matches) or (not ub_shape_matches):
                all_false = torch.zeros_like(out)
                if not lb_shape_matches:
                    lb = lb | all_false
                if not ub_shape_matches:
                    ub = ub | all_false
            mask_for_always_false = (~lb) & (~ub)
            mask_for_always_true = lb & ub
            out[mask_for_always_false] = False
            out[mask_for_always_true] = True
        else:
            _invalid_bound_args()
    elif out.dtype in (torch.uint8, torch.int8, torch.int16, torch.int32, torch.int64):
        out.random_(**gen_kwargs)
        if (lb is None) and (ub is None):
            out %= 2
        elif (lb is not None) and (ub is not None):
            _cast_bounds()
            diff = (ub - lb) + 1
            out -= lb
            out %= diff
            out += lb
        else:
            _invalid_bound_args()
    else:
        out.uniform_(**gen_kwargs)
        if (lb is None) and (ub is None):
            pass  # nothing to do
        elif (lb is not None) and (ub is not None):
            _cast_bounds()
            diff = ub - lb
            out *= diff
            out += lb
        else:
            _invalid_bound_args()

    if scalar_requested:
        out = out[0]

    return out

make_zeros(*size, *, out=None, dtype=None, device=None)

Make a new tensor filled with 0, or fill an existing tensor with 0.

The following example creates a float32 tensor filled with 0 values, of shape (3, 5):

zero_values = make_zeros(3, 5, dtype="float32")

The following example fills an existing tensor with 0s:

make_zeros(out=existing_tensor)

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with 0. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor with 0 values, then no positional argument is expected.

()
out Optional[torch.Tensor]

Optionally, the tensor to be filled by 0 values. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified, the default choice of torch.empty(...) is used, that is, torch.float32. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new tensor will be stored. If not specified, "cpu" will be used. If an out tensor is specified, then device is expected as None.

None

Returns:

Type Description
Tensor

The created or modified tensor after placing 0 values.

Source code in evotorch/tools/misc.py
def make_zeros(
    *size: Size,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
) -> torch.Tensor:
    """
    Make a new tensor filled with 0, or fill an existing tensor with 0.

    The following example creates a float32 tensor filled with 0 values,
    of shape (3, 5):

        zero_values = make_zeros(3, 5, dtype="float32")

    The following example fills an existing tensor with 0s:

        make_zeros(out=existing_tensor)

    Args:
        size: Size of the new tensor to be filled with 0.
            This can be given as multiple positional arguments, each such
            positional argument being an integer, or as a single positional
            argument of a tuple, the tuple containing multiple integers.
            Note that, if the user wishes to fill an existing tensor with
            0 values, then no positional argument is expected.
        out: Optionally, the tensor to be filled by 0 values.
            If an `out` tensor is given, then no `size` argument is expected.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified, the default choice of
            `torch.empty(...)` is used, that is, `torch.float32`.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new tensor will be stored.
            If not specified, "cpu" will be used.
            If an `out` tensor is specified, then `device` is expected
            as None.
    Returns:
        The created or modified tensor after placing 0 values.
    """
    if _scalar_requested(*size):
        return _scalar_tensor(0, out=out, dtype=dtype, device=device)
    else:
        out = _out_tensor(*size, out=out, dtype=dtype, device=device)
        out.zero_()
        return out

message_from(sender, message)

Prepend the sender object's name and id to a string message.

Let us imagine that we have a class named Example:

from evotorch.tools import message_from


class Example:
    def say_hello(self):
        print(message_from(self, "Hello!"))

Let us now instantiate this class and use its say_hello method:

ex = Example()
ex.say_hello()

The output becomes something like this:

Instance of `Example` (id:...) -- Hello!

Parameters:

Name Type Description Default
sender object

The object which produces the message

required
message Any

The message, as something that can be converted to string

required

Returns:

Type Description
str

The new message string, with the details regarding the sender object inserted to the beginning.

Source code in evotorch/tools/misc.py
def message_from(sender: object, message: Any) -> str:
    """
    Prepend the sender object's name and id to a string message.

    Let us imagine that we have a class named `Example`:

    ```python
    from evotorch.tools import message_from


    class Example:
        def say_hello(self):
            print(message_from(self, "Hello!"))
    ```

    Let us now instantiate this class and use its `say_hello` method:

    ```python
    ex = Example()
    ex.say_hello()
    ```

    The output becomes something like this:

    ```
    Instance of `Example` (id:...) -- Hello!
    ```

    Args:
        sender: The object which produces the message
        message: The message, as something that can be converted to string
    Returns:
        The new message string, with the details regarding the sender object
        inserted to the beginning.
    """
    sender_type = type(sender).__name__
    sender_id = id(sender)

    return f"Instance of `{sender_type}` (id:{sender_id}) -- {message}"

modify_tensor(original, target, lb=None, ub=None, max_change=None, in_place=False)

Return the modified version of the original tensor, with bounds checking.

Parameters:

Name Type Description Default
original Tensor

The original tensor.

required
target Tensor

The target tensor which contains the values to replace the old ones in the original tensor.

required
lb Union[float, torch.Tensor]

The lower bound(s), as a scalar or as an tensor. Values below these bounds are clipped in the resulting tensor. None means -inf.

None
ub Union[float, torch.Tensor]

The upper bound(s), as a scalar or as an tensor. Value above these bounds are clipped in the resulting tensor. None means +inf.

None
max_change Union[float, torch.Tensor]

The ratio of allowed change. In more details, when given as a real number r, modifications are allowed only within [original-(r*abs(original)) ... original+(r*abs(original))]. Modifications beyond this interval are clipped. This argument can also be left as None if no such limitation is needed.

None
in_place bool

Provide this as True if you wish the modification to be done within the original tensor. The default value of this argument is False, which means, the original tensor is not changed, and its modified version is returned as an independent copy.

False

Returns:

Type Description
Tensor

The modified tensor.

Source code in evotorch/tools/misc.py
@torch.no_grad()
def modify_tensor(
    original: torch.Tensor,
    target: torch.Tensor,
    lb: Optional[Union[float, torch.Tensor]] = None,
    ub: Optional[Union[float, torch.Tensor]] = None,
    max_change: Optional[Union[float, torch.Tensor]] = None,
    in_place: bool = False,
) -> torch.Tensor:
    """Return the modified version of the original tensor, with bounds checking.

    Args:
        original: The original tensor.
        target: The target tensor which contains the values to replace the
            old ones in the original tensor.
        lb: The lower bound(s), as a scalar or as an tensor.
            Values below these bounds are clipped in the resulting tensor.
            None means -inf.
        ub: The upper bound(s), as a scalar or as an tensor.
            Value above these bounds are clipped in the resulting tensor.
            None means +inf.
        max_change: The ratio of allowed change.
            In more details, when given as a real number r,
            modifications are allowed only within
            ``[original-(r*abs(original)) ... original+(r*abs(original))]``.
            Modifications beyond this interval are clipped.
            This argument can also be left as None if no such limitation
            is needed.
        in_place: Provide this as True if you wish the modification to be
            done within the original tensor. The default value of this
            argument is False, which means, the original tensor is not
            changed, and its modified version is returned as an independent
            copy.
    Returns:
        The modified tensor.
    """
    if (lb is None) and (ub is None) and (max_change is None):
        # If there is no restriction regarding how the tensor
        # should be modified (no lb, no ub, no max_change),
        # then we simply use the target values
        # themselves for modifying the tensor.

        if in_place:
            original[:] = target
            return original
        else:
            return target
    else:
        # If there are some restriction regarding how the tensor
        # should be modified, then we turn to the following
        # operations

        def convert_to_tensor(x, tensorname: str):
            if isinstance(x, torch.Tensor):
                converted = x
            else:
                converted = torch.as_tensor(x, dtype=original.dtype, device=original.device)
            if converted.ndim == 0 or converted.shape == original.shape:
                return converted
            else:
                raise IndexError(
                    f"Argument {tensorname}: shape mismatch."
                    f" Shape of the original tensor: {original.shape}."
                    f" Shape of {tensorname}: {converted.shape}."
                )

        if lb is None:
            # If lb is None, then it should be taken as -inf
            lb = convert_to_tensor(float("-inf"), "lb")
        else:
            lb = convert_to_tensor(lb, "lb")

        if ub is None:
            # If ub is None, then it should be taken as +inf
            ub = convert_to_tensor(float("inf"), "ub")
        else:
            ub = convert_to_tensor(ub, "ub")

        if max_change is not None:
            # If max_change is provided as something other than None,
            # then we update the lb and ub so that they are tight
            # enough to satisfy the max_change restriction.

            max_change = convert_to_tensor(max_change, "max_change")
            allowed_amounts = torch.abs(original) * max_change
            allowed_lb = original - allowed_amounts
            allowed_ub = original + allowed_amounts
            lb = torch.max(lb, allowed_lb)
            ub = torch.min(ub, allowed_ub)

        ## If in_place is given as True, the clipping (that we are about
        ## to perform), should be in-place.
        # more_config = {}
        # if in_place:
        #    more_config['out'] = original
        #
        ## Return the clipped version of the target values
        # return torch.clamp(target, lb, ub, **more_config)

        result = torch.max(target, lb)
        result = torch.min(result, ub)

        if in_place:
            original[:] = result
            return original
        else:
            return result

numpy_copy(x, dtype=None)

Return a numpy copy of the given iterable.

The newly created numpy array will be mutable, even if the original iterable object is read-only.

Parameters:

Name Type Description Default
x Iterable

Any Iterable whose numpy copy will be returned.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

The desired dtype. Can be given as a numpy dtype, as a torch dtype, or a native dtype (e.g. int, float), or as a string (e.g. "float32"). If left as None, dtype will be determined according to the data contained by the original iterable object.

None

Returns:

Type Description
ndarray

The numpy copy of the original iterable object.

Source code in evotorch/tools/misc.py
def numpy_copy(x: Iterable, dtype: Optional[DType] = None) -> np.ndarray:
    """
    Return a numpy copy of the given iterable.

    The newly created numpy array will be mutable, even if the
    original iterable object is read-only.

    Args:
        x: Any Iterable whose numpy copy will be returned.
        dtype: The desired dtype. Can be given as a numpy dtype,
            as a torch dtype, or a native dtype (e.g. int, float),
            or as a string (e.g. "float32").
            If left as None, dtype will be determined according
            to the data contained by the original iterable object.
    Returns:
        The numpy copy of the original iterable object.
    """
    from .objectarray import ObjectArray

    needs_casting = dtype is not None

    if isinstance(x, ObjectArray):
        result = x.numpy()
    elif isinstance(x, torch.Tensor):
        result = x.cpu().clone().numpy()
    elif isinstance(x, np.ndarray):
        result = x.copy()
    else:
        needs_casting = False
        result = np.array(x, dtype=dtype)

    if needs_casting:
        result = result.astype(dtype)

    return result

pass_info_if_needed(f, info)

Pass additional arguments into a callable, the info dictionary is unpacked and passed as additional keyword arguments only if the policy is decorated with the pass_info decorator.

Parameters:

Name Type Description Default
f Callable

The callable to be called.

required
info Dict[str, Any]

The info to be passed to the callable.

required

Returns:

Type Description
Callable

The callable with extra arguments

Exceptions:

Type Description
TypeError

If the callable is decorated with the pass_info decorator, but its signature does not match the expected signature.

Source code in evotorch/tools/misc.py
def pass_info_if_needed(f: Callable, info: Dict[str, Any]) -> Callable:
    """
    Pass additional arguments into a callable, the info dictionary is unpacked
    and passed as additional keyword arguments only if the policy is decorated
    with the [pass_info][evotorch.decorators.pass_info] decorator.

    Args:
        f (Callable): The callable to be called.
        info (Dict[str, Any]): The info to be passed to the callable.
    Returns:
        Callable: The callable with extra arguments
    Raises:
        TypeError: If the callable is decorated with the [pass_info][evotorch.decorators.pass_info] decorator,
            but its signature does not match the expected signature.
    """
    if hasattr(f, "__evotorch_pass_info__"):
        try:
            sig = inspect.signature(f)
            sig.bind_partial(**info)
        except TypeError:
            raise TypeError(
                "Callable {f} is decorated with @pass_info, but it doesn't expect some of the extra arguments "
                f"({', '.join(info.keys())}). Hint: maybe you forgot to add **kwargs to the function signature?"
            )
        except Exception:
            pass

        return functools.partial(f, **info)
    else:
        return f

set_default_logger_config(logger_name='evotorch', logger_level=20, show_process=True, show_lineno=False, override=False)

Configure the "EvoTorch" Python logger to print to the console with default format.

The logger will be configured to print to all messages with level INFO or lower to stdout and all messages with level WARNING or higher to stderr.

The default format is:

[2022-11-23 22:28:47] INFO     <75935>   evotorch:      This is a log message
{asctime}             {level}  {process} {logger_name}: {message}
The format can be slightly customized by passing show_process=False to hide Process ID or show_lineno=True to show the filename and line number of the log message instead of the Logger Name.

This function should be called before any other logging is performed, otherwise the default configuration will not be applied. If the logger is already configured, this function will do nothing unless override=True is passed, in which case the logger will be reconfigured.

Parameters:

Name Type Description Default
logger_name str

Name of the logger to configure.

'evotorch'
logger_level int

Level of the logger to configure.

20
show_process bool

Whether to show the process name in the log message.

True
show_lineno bool

Whether to show the filename with the line number in the log message or just the name of the logger.

False
override bool

Whether to override the logger configuration if it has already been configured.

False
Source code in evotorch/tools/misc.py
def set_default_logger_config(
    logger_name: str = "evotorch",
    logger_level: int = logging.INFO,
    show_process: bool = True,
    show_lineno: bool = False,
    override: bool = False,
):
    """
    Configure the "EvoTorch" Python logger to print to the console with default format.

    The logger will be configured to print to all messages with level INFO or lower to stdout and all
    messages with level WARNING or higher to stderr.

    The default format is:
    ```
    [2022-11-23 22:28:47] INFO     <75935>   evotorch:      This is a log message
    {asctime}             {level}  {process} {logger_name}: {message}
    ```
    The format can be slightly customized by passing `show_process=False` to hide Process ID or `show_lineno=True` to
    show the filename and line number of the log message instead of the Logger Name.

    This function should be called before any other logging is performed, otherwise the default configuration will
    not be applied. If the logger is already configured, this function will do nothing unless `override=True` is passed,
    in which case the logger will be reconfigured.

    Args:
        logger_name: Name of the logger to configure.
        logger_level: Level of the logger to configure.
        show_process: Whether to show the process name in the log message.
        show_lineno: Whether to show the filename with the line number in the log message or just the name of the logger.
        override: Whether to override the logger configuration if it has already been configured.
    """
    logger = logging.getLogger(logger_name)

    if not override and logger.hasHandlers():
        # warn user that the logger is already configured
        logger.warning(
            "The logger is already configured. "
            "The default configuration will not be applied. "
            "Call `set_default_logger_config` with `override=True` to override the current configuration."
        )
        return
    elif override:
        # remove all handlers
        for handler in logger.handlers:
            logger.removeHandler(handler)

    logger.setLevel(logger_level)
    logger.propagate = False

    formatter = logging.Formatter(
        "[{asctime}] "
        + "{levelname:<8s} "
        + ("<{process:5d}> " if show_process else "")
        + ("{filename}:{lineno}: " if show_lineno else "{name}: ")
        + "{message}",
        datefmt="%Y-%m-%d %H:%M:%S",
        style="{",
    )

    _stdout_handler = logging.StreamHandler(sys.stdout)
    _stdout_handler.addFilter(lambda log_record: log_record.levelno < logging.WARNING)
    _stdout_handler.setFormatter(formatter)
    logger.addHandler(_stdout_handler)

    _stderr_handler = logging.StreamHandler(sys.stderr)
    _stderr_handler.addFilter(lambda log_record: log_record.levelno >= logging.WARNING)
    _stderr_handler.setFormatter(formatter)
    logger.addHandler(_stderr_handler)

split_workload(workload, num_actors)

Split a workload among actors.

By "workload" what is meant is the total amount of a work, this amount being expressed by an integer. For example, if the "work" is the evaluation of a population, the "workload" would usually be the population size.

Parameters:

Name Type Description Default
workload int

Total amount of work, as an integer.

required
num_actors int

Number of actors (i.e. remote workers) among which the workload will be distributed.

required

Returns:

Type Description
list

A list of integers. The i-th item of the returned list expresses the suggested workload for the i-th actor.

Source code in evotorch/tools/misc.py
def split_workload(workload: int, num_actors: int) -> list:
    """
    Split a workload among actors.

    By "workload" what is meant is the total amount of a work,
    this amount being expressed by an integer.
    For example, if the "work" is the evaluation of a population,
    the "workload" would usually be the population size.

    Args:
        workload: Total amount of work, as an integer.
        num_actors: Number of actors (i.e. remote workers) among
            which the workload will be distributed.
    Returns:
        A list of integers. The i-th item of the returned list
        expresses the suggested workload for the i-th actor.
    """
    base_workload = workload // num_actors
    extra_workload = workload % num_actors
    result = [base_workload] * num_actors
    for i in range(extra_workload):
        result[i] += 1
    return result

stdev_from_radius(radius, solution_length)

Get elementwise standard deviation from a given radius.

Sometimes, for a distribution-based search algorithm, the user might choose to configure the initial coverage area of the search distribution not via standard deviation, but via a radius value, as was done in the study of Toklu et al. (2020). This function takes the desired radius value and the solution length of the problem at hand, and returns the elementwise standard deviation value. Let us name this returned standard deviation value as s. When a new Gaussian distribution is constructed such that its initial standard deviation is [s, s, s, ...] (the length of this vector being equal to the solution length), this constructed distribution's radius corresponds with the desired radius.

Here, the "radius" of a Gaussian distribution is defined as the norm of the standard deviation vector. In the case of a standard normal distribution, this radius formulation serves as a simplified approximation to E[||Normal(0, I)||] (for which a closer approximation is used in the study of Hansen & Ostermeier (2001)).

Reference:

Toklu, N.E., Liskowski, P., Srivastava, R.K. (2020).
ClipUp: A Simple and Powerful Optimizer
for Distribution-based Policy Evolution.
Parallel Problem Solving from Nature (PPSN 2020).

Nikolaus Hansen, Andreas Ostermeier (2001).
Completely Derandomized Self-Adaptation in Evolution Strategies.

Parameters:

Name Type Description Default
radius float

The radius whose elementwise standard deviation counterpart will be returned.

required
solution_length int

Length of a solution for the problem at hand.

required

Returns:

Type Description
float

An elementwise standard deviation value s, such that a Gaussian distribution constructed with the standard deviation [s, s, s, ...] has the desired radius.

Source code in evotorch/tools/misc.py
def stdev_from_radius(radius: float, solution_length: int) -> float:
    """
    Get elementwise standard deviation from a given radius.

    Sometimes, for a distribution-based search algorithm, the user might
    choose to configure the initial coverage area of the search distribution
    not via standard deviation, but via a radius value, as was done in the
    study of Toklu et al. (2020).
    This function takes the desired radius value and the solution length of
    the problem at hand, and returns the elementwise standard deviation value.
    Let us name this returned standard deviation value as `s`.
    When a new Gaussian distribution is constructed such that its initial
    standard deviation is `[s, s, s, ...]` (the length of this vector being
    equal to the solution length), this constructed distribution's radius
    corresponds with the desired radius.

    Here, the "radius" of a Gaussian distribution is defined as the norm
    of the standard deviation vector. In the case of a standard normal
    distribution, this radius formulation serves as a simplified approximation
    to `E[||Normal(0, I)||]` (for which a closer approximation is used in
    the study of Hansen & Ostermeier (2001)).

    Reference:

        Toklu, N.E., Liskowski, P., Srivastava, R.K. (2020).
        ClipUp: A Simple and Powerful Optimizer
        for Distribution-based Policy Evolution.
        Parallel Problem Solving from Nature (PPSN 2020).

        Nikolaus Hansen, Andreas Ostermeier (2001).
        Completely Derandomized Self-Adaptation in Evolution Strategies.

    Args:
        radius: The radius whose elementwise standard deviation counterpart
            will be returned.
        solution_length: Length of a solution for the problem at hand.
    Returns:
        An elementwise standard deviation value `s`, such that a Gaussian
        distribution constructed with the standard deviation `[s, s, s, ...]`
        has the desired radius.
    """
    radius = float(radius)
    solution_length = int(solution_length)
    return math.sqrt((radius**2) / solution_length)

storage_ptr(x)

Get the pointer to the underlying storage of a tensor of an ObjectArray.

Calling storage_ptr(x) is equivalent to x.untyped_storage().data_ptr().

Parameters:

Name Type Description Default
x Iterable

A regular PyTorch tensor, or a ReadOnlyTensor, or an ObjectArray.

required

Returns:

Type Description
int

The address of the underlying storage.

Source code in evotorch/tools/misc.py
def storage_ptr(x: Iterable) -> int:
    """
    Get the pointer to the underlying storage of a tensor of an ObjectArray.

    Calling `storage_ptr(x)` is equivalent to `x.untyped_storage().data_ptr()`.

    Args:
        x: A regular PyTorch tensor, or a ReadOnlyTensor, or an ObjectArray.
    Returns:
        The address of the underlying storage.
    """
    return _storage_ptr(x)

to_numpy_dtype(dtype)

Convert the given string or the given PyTorch dtype to a numpy dtype. If the argument is already a numpy dtype, then the argument is returned as it is.

Returns:

Type Description
dtype

The dtype, converted to a numpy dtype.

Source code in evotorch/tools/misc.py
def to_numpy_dtype(dtype: DType) -> np.dtype:
    """
    Convert the given string or the given PyTorch dtype to a numpy dtype.
    If the argument is already a numpy dtype, then the argument is returned
    as it is.

    Returns:
        The dtype, converted to a numpy dtype.
    """
    if isinstance(dtype, torch.dtype):
        return torch.tensor([], dtype=dtype).numpy().dtype
    elif is_dtype_object(dtype):
        return np.dtype(object)
    elif isinstance(dtype, np.dtype):
        return dtype
    else:
        return np.dtype(dtype)

to_stdev_init(*, solution_length, stdev_init=None, radius_init=None)

Ask for both standard deviation and radius, return the standard deviation.

It is very common among the distribution-based search algorithms to ask for both standard deviation and for radius for initializing the coverage area of the search distribution. During their initialization phases, these algorithms must check which one the user provided (radius or standard deviation), and return the result as the standard deviation so that a Gaussian distribution can easily be constructed.

This function serves as a helper function for such search algorithms by performing these actions:

  • If the user provided a standard deviation and not a radius, then this provided standard deviation is simply returned.
  • If the user provided a radius and not a standard deviation, then this provided radius is converted to its standard deviation counterpart, and then returned.
  • If both standard deviation and radius are missing, or they are both given at the same time, then an error is raised.

Parameters:

Name Type Description Default
solution_length int

Length of a solution for the problem at hand.

required
stdev_init Union[float, Iterable[float], torch.Tensor]

Standard deviation. If one wishes to provide a radius instead, then stdev_init is expected as None.

None
radius_init Union[float, Iterable[float], torch.Tensor]

Radius. If one wishes to provide a standard deviation instead, then radius_init is expected as None.

None

Returns:

Type Description
Union[float, Iterable[float], torch.Tensor]

The standard deviation for the search distribution to be constructed.

Source code in evotorch/tools/misc.py
def to_stdev_init(
    *,
    solution_length: int,
    stdev_init: Optional[RealOrVector] = None,
    radius_init: Optional[RealOrVector] = None,
) -> RealOrVector:
    """
    Ask for both standard deviation and radius, return the standard deviation.

    It is very common among the distribution-based search algorithms to ask
    for both standard deviation and for radius for initializing the coverage
    area of the search distribution. During their initialization phases,
    these algorithms must check which one the user provided (radius or
    standard deviation), and return the result as the standard deviation
    so that a Gaussian distribution can easily be constructed.

    This function serves as a helper function for such search algorithms
    by performing these actions:

    - If the user provided a standard deviation and not a radius, then this
      provided standard deviation is simply returned.
    - If the user provided a radius and not a standard deviation, then this
      provided radius is converted to its standard deviation counterpart,
      and then returned.
    - If both standard deviation and radius are missing, or they are both
      given at the same time, then an error is raised.

    Args:
        solution_length: Length of a solution for the problem at hand.
        stdev_init: Standard deviation. If one wishes to provide a radius
            instead, then `stdev_init` is expected as None.
        radius_init: Radius. If one wishes to provide a standard deviation
            instead, then `radius_init` is expected as None.
    Returns:
        The standard deviation for the search distribution to be constructed.
    """
    if (stdev_init is not None) and (radius_init is None):
        return stdev_init
    elif (stdev_init is None) and (radius_init is not None):
        return stdev_from_radius(radius_init, solution_length)
    elif (stdev_init is None) and (radius_init is None):
        raise ValueError(
            "Received both `stdev_init` and `radius_init` as None."
            " Please provide a value either for `stdev_init` or for `radius_init`."
        )
    else:
        raise ValueError(
            "Found both `stdev_init` and `radius_init` with values other than None."
            " Please provide a value either for `stdev_init` or for `radius_init`, but not for both."
        )

to_torch_dtype(dtype)

Convert the given string or the given numpy dtype to a PyTorch dtype. If the argument is already a PyTorch dtype, then the argument is returned as it is.

Returns:

Type Description
dtype

The dtype, converted to a PyTorch dtype.

Source code in evotorch/tools/misc.py
def to_torch_dtype(dtype: DType) -> torch.dtype:
    """
    Convert the given string or the given numpy dtype to a PyTorch dtype.
    If the argument is already a PyTorch dtype, then the argument is returned
    as it is.

    Returns:
        The dtype, converted to a PyTorch dtype.
    """
    if isinstance(dtype, str) and hasattr(torch, dtype):
        attrib_within_torch = getattr(torch, dtype)
    else:
        attrib_within_torch = None

    if isinstance(attrib_within_torch, torch.dtype):
        return attrib_within_torch
    elif isinstance(dtype, torch.dtype):
        return dtype
    elif dtype is Any or dtype is object:
        raise TypeError(f"Cannot make a numeric tensor with dtype {repr(dtype)}")
    else:
        return torch.from_numpy(np.array([], dtype=dtype)).dtype

objectarray

This module contains the ObjectArray class, which is an array-like data structure with an interface similar to PyTorch tensors, but with an ability to store arbitrary type of data (not just numbers).

ObjectArray (Sequence, RecursivePrintable)

An object container with an interface similar to PyTorch tensors.

It is strictly one-dimensional, and supports advanced indexing and slicing operations supported by PyTorch tensors.

An ObjectArray can store None values, strings, numbers, booleans, lists, sets, dictionaries, PyTorch tensors, and numpy arrays.

When a container (such as a list, dictionary, set, is placed into an ObjectArray, an immutable clone of this container is first created, and then this newly created immutable clone gets stored within the ObjectArray. This behavior is to prevent accidental modification of the stored data.

When a numeric array (such as a PyTorch tensor or a numpy array with a numeric dtype) is placed into an ObjectArray, the target ObjectArray first checks if the numeric array is read-only. If the numeric array is indeed read-only, then the array is put into the ObjectArray as it is. If the array is not read-only, then a read-only clone of the original numeric array is first created, and then this clone gets stored by the ObjectArray. This behavior has the following implications: (i) even when an ObjectArray is shared by multiple components of the program, the risk of accidental modification of the stored data through this shared ObjectArray is significantly reduced as the stored numeric arrays are read-only; (ii) although not recommended, one could still forcefully modify the numeric arrays stored by an ObjectArray by explicitly casting them as mutable arrays (in the case of a numpy array, one could forcefully set the WRITEABLE flag, and, in the case of a ReadOnlyTensor, one could forcefully cast it as a regular PyTorch tensor); (iii) if an already read-only array x is placed into an ObjectArray, but x shares its memory with a mutable array y, then the contents of the ObjectArray can be affected by modifying y. The implication (ii) is demonstrated as follows:

objs = ObjectArray(1)  # a single-element ObjectArray

# Place a numpy array into objs:
objs[0] = np.array([1, 2, 3], dtype=float)

# At this point, objs[0] is a read-only numpy array.
# objs[0] *= 2   # <- Not allowed

# Possible but NOT recommended:
objs.flags["WRITEABLE"] = True
objs[0] *= 2

The implication (iii) is demonstrated as follows:

objs = ObjectArray(1)  # a single-element ObjectArray

# Make a new mutable numpy array
y = np.array([1, 2, 3], dtype=float)

# Make a read-only view to y:
x = y[:]
x.flags["WRITEABLE"] = False

# Place x into objs.
objs[0] = x

# At this point, objs[0] is a read-only numpy array.
# objs[0] *= 2   # <- Not allowed

# During the operation of setting its 0-th item, the ObjectArray
# `objs` did not clone `x` because `x` was already read-only.
# However, the contents of `x` could actually be modified because
# `x` shares its memory with the mutable array `y`.

# Possible but NOT recommended:
y *= 2  # This affects both x and objs!

When a numpy array of dtype object is placed into an ObjectArray, a read-only ObjectArray copy of the original array will first be created, and then, this newly created ObjectArray will be stored by the outer ObjectArray.

An ObjectArray itself has a read-only mode, so that, in addition to its stored data, the ObjectArray itself can be protected against undesired modifications.

An interesting feature of PyTorch: if one slices a tensor A and the result is a new tensor B, and if B is sharing storage memory with A, then A.untyped_storage().data_ptr() and B.untyped_storage().data_ptr() will return the same pointer. This means, one can compare the storage pointers of A and B and see whether or not the two are sharing memory. ObjectArray was designed to have this exact behavior, so that one can understand if two ObjectArray instances are sharing memory. Note that NumPy does NOT have such a behavior. In more details, a NumPy array C and a NumPy array D could report different pointers even when D was created via a basic slicing operation on C.

Source code in evotorch/tools/objectarray.py
class ObjectArray(Sequence, RecursivePrintable):
    """
    An object container with an interface similar to PyTorch tensors.

    It is strictly one-dimensional, and supports advanced indexing and
    slicing operations supported by PyTorch tensors.

    An ObjectArray can store `None` values, strings, numbers, booleans,
    lists, sets, dictionaries, PyTorch tensors, and numpy arrays.

    When a container (such as a list, dictionary, set, is placed into an
    ObjectArray, an immutable clone of this container is first created, and
    then this newly created immutable clone gets stored within the
    ObjectArray. This behavior is to prevent accidental modification of the
    stored data.

    When a numeric array (such as a PyTorch tensor or a numpy array with a
    numeric dtype) is placed into an ObjectArray, the target ObjectArray
    first checks if the numeric array is read-only. If the numeric array
    is indeed read-only, then the array is put into the ObjectArray as it
    is. If the array is not read-only, then a read-only clone of the
    original numeric array is first created, and then this clone gets
    stored by the ObjectArray. This behavior has the following implications:
    (i) even when an ObjectArray is shared by multiple components of the
    program, the risk of accidental modification of the stored data through
    this shared ObjectArray is significantly reduced as the stored numeric
    arrays are read-only;
    (ii) although not recommended, one could still forcefully modify the
    numeric arrays stored by an ObjectArray by explicitly casting them as
    mutable arrays
    (in the case of a numpy array, one could forcefully set the WRITEABLE
    flag, and, in the case of a ReadOnlyTensor, one could forcefully cast it
    as a regular PyTorch tensor);
    (iii) if an already read-only array `x` is placed into an ObjectArray,
    but `x` shares its memory with a mutable array `y`, then the contents
    of the ObjectArray can be affected by modifying `y`.
    The implication (ii) is demonstrated as follows:

    ```python
    objs = ObjectArray(1)  # a single-element ObjectArray

    # Place a numpy array into objs:
    objs[0] = np.array([1, 2, 3], dtype=float)

    # At this point, objs[0] is a read-only numpy array.
    # objs[0] *= 2   # <- Not allowed

    # Possible but NOT recommended:
    objs.flags["WRITEABLE"] = True
    objs[0] *= 2
    ```

    The implication (iii) is demonstrated as follows:

    ```python
    objs = ObjectArray(1)  # a single-element ObjectArray

    # Make a new mutable numpy array
    y = np.array([1, 2, 3], dtype=float)

    # Make a read-only view to y:
    x = y[:]
    x.flags["WRITEABLE"] = False

    # Place x into objs.
    objs[0] = x

    # At this point, objs[0] is a read-only numpy array.
    # objs[0] *= 2   # <- Not allowed

    # During the operation of setting its 0-th item, the ObjectArray
    # `objs` did not clone `x` because `x` was already read-only.
    # However, the contents of `x` could actually be modified because
    # `x` shares its memory with the mutable array `y`.

    # Possible but NOT recommended:
    y *= 2  # This affects both x and objs!
    ```

    When a numpy array of dtype object is placed into an ObjectArray,
    a read-only ObjectArray copy of the original array will first be
    created, and then, this newly created ObjectArray will be stored
    by the outer ObjectArray.

    An ObjectArray itself has a read-only mode, so that, in addition to its
    stored data, the ObjectArray itself can be protected against undesired
    modifications.

    An interesting feature of PyTorch: if one slices a tensor A and the
    result is a new tensor B, and if B is sharing storage memory with A,
    then A.untyped_storage().data_ptr() and B.untyped_storage().data_ptr()
    will return the same pointer. This means, one can compare the storage
    pointers of A and B and see whether or not the two are sharing memory.
    ObjectArray was designed to have this exact behavior, so that one
    can understand if two ObjectArray instances are sharing memory.
    Note that NumPy does NOT have such a behavior. In more details,
    a NumPy array C and a NumPy array D could report different pointers
    even when D was created via a basic slicing operation on C.
    """

    def __init__(
        self,
        size: Optional[Size] = None,
        *,
        slice_of: Optional[tuple] = None,
    ):
        """
        `__init__(...)`: Instantiate a new ObjectArray.

        Args:
            size: Length of the ObjectArray. If this argument is present and
                is an integer `n`, then the resulting ObjectArray will be
                of length `n`, and will be filled with `None` values.
                This argument cannot be used together with the keyword
                argument `slice_of`.
            slice_of: Optionally a tuple in the form
                `(original_object_tensor, slice_info)`.
                When this argument is present, then the resulting ObjectArray
                will be a slice of the given `original_object_tensor` (which
                is expected as an ObjectArray instance). `slice_info` is
                either a `slice` instance, or a sequence of integers.
                The resulting ObjectArray might be a view of
                `original_object_tensor` (i.e. it might share its memory with
                `original_object_tensor`).
                This keyword argument cannot be used together with the
                argument `size`.
        """
        if size is not None and slice_of is not None:
            raise ValueError("Expected either `size` argument or `slice_of` argument, but got both.")
        elif size is None and slice_of is None:
            raise ValueError("Expected either `size` argument or `slice_of` argument, but got none.")
        elif size is not None:
            if not is_sequence(size):
                length = size
            elif isinstance(size, (np.ndarray, torch.Tensor)) and (size.ndim > 1):
                raise ValueError(f"Invalid size: {size}")
            else:
                [length] = size
            length = int(length)
            self._indices = torch.arange(length, dtype=torch.int64)
            self._objects = [None] * length
        elif slice_of is not None:
            source: ObjectArray

            source, slicing = slice_of

            if not isinstance(source, ObjectArray):
                raise TypeError(
                    f"`slice_of`: The first element was expected as an ObjectArray."
                    f" But it is of type {repr(type(source))}"
                )

            if isinstance(slicing, tuple) or is_integer(slicing):
                raise TypeError(f"Invalid slice: {slicing}")

            self._indices = source._indices[slicing]
            self._objects = source._objects

            if storage_ptr(self._indices) != storage_ptr(source._indices):
                self._objects = clone(self._objects)

        self._device = torch.device("cpu")
        self._read_only = False

    @property
    def shape(self) -> Size:
        """Shape of the ObjectArray, as a PyTorch Size tuple."""
        return self._indices.shape

    def size(self) -> Size:
        """
        Get the size of the ObjectArray, as a PyTorch Size tuple.

        Returns:
            The size (i.e. the shape) of the ObjectArray.
        """
        return self._indices.size()

    @property
    def ndim(self) -> int:
        """
        Number of dimensions handled by the ObjectArray.
        This is equivalent to getting the length of the size tuple.
        """
        return self._indices.ndim

    def dim(self) -> int:
        """
        Get the number of dimensions handled by the ObjectArray.
        This is equivalent to getting the length of the size tuple.

        Returns:
            The number of dimensions, as an integer.
        """
        return self._indices.dim()

    def numel(self) -> int:
        """
        Number of elements stored by the ObjectArray.

        Returns:
            The number of elements, as an integer.
        """
        return self._indices.numel()

    def repeat(self, *sizes) -> "ObjectArray":
        """
        Repeat the contents of this ObjectArray.

        For example, if we have an ObjectArray `objs` which stores
        `["hello", "world"]`, the following line:

            objs.repeat(3)

        will result in an ObjectArray which stores:

            `["hello", "world", "hello", "world", "hello", "world"]`

        Args:
            sizes: Although this argument is named `sizes` to be compatible
                with PyTorch, what is expected here is a single positional
                argument, as a single integer, or as a single-element
                tuple.
                The given integer (which can be the argument itself, or
                the integer within the given single-element tuple),
                specifies how many times the stored sequence will be
                repeated.
        Returns:
            A new ObjectArray which repeats the original one's values
        """

        if len(sizes) != 1:
            type_name = type(self).__name__
            raise ValueError(
                f"The `repeat(...)` method of {type_name} expects exactly one positional argument."
                f" This is because {type_name} supports only 1-dimensional storage."
                f" The received positional arguments are: {sizes}."
            )
        if isinstance(sizes, tuple):
            if len(sizes) == 1:
                sizes = sizes[0]
            else:
                type_name = type(self).__name__
                raise ValueError(
                    f"The `repeat(...)` method of {type_name} can accept a size tuple with only one element."
                    f" This is because {type_name} supports only 1-dimensional storage."
                    f" The received size tuple is: {sizes}."
                )
        num_repetitions = int(sizes[0])
        self_length = len(self)
        result = ObjectArray(num_repetitions * self_length)

        source_index = 0
        for result_index in range(len(result)):
            result[result_index] = self[source_index]
            source_index = (source_index + 1) % self_length

        return result

    @property
    def device(self) -> Device:
        """
        The device which stores the elements of the ObjectArray.
        In the case of ObjectArray, this property always returns
        the CPU device.

        Returns:
            The CPU device, as a torch.device object.
        """
        return self._device

    @property
    def dtype(self) -> DType:
        """
        The dtype of the elements stored by the ObjectArray.
        In the case of ObjectArray, the dtype is always `object`.
        """
        return object

    def __getitem__(self, i: Any) -> Any:
        if is_integer(i):
            index = int(self._indices[i])
            return self._objects[index]
        else:
            indices = self._indices[i]

            same_ptr = storage_ptr(indices) == storage_ptr(self._indices)

            result = ObjectArray(len(indices))

            if same_ptr:
                result._indices[:] = indices
                result._objects = self._objects
            else:
                result._objects = []
                for index in indices:
                    result._objects.append(self._objects[int(index)])

            result._read_only = self._read_only

            return result

    def __setitem__(self, i: Any, x: Any):
        self.set_item(i, x)

    def set_item(self, i: Any, x: Any, *, memo: Optional[dict] = None):
        """
        Set the i-th item of the ObjectArray as x.

        Args:
            i: An index or a slice.
            x: The object that will be put into the ObjectArray.
            memo: Optionally a dictionary which maps from the ids of the
                already placed objects to their clones within ObjectArray.
                In most scenarios, when this method is called from outside,
                this can be left as None.
        """
        from .immutable import as_immutable

        if memo is None:
            memo = {}

        memo[id(self)] = self

        if self._read_only:
            raise ValueError("This ObjectArray is read-only, therefore, modification is not allowed.")

        if is_integer(i):
            index = int(self._indices[i])
            self._objects[index] = as_immutable(x, memo=memo)
        else:
            indices = self._indices[i]
            if not isinstance(x, Iterable):
                raise TypeError(f"Expected an iterable, but got {repr(x)}")

            if indices.ndim != 1:
                raise ValueError(
                    "Received indices that would change the dimensionality of the ObjectArray."
                    " However, an ObjectArray can only be 1-dimensional."
                )

            slice_refers_to_whole_array = (len(indices) == len(self._indices)) and torch.all(indices == self._indices)
            if slice_refers_to_whole_array:
                memo[id(x)] = self

            if not hasattr(x, "__len__"):
                x = list(x)

            if len(x) != len(indices):
                raise TypeError(
                    f"The slicing operation refers to {len(indices)} elements."
                    f" However, the given objects sequence has {len(x)} elements."
                )

            for q, obj in enumerate(x):
                index = int(indices[q])
                self._objects[index] = as_immutable(obj, memo=memo)

    def __len__(self) -> int:
        return len(self._indices)

    def __iter__(self):
        for i in range(len(self)):
            yield self[i]

    def clone(self, *, preserve_read_only: bool = False, memo: Optional[dict] = None) -> Iterable:
        """
        Get a deep copy of the ObjectArray.

        Args:
            preserve_read_only: Whether or not to preserve the read-only
                attribute. Note that the default value is False, which
                means that the newly made clone will NOT be read-only
                even if the original ObjectArray is.
            memo: Optionally a dictionary which maps from the ids of the
                already cloned objects to their clones.
                In most scenarios, when this method is called from outside,
                this can be left as None.
        Returns:
            The clone of the original ObjectArray.
        """
        from .cloning import deep_clone

        if memo is None:
            memo = {}

        self_id = id(self)
        if self_id in memo:
            return memo[self_id]

        if not preserve_read_only:
            return self.numpy(memo=memo)
        else:
            result = ObjectArray(len(self))
            memo[self_id] = result

            for i, item in enumerate(self):
                result[i] = deep_clone(item, otherwise_deepcopy=True, memo=memo)

            return result

    def __copy__(self) -> "ObjectArray":
        return self.clone(preserve_read_only=True)

    def __deepcopy__(self, memo: Optional[dict]) -> "ObjectArray":
        if memo is None:
            memo = {}
        return self.clone(preserve_read_only=True, memo=memo)

    def __setstate__(self, state: dict):
        self.__dict__.update(state)

        # After pickling and unpickling, numpy arrays become mutable.
        # Since we are dealing with immutable containers here, we need to forcefully make all numpy arrays read-only.
        for v in self:
            if isinstance(v, np.ndarray):
                v.flags["WRITEABLE"] = False

    # def __getstate__(self) -> dict:
    #     from .cloning import deep_clone
    #     self_id = id(self)
    #     memo = {self_id: self}
    #     cloned_dict = deep_clone(self.__dict__, otherwise_deepcopy=True, memo=memo)
    #     return cloned_dict

    def get_read_only_view(self) -> "ObjectArray":
        """
        Get a read-only view of this ObjectArray.
        """
        result = self[:]
        result._read_only = True
        return result

    @property
    def is_read_only(self) -> bool:
        """
        True if this ObjectArray is read-only; False otherwise.
        """
        return self._read_only

    def storage(self) -> ObjectArrayStorage:
        return ObjectArrayStorage(self)

    def untyped_storage(self) -> ObjectArrayStorage:
        return ObjectArrayStorage(self)

    def numpy(self, *, memo: Optional[dict] = None) -> np.ndarray:
        """
        Convert this ObjectArray to a numpy array.

        The resulting numpy array will have its dtype set as `object`.
        This new array itself and its contents will be mutable (those
        mutable objects being the copies of their immutable sources).

        Returns:
            The numpy counterpart of this ObjectArray.
        """
        from .immutable import mutable_copy

        if memo is None:
            memo = {}

        n = len(self)
        result = np.empty(n, dtype=object)

        memo[id(self)] = result

        for i, item in enumerate(self):
            result[i] = mutable_copy(item, memo=memo)

        return result

    @staticmethod
    def from_numpy(ndarray: np.ndarray) -> "ObjectArray":
        """
        Convert a numpy array of dtype `object` to an `ObjectArray`.

        Args:
            ndarray: The numpy array that will be converted to `ObjectArray`.
        Returns:
            The ObjectArray counterpart of the given numpy array.
        """
        if isinstance(ndarray, np.ndarray):
            if ndarray.dtype == np.dtype(object):
                n = len(ndarray)
                result = ObjectArray(n)
                for i, element in enumerate(ndarray):
                    result[i] = element
                return result
            else:
                raise ValueError(
                    f"The dtype of the given array was expected as `object`."
                    f" However, the dtype was encountered as {ndarray.dtype}."
                )
        else:
            raise TypeError(f"Expected a `numpy.ndarray` instance, but received an object of type {type(ndarray)}.")
device: Union[str, torch.device] property readonly

The device which stores the elements of the ObjectArray. In the case of ObjectArray, this property always returns the CPU device.

Returns:

Type Description
Union[str, torch.device]

The CPU device, as a torch.device object.

dtype: Union[str, torch.dtype, numpy.dtype, Type] property readonly

The dtype of the elements stored by the ObjectArray. In the case of ObjectArray, the dtype is always object.

is_read_only: bool property readonly

True if this ObjectArray is read-only; False otherwise.

ndim: int property readonly

Number of dimensions handled by the ObjectArray. This is equivalent to getting the length of the size tuple.

shape: Union[int, torch.Size] property readonly

Shape of the ObjectArray, as a PyTorch Size tuple.

__init__(self, size=None, *, slice_of=None) special

__init__(...): Instantiate a new ObjectArray.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Length of the ObjectArray. If this argument is present and is an integer n, then the resulting ObjectArray will be of length n, and will be filled with None values. This argument cannot be used together with the keyword argument slice_of.

None
slice_of Optional[tuple]

Optionally a tuple in the form (original_object_tensor, slice_info). When this argument is present, then the resulting ObjectArray will be a slice of the given original_object_tensor (which is expected as an ObjectArray instance). slice_info is either a slice instance, or a sequence of integers. The resulting ObjectArray might be a view of original_object_tensor (i.e. it might share its memory with original_object_tensor). This keyword argument cannot be used together with the argument size.

None
Source code in evotorch/tools/objectarray.py
def __init__(
    self,
    size: Optional[Size] = None,
    *,
    slice_of: Optional[tuple] = None,
):
    """
    `__init__(...)`: Instantiate a new ObjectArray.

    Args:
        size: Length of the ObjectArray. If this argument is present and
            is an integer `n`, then the resulting ObjectArray will be
            of length `n`, and will be filled with `None` values.
            This argument cannot be used together with the keyword
            argument `slice_of`.
        slice_of: Optionally a tuple in the form
            `(original_object_tensor, slice_info)`.
            When this argument is present, then the resulting ObjectArray
            will be a slice of the given `original_object_tensor` (which
            is expected as an ObjectArray instance). `slice_info` is
            either a `slice` instance, or a sequence of integers.
            The resulting ObjectArray might be a view of
            `original_object_tensor` (i.e. it might share its memory with
            `original_object_tensor`).
            This keyword argument cannot be used together with the
            argument `size`.
    """
    if size is not None and slice_of is not None:
        raise ValueError("Expected either `size` argument or `slice_of` argument, but got both.")
    elif size is None and slice_of is None:
        raise ValueError("Expected either `size` argument or `slice_of` argument, but got none.")
    elif size is not None:
        if not is_sequence(size):
            length = size
        elif isinstance(size, (np.ndarray, torch.Tensor)) and (size.ndim > 1):
            raise ValueError(f"Invalid size: {size}")
        else:
            [length] = size
        length = int(length)
        self._indices = torch.arange(length, dtype=torch.int64)
        self._objects = [None] * length
    elif slice_of is not None:
        source: ObjectArray

        source, slicing = slice_of

        if not isinstance(source, ObjectArray):
            raise TypeError(
                f"`slice_of`: The first element was expected as an ObjectArray."
                f" But it is of type {repr(type(source))}"
            )

        if isinstance(slicing, tuple) or is_integer(slicing):
            raise TypeError(f"Invalid slice: {slicing}")

        self._indices = source._indices[slicing]
        self._objects = source._objects

        if storage_ptr(self._indices) != storage_ptr(source._indices):
            self._objects = clone(self._objects)

    self._device = torch.device("cpu")
    self._read_only = False
clone(self, *, preserve_read_only=False, memo=None)

Get a deep copy of the ObjectArray.

Parameters:

Name Type Description Default
preserve_read_only bool

Whether or not to preserve the read-only attribute. Note that the default value is False, which means that the newly made clone will NOT be read-only even if the original ObjectArray is.

False
memo Optional[dict]

Optionally a dictionary which maps from the ids of the already cloned objects to their clones. In most scenarios, when this method is called from outside, this can be left as None.

None

Returns:

Type Description
Iterable

The clone of the original ObjectArray.

Source code in evotorch/tools/objectarray.py
def clone(self, *, preserve_read_only: bool = False, memo: Optional[dict] = None) -> Iterable:
    """
    Get a deep copy of the ObjectArray.

    Args:
        preserve_read_only: Whether or not to preserve the read-only
            attribute. Note that the default value is False, which
            means that the newly made clone will NOT be read-only
            even if the original ObjectArray is.
        memo: Optionally a dictionary which maps from the ids of the
            already cloned objects to their clones.
            In most scenarios, when this method is called from outside,
            this can be left as None.
    Returns:
        The clone of the original ObjectArray.
    """
    from .cloning import deep_clone

    if memo is None:
        memo = {}

    self_id = id(self)
    if self_id in memo:
        return memo[self_id]

    if not preserve_read_only:
        return self.numpy(memo=memo)
    else:
        result = ObjectArray(len(self))
        memo[self_id] = result

        for i, item in enumerate(self):
            result[i] = deep_clone(item, otherwise_deepcopy=True, memo=memo)

        return result
dim(self)

Get the number of dimensions handled by the ObjectArray. This is equivalent to getting the length of the size tuple.

Returns:

Type Description
int

The number of dimensions, as an integer.

Source code in evotorch/tools/objectarray.py
def dim(self) -> int:
    """
    Get the number of dimensions handled by the ObjectArray.
    This is equivalent to getting the length of the size tuple.

    Returns:
        The number of dimensions, as an integer.
    """
    return self._indices.dim()
from_numpy(ndarray) staticmethod

Convert a numpy array of dtype object to an ObjectArray.

Parameters:

Name Type Description Default
ndarray ndarray

The numpy array that will be converted to ObjectArray.

required

Returns:

Type Description
ObjectArray

The ObjectArray counterpart of the given numpy array.

Source code in evotorch/tools/objectarray.py
@staticmethod
def from_numpy(ndarray: np.ndarray) -> "ObjectArray":
    """
    Convert a numpy array of dtype `object` to an `ObjectArray`.

    Args:
        ndarray: The numpy array that will be converted to `ObjectArray`.
    Returns:
        The ObjectArray counterpart of the given numpy array.
    """
    if isinstance(ndarray, np.ndarray):
        if ndarray.dtype == np.dtype(object):
            n = len(ndarray)
            result = ObjectArray(n)
            for i, element in enumerate(ndarray):
                result[i] = element
            return result
        else:
            raise ValueError(
                f"The dtype of the given array was expected as `object`."
                f" However, the dtype was encountered as {ndarray.dtype}."
            )
    else:
        raise TypeError(f"Expected a `numpy.ndarray` instance, but received an object of type {type(ndarray)}.")
get_read_only_view(self)

Get a read-only view of this ObjectArray.

Source code in evotorch/tools/objectarray.py
def get_read_only_view(self) -> "ObjectArray":
    """
    Get a read-only view of this ObjectArray.
    """
    result = self[:]
    result._read_only = True
    return result
numel(self)

Number of elements stored by the ObjectArray.

Returns:

Type Description
int

The number of elements, as an integer.

Source code in evotorch/tools/objectarray.py
def numel(self) -> int:
    """
    Number of elements stored by the ObjectArray.

    Returns:
        The number of elements, as an integer.
    """
    return self._indices.numel()
numpy(self, *, memo=None)

Convert this ObjectArray to a numpy array.

The resulting numpy array will have its dtype set as object. This new array itself and its contents will be mutable (those mutable objects being the copies of their immutable sources).

Returns:

Type Description
ndarray

The numpy counterpart of this ObjectArray.

Source code in evotorch/tools/objectarray.py
def numpy(self, *, memo: Optional[dict] = None) -> np.ndarray:
    """
    Convert this ObjectArray to a numpy array.

    The resulting numpy array will have its dtype set as `object`.
    This new array itself and its contents will be mutable (those
    mutable objects being the copies of their immutable sources).

    Returns:
        The numpy counterpart of this ObjectArray.
    """
    from .immutable import mutable_copy

    if memo is None:
        memo = {}

    n = len(self)
    result = np.empty(n, dtype=object)

    memo[id(self)] = result

    for i, item in enumerate(self):
        result[i] = mutable_copy(item, memo=memo)

    return result
repeat(self, *sizes)

Repeat the contents of this ObjectArray.

For example, if we have an ObjectArray objs which stores ["hello", "world"], the following line:

objs.repeat(3)

will result in an ObjectArray which stores:

`["hello", "world", "hello", "world", "hello", "world"]`

Parameters:

Name Type Description Default
sizes

Although this argument is named sizes to be compatible with PyTorch, what is expected here is a single positional argument, as a single integer, or as a single-element tuple. The given integer (which can be the argument itself, or the integer within the given single-element tuple), specifies how many times the stored sequence will be repeated.

()

Returns:

Type Description
ObjectArray

A new ObjectArray which repeats the original one's values

Source code in evotorch/tools/objectarray.py
def repeat(self, *sizes) -> "ObjectArray":
    """
    Repeat the contents of this ObjectArray.

    For example, if we have an ObjectArray `objs` which stores
    `["hello", "world"]`, the following line:

        objs.repeat(3)

    will result in an ObjectArray which stores:

        `["hello", "world", "hello", "world", "hello", "world"]`

    Args:
        sizes: Although this argument is named `sizes` to be compatible
            with PyTorch, what is expected here is a single positional
            argument, as a single integer, or as a single-element
            tuple.
            The given integer (which can be the argument itself, or
            the integer within the given single-element tuple),
            specifies how many times the stored sequence will be
            repeated.
    Returns:
        A new ObjectArray which repeats the original one's values
    """

    if len(sizes) != 1:
        type_name = type(self).__name__
        raise ValueError(
            f"The `repeat(...)` method of {type_name} expects exactly one positional argument."
            f" This is because {type_name} supports only 1-dimensional storage."
            f" The received positional arguments are: {sizes}."
        )
    if isinstance(sizes, tuple):
        if len(sizes) == 1:
            sizes = sizes[0]
        else:
            type_name = type(self).__name__
            raise ValueError(
                f"The `repeat(...)` method of {type_name} can accept a size tuple with only one element."
                f" This is because {type_name} supports only 1-dimensional storage."
                f" The received size tuple is: {sizes}."
            )
    num_repetitions = int(sizes[0])
    self_length = len(self)
    result = ObjectArray(num_repetitions * self_length)

    source_index = 0
    for result_index in range(len(result)):
        result[result_index] = self[source_index]
        source_index = (source_index + 1) % self_length

    return result
set_item(self, i, x, *, memo=None)

Set the i-th item of the ObjectArray as x.

Parameters:

Name Type Description Default
i Any

An index or a slice.

required
x Any

The object that will be put into the ObjectArray.

required
memo Optional[dict]

Optionally a dictionary which maps from the ids of the already placed objects to their clones within ObjectArray. In most scenarios, when this method is called from outside, this can be left as None.

None
Source code in evotorch/tools/objectarray.py
def set_item(self, i: Any, x: Any, *, memo: Optional[dict] = None):
    """
    Set the i-th item of the ObjectArray as x.

    Args:
        i: An index or a slice.
        x: The object that will be put into the ObjectArray.
        memo: Optionally a dictionary which maps from the ids of the
            already placed objects to their clones within ObjectArray.
            In most scenarios, when this method is called from outside,
            this can be left as None.
    """
    from .immutable import as_immutable

    if memo is None:
        memo = {}

    memo[id(self)] = self

    if self._read_only:
        raise ValueError("This ObjectArray is read-only, therefore, modification is not allowed.")

    if is_integer(i):
        index = int(self._indices[i])
        self._objects[index] = as_immutable(x, memo=memo)
    else:
        indices = self._indices[i]
        if not isinstance(x, Iterable):
            raise TypeError(f"Expected an iterable, but got {repr(x)}")

        if indices.ndim != 1:
            raise ValueError(
                "Received indices that would change the dimensionality of the ObjectArray."
                " However, an ObjectArray can only be 1-dimensional."
            )

        slice_refers_to_whole_array = (len(indices) == len(self._indices)) and torch.all(indices == self._indices)
        if slice_refers_to_whole_array:
            memo[id(x)] = self

        if not hasattr(x, "__len__"):
            x = list(x)

        if len(x) != len(indices):
            raise TypeError(
                f"The slicing operation refers to {len(indices)} elements."
                f" However, the given objects sequence has {len(x)} elements."
            )

        for q, obj in enumerate(x):
            index = int(indices[q])
            self._objects[index] = as_immutable(obj, memo=memo)
size(self)

Get the size of the ObjectArray, as a PyTorch Size tuple.

Returns:

Type Description
Union[int, torch.Size]

The size (i.e. the shape) of the ObjectArray.

Source code in evotorch/tools/objectarray.py
def size(self) -> Size:
    """
    Get the size of the ObjectArray, as a PyTorch Size tuple.

    Returns:
        The size (i.e. the shape) of the ObjectArray.
    """
    return self._indices.size()

ranking

This module contains ranking functions which work with PyTorch tensors.

centered(fitnesses, *, higher_is_better=True)

Apply linearly spaced 0-centered ranking on a PyTorch tensor. The lowest weight is -0.5, and the highest weight is 0.5. This is the same ranking method that was used in:

Tim Salimans, Jonathan Ho, Xi Chen, Szymon Sidor, Ilya Sutskever (2017).
Evolution Strategies as a Scalable Alternative to Reinforcement Learning

Parameters:

Name Type Description Default
fitnesses Tensor

A PyTorch tensor which contains real numbers which we want to rank.

required
higher_is_better bool

Whether or not the higher values will be assigned higher ranks. Changing this to False means that lower values are interpreted as better, and therefore lower values will have higher ranks.

True

Returns:

Type Description
Tensor

The ranks, in the same device, with the same dtype with the original tensor.

Source code in evotorch/tools/ranking.py
def centered(fitnesses: torch.Tensor, *, higher_is_better: bool = True) -> torch.Tensor:
    """
    Apply linearly spaced 0-centered ranking on a PyTorch tensor.
    The lowest weight is -0.5, and the highest weight is 0.5.
    This is the same ranking method that was used in:

        Tim Salimans, Jonathan Ho, Xi Chen, Szymon Sidor, Ilya Sutskever (2017).
        Evolution Strategies as a Scalable Alternative to Reinforcement Learning

    Args:
        fitnesses: A PyTorch tensor which contains real numbers which we want
             to rank.
        higher_is_better: Whether or not the higher values will be assigned
             higher ranks. Changing this to False means that lower values
             are interpreted as better, and therefore lower values will have
             higher ranks.
    Returns:
        The ranks, in the same device, with the same dtype with the original
        tensor.
    """
    device = fitnesses.device
    dtype = fitnesses.dtype
    with torch.no_grad():
        x = fitnesses.reshape(-1)
        n = len(x)
        indices = x.argsort(descending=(not higher_is_better))
        weights = (torch.arange(n, dtype=dtype, device=device) / (n - 1)) - 0.5
        ranks = torch.empty_like(x)
        ranks[indices] = weights
        return ranks.reshape(*(fitnesses.shape))

linear(fitnesses, *, higher_is_better=True)

Apply linearly spaced ranking on a PyTorch tensor. The lowest weight is 0, and the highest weight is 1.

Parameters:

Name Type Description Default
fitnesses Tensor

A PyTorch tensor which contains real numbers which we want to rank.

required
higher_is_better bool

Whether or not the higher values will be assigned higher ranks. Changing this to False means that lower values are interpreted as better, and therefore lower values will have higher ranks.

True

Returns:

Type Description
Tensor

The ranks, in the same device, with the same dtype with the original tensor.

Source code in evotorch/tools/ranking.py
def linear(fitnesses: torch.Tensor, *, higher_is_better: bool = True) -> torch.Tensor:
    """
    Apply linearly spaced ranking on a PyTorch tensor.
    The lowest weight is 0, and the highest weight is 1.

    Args:
        fitnesses: A PyTorch tensor which contains real numbers which we want
             to rank.
        higher_is_better: Whether or not the higher values will be assigned
             higher ranks. Changing this to False means that lower values
             are interpreted as better, and therefore lower values will have
             higher ranks.
    Returns:
        The ranks, in the same device, with the same dtype with the original
        tensor.
    """
    device = fitnesses.device
    dtype = fitnesses.dtype
    with torch.no_grad():
        x = fitnesses.reshape(-1)
        n = len(x)
        indices = x.argsort(descending=(not higher_is_better))
        weights = torch.arange(n, dtype=dtype, device=device) / (n - 1)
        ranks = torch.empty_like(x)
        ranks[indices] = weights
        return ranks.reshape(*(fitnesses.shape))

nes(fitnesses, *, higher_is_better=True)

Apply the ranking mechanism proposed in:

Wierstra, D., Schaul, T., Glasmachers, T., Sun, Y., Peters, J., & Schmidhuber, J. (2014).
Natural evolution strategies. The Journal of Machine Learning Research, 15(1), 949-980.

Parameters:

Name Type Description Default
fitnesses Tensor

A PyTorch tensor which contains real numbers which we want to rank.

required
higher_is_better bool

Whether or not the higher values will be assigned higher ranks. Changing this to False means that lower values are interpreted as better, and therefore lower values will have higher ranks.

True

Returns:

Type Description
Tensor

The ranks, in the same device, with the same dtype with the original tensor.

Source code in evotorch/tools/ranking.py
def nes(fitnesses: torch.Tensor, *, higher_is_better: bool = True) -> torch.Tensor:
    """
    Apply the ranking mechanism proposed in:

        Wierstra, D., Schaul, T., Glasmachers, T., Sun, Y., Peters, J., & Schmidhuber, J. (2014).
        Natural evolution strategies. The Journal of Machine Learning Research, 15(1), 949-980.

    Args:
        fitnesses: A PyTorch tensor which contains real numbers which we want
             to rank.
        higher_is_better: Whether or not the higher values will be assigned
             higher ranks. Changing this to False means that lower values
             are interpreted as better, and therefore lower values will have
             higher ranks.
    Returns:
        The ranks, in the same device, with the same dtype with the original
        tensor.
    """
    device = fitnesses.device
    dtype = fitnesses.dtype

    with torch.no_grad():
        x = fitnesses.reshape(-1)
        n = len(x)

        incr_indices = torch.arange(n, dtype=dtype, device=device)
        N = torch.tensor(n, dtype=dtype, device=device)

        weights = torch.max(
            torch.tensor(0, dtype=dtype, device=device), torch.log((N / 2.0) + 1.0) - torch.log(N - incr_indices)
        )

        indices = torch.argsort(x, descending=(not higher_is_better))
        ranks = torch.empty(n, dtype=indices.dtype, device=device)
        ranks[indices] = torch.arange(n, dtype=indices.dtype, device=device)

        utils = weights[ranks]
        utils /= torch.sum(utils)
        utils -= 1 / N

        return utils.reshape(*(fitnesses.shape))

normalized(fitnesses, *, higher_is_better=True)

Normalize the fitnesses and return the result as ranks.

The normalization is done in such a way that the mean becomes 0.0 and the standard deviation becomes 1.0.

According to the value of higher_is_better, it will be ensured that better solutions will have numerically higher rank. In more details, if higher_is_better is set as False, then the fitnesses will be multiplied by -1.0 in addition to being subject to normalization.

Parameters:

Name Type Description Default
fitnesses Tensor

A PyTorch tensor which contains real numbers which we want to rank.

required
higher_is_better bool

Whether or not the higher values will be assigned higher ranks. Changing this to False means that lower values are interpreted as better, and therefore lower values will have higher ranks.

True

Returns:

Type Description
Tensor

The ranks, in the same device, with the same dtype with the original tensor.

Source code in evotorch/tools/ranking.py
def normalized(fitnesses: torch.Tensor, *, higher_is_better: bool = True) -> torch.Tensor:
    """
    Normalize the fitnesses and return the result as ranks.

    The normalization is done in such a way that the mean becomes 0.0 and
    the standard deviation becomes 1.0.

    According to the value of `higher_is_better`, it will be ensured that
    better solutions will have numerically higher rank.
    In more details, if `higher_is_better` is set as False, then the
    fitnesses will be multiplied by -1.0 in addition to being subject
    to normalization.

    Args:
        fitnesses: A PyTorch tensor which contains real numbers which we want
             to rank.
        higher_is_better: Whether or not the higher values will be assigned
             higher ranks. Changing this to False means that lower values
             are interpreted as better, and therefore lower values will have
             higher ranks.
    Returns:
        The ranks, in the same device, with the same dtype with the original
        tensor.
    """
    if not higher_is_better:
        fitnesses = -fitnesses

    fitness_mean = torch.mean(fitnesses)
    fitness_stdev = torch.std(fitnesses)

    fitnesses = fitnesses - fitness_mean
    fitnesses = fitnesses / fitness_stdev

    return fitnesses

rank(fitnesses, ranking_method, *, higher_is_better)

Get the ranks of the given sequence of numbers.

Better solutions will have numerically higher ranks.

Parameters:

Name Type Description Default
fitnesses Iterable[float]

A sequence of numbers to be ranked.

required
ranking_method str

The ranking method to be used. Can be "centered", which means 0-centered linear ranking from -0.5 to 0.5. Can be "linear", which means a linear ranking from 0 to 1. Can be "nes", which means the ranking method used by Natural Evolution Strategies. Can be "normalized", which means that the ranks will be the normalized counterparts of the fitnesses. Can be "raw", which means that the fitnesses themselves (or, if higher_is_better is False, their inverted counterparts, inversion meaning the operation of multiplying by -1 in this context) will be the ranks.

required
higher_is_better bool

Whether or not the higher values will be assigned higher ranks. Changing this to False means that lower values are interpreted as better, and therefore lower values will have higher ranks.

required
Source code in evotorch/tools/ranking.py
def rank(fitnesses: Iterable[float], ranking_method: str, *, higher_is_better: bool):
    """
    Get the ranks of the given sequence of numbers.

    Better solutions will have numerically higher ranks.

    Args:
        fitnesses: A sequence of numbers to be ranked.
        ranking_method: The ranking method to be used.
            Can be "centered", which means 0-centered linear ranking
                from -0.5 to 0.5.
            Can be "linear", which means a linear ranking from 0 to 1.
            Can be "nes", which means the ranking method used by
                Natural Evolution Strategies.
            Can be "normalized", which means that the ranks will be
                the normalized counterparts of the fitnesses.
            Can be "raw", which means that the fitnesses themselves
                (or, if `higher_is_better` is False, their inverted
                counterparts, inversion meaning the operation of
                multiplying by -1 in this context) will be the ranks.
        higher_is_better: Whether or not the higher values will be assigned
             higher ranks. Changing this to False means that lower values
             are interpreted as better, and therefore lower values will have
             higher ranks.
    """
    fitnesses = torch.as_tensor(fitnesses)
    rank_func = rankers[ranking_method]
    return rank_func(fitnesses, higher_is_better=higher_is_better)

raw(fitnesses, *, higher_is_better=True)

Return the fitnesses themselves as ranks.

If higher_is_better is given as False, then the fitnesses will first be multiplied by -1 and then the result will be returned as ranks.

Parameters:

Name Type Description Default
fitnesses Tensor

A PyTorch tensor which contains real numbers which we want to rank.

required
higher_is_better bool

Whether or not the higher values will be assigned higher ranks. Changing this to False means that lower values are interpreted as better, and therefore lower values will have higher ranks.

True

Returns:

Type Description
Tensor

The ranks, in the same device, with the same dtype with the original tensor.

Source code in evotorch/tools/ranking.py
def raw(fitnesses: torch.Tensor, *, higher_is_better: bool = True) -> torch.Tensor:
    """
    Return the fitnesses themselves as ranks.

    If `higher_is_better` is given as False, then the fitnesses will first
    be multiplied by -1 and then the result will be returned as ranks.

    Args:
        fitnesses: A PyTorch tensor which contains real numbers which we want
             to rank.
        higher_is_better: Whether or not the higher values will be assigned
             higher ranks. Changing this to False means that lower values
             are interpreted as better, and therefore lower values will have
             higher ranks.
    Returns:
        The ranks, in the same device, with the same dtype with the original
        tensor.
    """
    if not higher_is_better:
        fitnesses = -fitnesses
    return fitnesses

readonlytensor

ReadOnlyTensor (Tensor)

A special type of tensor which is read-only.

This is a subclass of torch.Tensor which explicitly disallows operations that would cause in-place modifications.

Since ReadOnlyTensor if a subclass of torch.Tensor, most non-destructive PyTorch operations are on this tensor are supported.

Cloning a ReadOnlyTensor using the clone() method or Python's deepcopy(...) function results in a regular PyTorch tensor.

Reshaping or slicing operations might return a ReadOnlyTensor if the result ends up being a view of the original ReadOnlyTensor; otherwise, the returned tensor is a regular torch.Tensor.

Source code in evotorch/tools/readonlytensor.py
class ReadOnlyTensor(torch.Tensor):
    """
    A special type of tensor which is read-only.

    This is a subclass of `torch.Tensor` which explicitly disallows
    operations that would cause in-place modifications.

    Since ReadOnlyTensor if a subclass of `torch.Tensor`, most
    non-destructive PyTorch operations are on this tensor are supported.

    Cloning a ReadOnlyTensor using the `clone()` method or Python's
    `deepcopy(...)` function results in a regular PyTorch tensor.

    Reshaping or slicing operations might return a ReadOnlyTensor if the
    result ends up being a view of the original ReadOnlyTensor; otherwise,
    the returned tensor is a regular `torch.Tensor`.
    """

    def __getattribute__(self, attribute_name: str) -> Any:
        if (
            isinstance(attribute_name, str)
            and attribute_name.endswith("_")
            and (not ((attribute_name.startswith("__")) and (attribute_name.endswith("__"))))
        ):
            raise AttributeError(
                f"A ReadOnlyTensor explicitly disables all members whose names end with '_'."
                f" Cannot access member {repr(attribute_name)}."
            )
        else:
            return super().__getattribute__(attribute_name)

    def __cannot_modify(self, *ignore, **ignore_too):
        raise TypeError("The contents of a ReadOnlyTensor cannot be modified")

    __setitem__ = __cannot_modify
    __iadd__ = __cannot_modify
    __iand__ = __cannot_modify
    __idiv__ = __cannot_modify
    __ifloordiv__ = __cannot_modify
    __ilshift__ = __cannot_modify
    __imatmul__ = __cannot_modify
    __imod__ = __cannot_modify
    __imul__ = __cannot_modify
    __ior__ = __cannot_modify
    __ipow__ = __cannot_modify
    __irshift__ = __cannot_modify
    __isub__ = __cannot_modify
    __itruediv__ = __cannot_modify
    __ixor__ = __cannot_modify

    if _torch_older_than_1_12:
        # Define __str__ and __repr__ for when using PyTorch 1.11 or older.
        # With PyTorch 1.12, overriding __str__ and __repr__ are not necessary.
        def __to_string(self) -> str:
            s = super().__repr__()
            if "\n" not in s:
                return f"ReadOnlyTensor({super().__repr__()})"
            else:
                indenter = " " * 4
                s = (indenter + s.replace("\n", "\n" + indenter)).rstrip()
                return f"ReadOnlyTensor(\n{s}\n)"

        __str__ = __to_string
        __repr__ = __to_string

    def clone(self, *, preserve_read_only: bool = False) -> torch.Tensor:
        result = super().clone()
        if not preserve_read_only:
            result = result.as_subclass(torch.Tensor)
        return result

    def __mutable_if_independent(self, other: torch.Tensor) -> torch.Tensor:
        from .misc import storage_ptr

        self_ptr = storage_ptr(self)
        other_ptr = storage_ptr(other)
        if self_ptr != other_ptr:
            other = other.as_subclass(torch.Tensor)
        return other

    def __getitem__(self, index_or_slice) -> torch.Tensor:
        result = super().__getitem__(index_or_slice)
        return self.__mutable_if_independent(result)

    def reshape(self, *args, **kwargs) -> torch.Tensor:
        result = super().reshape(*args, **kwargs)
        return self.__mutable_if_independent(result)

    def numpy(self) -> np.ndarray:
        arr: np.ndarray = torch.Tensor.numpy(self)
        arr.flags["WRITEABLE"] = False
        return arr

    def __array__(self, *args, **kwargs) -> np.ndarray:
        arr: np.ndarray = super().__array__(*args, **kwargs)
        arr.flags["WRITEABLE"] = False
        return arr

    def __copy__(self):
        return self.clone(preserve_read_only=True)

    def __deepcopy__(self, memo):
        return self.clone(preserve_read_only=True)

    @classmethod
    def __torch_function__(cls, func: Callable, types: Iterable, args: tuple = (), kwargs: Optional[Mapping] = None):
        if (kwargs is not None) and ("out" in kwargs):
            if isinstance(kwargs["out"], ReadOnlyTensor):
                raise TypeError(
                    f"The `out` keyword argument passed to {func} is a ReadOnlyTensor."
                    f" A ReadOnlyTensor explicitly fails when referenced via the `out` keyword argument of any torch"
                    f" function."
                    f" This restriction is for making sure that the torch operations which could normally do in-place"
                    f" modifications do not operate on ReadOnlyTensor instances."
                )
        return super().__torch_function__(func, types, args, kwargs)
__torch_function__(func, types, args=(), kwargs=None) classmethod special

This torch_function implementation wraps subclasses such that methods called on subclasses return a subclass instance instead of a torch.Tensor instance.

One corollary to this is that you need coverage for torch.Tensor methods if implementing torch_function for subclasses.

We recommend always calling super().__torch_function__ as the base case when doing the above.

While not mandatory, we recommend making __torch_function__ a classmethod.

Source code in evotorch/tools/readonlytensor.py
@classmethod
def __torch_function__(cls, func: Callable, types: Iterable, args: tuple = (), kwargs: Optional[Mapping] = None):
    if (kwargs is not None) and ("out" in kwargs):
        if isinstance(kwargs["out"], ReadOnlyTensor):
            raise TypeError(
                f"The `out` keyword argument passed to {func} is a ReadOnlyTensor."
                f" A ReadOnlyTensor explicitly fails when referenced via the `out` keyword argument of any torch"
                f" function."
                f" This restriction is for making sure that the torch operations which could normally do in-place"
                f" modifications do not operate on ReadOnlyTensor instances."
            )
    return super().__torch_function__(func, types, args, kwargs)
clone(self, *, preserve_read_only=False)

clone(*, memory_format=torch.preserve_format) -> Tensor

See :func:torch.clone

Source code in evotorch/tools/readonlytensor.py
def clone(self, *, preserve_read_only: bool = False) -> torch.Tensor:
    result = super().clone()
    if not preserve_read_only:
        result = result.as_subclass(torch.Tensor)
    return result
numpy(self)

numpy(*, force=False) -> numpy.ndarray

Returns the tensor as a NumPy :class:ndarray.

If :attr:force is False (the default), the conversion is performed only if the tensor is on the CPU, does not require grad, does not have its conjugate bit set, and is a dtype and layout that NumPy supports. The returned ndarray and the tensor will share their storage, so changes to the tensor will be reflected in the ndarray and vice versa.

If :attr:force is True this is equivalent to calling t.detach().cpu().resolve_conj().resolve_neg().numpy(). If the tensor isn't on the CPU or the conjugate or negative bit is set, the tensor won't share its storage with the returned ndarray. Setting :attr:force to True can be a useful shorthand.

Parameters:

Name Type Description Default
force bool

if True, the ndarray may be a copy of the tensor instead of always sharing memory, defaults to False.

required
Source code in evotorch/tools/readonlytensor.py
def numpy(self) -> np.ndarray:
    arr: np.ndarray = torch.Tensor.numpy(self)
    arr.flags["WRITEABLE"] = False
    return arr
reshape(self, *args, **kwargs)

reshape(*shape) -> Tensor

Returns a tensor with the same data and number of elements as :attr:self but with the specified shape. This method returns a view if :attr:shape is compatible with the current shape. See :meth:torch.Tensor.view on when it is possible to return a view.

See :func:torch.reshape

Parameters:

Name Type Description Default
shape tuple of ints or int...

the desired shape

required
Source code in evotorch/tools/readonlytensor.py
def reshape(self, *args, **kwargs) -> torch.Tensor:
    result = super().reshape(*args, **kwargs)
    return self.__mutable_if_independent(result)

as_read_only_tensor(x, *, dtype=None, device=None)

Convert the given object to a ReadOnlyTensor.

The provided object can be a scalar, or an Iterable of numeric data, or an ObjectArray.

This function can be thought as the read-only counterpart of PyTorch's torch.as_tensor(...) function.

Parameters:

Name Type Description Default
x Any

The object to be converted to a ReadOnlyTensor.

required
dtype Optional[torch.dtype]

The dtype of the new ReadOnlyTensor (e.g. torch.float32). If this argument is not specified, dtype will be inferred from x. For example, if x is a PyTorch tensor or a numpy array, its existing dtype will be kept.

None
device Union[str, torch.device]

The device in which the ReadOnlyTensor will be stored (e.g. "cpu"). If this argument is not specified, the device which is storing the original x will be re-used.

None

Returns:

Type Description
Iterable

The read-only counterpart of the provided object.

Source code in evotorch/tools/readonlytensor.py
def as_read_only_tensor(
    x: Any, *, dtype: Optional[torch.dtype] = None, device: Optional[Union[str, torch.device]] = None
) -> Iterable:
    """
    Convert the given object to a ReadOnlyTensor.

    The provided object can be a scalar, or an Iterable of numeric data,
    or an ObjectArray.

    This function can be thought as the read-only counterpart of PyTorch's
    `torch.as_tensor(...)` function.

    Args:
        x: The object to be converted to a ReadOnlyTensor.
        dtype: The dtype of the new ReadOnlyTensor (e.g. torch.float32).
            If this argument is not specified, dtype will be inferred from `x`.
            For example, if `x` is a PyTorch tensor or a numpy array, its
            existing dtype will be kept.
        device: The device in which the ReadOnlyTensor will be stored
            (e.g. "cpu").
            If this argument is not specified, the device which is storing
            the original `x` will be re-used.
    Returns:
        The read-only counterpart of the provided object.
    """
    from .objectarray import ObjectArray

    kwargs = _device_and_dtype_kwargs(dtype=dtype, device=device)
    if isinstance(x, ObjectArray):
        if len(kwargs) != 0:
            raise ValueError(
                f"read_only_tensor(...): when making a read-only tensor from an ObjectArray,"
                f" the arguments `dtype` and `device` were not expected."
                f" However, the received keyword arguments are: {kwargs}."
            )
        return x.get_read_only_view()
    else:
        return torch.as_tensor(x, **kwargs).as_subclass(ReadOnlyTensor)

read_only_tensor(x, *, dtype=None, device=None)

Make a ReadOnlyTensor from the given object.

The provided object can be a scalar, or an Iterable of numeric data, or an ObjectArray.

This function can be thought as the read-only counterpart of PyTorch's torch.tensor(...) function.

Parameters:

Name Type Description Default
x Any

The object from which the new ReadOnlyTensor will be made.

required
dtype Optional[torch.dtype]

The dtype of the new ReadOnlyTensor (e.g. torch.float32).

None
device Union[str, torch.device]

The device in which the ReadOnlyTensor will be stored (e.g. "cpu").

None

Returns:

Type Description
Iterable

The new read-only tensor.

Source code in evotorch/tools/readonlytensor.py
def read_only_tensor(
    x: Any, *, dtype: Optional[torch.dtype] = None, device: Optional[Union[str, torch.device]] = None
) -> Iterable:
    """
    Make a ReadOnlyTensor from the given object.

    The provided object can be a scalar, or an Iterable of numeric data,
    or an ObjectArray.

    This function can be thought as the read-only counterpart of PyTorch's
    `torch.tensor(...)` function.

    Args:
        x: The object from which the new ReadOnlyTensor will be made.
        dtype: The dtype of the new ReadOnlyTensor (e.g. torch.float32).
        device: The device in which the ReadOnlyTensor will be stored
            (e.g. "cpu").
    Returns:
        The new read-only tensor.
    """
    from .objectarray import ObjectArray

    kwargs = _device_and_dtype_kwargs(dtype=dtype, device=device)
    if isinstance(x, ObjectArray):
        if len(kwargs) != 0:
            raise ValueError(
                f"read_only_tensor(...): when making a read-only tensor from an ObjectArray,"
                f" the arguments `dtype` and `device` were not expected."
                f" However, the received keyword arguments are: {kwargs}."
            )
        return x.get_read_only_view()
    else:
        return torch.as_tensor(x, **kwargs).as_subclass(ReadOnlyTensor)

recursiveprintable

RecursivePrintable

A base class for making a class printable.

This base class considers custom container types which can recursively contain themselves (even in a cyclic manner). Classes inheriting from RecursivePrintable will gain a new ready-to-use method named to_string(...). This to_string(...) method, upon being called, checks if the current class is an Iterable or a Mapping, and prints the representation accordingly, with a recursion limit to avoid RecursionError. The methods __str__(...) and __repr__(...) are also defined as aliases of this to_string method.

Source code in evotorch/tools/recursiveprintable.py
class RecursivePrintable:
    """
    A base class for making a class printable.

    This base class considers custom container types which can recursively
    contain themselves (even in a cyclic manner). Classes inheriting from
    `RecursivePrintable` will gain a new ready-to-use method named
    `to_string(...)`. This `to_string(...)` method, upon being called,
    checks if the current class is an Iterable or a Mapping, and prints
    the representation accordingly, with a recursion limit to avoid
    `RecursionError`. The methods `__str__(...)` and `__repr__(...)`
    are also defined as aliases of this `to_string` method.
    """

    def to_string(self, *, max_depth: int = 10) -> str:
        if max_depth <= 0:
            return "<...>"

        def item_repr(x: Any) -> str:
            if isinstance(x, RecursivePrintable):
                return x.to_string(max_depth=(max_depth - 1))
            else:
                return repr(x)

        result = []

        def puts(*x: Any):
            for item_of_x in x:
                result.append(str(item_of_x))

        clsname = type(self).__name__
        first_one = True

        if isinstance(self, Mapping):
            puts(clsname, "({")
            for k, v in self.items():
                if first_one:
                    first_one = False
                else:
                    puts(", ")
                puts(item_repr(k), ": ", item_repr(v))
            puts("})")
        elif isinstance(self, Iterable):
            puts(clsname, "([")
            for v in self:
                if first_one:
                    first_one = False
                else:
                    puts(", ")
                puts(item_repr(v))
            puts("])")
        else:
            raise NotImplementedError

        return "".join(result)

    def __str__(self) -> str:
        return self.to_string()

    def __repr__(self) -> str:
        return self.to_string()

structures

This namespace contains data structures whose underlying storages are contiguous and therefore vectorization-friendly.

CBag (Structure)

An integer bag from which one can do sampling without replacement.

Let us imagine that we wish to create a bag whose maximum length (i.e. whose maximum number of contained elements) is 5. For this, we can do:

bag = CBag(max_length=5)

which gives us an empty bag (i.e. a bag in which all pre-allocated slots are empty):

 _________________________________________________
|         |         |         |         |         |
| <empty> | <empty> | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

Given that the maximum length for this bag is 5, the default set of acceptable values for this bag is 0, 1, 2, 3, 4. Let us put three values into our bag:

bag.push_(torch.tensor(1))
bag.push_(torch.tensor(3))
bag.push_(torch.tensor(4))

After these push operations, our bag can be visualized like this:

 _________________________________________________
|         |         |         |         |         |
|   1     |   3     |   4     | <empty> | <empty> |
|_________|_________|_________|_________|_________|

Let us now sample an element from this bag:

sampled1 = bag.pop_()

Because this is the first time we are sampling from this bag, the elements will be first shuffled. Let us assume that the shuffling resulted in:

 _________________________________________________
|         |         |         |         |         |
|   3     |   1     |   4     | <empty> | <empty> |
|_________|_________|_________|_________|_________|

Given this shuffed state, our call to pop_(...) will pop the leftmost element (3 in this case). Therefore, the value of sampled1 will be 3 (as a scalar PyTorch tensor), and the state of the bag after the pop operation will be:

 _________________________________________________
|         |         |         |         |         |
|   1     |   4     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

Let us keep sampling until the bag is empty:

sampled2 = bag.pop_()
sampled3 = bag.pop_()

The value of sampled2 becomes 1, and the value of sampled3 becomes 4.

This class can also represent a contiguous batch of bags. As an example, let us create 4 bags, each of length 5:

bag_batch = CBag(batch_size=4, max_length=5)

After this instantiation, bag_batch can be visualized like this:

 __[ batch item 0 ]_______________________________
|         |         |         |         |         |
| <empty> | <empty> | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 1 ]_______________________________
|         |         |         |         |         |
| <empty> | <empty> | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 2 ]_______________________________
|         |         |         |         |         |
| <empty> | <empty> | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 3 ]_______________________________
|         |         |         |         |         |
| <empty> | <empty> | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

We can add values to our batch like this:

bag_batch.push_(torch.tensor([3, 2, 3, 1]))
bag_batch.push_(torch.tensor([3, 1, 1, 4]))

which would result in:

 __[ batch item 0 ]_______________________________
|         |         |         |         |         |
|   3     |   3     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 1 ]_______________________________
|         |         |         |         |         |
|   2     |   1     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 2 ]_______________________________
|         |         |         |         |         |
|   3     |   1     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 3 ]_______________________________
|         |         |         |         |         |
|   1     |   4     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

We can also add values only to some of the bags within the batch:

bag_batch.push_(
    torch.tensor([0, 2, 1, 0]),
    where=torch.tensor([True, True, False, False])),
)

which would result in:

 __[ batch item 0 ]_______________________________
|         |         |         |         |         |
|   3     |   3     |   0     | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 1 ]_______________________________
|         |         |         |         |         |
|   2     |   1     |   2     | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 2 ]_______________________________
|         |         |         |         |         |
|   3     |   1     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 3 ]_______________________________
|         |         |         |         |         |
|   1     |   4     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

Notice that the batch items 2 and 3 were not affected, because their corresponding values in the where tensor were given as False.

Let us now assume that we wish to obtain a sample from each bag. We can do:

sample_batch1 = bag_batch.pop_()

Since this is the first sampling operation on this bag batch, each bag will first be shuffled. Let us assume that the shuffling resulted in:

 __[ batch item 0 ]_______________________________
|         |         |         |         |         |
|   0     |   3     |   3     | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 1 ]_______________________________
|         |         |         |         |         |
|   1     |   2     |   2     | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 2 ]_______________________________
|         |         |         |         |         |
|   3     |   1     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 3 ]_______________________________
|         |         |         |         |         |
|   4     |   1     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

Given this shuffled state, the pop operation takes the leftmost element from each bag. Therefore, the value of sample_batch1 becomes a 1-dimensional tensor containing [0, 1, 3, 4]. Once the pop operation is completed, the state of the batch of bags becomes:

 __[ batch item 0 ]_______________________________
|         |         |         |         |         |
|   3     |   3     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 1 ]_______________________________
|         |         |         |         |         |
|   2     |   2     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 2 ]_______________________________
|         |         |         |         |         |
|   1     | <empty> | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 3 ]_______________________________
|         |         |         |         |         |
|   1     | <empty> | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

Now, if we wish to pop only from some of the bags, we can do:

sample_batch2 = bag_batch.pop_(
    where=torch.tensor([True, False, True, False]),
)

which makes the value of sample_batch2 a 1-dimensional tensor containing [3, 2, 1, 1] (the leftmost element for each bag). The state of our batch of bags will become:

 __[ batch item 0 ]_______________________________
|         |         |         |         |         |
|   3     | <empty> | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 1 ]_______________________________
|         |         |         |         |         |
|   2     |   2     | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 2 ]_______________________________
|         |         |         |         |         |
| <empty> | <empty> | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

 __[ batch item 3 ]_______________________________
|         |         |         |         |         |
|   1     | <empty> | <empty> | <empty> | <empty> |
|_________|_________|_________|_________|_________|

Notice that the batch items 1 and 3 were not modified, because their corresponding values in the where argument were given as False.

Source code in evotorch/tools/structures.py
class CBag(Structure):
    """
    An integer bag from which one can do sampling without replacement.

    Let us imagine that we wish to create a bag whose maximum length (i.e.
    whose maximum number of contained elements) is 5. For this, we can do:

    ```python
    bag = CBag(max_length=5)
    ```

    which gives us an empty bag (i.e. a bag in which all pre-allocated slots
    are empty):

    ```
     _________________________________________________
    |         |         |         |         |         |
    | <empty> | <empty> | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|
    ```

    Given that the maximum length for this bag is 5, the default set of
    acceptable values for this bag is 0, 1, 2, 3, 4. Let us put three values
    into our bag:

    ```
    bag.push_(torch.tensor(1))
    bag.push_(torch.tensor(3))
    bag.push_(torch.tensor(4))
    ```

    After these push operations, our bag can be visualized like this:

    ```
     _________________________________________________
    |         |         |         |         |         |
    |   1     |   3     |   4     | <empty> | <empty> |
    |_________|_________|_________|_________|_________|
    ```

    Let us now sample an element from this bag:

    ```python
    sampled1 = bag.pop_()
    ```

    Because this is the first time we are sampling from this bag, the elements
    will be first shuffled. Let us assume that the shuffling resulted in:

    ```
     _________________________________________________
    |         |         |         |         |         |
    |   3     |   1     |   4     | <empty> | <empty> |
    |_________|_________|_________|_________|_________|
    ```

    Given this shuffed state, our call to `pop_(...)` will pop the leftmost
    element (3 in this case). Therefore, the value of `sampled1` will be 3
    (as a scalar PyTorch tensor), and the state of the bag after the pop
    operation will be:

    ```
     _________________________________________________
    |         |         |         |         |         |
    |   1     |   4     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|
    ```

    Let us keep sampling until the bag is empty:

    ```python
    sampled2 = bag.pop_()
    sampled3 = bag.pop_()
    ```

    The value of `sampled2` becomes 1, and the value of `sampled3` becomes 4.

    This class can also represent a contiguous batch of bags. As an example,
    let us create 4 bags, each of length 5:

    ```python
    bag_batch = CBag(batch_size=4, max_length=5)
    ```

    After this instantiation, `bag_batch` can be visualized like this:

    ```
     __[ batch item 0 ]_______________________________
    |         |         |         |         |         |
    | <empty> | <empty> | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 1 ]_______________________________
    |         |         |         |         |         |
    | <empty> | <empty> | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 2 ]_______________________________
    |         |         |         |         |         |
    | <empty> | <empty> | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 3 ]_______________________________
    |         |         |         |         |         |
    | <empty> | <empty> | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|
    ```

    We can add values to our batch like this:

    ```python
    bag_batch.push_(torch.tensor([3, 2, 3, 1]))
    bag_batch.push_(torch.tensor([3, 1, 1, 4]))
    ```

    which would result in:

    ```
     __[ batch item 0 ]_______________________________
    |         |         |         |         |         |
    |   3     |   3     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 1 ]_______________________________
    |         |         |         |         |         |
    |   2     |   1     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 2 ]_______________________________
    |         |         |         |         |         |
    |   3     |   1     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 3 ]_______________________________
    |         |         |         |         |         |
    |   1     |   4     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|
    ```

    We can also add values only to some of the bags within the batch:

    ```
    bag_batch.push_(
        torch.tensor([0, 2, 1, 0]),
        where=torch.tensor([True, True, False, False])),
    )
    ```

    which would result in:

    ```
     __[ batch item 0 ]_______________________________
    |         |         |         |         |         |
    |   3     |   3     |   0     | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 1 ]_______________________________
    |         |         |         |         |         |
    |   2     |   1     |   2     | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 2 ]_______________________________
    |         |         |         |         |         |
    |   3     |   1     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 3 ]_______________________________
    |         |         |         |         |         |
    |   1     |   4     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|
    ```

    Notice that the batch items 2 and 3 were not affected, because their
    corresponding values in the `where` tensor were given as False.

    Let us now assume that we wish to obtain a sample from each bag. We can do:

    ```python
    sample_batch1 = bag_batch.pop_()
    ```

    Since this is the first sampling operation on this bag batch, each bag
    will first be shuffled. Let us assume that the shuffling resulted in:

    ```
     __[ batch item 0 ]_______________________________
    |         |         |         |         |         |
    |   0     |   3     |   3     | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 1 ]_______________________________
    |         |         |         |         |         |
    |   1     |   2     |   2     | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 2 ]_______________________________
    |         |         |         |         |         |
    |   3     |   1     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 3 ]_______________________________
    |         |         |         |         |         |
    |   4     |   1     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|
    ```

    Given this shuffled state, the pop operation takes the leftmost element
    from each bag. Therefore, the value of `sample_batch1` becomes a
    1-dimensional tensor containing `[0, 1, 3, 4]`. Once the pop operation
    is completed, the state of the batch of bags becomes:

    ```
     __[ batch item 0 ]_______________________________
    |         |         |         |         |         |
    |   3     |   3     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 1 ]_______________________________
    |         |         |         |         |         |
    |   2     |   2     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 2 ]_______________________________
    |         |         |         |         |         |
    |   1     | <empty> | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 3 ]_______________________________
    |         |         |         |         |         |
    |   1     | <empty> | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|
    ```

    Now, if we wish to pop only from some of the bags, we can do:

    ```python
    sample_batch2 = bag_batch.pop_(
        where=torch.tensor([True, False, True, False]),
    )
    ```

    which makes the value of `sample_batch2` a 1-dimensional tensor containing
    `[3, 2, 1, 1]` (the leftmost element for each bag). The state of our batch
    of bags will become:

    ```
     __[ batch item 0 ]_______________________________
    |         |         |         |         |         |
    |   3     | <empty> | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 1 ]_______________________________
    |         |         |         |         |         |
    |   2     |   2     | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 2 ]_______________________________
    |         |         |         |         |         |
    | <empty> | <empty> | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|

     __[ batch item 3 ]_______________________________
    |         |         |         |         |         |
    |   1     | <empty> | <empty> | <empty> | <empty> |
    |_________|_________|_________|_________|_________|
    ```

    Notice that the batch items 1 and 3 were not modified, because their
    corresponding values in the `where` argument were given as False.
    """

    def __init__(
        self,
        *,
        max_length: int,
        value_range: Optional[tuple] = None,
        batch_size: Optional[Union[int, tuple, list]] = None,
        batch_shape: Optional[Union[int, tuple, list]] = None,
        generator: Any = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        verify: bool = True,
    ):
        """
        Initialize the CBag.

        Args:
            max_length: Maximum length (i.e. maximum capacity for storing
                elements).
            value_range: Optionally expected as a tuple of integers in the
                form `(a, b)` where `a` is the lower bound and `b` is the
                exclusive upper bound for the range of acceptable integer
                values. If this argument is omitted, the range will be
                `(0, n)` where `n` is `max_length`.
            batch_size: Optionally an integer or a size tuple, for when
                one wishes to create not just a single bag, but a batch
                of bags.
            batch_shape: Alias for the argument `batch_size`.
            generator: Optionally an instance of `torch.Generator` or any
                object with an attribute (or a property) named `generator`
                (in which case it will be expected that this attribute will
                provide the actual `torch.Generator` instance). If this
                argument is provided, then the shuffling operation will use
                this generator. Otherwise, the global generator of PyTorch
                will be used.
            dtype: dtype for the values contained by the bag(s).
                By default, the dtype is `torch.int64`.
            device: The device on which the bag(s) will be stored.
                By default, the device is `torch.device("cpu")`.
            verify: Whether or not to do explicit checks for the correctness
                of the operations (against popping from an empty bag or
                pushing into a full bag). By default, this is True.
                If you are sure that such errors will not occur, you might
                turn this to False for getting a performance gain.
        """

        if dtype is None:
            dtype = torch.int64
        else:
            dtype = to_torch_dtype(dtype)
            if dtype not in (torch.int16, torch.int32, torch.int64):
                raise RuntimeError(
                    f"CBag currently supports only torch.int16, torch.int32, and torch.int64."
                    f" This dtype is not supported: {repr(dtype)}."
                )

        self._gen_kwargs = {}
        if generator is not None:
            if isinstance(generator, torch.Generator):
                self._gen_kwargs["generator"] = generator
            else:
                generator = generator.generator
                if generator is not None:
                    self._gen_kwargs["generator"] = generator

        max_length = int(max_length)

        self._data = CList(
            max_length=max_length,
            batch_size=batch_size,
            batch_shape=batch_shape,
            dtype=dtype,
            device=device,
            verify=verify,
        )

        if value_range is None:
            a = 0
            b = max_length
        else:
            a, b = value_range

        self._low_item = int(a)
        self._high_item = int(b)  # upper bound is exclusive
        self._choice_count = self._high_item - self._low_item
        self._bignum = self._choice_count + 1

        if self._low_item < 1:
            self._shift = 1 - self._low_item
        else:
            self._shift = 0

        self._empty = self._low_item - 1
        self._data.data[:] = self._empty
        self._sampling_phase: bool = False

    def push_(self, value: Numbers, where: Optional[Numbers] = None):
        """
        Push new value(s) into the bag(s).

        Args:
            value: The value(s) to be pushed into the bag(s).
            where: Optionally a boolean tensor. If this is given, then only
                the bags with their corresponding boolean flags set as True
                will be affected.
        """
        if self._sampling_phase:
            raise RuntimeError("Cannot put a new element into the CBag after calling `sample_(...)`")
        self._data.push_(value, where)

    def _shuffle(self):
        dtype = self._data.dtype
        device = self._data.device
        nrows, ncols = self._data.data.shape

        try:
            gaussian_noise = torch.randn(nrows, ncols, dtype=torch.float32, device=device, **(self._gen_kwargs))
            noise = gaussian_noise.argsort().to(dtype=dtype) * self._bignum
            self._data.data[:] += torch.where(
                self._data.data != self._empty, self._shift + noise, torch.tensor(0, dtype=dtype, device=device)
            )
            self._data.data[:] = self._data.data.sort(dim=-1, descending=True, stable=False).values
        finally:
            self._data.data[:] %= self._bignum
            self._data.data[:] -= self._shift

    def pop_(self, where: Optional[Numbers] = None) -> torch.Tensor:
        """
        Sample value(s) from the bag(s).

        Upon being called for the first time, this method will cause the
        contained elements to be shuffled.

        Args:
            where: Optionally a boolean tensor. If this is given, then only
                the bags with their corresponding boolean flags set as True
                will be affected.
        """
        if not self._sampling_phase:
            self._shuffle()
            self._sampling_phase = True
        return self._data.pop_(where)

    def clear(self):
        """
        Clear the bag(s).
        """
        self._data.data[:] = self._empty
        self._data.clear()
        self._sampling_phase = False

    @property
    def length(self) -> torch.Tensor:
        """
        The length(s) of the bag(s)
        """
        return self._data.length

    @property
    def data(self) -> torch.Tensor:
        """
        The underlying data tensor
        """
        return self._data.data
data: Tensor property readonly

The underlying data tensor

length: Tensor property readonly

The length(s) of the bag(s)

__init__(self, *, max_length, value_range=None, batch_size=None, batch_shape=None, generator=None, dtype=None, device=None, verify=True) special

Initialize the CBag.

Parameters:

Name Type Description Default
max_length int

Maximum length (i.e. maximum capacity for storing elements).

required
value_range Optional[tuple]

Optionally expected as a tuple of integers in the form (a, b) where a is the lower bound and b is the exclusive upper bound for the range of acceptable integer values. If this argument is omitted, the range will be (0, n) where n is max_length.

None
batch_size Union[int, tuple, list]

Optionally an integer or a size tuple, for when one wishes to create not just a single bag, but a batch of bags.

None
batch_shape Union[int, tuple, list]

Alias for the argument batch_size.

None
generator Any

Optionally an instance of torch.Generator or any object with an attribute (or a property) named generator (in which case it will be expected that this attribute will provide the actual torch.Generator instance). If this argument is provided, then the shuffling operation will use this generator. Otherwise, the global generator of PyTorch will be used.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

dtype for the values contained by the bag(s). By default, the dtype is torch.int64.

None
device Union[str, torch.device]

The device on which the bag(s) will be stored. By default, the device is torch.device("cpu").

None
verify bool

Whether or not to do explicit checks for the correctness of the operations (against popping from an empty bag or pushing into a full bag). By default, this is True. If you are sure that such errors will not occur, you might turn this to False for getting a performance gain.

True
Source code in evotorch/tools/structures.py
def __init__(
    self,
    *,
    max_length: int,
    value_range: Optional[tuple] = None,
    batch_size: Optional[Union[int, tuple, list]] = None,
    batch_shape: Optional[Union[int, tuple, list]] = None,
    generator: Any = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    verify: bool = True,
):
    """
    Initialize the CBag.

    Args:
        max_length: Maximum length (i.e. maximum capacity for storing
            elements).
        value_range: Optionally expected as a tuple of integers in the
            form `(a, b)` where `a` is the lower bound and `b` is the
            exclusive upper bound for the range of acceptable integer
            values. If this argument is omitted, the range will be
            `(0, n)` where `n` is `max_length`.
        batch_size: Optionally an integer or a size tuple, for when
            one wishes to create not just a single bag, but a batch
            of bags.
        batch_shape: Alias for the argument `batch_size`.
        generator: Optionally an instance of `torch.Generator` or any
            object with an attribute (or a property) named `generator`
            (in which case it will be expected that this attribute will
            provide the actual `torch.Generator` instance). If this
            argument is provided, then the shuffling operation will use
            this generator. Otherwise, the global generator of PyTorch
            will be used.
        dtype: dtype for the values contained by the bag(s).
            By default, the dtype is `torch.int64`.
        device: The device on which the bag(s) will be stored.
            By default, the device is `torch.device("cpu")`.
        verify: Whether or not to do explicit checks for the correctness
            of the operations (against popping from an empty bag or
            pushing into a full bag). By default, this is True.
            If you are sure that such errors will not occur, you might
            turn this to False for getting a performance gain.
    """

    if dtype is None:
        dtype = torch.int64
    else:
        dtype = to_torch_dtype(dtype)
        if dtype not in (torch.int16, torch.int32, torch.int64):
            raise RuntimeError(
                f"CBag currently supports only torch.int16, torch.int32, and torch.int64."
                f" This dtype is not supported: {repr(dtype)}."
            )

    self._gen_kwargs = {}
    if generator is not None:
        if isinstance(generator, torch.Generator):
            self._gen_kwargs["generator"] = generator
        else:
            generator = generator.generator
            if generator is not None:
                self._gen_kwargs["generator"] = generator

    max_length = int(max_length)

    self._data = CList(
        max_length=max_length,
        batch_size=batch_size,
        batch_shape=batch_shape,
        dtype=dtype,
        device=device,
        verify=verify,
    )

    if value_range is None:
        a = 0
        b = max_length
    else:
        a, b = value_range

    self._low_item = int(a)
    self._high_item = int(b)  # upper bound is exclusive
    self._choice_count = self._high_item - self._low_item
    self._bignum = self._choice_count + 1

    if self._low_item < 1:
        self._shift = 1 - self._low_item
    else:
        self._shift = 0

    self._empty = self._low_item - 1
    self._data.data[:] = self._empty
    self._sampling_phase: bool = False
clear(self)

Clear the bag(s).

Source code in evotorch/tools/structures.py
def clear(self):
    """
    Clear the bag(s).
    """
    self._data.data[:] = self._empty
    self._data.clear()
    self._sampling_phase = False
pop_(self, where=None)

Sample value(s) from the bag(s).

Upon being called for the first time, this method will cause the contained elements to be shuffled.

Parameters:

Name Type Description Default
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean tensor. If this is given, then only the bags with their corresponding boolean flags set as True will be affected.

None
Source code in evotorch/tools/structures.py
def pop_(self, where: Optional[Numbers] = None) -> torch.Tensor:
    """
    Sample value(s) from the bag(s).

    Upon being called for the first time, this method will cause the
    contained elements to be shuffled.

    Args:
        where: Optionally a boolean tensor. If this is given, then only
            the bags with their corresponding boolean flags set as True
            will be affected.
    """
    if not self._sampling_phase:
        self._shuffle()
        self._sampling_phase = True
    return self._data.pop_(where)
push_(self, value, where=None)

Push new value(s) into the bag(s).

Parameters:

Name Type Description Default
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) to be pushed into the bag(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean tensor. If this is given, then only the bags with their corresponding boolean flags set as True will be affected.

None
Source code in evotorch/tools/structures.py
def push_(self, value: Numbers, where: Optional[Numbers] = None):
    """
    Push new value(s) into the bag(s).

    Args:
        value: The value(s) to be pushed into the bag(s).
        where: Optionally a boolean tensor. If this is given, then only
            the bags with their corresponding boolean flags set as True
            will be affected.
    """
    if self._sampling_phase:
        raise RuntimeError("Cannot put a new element into the CBag after calling `sample_(...)`")
    self._data.push_(value, where)

CDict (Structure)

Representation of a batchable dictionary.

This structure is very similar to a CMemory, but with the additional behavior of separately keeping track of which keys exist and which keys do not exist.

Let us consider an example where we have 5 keys, and each key is associated with a tensor of length 7. Such a dictionary could be allocated like this:

dictnry = CDict(7, num_keys=5)

Our allocated dictionary can be visualized as follows:

 _______________________________________
| key 0 -> ( missing )                  |
| key 1 -> ( missing )                  |
| key 2 -> ( missing )                  |
| key 3 -> ( missing )                  |
| key 4 -> ( missing )                  |
|_______________________________________|

Let us now sample a Gaussian noise and put it into the 0-th slot:

dictnry[0] = torch.randn(7)  # or: dictnry[torch.tensor(0)] = torch.randn(7)

which results in:

 _________________________________________
| key 0 -> [ Gaussian noise of length 7 ] |
| key 1 -> ( missing )                    |
| key 2 -> ( missing )                    |
| key 3 -> ( missing )                    |
| key 4 -> ( missing )                    |
|_________________________________________|

Let us now consider another example where we deal with not a single dictionary but with a dictionary batch. For the sake of this example, let us say that our desired batch size is 3. The allocation of such a batch would be as follows:

dict_batch = CDict(7, num_keys=5, batch_size=3)

Our dictionary batch can be visualized like this:

 __[ batch item 0 ]_____________________
| key 0 -> ( missing )                  |
| key 1 -> ( missing )                  |
| key 2 -> ( missing )                  |
| key 3 -> ( missing )                  |
| key 4 -> ( missing )                  |
|_______________________________________|

 __[ batch item 1 ]_____________________
| key 0 -> ( missing )                  |
| key 1 -> ( missing )                  |
| key 2 -> ( missing )                  |
| key 3 -> ( missing )                  |
| key 4 -> ( missing )                  |
|_______________________________________|

 __[ batch item 2 ]_____________________
| key 0 -> ( missing )                  |
| key 1 -> ( missing )                  |
| key 2 -> ( missing )                  |
| key 3 -> ( missing )                  |
| key 4 -> ( missing )                  |
|_______________________________________|

If we wish to set the 0-th element of each batch item, we could do:

dict_batch[0] = torch.tensor(
    [
        [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
        [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
        [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0],
    ],
)

and the result would be:

 __[ batch item 0 ]_____________________
| key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
| key 1 -> ( missing )                  |
| key 2 -> ( missing )                  |
| key 3 -> ( missing )                  |
| key 4 -> ( missing )                  |
|_______________________________________|

 __[ batch item 1 ]_____________________
| key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
| key 1 -> ( missing )                  |
| key 2 -> ( missing )                  |
| key 3 -> ( missing )                  |
| key 4 -> ( missing )                  |
|_______________________________________|

 __[ batch item 2 ]_____________________
| key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
| key 1 -> ( missing )                  |
| key 2 -> ( missing )                  |
| key 3 -> ( missing )                  |
| key 4 -> ( missing )                  |
|_______________________________________|

Continuing from the same example, if we wish to set the slot with key 1 in the 0th batch item, slot with key 2 in the 1st batch item, and slot with key 3 in the 2nd batch item, all in one go, we could do:

# Longer version: dict_batch[torch.tensor([1, 2, 3])] = ...

dict_batch[[1, 2, 3]] = torch.tensor(
    [
        [5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0],
        [6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0],
        [7.0, 7.0, 7.0, 7.0, 7.0, 7.0, 7.0],
    ],
)

Our updated dictionary batch would then look like this:

 __[ batch item 0 ]_____________________
| key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
| key 1 -> [ 5. 5. 5. 5. 5. 5. 5.     ] |
| key 2 -> ( missing )                  |
| key 3 -> ( missing )                  |
| key 4 -> ( missing )                  |
|_______________________________________|

 __[ batch item 1 ]_____________________
| key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
| key 1 -> ( missing )                  |
| key 2 -> [ 6. 6. 6. 6. 6. 6. 6.     ] |
| key 3 -> ( missing )                  |
| key 4 -> ( missing )                  |
|_______________________________________|

 __[ batch item 2 ]_____________________
| key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
| key 1 -> ( missing )                  |
| key 2 -> ( missing )                  |
| key 3 -> [ 7. 7. 7. 7. 7. 7. 7.     ] |
| key 4 -> ( missing )                  |
|_______________________________________|

Conditional modifications via boolean masks is also supported. For example, the following update on our dict_batch:

dict_batch.set_(
    [4, 3, 1],
    torch.tensor(
        [
            [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0],
            [9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0],
            [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0],
        ]
    ),
    where=[True, True, False],  # or: where=torch.tensor([True,True,False]),
)

would result in:

 __[ batch item 0 ]_____________________
| key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
| key 1 -> [ 5. 5. 5. 5. 5. 5. 5.     ] |
| key 2 -> ( missing )                  |
| key 3 -> ( missing )                  |
| key 4 -> [ 8. 8. 8. 8. 8. 8. 8.     ] |
|_______________________________________|

 __[ batch item 1 ]_____________________
| key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
| key 1 -> ( missing )                  |
| key 2 -> [ 6. 6. 6. 6. 6. 6. 6.     ] |
| key 3 -> [ 9. 9. 9. 9. 9. 9. 9.     ] |
| key 4 -> ( missing )                  |
|_______________________________________|

 __[ batch item 2 ]_____________________
| key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
| key 1 -> ( missing )                  |
| key 2 -> ( missing )                  |
| key 3 -> [ 7. 7. 7. 7. 7. 7. 7.     ] |
| key 4 -> ( missing )                  |
|_______________________________________|

Please notice above that the slot with key 1 of the batch item 2 was not modified because its corresponding mask value was given as False.

After all these modifications, querying whether or not an element with key 0 would give us the following output:

>>> dict_batch.contains(0)
torch.tensor([True, True, True], dtype=torch.bool)

which means that, for each dictionary within the batch, an element with key 0 exists. The same query for the key 3 would give us:

>>> dict_batch.contains(3)
torch.tensor([False, True, True], dtype=torch.bool)

which means that the 0-th dictionary within the batch does not have an element with key 3, but the dictionaries 1 and 2 do have their elements with that key.

Source code in evotorch/tools/structures.py
class CDict(Structure):
    """
    Representation of a batchable dictionary.

    This structure is very similar to a `CMemory`, but with the additional
    behavior of separately keeping track of which keys exist and which keys
    do not exist.

    Let us consider an example where we have 5 keys, and each key is associated
    with a tensor of length 7. Such a dictionary could be allocated like this:

    ```python
    dictnry = CDict(7, num_keys=5)
    ```

    Our allocated dictionary can be visualized as follows:

    ```text
     _______________________________________
    | key 0 -> ( missing )                  |
    | key 1 -> ( missing )                  |
    | key 2 -> ( missing )                  |
    | key 3 -> ( missing )                  |
    | key 4 -> ( missing )                  |
    |_______________________________________|
    ```

    Let us now sample a Gaussian noise and put it into the 0-th slot:

    ```python
    dictnry[0] = torch.randn(7)  # or: dictnry[torch.tensor(0)] = torch.randn(7)
    ```

    which results in:

    ```text
     _________________________________________
    | key 0 -> [ Gaussian noise of length 7 ] |
    | key 1 -> ( missing )                    |
    | key 2 -> ( missing )                    |
    | key 3 -> ( missing )                    |
    | key 4 -> ( missing )                    |
    |_________________________________________|
    ```

    Let us now consider another example where we deal with not a single
    dictionary but with a dictionary batch. For the sake of this example, let
    us say that our desired batch size is 3. The allocation of such a batch
    would be as follows:

    ```python
    dict_batch = CDict(7, num_keys=5, batch_size=3)
    ```

    Our dictionary batch can be visualized like this:

    ```text
     __[ batch item 0 ]_____________________
    | key 0 -> ( missing )                  |
    | key 1 -> ( missing )                  |
    | key 2 -> ( missing )                  |
    | key 3 -> ( missing )                  |
    | key 4 -> ( missing )                  |
    |_______________________________________|

     __[ batch item 1 ]_____________________
    | key 0 -> ( missing )                  |
    | key 1 -> ( missing )                  |
    | key 2 -> ( missing )                  |
    | key 3 -> ( missing )                  |
    | key 4 -> ( missing )                  |
    |_______________________________________|

     __[ batch item 2 ]_____________________
    | key 0 -> ( missing )                  |
    | key 1 -> ( missing )                  |
    | key 2 -> ( missing )                  |
    | key 3 -> ( missing )                  |
    | key 4 -> ( missing )                  |
    |_______________________________________|
    ```

    If we wish to set the 0-th element of each batch item, we could do:

    ```python
    dict_batch[0] = torch.tensor(
        [
            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
            [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
            [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0],
        ],
    )
    ```

    and the result would be:

    ```text
     __[ batch item 0 ]_____________________
    | key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
    | key 1 -> ( missing )                  |
    | key 2 -> ( missing )                  |
    | key 3 -> ( missing )                  |
    | key 4 -> ( missing )                  |
    |_______________________________________|

     __[ batch item 1 ]_____________________
    | key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
    | key 1 -> ( missing )                  |
    | key 2 -> ( missing )                  |
    | key 3 -> ( missing )                  |
    | key 4 -> ( missing )                  |
    |_______________________________________|

     __[ batch item 2 ]_____________________
    | key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
    | key 1 -> ( missing )                  |
    | key 2 -> ( missing )                  |
    | key 3 -> ( missing )                  |
    | key 4 -> ( missing )                  |
    |_______________________________________|
    ```

    Continuing from the same example, if we wish to set the slot with key 1
    in the 0th batch item, slot with key 2 in the 1st batch item, and
    slot with key 3 in the 2nd batch item, all in one go, we could do:

    ```python
    # Longer version: dict_batch[torch.tensor([1, 2, 3])] = ...

    dict_batch[[1, 2, 3]] = torch.tensor(
        [
            [5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0],
            [6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0],
            [7.0, 7.0, 7.0, 7.0, 7.0, 7.0, 7.0],
        ],
    )
    ```

    Our updated dictionary batch would then look like this:

    ```text
     __[ batch item 0 ]_____________________
    | key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
    | key 1 -> [ 5. 5. 5. 5. 5. 5. 5.     ] |
    | key 2 -> ( missing )                  |
    | key 3 -> ( missing )                  |
    | key 4 -> ( missing )                  |
    |_______________________________________|

     __[ batch item 1 ]_____________________
    | key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
    | key 1 -> ( missing )                  |
    | key 2 -> [ 6. 6. 6. 6. 6. 6. 6.     ] |
    | key 3 -> ( missing )                  |
    | key 4 -> ( missing )                  |
    |_______________________________________|

     __[ batch item 2 ]_____________________
    | key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
    | key 1 -> ( missing )                  |
    | key 2 -> ( missing )                  |
    | key 3 -> [ 7. 7. 7. 7. 7. 7. 7.     ] |
    | key 4 -> ( missing )                  |
    |_______________________________________|
    ```

    Conditional modifications via boolean masks is also supported.
    For example, the following update on our `dict_batch`:

    ```python
    dict_batch.set_(
        [4, 3, 1],
        torch.tensor(
            [
                [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0],
                [9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0],
                [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0],
            ]
        ),
        where=[True, True, False],  # or: where=torch.tensor([True,True,False]),
    )
    ```

    would result in:

    ```text
     __[ batch item 0 ]_____________________
    | key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
    | key 1 -> [ 5. 5. 5. 5. 5. 5. 5.     ] |
    | key 2 -> ( missing )                  |
    | key 3 -> ( missing )                  |
    | key 4 -> [ 8. 8. 8. 8. 8. 8. 8.     ] |
    |_______________________________________|

     __[ batch item 1 ]_____________________
    | key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
    | key 1 -> ( missing )                  |
    | key 2 -> [ 6. 6. 6. 6. 6. 6. 6.     ] |
    | key 3 -> [ 9. 9. 9. 9. 9. 9. 9.     ] |
    | key 4 -> ( missing )                  |
    |_______________________________________|

     __[ batch item 2 ]_____________________
    | key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
    | key 1 -> ( missing )                  |
    | key 2 -> ( missing )                  |
    | key 3 -> [ 7. 7. 7. 7. 7. 7. 7.     ] |
    | key 4 -> ( missing )                  |
    |_______________________________________|
    ```

    Please notice above that the slot with key 1 of the batch item 2 was not
    modified because its corresponding mask value was given as False.

    After all these modifications, querying whether or not an element with
    key 0 would give us the following output:

    ```text
    >>> dict_batch.contains(0)
    torch.tensor([True, True, True], dtype=torch.bool)
    ```

    which means that, for each dictionary within the batch, an element with
    key 0 exists. The same query for the key 3 would give us:

    ```text
    >>> dict_batch.contains(3)
    torch.tensor([False, True, True], dtype=torch.bool)
    ```

    which means that the 0-th dictionary within the batch does not have an
    element with key 3, but the dictionaries 1 and 2 do have their elements
    with that key.
    """

    def __init__(
        self,
        *size: Union[int, tuple, list],
        num_keys: Union[int, tuple, list],
        key_offset: Optional[Union[int, tuple, list]] = None,
        batch_size: Optional[Union[int, tuple, list]] = None,
        batch_shape: Optional[Union[int, tuple, list]] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        verify: bool = True,
    ):
        """
        `__init__(...)`: Initialize the CDict.

        Args:
            size: Size of a tensor associated with a key, expected as an
                integer, or as multiple positional arguments (each positional
                argument being an integer), or as a tuple of integers.
            num_keys: How many keys (and therefore how many slots) can the
                dictionary have. If given as an integer `n`, then there will be
                `n` slots available in the dictionary, and to access a slot one
                will need to use an integer key `k` (where, by default, the
                minimum acceptable `k` is 0 and the maximum acceptable `k` is
                `n-1`). If given as a tuple of integers, then the number of slots
                available in the dictionary will be computed as the product of
                all the integers in the tuple, and a key will be expected as a
                tuple. For example, when `num_keys` is `(3, 5)`, there will be
                15 slots available in the dictionary (where, by default, the
                minimum acceptable key will be `(0, 0)` and the maximum
                acceptable key will be `(2, 4)`.
            key_offset: Optionally can be used to shift the integer values of
                the keys. For example, if `num_keys` is 10, then, by default,
                the minimum key is 0 and the maximum key is 9. But together
                with `num_keys=10`, if `key_offset` is given as 1, then the
                minimum key will be 1 and the maximum key will be 10.
                This argument can also be used together with a tuple-valued
                `num_keys`. For example, with `num_keys` set as `(3, 5)`,
                if `key_offset` is given as 1, then the minimum key value
                will be `(1, 1)` (instead of `(0, 0)`) and the maximum key
                value will be `(3, 5)` (instead of `(2, 4)`).
                Also, with a tuple-valued `num_keys`, `key_offset` can be
                given as a tuple, to shift the key values differently for each
                item in the tuple.
            batch_size: If given as None, then this dictionary will not be
                batched. If given as an integer `n`, then this object will
                represent a contiguous batch containing `n` dictionary blocks.
                If given as a tuple `(size0, size1, ...)`, then this object
                will represent a contiguous batch of dictionary, shape of this
                batch being determined by the given tuple.
            batch_shape: Alias for the argument `batch_size`.
            fill_with: Optionally a numeric value using which the values will
                be initialized. If no initialization is needed, then this
                argument can be left as None.
            dtype: The `dtype` of the values stored by this CDict.
            device: The device on which the dictionary will be allocated.
            verify: If True, then explicit checks will be done to verify
                that there are no indexing errors. Can be set as False for
                performance.
        """
        self._data = CMemory(
            *size,
            num_keys=num_keys,
            key_offset=key_offset,
            batch_size=batch_size,
            batch_shape=batch_shape,
            dtype=dtype,
            device=device,
            verify=verify,
        )

        self._exist = CMemory(
            num_keys=num_keys,
            key_offset=key_offset,
            batch_size=batch_size,
            batch_shape=batch_shape,
            dtype=torch.bool,
            device=device,
            verify=verify,
        )

    def get(self, key: Numbers, default: Optional[Numbers] = None) -> torch.Tensor:
        """
        Get the value(s) associated with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            default: Optionally can be specified as the fallback value for when
                the element(s) with the given key(s) do not exist.
        Returns:
            The value(s) associated with the given key(s).
        """
        if default is None:
            return self._data[key]
        else:
            exist = self._exist[key]
            default = self._get_value(default)
            return do_where(exist, self._data[key], default)

    def set_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Set the value(s) associated with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The new value(s).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        self._data.set_(key, value, where)
        self._exist.set_(key, True, where)

    def add_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Add value(s) onto the existing values of slots with the given key(s).

        Note that this operation does not change the existence flags of the
        keys. In other words, if element(s) with `key` do not exist, then
        they will still be flagged as non-existent after this operation.

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The value(s) that will be added onto the existing value(s).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        self._data.add_(key, value, where)

    def subtract_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Subtract value(s) from existing values of slots with the given key(s).

        Note that this operation does not change the existence flags of the
        keys. In other words, if element(s) with `key` do not exist, then
        they will still be flagged as non-existent after this operation.

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The value(s) that will be subtracted from existing value(s).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        self._data.subtract_(key, value, where)

    def divide_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Divide the existing values of slots with the given key(s).

        Note that this operation does not change the existence flags of the
        keys. In other words, if element(s) with `key` do not exist, then
        they will still be flagged as non-existent after this operation.

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The value(s) that will be used as divisor(s).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        self._data.divide_(key, value, where)

    def multiply_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Multiply the existing values of slots with the given key(s).

        Note that this operation does not change the existence flags of the
        keys. In other words, if element(s) with `key` do not exist, then
        they will still be flagged as non-existent after this operation.

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The value(s) that will be used as the multiplier(s).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        self._data.multiply_(key, value, where)

    def contains(self, key: Numbers) -> torch.Tensor:
        """
        Query whether or not the element(s) with the given key(s) exist.

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
        Returns:
            A boolean tensor indicating whether or not the element(s) with the
            specified key(s) exist.
        """
        return self._exist[key]

    def __getitem__(self, key: Numbers) -> torch.Tensor:
        """
        Get the value(s) associated with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
        Returns:
            The value(s) associated with the given key(s).
        """
        return self.get(key)

    def __setitem__(self, key: Numbers, value: Numbers):
        """
        Set the value(s) associated with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The new value(s).
        """
        self.set_(key, value)

    def clear(self, where: Optional[torch.Tensor] = None):
        """
        Clear the dictionaries.

        In the context of this data structure, to "clear" means to set the
        status for each key to non-existent.

        Args:
            where: Optionally a boolean tensor, specifying which dictionaries
                within the batch should be cleared. If this argument is omitted
                (i.e. left as None), then all dictionaries will be cleared.
        """
        if where is None:
            self._exist.data[:] = False
        else:
            where = self._get_where(where)
            all_false = torch.tensor(False, dtype=torch.bool, device=self._exist.device).expand(self._exist.shape)
            self._exist.data[:] = do_where(where, all_false, self._exist.data[:])

    @property
    def data(self) -> torch.Tensor:
        """
        The entire value tensor
        """
        return self._data.data
data: Tensor property readonly

The entire value tensor

__getitem__(self, key) special

Get the value(s) associated with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required

Returns:

Type Description
Tensor

The value(s) associated with the given key(s).

Source code in evotorch/tools/structures.py
def __getitem__(self, key: Numbers) -> torch.Tensor:
    """
    Get the value(s) associated with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
    Returns:
        The value(s) associated with the given key(s).
    """
    return self.get(key)
__init__(self, *size, *, num_keys, key_offset=None, batch_size=None, batch_shape=None, dtype=None, device=None, verify=True) special

__init__(...): Initialize the CDict.

Parameters:

Name Type Description Default
size Union[int, tuple, list]

Size of a tensor associated with a key, expected as an integer, or as multiple positional arguments (each positional argument being an integer), or as a tuple of integers.

()
num_keys Union[int, tuple, list]

How many keys (and therefore how many slots) can the dictionary have. If given as an integer n, then there will be n slots available in the dictionary, and to access a slot one will need to use an integer key k (where, by default, the minimum acceptable k is 0 and the maximum acceptable k is n-1). If given as a tuple of integers, then the number of slots available in the dictionary will be computed as the product of all the integers in the tuple, and a key will be expected as a tuple. For example, when num_keys is (3, 5), there will be 15 slots available in the dictionary (where, by default, the minimum acceptable key will be (0, 0) and the maximum acceptable key will be (2, 4).

required
key_offset Union[int, tuple, list]

Optionally can be used to shift the integer values of the keys. For example, if num_keys is 10, then, by default, the minimum key is 0 and the maximum key is 9. But together with num_keys=10, if key_offset is given as 1, then the minimum key will be 1 and the maximum key will be 10. This argument can also be used together with a tuple-valued num_keys. For example, with num_keys set as (3, 5), if key_offset is given as 1, then the minimum key value will be (1, 1) (instead of (0, 0)) and the maximum key value will be (3, 5) (instead of (2, 4)). Also, with a tuple-valued num_keys, key_offset can be given as a tuple, to shift the key values differently for each item in the tuple.

None
batch_size Union[int, tuple, list]

If given as None, then this dictionary will not be batched. If given as an integer n, then this object will represent a contiguous batch containing n dictionary blocks. If given as a tuple (size0, size1, ...), then this object will represent a contiguous batch of dictionary, shape of this batch being determined by the given tuple.

None
batch_shape Union[int, tuple, list]

Alias for the argument batch_size.

None
fill_with

Optionally a numeric value using which the values will be initialized. If no initialization is needed, then this argument can be left as None.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

The dtype of the values stored by this CDict.

None
device Union[str, torch.device]

The device on which the dictionary will be allocated.

None
verify bool

If True, then explicit checks will be done to verify that there are no indexing errors. Can be set as False for performance.

True
Source code in evotorch/tools/structures.py
def __init__(
    self,
    *size: Union[int, tuple, list],
    num_keys: Union[int, tuple, list],
    key_offset: Optional[Union[int, tuple, list]] = None,
    batch_size: Optional[Union[int, tuple, list]] = None,
    batch_shape: Optional[Union[int, tuple, list]] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    verify: bool = True,
):
    """
    `__init__(...)`: Initialize the CDict.

    Args:
        size: Size of a tensor associated with a key, expected as an
            integer, or as multiple positional arguments (each positional
            argument being an integer), or as a tuple of integers.
        num_keys: How many keys (and therefore how many slots) can the
            dictionary have. If given as an integer `n`, then there will be
            `n` slots available in the dictionary, and to access a slot one
            will need to use an integer key `k` (where, by default, the
            minimum acceptable `k` is 0 and the maximum acceptable `k` is
            `n-1`). If given as a tuple of integers, then the number of slots
            available in the dictionary will be computed as the product of
            all the integers in the tuple, and a key will be expected as a
            tuple. For example, when `num_keys` is `(3, 5)`, there will be
            15 slots available in the dictionary (where, by default, the
            minimum acceptable key will be `(0, 0)` and the maximum
            acceptable key will be `(2, 4)`.
        key_offset: Optionally can be used to shift the integer values of
            the keys. For example, if `num_keys` is 10, then, by default,
            the minimum key is 0 and the maximum key is 9. But together
            with `num_keys=10`, if `key_offset` is given as 1, then the
            minimum key will be 1 and the maximum key will be 10.
            This argument can also be used together with a tuple-valued
            `num_keys`. For example, with `num_keys` set as `(3, 5)`,
            if `key_offset` is given as 1, then the minimum key value
            will be `(1, 1)` (instead of `(0, 0)`) and the maximum key
            value will be `(3, 5)` (instead of `(2, 4)`).
            Also, with a tuple-valued `num_keys`, `key_offset` can be
            given as a tuple, to shift the key values differently for each
            item in the tuple.
        batch_size: If given as None, then this dictionary will not be
            batched. If given as an integer `n`, then this object will
            represent a contiguous batch containing `n` dictionary blocks.
            If given as a tuple `(size0, size1, ...)`, then this object
            will represent a contiguous batch of dictionary, shape of this
            batch being determined by the given tuple.
        batch_shape: Alias for the argument `batch_size`.
        fill_with: Optionally a numeric value using which the values will
            be initialized. If no initialization is needed, then this
            argument can be left as None.
        dtype: The `dtype` of the values stored by this CDict.
        device: The device on which the dictionary will be allocated.
        verify: If True, then explicit checks will be done to verify
            that there are no indexing errors. Can be set as False for
            performance.
    """
    self._data = CMemory(
        *size,
        num_keys=num_keys,
        key_offset=key_offset,
        batch_size=batch_size,
        batch_shape=batch_shape,
        dtype=dtype,
        device=device,
        verify=verify,
    )

    self._exist = CMemory(
        num_keys=num_keys,
        key_offset=key_offset,
        batch_size=batch_size,
        batch_shape=batch_shape,
        dtype=torch.bool,
        device=device,
        verify=verify,
    )
__setitem__(self, key, value) special

Set the value(s) associated with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The new value(s).

required
Source code in evotorch/tools/structures.py
def __setitem__(self, key: Numbers, value: Numbers):
    """
    Set the value(s) associated with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The new value(s).
    """
    self.set_(key, value)
add_(self, key, value, where=None)

Add value(s) onto the existing values of slots with the given key(s).

Note that this operation does not change the existence flags of the keys. In other words, if element(s) with key do not exist, then they will still be flagged as non-existent after this operation.

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be added onto the existing value(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def add_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Add value(s) onto the existing values of slots with the given key(s).

    Note that this operation does not change the existence flags of the
    keys. In other words, if element(s) with `key` do not exist, then
    they will still be flagged as non-existent after this operation.

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The value(s) that will be added onto the existing value(s).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    self._data.add_(key, value, where)
clear(self, where=None)

Clear the dictionaries.

In the context of this data structure, to "clear" means to set the status for each key to non-existent.

Parameters:

Name Type Description Default
where Optional[torch.Tensor]

Optionally a boolean tensor, specifying which dictionaries within the batch should be cleared. If this argument is omitted (i.e. left as None), then all dictionaries will be cleared.

None
Source code in evotorch/tools/structures.py
def clear(self, where: Optional[torch.Tensor] = None):
    """
    Clear the dictionaries.

    In the context of this data structure, to "clear" means to set the
    status for each key to non-existent.

    Args:
        where: Optionally a boolean tensor, specifying which dictionaries
            within the batch should be cleared. If this argument is omitted
            (i.e. left as None), then all dictionaries will be cleared.
    """
    if where is None:
        self._exist.data[:] = False
    else:
        where = self._get_where(where)
        all_false = torch.tensor(False, dtype=torch.bool, device=self._exist.device).expand(self._exist.shape)
        self._exist.data[:] = do_where(where, all_false, self._exist.data[:])
contains(self, key)

Query whether or not the element(s) with the given key(s) exist.

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required

Returns:

Type Description
Tensor

A boolean tensor indicating whether or not the element(s) with the specified key(s) exist.

Source code in evotorch/tools/structures.py
def contains(self, key: Numbers) -> torch.Tensor:
    """
    Query whether or not the element(s) with the given key(s) exist.

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
    Returns:
        A boolean tensor indicating whether or not the element(s) with the
        specified key(s) exist.
    """
    return self._exist[key]
divide_(self, key, value, where=None)

Divide the existing values of slots with the given key(s).

Note that this operation does not change the existence flags of the keys. In other words, if element(s) with key do not exist, then they will still be flagged as non-existent after this operation.

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be used as divisor(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def divide_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Divide the existing values of slots with the given key(s).

    Note that this operation does not change the existence flags of the
    keys. In other words, if element(s) with `key` do not exist, then
    they will still be flagged as non-existent after this operation.

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The value(s) that will be used as divisor(s).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    self._data.divide_(key, value, where)
get(self, key, default=None)

Get the value(s) associated with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
default Union[numbers.Number, Iterable[numbers.Number]]

Optionally can be specified as the fallback value for when the element(s) with the given key(s) do not exist.

None

Returns:

Type Description
Tensor

The value(s) associated with the given key(s).

Source code in evotorch/tools/structures.py
def get(self, key: Numbers, default: Optional[Numbers] = None) -> torch.Tensor:
    """
    Get the value(s) associated with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        default: Optionally can be specified as the fallback value for when
            the element(s) with the given key(s) do not exist.
    Returns:
        The value(s) associated with the given key(s).
    """
    if default is None:
        return self._data[key]
    else:
        exist = self._exist[key]
        default = self._get_value(default)
        return do_where(exist, self._data[key], default)
multiply_(self, key, value, where=None)

Multiply the existing values of slots with the given key(s).

Note that this operation does not change the existence flags of the keys. In other words, if element(s) with key do not exist, then they will still be flagged as non-existent after this operation.

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be used as the multiplier(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def multiply_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Multiply the existing values of slots with the given key(s).

    Note that this operation does not change the existence flags of the
    keys. In other words, if element(s) with `key` do not exist, then
    they will still be flagged as non-existent after this operation.

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The value(s) that will be used as the multiplier(s).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    self._data.multiply_(key, value, where)
set_(self, key, value, where=None)

Set the value(s) associated with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The new value(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def set_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Set the value(s) associated with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The new value(s).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    self._data.set_(key, value, where)
    self._exist.set_(key, True, where)
subtract_(self, key, value, where=None)

Subtract value(s) from existing values of slots with the given key(s).

Note that this operation does not change the existence flags of the keys. In other words, if element(s) with key do not exist, then they will still be flagged as non-existent after this operation.

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be subtracted from existing value(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def subtract_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Subtract value(s) from existing values of slots with the given key(s).

    Note that this operation does not change the existence flags of the
    keys. In other words, if element(s) with `key` do not exist, then
    they will still be flagged as non-existent after this operation.

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The value(s) that will be subtracted from existing value(s).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    self._data.subtract_(key, value, where)

CList (Structure)

Representation of a batchable, contiguous, variable-length list structure.

This CList structure works with a pre-allocated contiguous block of memory with a separately stored length. In the batched case, each batch item has its own length.

This structure supports negative indexing (meaning that -1 refers to the last item, -2 refers to the second last item, etc.).

Let us imagine that we need a list where each element has a shape (3,), and our maximum length is 5. Such a list could be instantiated via:

lst = CList(3, max_length=5)

In its initial state, the list is empty, which can be visualized like:

 _______________________________________________________________
| index  |    0     |    1     |    2     |    3     |    4     |
| values | <unused> | <unused> | <unused> | <unused> | <unused> |
|________|__________|__________|__________|__________|__________|

We can add elements into our list like this:

lst.append_(torch.tensor([1.0, 2.0, 3.0]))
lst.append_(torch.tensor([4.0, 5.0, 6.0]))

After these two push operations, our list looks like this:

 __________________________________________________________________
| index  |      0     |     1     |    2     |    3     |    4     |
| values | [1. 2. 3.] | [4. 5. 6] | <unused> | <unused> | <unused> |
|________|____________|___________|__________|__________|__________|

Here, lst[0] returns [1. 2. 3.] and lst[1] returns [4. 5. 6.]. A CList also supports negative indices, allowing lst[-1] to return [4. 5. 6.] (the last element) and lst[-2] to return [1. 2. 3.] (the second last element).

One can also create a batch of lists. Let us imagine that we wish to create a batch of lists such that the batch size is 4, length of an element is 3, and the maximum length is 5. Such a batch can be created as follows:

list_batch = CList(3, max_length=5, batch_size=4)

Our batch can be visualized like this:

 __[ batch item 0 ]_____________________________________________
| index  |    0     |    1     |    2     |    3     |    4     |
| values | <unused> | <unused> | <unused> | <unused> | <unused> |
|________|__________|__________|__________|__________|__________|

 __[ batch item 1 ]_____________________________________________
| index  |    0     |    1     |    2     |    3     |    4     |
| values | <unused> | <unused> | <unused> | <unused> | <unused> |
|________|__________|__________|__________|__________|__________|

 __[ batch item 2 ]_____________________________________________
| index  |    0     |    1     |    2     |    3     |    4     |
| values | <unused> | <unused> | <unused> | <unused> | <unused> |
|________|__________|__________|__________|__________|__________|

 __[ batch item 3 ]_____________________________________________
| index  |    0     |    1     |    2     |    3     |    4     |
| values | <unused> | <unused> | <unused> | <unused> | <unused> |
|________|__________|__________|__________|__________|__________|

Let us now add [1. 1. 1.] to the batch item 0, [2. 2. 2.] to the batch item 1, and so on:

list_batch.append_(
    torch.tensor(
        [
            [1.0, 1.0, 1.0],
            [2.0, 2.0, 2.0],
            [3.0, 3.0, 3.0],
            [4.0, 4.0, 4.0],
        ]
    )
)

After these operations, list_batch looks like this:

 __[ batch item 0 ]_______________________________________________
| index  |    0       |    1     |    2     |    3     |    4     |
| values | [1. 1. 1.] | <unused> | <unused> | <unused> | <unused> |
|________|____________|__________|__________|__________|__________|

 __[ batch item 1 ]_______________________________________________
| index  |    0       |    1     |    2     |    3     |    4     |
| values | [2. 2. 2.] | <unused> | <unused> | <unused> | <unused> |
|________|____________|__________|__________|__________|__________|

 __[ batch item 2 ]_______________________________________________
| index  |    0       |    1     |    2     |    3     |    4     |
| values | [3. 3. 3.] | <unused> | <unused> | <unused> | <unused> |
|________|____________|__________|__________|__________|__________|

 __[ batch item 3 ]_______________________________________________
| index  |    0       |    1     |    2     |    3     |    4     |
| values | [4. 4. 4.] | <unused> | <unused> | <unused> | <unused> |
|________|____________|__________|__________|__________|__________|

We can also use a boolean mask to add to only some of the lists within the batch:

list_batch.append_(
    torch.tensor(
        [
            [5.0, 5.0, 5.0],
            [6.0, 6.0, 6.0],
            [7.0, 7.0, 7.0],
            [8.0, 8.0, 8.0],
        ]
    ),
    where=torch.tensor([True, False, False, True]),
)

which would update our batch of lists like this:

 __[ batch item 0 ]_________________________________________________
| index  |    0       |    1       |    2     |    3     |    4     |
| values | [1. 1. 1.] | [5. 5. 5.] | <unused> | <unused> | <unused> |
|________|____________|____________|__________|__________|__________|

 __[ batch item 1 ]_________________________________________________
| index  |    0       |    1       |    2     |    3     |    4     |
| values | [2. 2. 2.] |  <unused>  | <unused> | <unused> | <unused> |
|________|____________|____________|__________|__________|__________|

 __[ batch item 2 ]_________________________________________________
| index  |    0       |    1       |    2     |    3     |    4     |
| values | [3. 3. 3.] |  <unused>  | <unused> | <unused> | <unused> |
|________|____________|____________|__________|__________|__________|

 __[ batch item 3 ]_________________________________________________
| index  |    0       |    1       |    2     |    3     |    4     |
| values | [4. 4. 4.] | [8. 8. 8.] | <unused> | <unused> | <unused> |
|________|____________|____________|__________|__________|__________|

Please notice above how the batch items 1 and 2 were not modified because their corresponding boolean values in the where tensor were given as False.

After all these modifications we would get the following results:

>>> list_batch[0]
torch.tensor(
    [[1. 1. 1.],
     [2. 2. 2.],
     [3. 3. 3.],
     [4. 4. 4.]]
)

>>> list_batch[[1, 0, 0, 1]]
torch.tensor(
    [[5. 5. 5.],
     [2. 2. 2.],
     [3. 3. 3.],
     [8. 8. 8.]]
)

>>> list_batch[[-1, -1, -1, -1]]
torch.tensor(
    [[5. 5. 5.],
     [2. 2. 2.],
     [3. 3. 3.],
     [8. 8. 8.]]
)

Note that this CList structure also supports the ability to insert to the beginning, or to remove from the beginning. These operations internally shift the addresses for the beginning of the data within the underlying memory, and therefore, they are not any more costly than adding to or removing from the end of the list.

Source code in evotorch/tools/structures.py
class CList(Structure):
    """
    Representation of a batchable, contiguous, variable-length list structure.

    This CList structure works with a pre-allocated contiguous block of memory
    with a separately stored length. In the batched case, each batch item
    has its own length.

    This structure supports negative indexing (meaning that -1 refers to the
    last item, -2 refers to the second last item, etc.).

    Let us imagine that we need a list where each element has a shape `(3,)`,
    and our maximum length is 5.
    Such a list could be instantiated via:

    ```python
    lst = CList(3, max_length=5)
    ```

    In its initial state, the list is empty, which can be visualized like:

    ```text
     _______________________________________________________________
    | index  |    0     |    1     |    2     |    3     |    4     |
    | values | <unused> | <unused> | <unused> | <unused> | <unused> |
    |________|__________|__________|__________|__________|__________|
    ```

    We can add elements into our list like this:

    ```python
    lst.append_(torch.tensor([1.0, 2.0, 3.0]))
    lst.append_(torch.tensor([4.0, 5.0, 6.0]))
    ```

    After these two push operations, our list looks like this:

    ```text
     __________________________________________________________________
    | index  |      0     |     1     |    2     |    3     |    4     |
    | values | [1. 2. 3.] | [4. 5. 6] | <unused> | <unused> | <unused> |
    |________|____________|___________|__________|__________|__________|
    ```

    Here, `lst[0]` returns `[1. 2. 3.]` and `lst[1]` returns `[4. 5. 6.]`.
    A `CList` also supports negative indices, allowing `lst[-1]` to return
    `[4. 5. 6.]` (the last element) and `lst[-2]` to return `[1. 2. 3.]`
    (the second last element).

    One can also create a batch of lists. Let us imagine that we wish to
    create a batch of lists such that the batch size is 4, length of an
    element is 3, and the maximum length is 5. Such a batch can be created
    as follows:

    ```python
    list_batch = CList(3, max_length=5, batch_size=4)
    ```

    Our batch can be visualized like this:

    ```text
     __[ batch item 0 ]_____________________________________________
    | index  |    0     |    1     |    2     |    3     |    4     |
    | values | <unused> | <unused> | <unused> | <unused> | <unused> |
    |________|__________|__________|__________|__________|__________|

     __[ batch item 1 ]_____________________________________________
    | index  |    0     |    1     |    2     |    3     |    4     |
    | values | <unused> | <unused> | <unused> | <unused> | <unused> |
    |________|__________|__________|__________|__________|__________|

     __[ batch item 2 ]_____________________________________________
    | index  |    0     |    1     |    2     |    3     |    4     |
    | values | <unused> | <unused> | <unused> | <unused> | <unused> |
    |________|__________|__________|__________|__________|__________|

     __[ batch item 3 ]_____________________________________________
    | index  |    0     |    1     |    2     |    3     |    4     |
    | values | <unused> | <unused> | <unused> | <unused> | <unused> |
    |________|__________|__________|__________|__________|__________|
    ```

    Let us now add `[1. 1. 1.]` to the batch item 0, `[2. 2. 2.]` to the batch
    item 1, and so on:

    ```python
    list_batch.append_(
        torch.tensor(
            [
                [1.0, 1.0, 1.0],
                [2.0, 2.0, 2.0],
                [3.0, 3.0, 3.0],
                [4.0, 4.0, 4.0],
            ]
        )
    )
    ```

    After these operations, `list_batch` looks like this:

    ```text
     __[ batch item 0 ]_______________________________________________
    | index  |    0       |    1     |    2     |    3     |    4     |
    | values | [1. 1. 1.] | <unused> | <unused> | <unused> | <unused> |
    |________|____________|__________|__________|__________|__________|

     __[ batch item 1 ]_______________________________________________
    | index  |    0       |    1     |    2     |    3     |    4     |
    | values | [2. 2. 2.] | <unused> | <unused> | <unused> | <unused> |
    |________|____________|__________|__________|__________|__________|

     __[ batch item 2 ]_______________________________________________
    | index  |    0       |    1     |    2     |    3     |    4     |
    | values | [3. 3. 3.] | <unused> | <unused> | <unused> | <unused> |
    |________|____________|__________|__________|__________|__________|

     __[ batch item 3 ]_______________________________________________
    | index  |    0       |    1     |    2     |    3     |    4     |
    | values | [4. 4. 4.] | <unused> | <unused> | <unused> | <unused> |
    |________|____________|__________|__________|__________|__________|
    ```

    We can also use a boolean mask to add to only some of the lists within
    the batch:

    ```python
    list_batch.append_(
        torch.tensor(
            [
                [5.0, 5.0, 5.0],
                [6.0, 6.0, 6.0],
                [7.0, 7.0, 7.0],
                [8.0, 8.0, 8.0],
            ]
        ),
        where=torch.tensor([True, False, False, True]),
    )
    ```

    which would update our batch of lists like this:

    ```text
     __[ batch item 0 ]_________________________________________________
    | index  |    0       |    1       |    2     |    3     |    4     |
    | values | [1. 1. 1.] | [5. 5. 5.] | <unused> | <unused> | <unused> |
    |________|____________|____________|__________|__________|__________|

     __[ batch item 1 ]_________________________________________________
    | index  |    0       |    1       |    2     |    3     |    4     |
    | values | [2. 2. 2.] |  <unused>  | <unused> | <unused> | <unused> |
    |________|____________|____________|__________|__________|__________|

     __[ batch item 2 ]_________________________________________________
    | index  |    0       |    1       |    2     |    3     |    4     |
    | values | [3. 3. 3.] |  <unused>  | <unused> | <unused> | <unused> |
    |________|____________|____________|__________|__________|__________|

     __[ batch item 3 ]_________________________________________________
    | index  |    0       |    1       |    2     |    3     |    4     |
    | values | [4. 4. 4.] | [8. 8. 8.] | <unused> | <unused> | <unused> |
    |________|____________|____________|__________|__________|__________|
    ```

    Please notice above how the batch items 1 and 2 were not modified because
    their corresponding boolean values in the `where` tensor were given as
    `False`.

    After all these modifications we would get the following results:

    ```text
    >>> list_batch[0]
    torch.tensor(
        [[1. 1. 1.],
         [2. 2. 2.],
         [3. 3. 3.],
         [4. 4. 4.]]
    )

    >>> list_batch[[1, 0, 0, 1]]
    torch.tensor(
        [[5. 5. 5.],
         [2. 2. 2.],
         [3. 3. 3.],
         [8. 8. 8.]]
    )

    >>> list_batch[[-1, -1, -1, -1]]
    torch.tensor(
        [[5. 5. 5.],
         [2. 2. 2.],
         [3. 3. 3.],
         [8. 8. 8.]]
    )
    ```

    Note that this CList structure also supports the ability to insert to the
    beginning, or to remove from the beginning. These operations internally
    shift the addresses for the beginning of the data within the underlying
    memory, and therefore, they are not any more costly than adding to or
    removing from the end of the list.
    """

    def __init__(
        self,
        *size: Union[int, list, tuple],
        max_length: int,
        batch_size: Optional[Union[int, tuple, list]] = None,
        batch_shape: Optional[Union[int, tuple, list]] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        verify: bool = True,
    ):
        self._verify = bool(verify)
        self._max_length = int(max_length)

        self._data = CMemory(
            *size,
            num_keys=self._max_length,
            batch_size=batch_size,
            batch_shape=batch_shape,
            dtype=dtype,
            device=device,
            verify=False,
        )

        self._begin, self._end = [
            CMemory(
                num_keys=1,
                batch_size=batch_size,
                batch_shape=batch_shape,
                dtype=torch.int64,
                device=device,
                verify=False,
                fill_with=-1,
            )
            for _ in range(2)
        ]

        if "float" in str(self._data.dtype):
            self._pop_fallback = float("nan")
        else:
            self._pop_fallback = 0

        if self._begin.batch_ndim == 0:
            self._all_zeros = torch.tensor(0, dtype=torch.int64, device=self._begin.device)
        else:
            self._all_zeros = torch.zeros(1, dtype=torch.int64, device=self._begin.device).expand(
                self._begin.batch_shape
            )

    def _is_empty(self) -> torch.Tensor:
        # return (self._begin[self._all_zeros] == -1) & (self._end[self._all_zeros] == -1)
        return self._begin[self._all_zeros] == -1

    def _has_one_element(self) -> torch.Tensor:
        begin = self._begin[self._all_zeros]
        end = self._end[self._all_zeros]
        return (begin == end) & (begin >= 0)

    def _is_full(self) -> torch.Tensor:
        begin = self._begin[self._all_zeros]
        end = self._end[self._all_zeros]
        return ((end - begin) % self._max_length) == (self._max_length - 1)

    @staticmethod
    def _considering_where(other_mask: torch.Tensor, where: Optional[torch.Tensor]) -> torch.Tensor:
        return other_mask if where is None else other_mask & where

    def _get_info_for_adding_element(self, where: Optional[torch.Tensor]) -> _InfoForAddingElement:
        is_empty = self._is_empty()
        is_full = self._is_full()
        to_be_declared_non_empty = self._considering_where(is_empty, where)
        if self._verify:
            invalid_move = self._considering_where(is_full, where)
            if torch.any(invalid_move):
                raise IndexError("Some of the queues are full, and therefore elements cannot be added to them")
        valid_move = self._considering_where((~is_empty) & (~is_full), where)
        return _InfoForAddingElement(valid_move=valid_move, to_be_declared_non_empty=to_be_declared_non_empty)

    def _get_info_for_removing_element(self, where: Optional[torch.Tensor]) -> _InfoForRemovingElement:
        is_empty = self._is_empty()
        has_one_element = self._has_one_element()
        if self._verify:
            invalid_move = self._considering_where(is_empty, where)
            if torch.any(invalid_move):
                raise IndexError(
                    "Some of the queues are already empty, and therefore elements cannot be removed from them"
                )
        to_be_declared_empty = self._considering_where(has_one_element, where)
        valid_move = self._considering_where((~is_empty) & (~has_one_element), where)
        return _InfoForRemovingElement(valid_move=valid_move, to_be_declared_empty=to_be_declared_empty)

    def _move_begin_forward(self, where: Optional[torch.Tensor]):
        valid_move, to_be_declared_empty = self._get_info_for_removing_element(where)
        self._begin.set_(self._all_zeros, -1, where=to_be_declared_empty)
        self._end.set_(self._all_zeros, -1, where=to_be_declared_empty)
        self._begin.add_circular_(self._all_zeros, 1, self._max_length, where=valid_move)

    def _move_end_forward(self, where: Optional[torch.Tensor]):
        valid_move, to_be_declared_non_empty = self._get_info_for_adding_element(where)
        self._begin.set_(self._all_zeros, 0, where=to_be_declared_non_empty)
        self._end.set_(self._all_zeros, 0, where=to_be_declared_non_empty)
        self._end.add_circular_(self._all_zeros, 1, self._max_length, where=valid_move)

    def _move_begin_backward(self, where: Optional[torch.Tensor]):
        valid_move, to_be_declared_non_empty = self._get_info_for_adding_element(where)
        self._begin.set_(self._all_zeros, 0, where=to_be_declared_non_empty)
        self._end.set_(self._all_zeros, 0, where=to_be_declared_non_empty)
        self._begin.add_circular_(self._all_zeros, -1, self._max_length, where=valid_move)

    def _move_end_backward(self, where: Optional[torch.Tensor]):
        valid_move, to_be_declared_empty = self._get_info_for_removing_element(where)
        self._begin.set_(self._all_zeros, -1, where=to_be_declared_empty)
        self._end.set_(self._all_zeros, -1, where=to_be_declared_empty)
        self._end.add_circular_(self._all_zeros, -1, self._max_length, where=valid_move)

    def _get_key(self, key: Numbers) -> torch.Tensor:
        key = torch.as_tensor(key, dtype=torch.int64, device=self._data.device)
        batch_shape = self._data.batch_shape
        if key.shape != batch_shape:
            if key.ndim == 0:
                key = key.expand(self._data.batch_shape)
            else:
                raise ValueError(
                    f"Expected the keys of shape {batch_shape}, but received them in this shape: {key.shape}"
                )
        return key

    def _is_underlying_key_valid(self, underlying_key: torch.Tensor) -> torch.Tensor:
        within_valid_range = (underlying_key >= 0) & (underlying_key < self._max_length)
        begin = self._begin[self._all_zeros]
        end = self._end[self._all_zeros]
        empty = self._is_empty()
        non_empty = ~empty
        larger_end = non_empty & (end > begin)
        smaller_end = non_empty & (end < begin)
        same_begin_end = (begin == end) & (~empty)
        valid = within_valid_range & (
            (same_begin_end & (underlying_key == begin))
            | (larger_end & (underlying_key >= begin) & (underlying_key <= end))
            | (smaller_end & ((underlying_key <= end) | (underlying_key >= begin)))
        )
        return valid

    def _mod_underlying_key(self, underlying_key: torch.Tensor, *, verify: Optional[bool] = None) -> torch.Tensor:
        verify = self._verify if verify is None else verify
        if self._verify:
            where_negative = underlying_key < 0
            where_too_large = underlying_key >= self._max_length
            underlying_key = underlying_key.clone()
            underlying_key[where_negative] += self._max_length
            underlying_key[where_too_large] -= self._max_length
        else:
            underlying_key = underlying_key % self._max_length

        return underlying_key

    def _get_underlying_key(
        self,
        key: Numbers,
        *,
        verify: Optional[bool] = None,
        return_validity: bool = False,
        where: Optional[torch.Tensor] = None,
    ) -> Union[torch.Tensor, tuple]:
        if where is not None:
            where = self._get_where(where)
        verify = self._verify if verify is None else verify
        key = self._get_key(key)
        underlying_key_for_pos_index = self._begin[self._all_zeros] + key
        underlying_key_for_neg_index = self._end[self._all_zeros] + key + 1
        underlying_key = torch.where(key >= 0, underlying_key_for_pos_index, underlying_key_for_neg_index)
        underlying_key = self._mod_underlying_key(underlying_key, verify=verify)

        if verify or return_validity:
            valid = self._is_underlying_key_valid(underlying_key)
        else:
            valid = None

        if verify:
            okay = valid if where is None else valid | (~where)
            if not torch.all(okay):
                raise IndexError("Encountered invalid index/indices")

        if return_validity:
            return underlying_key, valid
        else:
            return underlying_key

    def get(self, key: Numbers, default: Optional[Numbers] = None) -> torch.Tensor:
        """
        Get the value(s) from the specified element(s).

        Args:
            key: The index/indices pointing to the element(s) whose value(s)
                is/are queried.
            default: Default value(s) to be returned for when the specified
                index/indices are invalid and/or out of range.
        Returns:
            The value(s) stored by the element(s).
        """
        if default is None:
            underlying_key = self._get_underlying_key(key)
            return self._data[underlying_key]
        else:
            default = self._data._get_value(default)
            underlying_key, valid_key = self._get_underlying_key(key, verify=False, return_validity=True)
            return do_where(valid_key, self._data[underlying_key % self._max_length], default)

    def __getitem__(self, key: Numbers) -> torch.Tensor:
        """
        Get the value(s) from the specified element(s).

        Args:
            key: The index/indices pointing to the element(s) whose value(s)
                is/are queried.
        Returns:
            The value(s) stored by the element(s).
        """
        return self.get(key)

    def _apply_modification_method(
        self, method_name: str, key: Numbers, value: Numbers, where: Optional[Numbers] = None
    ):
        underlying_key = self._get_underlying_key(key, where=where)
        getattr(self._data, method_name)(underlying_key, value, where)

    def set_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Set the element(s) addressed to by the given key(s).

        Args:
            key: The index/indices tensor.
            value: The new value(s).
            where: Optionally a boolean mask. When provided, only the elements
                whose corresponding mask value(s) is/are True will be subject
                to modification.
        """
        self._apply_modification_method("set_", key, value, where)

    def __setitem__(self, key: Numbers, value: Numbers):
        """
        Set the element(s) addressed to by the given key(s).

        Args:
            key: The index/indices tensor.
            value: The new value(s).
        """
        self._apply_modification_method("set_", key, value)

    def add_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Add to the element(s) addressed to by the given key(s).

        Please note that the word "add" is used in the arithmetic sense
        (i.e. in the sense of performing addition). For putting a new
        element into this list, please see the method `append_(...)`.

        Args:
            key: The index/indices tensor.
            value: The value(s) that will be added onto the existing
                element(s).
            where: Optionally a boolean mask. When provided, only the elements
                whose corresponding mask value(s) is/are True will be subject
                to modification.
        """
        self._apply_modification_method("add_", key, value, where)

    def subtract_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Subtract from the element(s) addressed to by the given key(s).

        Args:
            key: The index/indices tensor.
            value: The value(s) that will be subtracted from the existing
                element(s).
            where: Optionally a boolean mask. When provided, only the elements
                whose corresponding mask value(s) is/are True will be subject
                to modification.
        """
        self._apply_modification_method("subtract_", key, value, where)

    def multiply_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Multiply the element(s) addressed to by the given key(s).

        Args:
            key: The index/indices tensor.
            value: The value(s) that will be used as the multiplier(s) on the
                existing element(s).
            where: Optionally a boolean mask. When provided, only the elements
                whose corresponding mask value(s) is/are True will be subject
                to modification.
        """
        self._apply_modification_method("multiply_", key, value, where)

    def divide_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Divide the element(s) addressed to by the given key(s).

        Args:
            key: The index/indices tensor.
            value: The value(s) that will be used as the divisor(s) on the
                existing element(s).
            where: Optionally a boolean mask. When provided, only the elements
                whose corresponding mask value(s) is/are True will be subject
                to modification.
        """
        self._apply_modification_method("divide_", key, value, where)

    def append_(self, value: Numbers, where: Optional[Numbers] = None):
        """
        Add new item(s) to the end(s) of the list(s).

        The length(s) of the updated list(s) will increase by 1.

        Args:
            value: The element that will be added to the list.
                In the non-batched case, this element is expected as a tensor
                whose shape matches `value_shape`.
                In the batched case, this value is expected as a batch of
                elements with extra leftmost dimensions (those extra leftmost
                dimensions being expressed by `batch_shape`).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then additions will happen only
                on the lists whose corresponding mask values are True.
        """
        where = None if where is None else self._get_where(where)
        self._move_end_forward(where)
        self.set_(-1, value, where=where)

    def push_(self, value: Numbers, where: Optional[Numbers] = None):
        """
        Alias for the method `append_(...)`.
        We provide this alternative name so that users who wish to use this
        CList structure like a stack will be able to use familiar terminology.
        """
        return self.append_(value, where=where)

    def appendleft_(self, value: Numbers, where: Optional[Numbers] = None):
        """
        Add new item(s) to the beginning point(s) of the list(s).

        The length(s) of the updated list(s) will increase by 1.

        Args:
            value: The element that will be added to the list.
                In the non-batched case, this element is expected as a tensor
                whose shape matches `value_shape`.
                In the batched case, this value is expected as a batch of
                elements with extra leftmost dimensions (those extra leftmost
                dimensions being expressed by `batch_shape`).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then additions will happen only
                on the lists whose corresponding mask values are True.
        """
        where = None if where is None else self._get_where(where)
        self._move_begin_backward(where)
        self.set_(0, value, where=where)

    def pop_(self, where: Optional[Numbers] = None):
        """
        Pop the last item(s) from the ending point(s) list(s).

        The length(s) of the updated list(s) will decrease by 1.

        Args:
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then the pop operations will happen
                only on the lists whose corresponding mask values are True.
        Returns:
            The popped item(s).
        """
        where = None if where is None else self._get_where(where)
        result = self.get(-1, default=self._pop_fallback)
        self._move_end_backward(where)
        return result

    def popleft_(self, where: Optional[Numbers] = None):
        """
        Pop the last item(s) from the beginning point(s) list(s).

        The length(s) of the updated list(s) will decrease by 1.

        Args:
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then the pop operations will happen
                only on the lists whose corresponding mask values are True.
        Returns:
            The popped item(s).
        """
        where = None if where is None else self._get_where(where)
        result = self.get(0, default=self._pop_fallback)
        self._move_begin_forward(where)
        return result

    def clear(self, where: Optional[torch.Tensor] = None):
        """
        Clear the list(s).

        In the context of this data structure, to "clear" means to reduce their
        lengths to 0.

        Args:
            where: Optionally a boolean tensor, specifying which lists within
                the batch will be cleared. If this argument is omitted (i.e.
                left as None), then all of the lists will be cleared.
        """
        if where is None:
            self._begin.data[:] = -1
            self._end.data[:] = -1
        else:
            where = self._get_where(where)
            all_minus_ones = torch.tensor(-1, dtype=torch.int64, device=self._begin.device).expand(self._begin.shape)
            self._begin.data[:] = do_where(where, all_minus_ones, self._begin.data)
            self._end.data[:] = do_where(where, all_minus_ones, self._end.data)

    @property
    def data(self) -> torch.Tensor:
        """
        The underlying tensor which stores all the data
        """
        return self._data.data

    @property
    def length(self) -> torch.Tensor:
        """
        The length(s) of the list(s)
        """
        is_empty = self._is_empty()
        is_full = self._is_full()
        result = ((self._end[self._all_zeros] - self._begin[self._all_zeros]) % self._max_length) + 1
        result[is_empty] = 0
        result[is_full] = self._max_length
        return result

    @property
    def max_length(self) -> int:
        """
        Maximum length for the list(s)
        """
        return self._max_length
data: Tensor property readonly

The underlying tensor which stores all the data

length: Tensor property readonly

The length(s) of the list(s)

max_length: int property readonly

Maximum length for the list(s)

__getitem__(self, key) special

Get the value(s) from the specified element(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

The index/indices pointing to the element(s) whose value(s) is/are queried.

required

Returns:

Type Description
Tensor

The value(s) stored by the element(s).

Source code in evotorch/tools/structures.py
def __getitem__(self, key: Numbers) -> torch.Tensor:
    """
    Get the value(s) from the specified element(s).

    Args:
        key: The index/indices pointing to the element(s) whose value(s)
            is/are queried.
    Returns:
        The value(s) stored by the element(s).
    """
    return self.get(key)
__setitem__(self, key, value) special

Set the element(s) addressed to by the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

The index/indices tensor.

required
value Union[numbers.Number, Iterable[numbers.Number]]

The new value(s).

required
Source code in evotorch/tools/structures.py
def __setitem__(self, key: Numbers, value: Numbers):
    """
    Set the element(s) addressed to by the given key(s).

    Args:
        key: The index/indices tensor.
        value: The new value(s).
    """
    self._apply_modification_method("set_", key, value)
add_(self, key, value, where=None)

Add to the element(s) addressed to by the given key(s).

Please note that the word "add" is used in the arithmetic sense (i.e. in the sense of performing addition). For putting a new element into this list, please see the method append_(...).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

The index/indices tensor.

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be added onto the existing element(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask. When provided, only the elements whose corresponding mask value(s) is/are True will be subject to modification.

None
Source code in evotorch/tools/structures.py
def add_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Add to the element(s) addressed to by the given key(s).

    Please note that the word "add" is used in the arithmetic sense
    (i.e. in the sense of performing addition). For putting a new
    element into this list, please see the method `append_(...)`.

    Args:
        key: The index/indices tensor.
        value: The value(s) that will be added onto the existing
            element(s).
        where: Optionally a boolean mask. When provided, only the elements
            whose corresponding mask value(s) is/are True will be subject
            to modification.
    """
    self._apply_modification_method("add_", key, value, where)
append_(self, value, where=None)

Add new item(s) to the end(s) of the list(s).

The length(s) of the updated list(s) will increase by 1.

Parameters:

Name Type Description Default
value Union[numbers.Number, Iterable[numbers.Number]]

The element that will be added to the list. In the non-batched case, this element is expected as a tensor whose shape matches value_shape. In the batched case, this value is expected as a batch of elements with extra leftmost dimensions (those extra leftmost dimensions being expressed by batch_shape).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then additions will happen only on the lists whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def append_(self, value: Numbers, where: Optional[Numbers] = None):
    """
    Add new item(s) to the end(s) of the list(s).

    The length(s) of the updated list(s) will increase by 1.

    Args:
        value: The element that will be added to the list.
            In the non-batched case, this element is expected as a tensor
            whose shape matches `value_shape`.
            In the batched case, this value is expected as a batch of
            elements with extra leftmost dimensions (those extra leftmost
            dimensions being expressed by `batch_shape`).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then additions will happen only
            on the lists whose corresponding mask values are True.
    """
    where = None if where is None else self._get_where(where)
    self._move_end_forward(where)
    self.set_(-1, value, where=where)
appendleft_(self, value, where=None)

Add new item(s) to the beginning point(s) of the list(s).

The length(s) of the updated list(s) will increase by 1.

Parameters:

Name Type Description Default
value Union[numbers.Number, Iterable[numbers.Number]]

The element that will be added to the list. In the non-batched case, this element is expected as a tensor whose shape matches value_shape. In the batched case, this value is expected as a batch of elements with extra leftmost dimensions (those extra leftmost dimensions being expressed by batch_shape).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then additions will happen only on the lists whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def appendleft_(self, value: Numbers, where: Optional[Numbers] = None):
    """
    Add new item(s) to the beginning point(s) of the list(s).

    The length(s) of the updated list(s) will increase by 1.

    Args:
        value: The element that will be added to the list.
            In the non-batched case, this element is expected as a tensor
            whose shape matches `value_shape`.
            In the batched case, this value is expected as a batch of
            elements with extra leftmost dimensions (those extra leftmost
            dimensions being expressed by `batch_shape`).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then additions will happen only
            on the lists whose corresponding mask values are True.
    """
    where = None if where is None else self._get_where(where)
    self._move_begin_backward(where)
    self.set_(0, value, where=where)
clear(self, where=None)

Clear the list(s).

In the context of this data structure, to "clear" means to reduce their lengths to 0.

Parameters:

Name Type Description Default
where Optional[torch.Tensor]

Optionally a boolean tensor, specifying which lists within the batch will be cleared. If this argument is omitted (i.e. left as None), then all of the lists will be cleared.

None
Source code in evotorch/tools/structures.py
def clear(self, where: Optional[torch.Tensor] = None):
    """
    Clear the list(s).

    In the context of this data structure, to "clear" means to reduce their
    lengths to 0.

    Args:
        where: Optionally a boolean tensor, specifying which lists within
            the batch will be cleared. If this argument is omitted (i.e.
            left as None), then all of the lists will be cleared.
    """
    if where is None:
        self._begin.data[:] = -1
        self._end.data[:] = -1
    else:
        where = self._get_where(where)
        all_minus_ones = torch.tensor(-1, dtype=torch.int64, device=self._begin.device).expand(self._begin.shape)
        self._begin.data[:] = do_where(where, all_minus_ones, self._begin.data)
        self._end.data[:] = do_where(where, all_minus_ones, self._end.data)
divide_(self, key, value, where=None)

Divide the element(s) addressed to by the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

The index/indices tensor.

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be used as the divisor(s) on the existing element(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask. When provided, only the elements whose corresponding mask value(s) is/are True will be subject to modification.

None
Source code in evotorch/tools/structures.py
def divide_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Divide the element(s) addressed to by the given key(s).

    Args:
        key: The index/indices tensor.
        value: The value(s) that will be used as the divisor(s) on the
            existing element(s).
        where: Optionally a boolean mask. When provided, only the elements
            whose corresponding mask value(s) is/are True will be subject
            to modification.
    """
    self._apply_modification_method("divide_", key, value, where)
get(self, key, default=None)

Get the value(s) from the specified element(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

The index/indices pointing to the element(s) whose value(s) is/are queried.

required
default Union[numbers.Number, Iterable[numbers.Number]]

Default value(s) to be returned for when the specified index/indices are invalid and/or out of range.

None

Returns:

Type Description
Tensor

The value(s) stored by the element(s).

Source code in evotorch/tools/structures.py
def get(self, key: Numbers, default: Optional[Numbers] = None) -> torch.Tensor:
    """
    Get the value(s) from the specified element(s).

    Args:
        key: The index/indices pointing to the element(s) whose value(s)
            is/are queried.
        default: Default value(s) to be returned for when the specified
            index/indices are invalid and/or out of range.
    Returns:
        The value(s) stored by the element(s).
    """
    if default is None:
        underlying_key = self._get_underlying_key(key)
        return self._data[underlying_key]
    else:
        default = self._data._get_value(default)
        underlying_key, valid_key = self._get_underlying_key(key, verify=False, return_validity=True)
        return do_where(valid_key, self._data[underlying_key % self._max_length], default)
multiply_(self, key, value, where=None)

Multiply the element(s) addressed to by the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

The index/indices tensor.

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be used as the multiplier(s) on the existing element(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask. When provided, only the elements whose corresponding mask value(s) is/are True will be subject to modification.

None
Source code in evotorch/tools/structures.py
def multiply_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Multiply the element(s) addressed to by the given key(s).

    Args:
        key: The index/indices tensor.
        value: The value(s) that will be used as the multiplier(s) on the
            existing element(s).
        where: Optionally a boolean mask. When provided, only the elements
            whose corresponding mask value(s) is/are True will be subject
            to modification.
    """
    self._apply_modification_method("multiply_", key, value, where)
pop_(self, where=None)

Pop the last item(s) from the ending point(s) list(s).

The length(s) of the updated list(s) will decrease by 1.

Parameters:

Name Type Description Default
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then the pop operations will happen only on the lists whose corresponding mask values are True.

None

Returns:

Type Description

The popped item(s).

Source code in evotorch/tools/structures.py
def pop_(self, where: Optional[Numbers] = None):
    """
    Pop the last item(s) from the ending point(s) list(s).

    The length(s) of the updated list(s) will decrease by 1.

    Args:
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then the pop operations will happen
            only on the lists whose corresponding mask values are True.
    Returns:
        The popped item(s).
    """
    where = None if where is None else self._get_where(where)
    result = self.get(-1, default=self._pop_fallback)
    self._move_end_backward(where)
    return result
popleft_(self, where=None)

Pop the last item(s) from the beginning point(s) list(s).

The length(s) of the updated list(s) will decrease by 1.

Parameters:

Name Type Description Default
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then the pop operations will happen only on the lists whose corresponding mask values are True.

None

Returns:

Type Description

The popped item(s).

Source code in evotorch/tools/structures.py
def popleft_(self, where: Optional[Numbers] = None):
    """
    Pop the last item(s) from the beginning point(s) list(s).

    The length(s) of the updated list(s) will decrease by 1.

    Args:
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then the pop operations will happen
            only on the lists whose corresponding mask values are True.
    Returns:
        The popped item(s).
    """
    where = None if where is None else self._get_where(where)
    result = self.get(0, default=self._pop_fallback)
    self._move_begin_forward(where)
    return result
push_(self, value, where=None)

Alias for the method append_(...). We provide this alternative name so that users who wish to use this CList structure like a stack will be able to use familiar terminology.

Source code in evotorch/tools/structures.py
def push_(self, value: Numbers, where: Optional[Numbers] = None):
    """
    Alias for the method `append_(...)`.
    We provide this alternative name so that users who wish to use this
    CList structure like a stack will be able to use familiar terminology.
    """
    return self.append_(value, where=where)
set_(self, key, value, where=None)

Set the element(s) addressed to by the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

The index/indices tensor.

required
value Union[numbers.Number, Iterable[numbers.Number]]

The new value(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask. When provided, only the elements whose corresponding mask value(s) is/are True will be subject to modification.

None
Source code in evotorch/tools/structures.py
def set_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Set the element(s) addressed to by the given key(s).

    Args:
        key: The index/indices tensor.
        value: The new value(s).
        where: Optionally a boolean mask. When provided, only the elements
            whose corresponding mask value(s) is/are True will be subject
            to modification.
    """
    self._apply_modification_method("set_", key, value, where)
subtract_(self, key, value, where=None)

Subtract from the element(s) addressed to by the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

The index/indices tensor.

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be subtracted from the existing element(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask. When provided, only the elements whose corresponding mask value(s) is/are True will be subject to modification.

None
Source code in evotorch/tools/structures.py
def subtract_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Subtract from the element(s) addressed to by the given key(s).

    Args:
        key: The index/indices tensor.
        value: The value(s) that will be subtracted from the existing
            element(s).
        where: Optionally a boolean mask. When provided, only the elements
            whose corresponding mask value(s) is/are True will be subject
            to modification.
    """
    self._apply_modification_method("subtract_", key, value, where)

CMemory

Representation of a batchable contiguous memory.

This container can be seen as a batchable primitive dictionary where the keys are allowed either as integers or as tuples of integers. Please also note that, a memory block for each key is already allocated, meaning that unlike a dictionary of Python, each key already exists and is associated with a tensor.

Let us consider an example where we have 5 keys, and each key is associated with a tensor of length 7. Such a memory could be allocated like this:

memory = CMemory(7, num_keys=5)

Our allocated memory can be visualized as follows:

 _______________________________________
| key 0 -> [ empty tensor of length 7 ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ empty tensor of length 7 ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

Let us now sample a Gaussian noise and put it into the 0-th slot:

memory[0] = torch.randn(7)  # or: memory[torch.tensor(0)] = torch.randn(7)

which results in:

 _________________________________________
| key 0 -> [ Gaussian noise of length 7 ] |
| key 1 -> [ empty tensor of length 7   ] |
| key 2 -> [ empty tensor of length 7   ] |
| key 3 -> [ empty tensor of length 7   ] |
| key 4 -> [ empty tensor of length 7   ] |
|_________________________________________|

Let us now consider another example where we deal with not a single CMemory, but with a CMemory batch. For the sake of this example, let us say that our desired batch size is 3. The allocation of such a batch would be as follows:

memory_batch = CMemory(7, num_keys=5, batch_size=3)

Our memory batch can be visualized like this:

 __[ batch item 0 ]_____________________
| key 0 -> [ empty tensor of length 7 ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ empty tensor of length 7 ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

 __[ batch item 1 ]_____________________
| key 0 -> [ empty tensor of length 7 ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ empty tensor of length 7 ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

 __[ batch item 2 ]_____________________
| key 0 -> [ empty tensor of length 7 ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ empty tensor of length 7 ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

If we wish to set the 0-th element of each batch item, we could do:

memory_batch[0] = torch.tensor(
    [
        [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
        [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
        [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0],
    ],
)

and the result would be:

 __[ batch item 0 ]_____________________
| key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ empty tensor of length 7 ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

 __[ batch item 1 ]_____________________
| key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ empty tensor of length 7 ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

 __[ batch item 2 ]_____________________
| key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ empty tensor of length 7 ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

Continuing from the same example, if we wish to set the slot with key 1 in the 0th batch item, slot with key 2 in the 1st batch item, and slot with key 3 in the 2nd batch item, all in one go, we could do:

# Longer version: memory_batch[torch.tensor([1, 2, 3])] = ...

memory_batch[[1, 2, 3]] = torch.tensor(
    [
        [5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0],
        [6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0],
        [7.0, 7.0, 7.0, 7.0, 7.0, 7.0, 7.0],
    ],
)

Our updated memory batch would then look like this:

 __[ batch item 0 ]_____________________
| key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
| key 1 -> [ 5. 5. 5. 5. 5. 5. 5.     ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ empty tensor of length 7 ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

 __[ batch item 1 ]_____________________
| key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ 6. 6. 6. 6. 6. 6. 6.     ] |
| key 3 -> [ empty tensor of length 7 ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

 __[ batch item 2 ]_____________________
| key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ 7. 7. 7. 7. 7. 7. 7.     ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

Conditional modifications via boolean masks is also supported. For example, the following update on our memory_batch:

memory_batch.set_(
    [4, 3, 1],
    torch.tensor(
        [
            [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0],
            [9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0],
            [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0],
        ]
    ),
    where=[True, True, False],  # or: where=torch.tensor([True,True,False]),
)

would result in:

 __[ batch item 0 ]_____________________
| key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
| key 1 -> [ 5. 5. 5. 5. 5. 5. 5.     ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ empty tensor of length 7 ] |
| key 4 -> [ 8. 8. 8. 8. 8. 8. 8.     ] |
|_______________________________________|

 __[ batch item 1 ]_____________________
| key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ 6. 6. 6. 6. 6. 6. 6.     ] |
| key 3 -> [ 9. 9. 9. 9. 9. 9. 9.     ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

 __[ batch item 2 ]_____________________
| key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
| key 1 -> [ empty tensor of length 7 ] |
| key 2 -> [ empty tensor of length 7 ] |
| key 3 -> [ 7. 7. 7. 7. 7. 7. 7.     ] |
| key 4 -> [ empty tensor of length 7 ] |
|_______________________________________|

Please notice above that the slot with key 1 of the batch item 2 was not modified because its corresponding mask value was given as False.

Source code in evotorch/tools/structures.py
class CMemory:
    """
    Representation of a batchable contiguous memory.

    This container can be seen as a batchable primitive dictionary where the
    keys are allowed either as integers or as tuples of integers. Please also
    note that, a memory block for each key is already allocated, meaning that
    unlike a dictionary of Python, each key already exists and is associated
    with a tensor.

    Let us consider an example where we have 5 keys, and each key is associated
    with a tensor of length 7. Such a memory could be allocated like this:

    ```python
    memory = CMemory(7, num_keys=5)
    ```

    Our allocated memory can be visualized as follows:

    ```text
     _______________________________________
    | key 0 -> [ empty tensor of length 7 ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ empty tensor of length 7 ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|
    ```

    Let us now sample a Gaussian noise and put it into the 0-th slot:

    ```python
    memory[0] = torch.randn(7)  # or: memory[torch.tensor(0)] = torch.randn(7)
    ```

    which results in:

    ```text
     _________________________________________
    | key 0 -> [ Gaussian noise of length 7 ] |
    | key 1 -> [ empty tensor of length 7   ] |
    | key 2 -> [ empty tensor of length 7   ] |
    | key 3 -> [ empty tensor of length 7   ] |
    | key 4 -> [ empty tensor of length 7   ] |
    |_________________________________________|
    ```

    Let us now consider another example where we deal with not a single CMemory,
    but with a CMemory batch. For the sake of this example, let us say that our
    desired batch size is 3. The allocation of such a batch would be as
    follows:

    ```python
    memory_batch = CMemory(7, num_keys=5, batch_size=3)
    ```

    Our memory batch can be visualized like this:

    ```text
     __[ batch item 0 ]_____________________
    | key 0 -> [ empty tensor of length 7 ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ empty tensor of length 7 ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|

     __[ batch item 1 ]_____________________
    | key 0 -> [ empty tensor of length 7 ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ empty tensor of length 7 ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|

     __[ batch item 2 ]_____________________
    | key 0 -> [ empty tensor of length 7 ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ empty tensor of length 7 ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|
    ```

    If we wish to set the 0-th element of each batch item, we could do:

    ```python
    memory_batch[0] = torch.tensor(
        [
            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
            [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
            [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0],
        ],
    )
    ```

    and the result would be:

    ```text
     __[ batch item 0 ]_____________________
    | key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ empty tensor of length 7 ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|

     __[ batch item 1 ]_____________________
    | key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ empty tensor of length 7 ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|

     __[ batch item 2 ]_____________________
    | key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ empty tensor of length 7 ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|
    ```

    Continuing from the same example, if we wish to set the slot with key 1
    in the 0th batch item, slot with key 2 in the 1st batch item, and
    slot with key 3 in the 2nd batch item, all in one go, we could do:

    ```python
    # Longer version: memory_batch[torch.tensor([1, 2, 3])] = ...

    memory_batch[[1, 2, 3]] = torch.tensor(
        [
            [5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0],
            [6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0],
            [7.0, 7.0, 7.0, 7.0, 7.0, 7.0, 7.0],
        ],
    )
    ```

    Our updated memory batch would then look like this:

    ```text
     __[ batch item 0 ]_____________________
    | key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
    | key 1 -> [ 5. 5. 5. 5. 5. 5. 5.     ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ empty tensor of length 7 ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|

     __[ batch item 1 ]_____________________
    | key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ 6. 6. 6. 6. 6. 6. 6.     ] |
    | key 3 -> [ empty tensor of length 7 ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|

     __[ batch item 2 ]_____________________
    | key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ 7. 7. 7. 7. 7. 7. 7.     ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|
    ```

    Conditional modifications via boolean masks is also supported.
    For example, the following update on our `memory_batch`:

    ```python
    memory_batch.set_(
        [4, 3, 1],
        torch.tensor(
            [
                [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0],
                [9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0],
                [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0],
            ]
        ),
        where=[True, True, False],  # or: where=torch.tensor([True,True,False]),
    )
    ```

    would result in:

    ```text
     __[ batch item 0 ]_____________________
    | key 0 -> [ 0. 0. 0. 0. 0. 0. 0.     ] |
    | key 1 -> [ 5. 5. 5. 5. 5. 5. 5.     ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ empty tensor of length 7 ] |
    | key 4 -> [ 8. 8. 8. 8. 8. 8. 8.     ] |
    |_______________________________________|

     __[ batch item 1 ]_____________________
    | key 0 -> [ 1. 1. 1. 1. 1. 1. 1.     ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ 6. 6. 6. 6. 6. 6. 6.     ] |
    | key 3 -> [ 9. 9. 9. 9. 9. 9. 9.     ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|

     __[ batch item 2 ]_____________________
    | key 0 -> [ 2. 2. 2. 2. 2. 2. 2.     ] |
    | key 1 -> [ empty tensor of length 7 ] |
    | key 2 -> [ empty tensor of length 7 ] |
    | key 3 -> [ 7. 7. 7. 7. 7. 7. 7.     ] |
    | key 4 -> [ empty tensor of length 7 ] |
    |_______________________________________|
    ```

    Please notice above that the slot with key 1 of the batch item 2 was not
    modified because its corresponding mask value was given as False.
    """

    def __init__(
        self,
        *size: Union[int, tuple, list],
        num_keys: Union[int, tuple, list],
        key_offset: Optional[Union[int, tuple, list]] = None,
        batch_size: Optional[Union[int, tuple, list]] = None,
        batch_shape: Optional[Union[int, tuple, list]] = None,
        fill_with: Optional[Numbers] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        verify: bool = True,
    ):
        """
        `__init__(...)`: Initialize the CMemory.

        Args:
            size: Size of a tensor associated with a key, expected as an
                integer, or as multiple positional arguments (each positional
                argument being an integer), or as a tuple of integers.
            num_keys: How many keys (and therefore how many slots) will the
                memory have. If given as an integer `n`, then there will be `n`
                slots in the memory, and to access a slot one will need to use
                an integer key `k` (where, by default, the minimum acceptable
                `k` is 0 and the maximum acceptable `k` is `n-1`).
                If given as a tuple of integers, then the number of slots in
                the memory will be computed as the product of all the integers
                in the tuple, and a key will be expected as a tuple.
                For example, when `num_keys` is `(3, 5)`, there will be 15
                slots in the memory (where, by default, the minimum acceptable
                key will be `(0, 0)` and the maximum acceptable key will be
                `(2, 4)`.
            key_offset: Optionally can be used to shift the integer values of
                the keys. For example, if `num_keys` is 10, then, by default,
                the minimum key is 0 and the maximum key is 9. But together
                with `num_keys=10`, if `key_offset` is given as 1, then the
                minimum key will be 1 and the maximum key will be 10.
                This argument can also be used together with a tuple-valued
                `num_keys`. For example, with `num_keys` set as `(3, 5)`,
                if `key_offset` is given as 1, then the minimum key value
                will be `(1, 1)` (instead of `(0, 0)`) and the maximum key
                value will be `(3, 5)` (instead of `(2, 4)`).
                Also, with a tuple-valued `num_keys`, `key_offset` can be
                given as a tuple, to shift the key values differently for each
                item in the tuple.
            batch_size: If given as None, then this memory will not be batched.
                If given as an integer `n`, then this object will represent
                a contiguous batch containing `n` memory blocks.
                If given as a tuple `(size0, size1, ...)`, then this object
                will represent a contiguous batch of memory, shape of this
                batch being determined by the given tuple.
            batch_shape: Alias for the argument `batch_size`.
            fill_with: Optionally a numeric value using which the values will
                be initialized. If no initialization is needed, then this
                argument can be left as None.
            dtype: The `dtype` of the memory tensor.
            device: The device on which the memory will be allocated.
            verify: If True, then explicit checks will be done to verify
                that there are no indexing errors. Can be set as False for
                performance.
        """
        self._dtype = torch.float32 if dtype is None else to_torch_dtype(dtype)
        self._device = torch.device("cpu") if device is None else torch.device(device)
        self._verify = bool(verify)

        if isinstance(num_keys, (list, tuple)):
            if len(num_keys) < 2:
                raise RuntimeError(
                    f"When expressed via a list or a tuple, the length of `num_keys` must be at least 2."
                    f" However, the encountered `num_keys` is {repr(num_keys)}, whose length is {len(num_keys)}."
                )
            self._multi_key = True
            self._num_keys = tuple((int(n) for n in num_keys))
            self._internal_key_shape = torch.Size(self._num_keys)
        else:
            self._multi_key = False
            self._num_keys = int(num_keys)
            self._internal_key_shape = torch.Size([self._num_keys])
        self._internal_key_ndim = len(self._internal_key_shape)

        if key_offset is None:
            self._key_offset = None
        else:
            if self._multi_key:
                if isinstance(key_offset, (list, tuple)):
                    key_offset = [int(n) for n in key_offset]
                    if len(key_offset) != len(self._num_keys):
                        raise RuntimeError("The length of `key_offset` does not match the length of `num_keys`")
                else:
                    key_offset = [int(key_offset) for _ in range(len(self._num_keys))]
                self._key_offset = torch.as_tensor(key_offset, dtype=torch.int64, device=self._device)
            else:
                if isinstance(key_offset, (list, tuple)):
                    raise RuntimeError("`key_offset` cannot be a sequence of integers when `num_keys` is a scalar")
                else:
                    self._key_offset = torch.as_tensor(int(key_offset), dtype=torch.int64, device=self._device)

        if self._verify:
            if self._multi_key:
                self._min_key = torch.zeros(len(self._num_keys), dtype=torch.int64, device=self._device)
                self._max_key = torch.tensor(list(self._num_keys), dtype=torch.int64, device=self._device) - 1
            else:
                self._min_key = torch.tensor(0, dtype=torch.int64, device=self._device)
                self._max_key = torch.tensor(self._num_keys - 1, dtype=torch.int64, device=self._device)
            if self._key_offset is not None:
                self._min_key += self._key_offset
                self._max_key += self._key_offset
        else:
            self._min_key = None
            self._max_key = None

        nsize = len(size)
        if nsize == 0:
            self._value_shape = torch.Size([])
        elif nsize == 1:
            if isinstance(size[0], (tuple, list)):
                self._value_shape = torch.Size((int(n) for n in size[0]))
            else:
                self._value_shape = torch.Size([int(size[0])])
        else:
            self._value_shape = torch.Size((int(n) for n in size))
        self._value_ndim = len(self._value_shape)

        if (batch_size is None) and (batch_shape is None):
            batch_size = None
        elif (batch_size is not None) and (batch_shape is None):
            pass
        elif (batch_size is None) and (batch_shape is not None):
            batch_size = batch_shape
        else:
            raise RuntimeError(
                "Encountered both `batch_shape` and `batch_size` at the same time."
                " None of them or one of them can be accepted, but not both of them at the same time."
            )

        if batch_size is None:
            self._batch_shape = torch.Size([])
        elif isinstance(batch_size, (tuple, list)):
            self._batch_shape = torch.Size((int(n) for n in batch_size))
        else:
            self._batch_shape = torch.Size([int(batch_size)])
        self._batch_ndim = len(self._batch_shape)

        self._for_all_batches = tuple(
            (
                torch.arange(self._batch_shape[i], dtype=torch.int64, device=self._device)
                for i in range(self._batch_ndim)
            )
        )

        self._data = torch.empty(
            self._batch_shape + self._internal_key_shape + self._value_shape,
            dtype=(self._dtype),
            device=(self._device),
        )

        if fill_with is not None:
            self._data[:] = fill_with

    @property
    def _is_dtype_bool(self) -> bool:
        return self._data.dtype is torch.bool

    def _key_must_be_valid(self, key: torch.Tensor) -> torch.Tensor:
        lb_satisfied = key >= self._min_key
        ub_satisfied = key <= self._max_key
        all_satisfied = lb_satisfied & ub_satisfied
        if not torch.all(all_satisfied):
            raise KeyError("Encountered invalid key(s)")

    def _get_key(self, key: Numbers, where: Optional[torch.Tensor] = None) -> torch.Tensor:
        key = torch.as_tensor(key, dtype=torch.int64, device=self._data.device)
        expected_shape = self.batch_shape + self.key_shape
        if key.shape == expected_shape:
            result = key
        elif key.shape == self.key_shape:
            result = key.expand(expected_shape)
        else:
            raise RuntimeError(f"The key tensor has an incompatible shape: {key.shape}")
        if where is not None:
            min_key = (
                torch.tensor(0, dtype=torch.int64, device=self._data.device) if self._min_key is None else self._min_key
            )
            key = do_where(where, key, min_key.expand(expected_shape))
        if self._verify:
            self._key_must_be_valid(key)

        return result

    def _get_value(self, value: Numbers) -> torch.Tensor:
        value = torch.as_tensor(value, dtype=self._data.dtype, device=self._data.device)
        expected_shape = self.batch_shape + self.value_shape
        if value.shape == expected_shape:
            return value
        elif (value.ndim == 0) or (value.shape == self.value_shape):
            return value.expand(expected_shape)
        else:
            raise RuntimeError(f"The value tensor has an incompatible shape: {value.shape}")
        return value

    def _get_where(self, where: Numbers) -> torch.Tensor:
        where = torch.as_tensor(where, dtype=torch.bool, device=self._data.device)
        if where.shape != self.batch_shape:
            raise RuntimeError(
                f"The boolean mask `where` has an incompatible shape: {where.shape}."
                f" Acceptable shape is: {self.batch_shape}"
            )
        return where

    def prepare_key_tensor(self, key: Numbers) -> torch.Tensor:
        """
        Return the tensor-counterpart of a key.

        Args:
            key: A key which can be a sequence of integers or a PyTorch tensor
                with an integer dtype.
                The shape of the given key must conform with the `key_shape`
                of this memory object.
                To address to a different key in each batch item, the shape of
                the given key can also have extra leftmost dimensions expressed
                by `batch_shape`.
        Returns:
            A copy of the key that is converted to PyTorch tensor.
        """
        return self._get_key(key)

    def prepare_value_tensor(self, value: Numbers) -> torch.Tensor:
        """
        Return the tensor-counterpart of a value.

        Args:
            value: A value that can be a numeric sequence or a PyTorch tensor.
                The shape of the given value must conform with the
                `value_shape` of this memory object.
                To express a different value for each batch item, the shape of
                the given value can also have extra leftmost dimensions
                expressed by `value_shape`.
        Returns:
            A copy of the given value(s), converted to PyTorch tensor.
        """
        return self._get_value(value)

    def prepare_where_tensor(self, where: Numbers) -> torch.Tensor:
        """
        Return the tensor-counterpart of a boolean mask.

        Args:
            where: A boolean mask expressed as a sequence of bools or as a
                boolean PyTorch tensor.
                The shape of the given mask must conform with the batch shape
                that is expressed by the property `batch_shape`.
        Returns:
            A copy of the boolean mask, converted to PyTorch tensor.
        """
        return self._get_where(where)

    def _get_address(self, key: Numbers, where: Optional[torch.Tensor] = None) -> tuple:
        key = self._get_key(key, where=where)
        if self._key_offset is not None:
            key = key - self._key_offset
        if self._multi_key:
            keys = tuple((key[..., j] for j in range(self._internal_key_ndim)))
            return self._for_all_batches + keys
        else:
            return self._for_all_batches + (key,)

    def get(self, key: Numbers) -> torch.Tensor:
        """
        Get the value(s) associated with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
        Returns:
            The value(s) associated with the given key(s).
        """
        address = self._get_address(key)
        return self._data[address]

    def set_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Set the value(s) associated with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The new value(s).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        where = None if where is None else self._get_where(where)
        address = self._get_address(key, where=where)
        value = self._get_value(value)

        if where is None:
            self._data[address] = value
        else:
            old_value = self._data[address]
            new_value = value
            self._data[address] = do_where(where, new_value, old_value)

    def add_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Add value(s) onto the existing values of slots with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The value(s) that will be added onto the existing value(s).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        where = None if where is None else self._get_where(where)
        address = self._get_address(key, where=where)
        value = self._get_value(value)

        if where is None:
            if self._is_dtype_bool:
                self._data[address] |= value
            else:
                self._data[address] += value
        else:
            if self._is_dtype_bool:
                mask_shape = self._batch_shape + tuple((1 for _ in range(self._value_ndim)))
                self._data[address] |= value & where.reshape(mask_shape)
            else:
                self._data[address] += do_where(where, value, torch.tensor(0, dtype=value.dtype, device=value.device))

    def add_circular_(self, key: Numbers, value: Numbers, mod: Numbers, where: Optional[Numbers] = None):
        """
        Increase the values of the specified slots in a circular manner.

        This operation combines the add and modulo operations.
        Circularly adding `value` onto `x` with a modulo `mod` means:
        `x = (x + value) % mod`.

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The value(s) that will be added onto the existing value(s).
            mod: After the raw adding operation, the modulos according to this
                `mod` argument will be computed and placed.
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        where = None if where is None else self._get_where(where)
        address = self._get_address(key, where=where)
        value = self._get_value(value)
        mod = self._get_value(mod)

        if self._is_dtype_bool:
            raise ValueError("Circular addition is not supported for dtype `torch.bool`")

        if where is None:
            self._data[address] = (self._data[address] + value) % mod
        else:
            old_value = self._data[address]
            new_value = (old_value + value) % mod
            self._data[address] = do_where(where, new_value, old_value)

    def multiply_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Multiply the existing values of slots with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The value(s) that will be used as the multiplier(s).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        where = None if where is None else self._get_where(where)
        address = self._get_address(key, where=where)
        value = self._get_value(value)

        if where is None:
            if self._is_dtype_bool:
                self._data[address] &= value
            else:
                self._data[address] += value
        else:
            if self._is_dtype_bool:
                self._data[address] &= do_where(
                    where, value, torch.tensor(True, dtype=value.dtype, device=value.device)
                )
            else:
                self._data[address] *= do_where(where, value, torch.tensor(1, dtype=value.dtype, device=value.device))

    def subtract_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Subtract value(s) from existing values of slots with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The value(s) that will be subtracted from existing value(s).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        self.add_(key, -value, where)

    def divide_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
        """
        Divide the existing values of slots with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The value(s) that will be used as divisor(s).
            where: Optionally a boolean mask whose shape matches `batch_shape`.
                If a `where` mask is given, then modifications will happen only
                on the memory slots whose corresponding mask values are True.
        """
        self.multiply_(key, 1 / value, where)

    def __getitem__(self, key: Numbers) -> torch.Tensor:
        """
        Get the value(s) associated with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
        Returns:
            The value(s) associated with the given key(s).
        """
        return self.get(key)

    def __setitem__(self, key: Numbers, value: Numbers):
        """
        Set the value(s) associated with the given key(s).

        Args:
            key: A single key, or multiple keys (where the leftmost dimension
                of the given keys conform with the `batch_shape`).
            value: The new value(s).
        """
        self.set_(key, value)

    @property
    def data(self) -> torch.Tensor:
        """
        The entire value tensor
        """
        return self._data

    @property
    def key_shape(self) -> torch.Size:
        """
        Shape of a key
        """
        return torch.Size([self._internal_key_ndim]) if self._multi_key else torch.Size([])

    @property
    def key_ndim(self) -> int:
        """
        Number of dimensions of a key
        """
        return 1 if self._multi_key else 0

    @property
    def batch_shape(self) -> torch.Size:
        """
        Batch size of this memory object
        """
        return self._batch_shape

    @property
    def batch_ndim(self) -> int:
        """
        Number of dimensions expressed by `batch_shape`
        """
        return self._batch_ndim

    @property
    def is_batched(self) -> bool:
        """
        True if this CMemory object is batched; False otherwise.
        """
        return self._batch_ndim > 0

    @property
    def value_shape(self) -> torch.Size:
        """
        Tensor shape of a single value
        """
        return self._value_shape

    @property
    def value_ndim(self) -> int:
        """
        Number of dimensions expressed by `value_shape`
        """
        return self._value_ndim

    @property
    def dtype(self) -> torch.dtype:
        """
        `dtype` of the value tensor
        """
        return self._data.dtype

    @property
    def device(self) -> torch.device:
        """
        The device on which this memory object lives
        """
        return self._data.device
batch_ndim: int property readonly

Number of dimensions expressed by batch_shape

batch_shape: Size property readonly

Batch size of this memory object

data: Tensor property readonly

The entire value tensor

device: device property readonly

The device on which this memory object lives

dtype: dtype property readonly

dtype of the value tensor

is_batched: bool property readonly

True if this CMemory object is batched; False otherwise.

key_ndim: int property readonly

Number of dimensions of a key

key_shape: Size property readonly

Shape of a key

value_ndim: int property readonly

Number of dimensions expressed by value_shape

value_shape: Size property readonly

Tensor shape of a single value

__getitem__(self, key) special

Get the value(s) associated with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required

Returns:

Type Description
Tensor

The value(s) associated with the given key(s).

Source code in evotorch/tools/structures.py
def __getitem__(self, key: Numbers) -> torch.Tensor:
    """
    Get the value(s) associated with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
    Returns:
        The value(s) associated with the given key(s).
    """
    return self.get(key)
__init__(self, *size, *, num_keys, key_offset=None, batch_size=None, batch_shape=None, fill_with=None, dtype=None, device=None, verify=True) special

__init__(...): Initialize the CMemory.

Parameters:

Name Type Description Default
size Union[int, tuple, list]

Size of a tensor associated with a key, expected as an integer, or as multiple positional arguments (each positional argument being an integer), or as a tuple of integers.

()
num_keys Union[int, tuple, list]

How many keys (and therefore how many slots) will the memory have. If given as an integer n, then there will be n slots in the memory, and to access a slot one will need to use an integer key k (where, by default, the minimum acceptable k is 0 and the maximum acceptable k is n-1). If given as a tuple of integers, then the number of slots in the memory will be computed as the product of all the integers in the tuple, and a key will be expected as a tuple. For example, when num_keys is (3, 5), there will be 15 slots in the memory (where, by default, the minimum acceptable key will be (0, 0) and the maximum acceptable key will be (2, 4).

required
key_offset Union[int, tuple, list]

Optionally can be used to shift the integer values of the keys. For example, if num_keys is 10, then, by default, the minimum key is 0 and the maximum key is 9. But together with num_keys=10, if key_offset is given as 1, then the minimum key will be 1 and the maximum key will be 10. This argument can also be used together with a tuple-valued num_keys. For example, with num_keys set as (3, 5), if key_offset is given as 1, then the minimum key value will be (1, 1) (instead of (0, 0)) and the maximum key value will be (3, 5) (instead of (2, 4)). Also, with a tuple-valued num_keys, key_offset can be given as a tuple, to shift the key values differently for each item in the tuple.

None
batch_size Union[int, tuple, list]

If given as None, then this memory will not be batched. If given as an integer n, then this object will represent a contiguous batch containing n memory blocks. If given as a tuple (size0, size1, ...), then this object will represent a contiguous batch of memory, shape of this batch being determined by the given tuple.

None
batch_shape Union[int, tuple, list]

Alias for the argument batch_size.

None
fill_with Union[numbers.Number, Iterable[numbers.Number]]

Optionally a numeric value using which the values will be initialized. If no initialization is needed, then this argument can be left as None.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

The dtype of the memory tensor.

None
device Union[str, torch.device]

The device on which the memory will be allocated.

None
verify bool

If True, then explicit checks will be done to verify that there are no indexing errors. Can be set as False for performance.

True
Source code in evotorch/tools/structures.py
def __init__(
    self,
    *size: Union[int, tuple, list],
    num_keys: Union[int, tuple, list],
    key_offset: Optional[Union[int, tuple, list]] = None,
    batch_size: Optional[Union[int, tuple, list]] = None,
    batch_shape: Optional[Union[int, tuple, list]] = None,
    fill_with: Optional[Numbers] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    verify: bool = True,
):
    """
    `__init__(...)`: Initialize the CMemory.

    Args:
        size: Size of a tensor associated with a key, expected as an
            integer, or as multiple positional arguments (each positional
            argument being an integer), or as a tuple of integers.
        num_keys: How many keys (and therefore how many slots) will the
            memory have. If given as an integer `n`, then there will be `n`
            slots in the memory, and to access a slot one will need to use
            an integer key `k` (where, by default, the minimum acceptable
            `k` is 0 and the maximum acceptable `k` is `n-1`).
            If given as a tuple of integers, then the number of slots in
            the memory will be computed as the product of all the integers
            in the tuple, and a key will be expected as a tuple.
            For example, when `num_keys` is `(3, 5)`, there will be 15
            slots in the memory (where, by default, the minimum acceptable
            key will be `(0, 0)` and the maximum acceptable key will be
            `(2, 4)`.
        key_offset: Optionally can be used to shift the integer values of
            the keys. For example, if `num_keys` is 10, then, by default,
            the minimum key is 0 and the maximum key is 9. But together
            with `num_keys=10`, if `key_offset` is given as 1, then the
            minimum key will be 1 and the maximum key will be 10.
            This argument can also be used together with a tuple-valued
            `num_keys`. For example, with `num_keys` set as `(3, 5)`,
            if `key_offset` is given as 1, then the minimum key value
            will be `(1, 1)` (instead of `(0, 0)`) and the maximum key
            value will be `(3, 5)` (instead of `(2, 4)`).
            Also, with a tuple-valued `num_keys`, `key_offset` can be
            given as a tuple, to shift the key values differently for each
            item in the tuple.
        batch_size: If given as None, then this memory will not be batched.
            If given as an integer `n`, then this object will represent
            a contiguous batch containing `n` memory blocks.
            If given as a tuple `(size0, size1, ...)`, then this object
            will represent a contiguous batch of memory, shape of this
            batch being determined by the given tuple.
        batch_shape: Alias for the argument `batch_size`.
        fill_with: Optionally a numeric value using which the values will
            be initialized. If no initialization is needed, then this
            argument can be left as None.
        dtype: The `dtype` of the memory tensor.
        device: The device on which the memory will be allocated.
        verify: If True, then explicit checks will be done to verify
            that there are no indexing errors. Can be set as False for
            performance.
    """
    self._dtype = torch.float32 if dtype is None else to_torch_dtype(dtype)
    self._device = torch.device("cpu") if device is None else torch.device(device)
    self._verify = bool(verify)

    if isinstance(num_keys, (list, tuple)):
        if len(num_keys) < 2:
            raise RuntimeError(
                f"When expressed via a list or a tuple, the length of `num_keys` must be at least 2."
                f" However, the encountered `num_keys` is {repr(num_keys)}, whose length is {len(num_keys)}."
            )
        self._multi_key = True
        self._num_keys = tuple((int(n) for n in num_keys))
        self._internal_key_shape = torch.Size(self._num_keys)
    else:
        self._multi_key = False
        self._num_keys = int(num_keys)
        self._internal_key_shape = torch.Size([self._num_keys])
    self._internal_key_ndim = len(self._internal_key_shape)

    if key_offset is None:
        self._key_offset = None
    else:
        if self._multi_key:
            if isinstance(key_offset, (list, tuple)):
                key_offset = [int(n) for n in key_offset]
                if len(key_offset) != len(self._num_keys):
                    raise RuntimeError("The length of `key_offset` does not match the length of `num_keys`")
            else:
                key_offset = [int(key_offset) for _ in range(len(self._num_keys))]
            self._key_offset = torch.as_tensor(key_offset, dtype=torch.int64, device=self._device)
        else:
            if isinstance(key_offset, (list, tuple)):
                raise RuntimeError("`key_offset` cannot be a sequence of integers when `num_keys` is a scalar")
            else:
                self._key_offset = torch.as_tensor(int(key_offset), dtype=torch.int64, device=self._device)

    if self._verify:
        if self._multi_key:
            self._min_key = torch.zeros(len(self._num_keys), dtype=torch.int64, device=self._device)
            self._max_key = torch.tensor(list(self._num_keys), dtype=torch.int64, device=self._device) - 1
        else:
            self._min_key = torch.tensor(0, dtype=torch.int64, device=self._device)
            self._max_key = torch.tensor(self._num_keys - 1, dtype=torch.int64, device=self._device)
        if self._key_offset is not None:
            self._min_key += self._key_offset
            self._max_key += self._key_offset
    else:
        self._min_key = None
        self._max_key = None

    nsize = len(size)
    if nsize == 0:
        self._value_shape = torch.Size([])
    elif nsize == 1:
        if isinstance(size[0], (tuple, list)):
            self._value_shape = torch.Size((int(n) for n in size[0]))
        else:
            self._value_shape = torch.Size([int(size[0])])
    else:
        self._value_shape = torch.Size((int(n) for n in size))
    self._value_ndim = len(self._value_shape)

    if (batch_size is None) and (batch_shape is None):
        batch_size = None
    elif (batch_size is not None) and (batch_shape is None):
        pass
    elif (batch_size is None) and (batch_shape is not None):
        batch_size = batch_shape
    else:
        raise RuntimeError(
            "Encountered both `batch_shape` and `batch_size` at the same time."
            " None of them or one of them can be accepted, but not both of them at the same time."
        )

    if batch_size is None:
        self._batch_shape = torch.Size([])
    elif isinstance(batch_size, (tuple, list)):
        self._batch_shape = torch.Size((int(n) for n in batch_size))
    else:
        self._batch_shape = torch.Size([int(batch_size)])
    self._batch_ndim = len(self._batch_shape)

    self._for_all_batches = tuple(
        (
            torch.arange(self._batch_shape[i], dtype=torch.int64, device=self._device)
            for i in range(self._batch_ndim)
        )
    )

    self._data = torch.empty(
        self._batch_shape + self._internal_key_shape + self._value_shape,
        dtype=(self._dtype),
        device=(self._device),
    )

    if fill_with is not None:
        self._data[:] = fill_with
__setitem__(self, key, value) special

Set the value(s) associated with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The new value(s).

required
Source code in evotorch/tools/structures.py
def __setitem__(self, key: Numbers, value: Numbers):
    """
    Set the value(s) associated with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The new value(s).
    """
    self.set_(key, value)
add_(self, key, value, where=None)

Add value(s) onto the existing values of slots with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be added onto the existing value(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def add_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Add value(s) onto the existing values of slots with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The value(s) that will be added onto the existing value(s).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    where = None if where is None else self._get_where(where)
    address = self._get_address(key, where=where)
    value = self._get_value(value)

    if where is None:
        if self._is_dtype_bool:
            self._data[address] |= value
        else:
            self._data[address] += value
    else:
        if self._is_dtype_bool:
            mask_shape = self._batch_shape + tuple((1 for _ in range(self._value_ndim)))
            self._data[address] |= value & where.reshape(mask_shape)
        else:
            self._data[address] += do_where(where, value, torch.tensor(0, dtype=value.dtype, device=value.device))
add_circular_(self, key, value, mod, where=None)

Increase the values of the specified slots in a circular manner.

This operation combines the add and modulo operations. Circularly adding value onto x with a modulo mod means: x = (x + value) % mod.

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be added onto the existing value(s).

required
mod Union[numbers.Number, Iterable[numbers.Number]]

After the raw adding operation, the modulos according to this mod argument will be computed and placed.

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def add_circular_(self, key: Numbers, value: Numbers, mod: Numbers, where: Optional[Numbers] = None):
    """
    Increase the values of the specified slots in a circular manner.

    This operation combines the add and modulo operations.
    Circularly adding `value` onto `x` with a modulo `mod` means:
    `x = (x + value) % mod`.

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The value(s) that will be added onto the existing value(s).
        mod: After the raw adding operation, the modulos according to this
            `mod` argument will be computed and placed.
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    where = None if where is None else self._get_where(where)
    address = self._get_address(key, where=where)
    value = self._get_value(value)
    mod = self._get_value(mod)

    if self._is_dtype_bool:
        raise ValueError("Circular addition is not supported for dtype `torch.bool`")

    if where is None:
        self._data[address] = (self._data[address] + value) % mod
    else:
        old_value = self._data[address]
        new_value = (old_value + value) % mod
        self._data[address] = do_where(where, new_value, old_value)
divide_(self, key, value, where=None)

Divide the existing values of slots with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be used as divisor(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def divide_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Divide the existing values of slots with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The value(s) that will be used as divisor(s).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    self.multiply_(key, 1 / value, where)
get(self, key)

Get the value(s) associated with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required

Returns:

Type Description
Tensor

The value(s) associated with the given key(s).

Source code in evotorch/tools/structures.py
def get(self, key: Numbers) -> torch.Tensor:
    """
    Get the value(s) associated with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
    Returns:
        The value(s) associated with the given key(s).
    """
    address = self._get_address(key)
    return self._data[address]
multiply_(self, key, value, where=None)

Multiply the existing values of slots with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be used as the multiplier(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def multiply_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Multiply the existing values of slots with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The value(s) that will be used as the multiplier(s).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    where = None if where is None else self._get_where(where)
    address = self._get_address(key, where=where)
    value = self._get_value(value)

    if where is None:
        if self._is_dtype_bool:
            self._data[address] &= value
        else:
            self._data[address] += value
    else:
        if self._is_dtype_bool:
            self._data[address] &= do_where(
                where, value, torch.tensor(True, dtype=value.dtype, device=value.device)
            )
        else:
            self._data[address] *= do_where(where, value, torch.tensor(1, dtype=value.dtype, device=value.device))
prepare_key_tensor(self, key)

Return the tensor-counterpart of a key.

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A key which can be a sequence of integers or a PyTorch tensor with an integer dtype. The shape of the given key must conform with the key_shape of this memory object. To address to a different key in each batch item, the shape of the given key can also have extra leftmost dimensions expressed by batch_shape.

required

Returns:

Type Description
Tensor

A copy of the key that is converted to PyTorch tensor.

Source code in evotorch/tools/structures.py
def prepare_key_tensor(self, key: Numbers) -> torch.Tensor:
    """
    Return the tensor-counterpart of a key.

    Args:
        key: A key which can be a sequence of integers or a PyTorch tensor
            with an integer dtype.
            The shape of the given key must conform with the `key_shape`
            of this memory object.
            To address to a different key in each batch item, the shape of
            the given key can also have extra leftmost dimensions expressed
            by `batch_shape`.
    Returns:
        A copy of the key that is converted to PyTorch tensor.
    """
    return self._get_key(key)
prepare_value_tensor(self, value)

Return the tensor-counterpart of a value.

Parameters:

Name Type Description Default
value Union[numbers.Number, Iterable[numbers.Number]]

A value that can be a numeric sequence or a PyTorch tensor. The shape of the given value must conform with the value_shape of this memory object. To express a different value for each batch item, the shape of the given value can also have extra leftmost dimensions expressed by value_shape.

required

Returns:

Type Description
Tensor

A copy of the given value(s), converted to PyTorch tensor.

Source code in evotorch/tools/structures.py
def prepare_value_tensor(self, value: Numbers) -> torch.Tensor:
    """
    Return the tensor-counterpart of a value.

    Args:
        value: A value that can be a numeric sequence or a PyTorch tensor.
            The shape of the given value must conform with the
            `value_shape` of this memory object.
            To express a different value for each batch item, the shape of
            the given value can also have extra leftmost dimensions
            expressed by `value_shape`.
    Returns:
        A copy of the given value(s), converted to PyTorch tensor.
    """
    return self._get_value(value)
prepare_where_tensor(self, where)

Return the tensor-counterpart of a boolean mask.

Parameters:

Name Type Description Default
where Union[numbers.Number, Iterable[numbers.Number]]

A boolean mask expressed as a sequence of bools or as a boolean PyTorch tensor. The shape of the given mask must conform with the batch shape that is expressed by the property batch_shape.

required

Returns:

Type Description
Tensor

A copy of the boolean mask, converted to PyTorch tensor.

Source code in evotorch/tools/structures.py
def prepare_where_tensor(self, where: Numbers) -> torch.Tensor:
    """
    Return the tensor-counterpart of a boolean mask.

    Args:
        where: A boolean mask expressed as a sequence of bools or as a
            boolean PyTorch tensor.
            The shape of the given mask must conform with the batch shape
            that is expressed by the property `batch_shape`.
    Returns:
        A copy of the boolean mask, converted to PyTorch tensor.
    """
    return self._get_where(where)
set_(self, key, value, where=None)

Set the value(s) associated with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The new value(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def set_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Set the value(s) associated with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The new value(s).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    where = None if where is None else self._get_where(where)
    address = self._get_address(key, where=where)
    value = self._get_value(value)

    if where is None:
        self._data[address] = value
    else:
        old_value = self._data[address]
        new_value = value
        self._data[address] = do_where(where, new_value, old_value)
subtract_(self, key, value, where=None)

Subtract value(s) from existing values of slots with the given key(s).

Parameters:

Name Type Description Default
key Union[numbers.Number, Iterable[numbers.Number]]

A single key, or multiple keys (where the leftmost dimension of the given keys conform with the batch_shape).

required
value Union[numbers.Number, Iterable[numbers.Number]]

The value(s) that will be subtracted from existing value(s).

required
where Union[numbers.Number, Iterable[numbers.Number]]

Optionally a boolean mask whose shape matches batch_shape. If a where mask is given, then modifications will happen only on the memory slots whose corresponding mask values are True.

None
Source code in evotorch/tools/structures.py
def subtract_(self, key: Numbers, value: Numbers, where: Optional[Numbers] = None):
    """
    Subtract value(s) from existing values of slots with the given key(s).

    Args:
        key: A single key, or multiple keys (where the leftmost dimension
            of the given keys conform with the `batch_shape`).
        value: The value(s) that will be subtracted from existing value(s).
        where: Optionally a boolean mask whose shape matches `batch_shape`.
            If a `where` mask is given, then modifications will happen only
            on the memory slots whose corresponding mask values are True.
    """
    self.add_(key, -value, where)

Structure

A mixin class for vectorized structures.

This mixin class assumes that the inheriting structure has a protected attribute _data which is either a CMemory object or another Structure. With this assumption, this mixin class provides certain methods and properties to bring a unified interface for all vectorized structures provided in this namespace.

Source code in evotorch/tools/structures.py
class Structure:
    """
    A mixin class for vectorized structures.

    This mixin class assumes that the inheriting structure has a protected
    attribute `_data` which is either a `CMemory` object or another
    `Structure`. With this assumption, this mixin class provides certain
    methods and properties to bring a unified interface for all vectorized
    structures provided in this namespace.
    """

    _data: Union[CMemory, "Structure"]

    @property
    def value_shape(self) -> torch.Size:
        """
        Shape of a single value
        """
        return self._data.value_shape

    @property
    def value_ndim(self) -> int:
        """
        Number of dimensions expressed by `value_shape`
        """
        return self._data.value_ndim

    @property
    def batch_shape(self) -> torch.Size:
        """
        Batch size of this structure
        """
        return self._data.batch_shape

    @property
    def batch_ndim(self) -> int:
        """
        Number of dimensions expressed by `batch_shape`
        """
        return self._data.batch_ndim

    @property
    def is_batched(self) -> bool:
        """
        True if this structure is batched; False otherwise.
        """
        return self._batch_ndim > 0

    @property
    def dtype(self) -> torch.dtype:
        """
        `dtype` of the values
        """
        return self._data.dtype

    @property
    def device(self) -> torch.device:
        """
        The device on which this structure lives
        """
        return self._data.device

    def prepare_value_tensor(self, value: Numbers) -> torch.Tensor:
        """
        Return the tensor-counterpart of a value.

        Args:
            value: A value that can be a numeric sequence or a PyTorch tensor.
                The shape of the given value must conform with the
                `value_shape` of this memory object.
                To express a different value for each batch item, the shape of
                the given value can also have extra leftmost dimensions
                expressed by `value_shape`.
        Returns:
            A copy of the given value(s), converted to PyTorch tensor.
        """
        return self._data.prepare_value_tensor(value)

    def prepare_where_tensor(self, where: Numbers) -> torch.Tensor:
        """
        Return the tensor-counterpart of a boolean mask.

        Args:
            where: A boolean mask expressed as a sequence of bools or as a
                boolean PyTorch tensor.
                The shape of the given mask must conform with the batch shape
                that is expressed by the property `batch_shape`.
        Returns:
            A copy of the boolean mask, converted to PyTorch tensor.
        """
        return self._data.prepare_where_tensor(where)

    def _get_value(self, value: Numbers) -> torch.Tensor:
        return self._data.prepare_value_tensor(value)

    def _get_where(self, where: Numbers) -> torch.Tensor:
        return self._data.prepare_where_tensor(where)

    def __contains__(self, x: Any) -> torch.Tensor:
        raise TypeError("This structure does not support the `in` operator")
batch_ndim: int property readonly

Number of dimensions expressed by batch_shape

batch_shape: Size property readonly

Batch size of this structure

device: device property readonly

The device on which this structure lives

dtype: dtype property readonly

dtype of the values

is_batched: bool property readonly

True if this structure is batched; False otherwise.

value_ndim: int property readonly

Number of dimensions expressed by value_shape

value_shape: Size property readonly

Shape of a single value

prepare_value_tensor(self, value)

Return the tensor-counterpart of a value.

Parameters:

Name Type Description Default
value Union[numbers.Number, Iterable[numbers.Number]]

A value that can be a numeric sequence or a PyTorch tensor. The shape of the given value must conform with the value_shape of this memory object. To express a different value for each batch item, the shape of the given value can also have extra leftmost dimensions expressed by value_shape.

required

Returns:

Type Description
Tensor

A copy of the given value(s), converted to PyTorch tensor.

Source code in evotorch/tools/structures.py
def prepare_value_tensor(self, value: Numbers) -> torch.Tensor:
    """
    Return the tensor-counterpart of a value.

    Args:
        value: A value that can be a numeric sequence or a PyTorch tensor.
            The shape of the given value must conform with the
            `value_shape` of this memory object.
            To express a different value for each batch item, the shape of
            the given value can also have extra leftmost dimensions
            expressed by `value_shape`.
    Returns:
        A copy of the given value(s), converted to PyTorch tensor.
    """
    return self._data.prepare_value_tensor(value)
prepare_where_tensor(self, where)

Return the tensor-counterpart of a boolean mask.

Parameters:

Name Type Description Default
where Union[numbers.Number, Iterable[numbers.Number]]

A boolean mask expressed as a sequence of bools or as a boolean PyTorch tensor. The shape of the given mask must conform with the batch shape that is expressed by the property batch_shape.

required

Returns:

Type Description
Tensor

A copy of the boolean mask, converted to PyTorch tensor.

Source code in evotorch/tools/structures.py
def prepare_where_tensor(self, where: Numbers) -> torch.Tensor:
    """
    Return the tensor-counterpart of a boolean mask.

    Args:
        where: A boolean mask expressed as a sequence of bools or as a
            boolean PyTorch tensor.
            The shape of the given mask must conform with the batch shape
            that is expressed by the property `batch_shape`.
    Returns:
        A copy of the boolean mask, converted to PyTorch tensor.
    """
    return self._data.prepare_where_tensor(where)

tensormaker

Base classes with various utilities for creating tensors.

TensorMakerMixin

Source code in evotorch/tools/tensormaker.py
class TensorMakerMixin:
    def __get_dtype_and_device_kwargs(
        self,
        *,
        dtype: Optional[DType],
        device: Optional[Device],
        use_eval_dtype: bool,
        out: Optional[Iterable],
    ) -> dict:
        result = {}
        if out is None:
            if dtype is None:
                if use_eval_dtype:
                    if hasattr(self, "eval_dtype"):
                        result["dtype"] = self.eval_dtype
                    else:
                        raise AttributeError(
                            f"Received `use_eval_dtype` as {repr(use_eval_dtype)}, which represents boolean truth."
                            f" However, evaluation dtype cannot be determined, because this object does not have"
                            f" an attribute named `eval_dtype`."
                        )
                else:
                    result["dtype"] = self.dtype
            else:
                if use_eval_dtype:
                    raise ValueError(
                        f"Received both a `dtype` argument ({repr(dtype)}) and `use_eval_dtype` as True."
                        f" These arguments are conflicting."
                        f" Please either provide a `dtype`, or leave `dtype` as None and pass `use_eval_dtype=True`."
                    )
                else:
                    result["dtype"] = dtype

            if device is None:
                result["device"] = self.device
            else:
                result["device"] = device

        return result

    def __get_size_args(self, *size: Size, num_solutions: Optional[int], out: Optional[Iterable]) -> tuple:
        if out is None:
            nsize = len(size)
            if (nsize == 0) and (num_solutions is None):
                return tuple()
            elif (nsize >= 1) and (num_solutions is None):
                return size
            elif (nsize == 0) and (num_solutions is not None):
                if hasattr(self, "solution_length"):
                    num_solutions = int(num_solutions)
                    if self.solution_length is None:
                        return (num_solutions,)
                    else:
                        return (num_solutions, self.solution_length)
                else:
                    raise AttributeError(
                        f"Received `num_solutions` as {repr(num_solutions)}."
                        f" However, to determine the target tensor's size via `num_solutions`, this object"
                        f" needs to have an attribute named `solution_length`, which seems to be missing."
                    )
            else:
                raise ValueError(
                    f"Encountered both `size` arguments ({repr(size)})"
                    f" and `num_solutions` keyword argument (num_solutions={repr(num_solutions)})."
                    f" Specifying both `size` and `num_solutions` is not valid."
                )
        else:
            return tuple()

    def __get_generator_kwargs(self, *, generator: Any) -> dict:
        result = {}
        if generator is None:
            if hasattr(self, "generator"):
                result["generator"] = self.generator
        else:
            result["generator"] = generator
        return result

    def __get_all_args_for_maker(
        self,
        *size: Size,
        num_solutions: Optional[int],
        out: Optional[Iterable],
        dtype: Optional[DType],
        device: Optional[Device],
        use_eval_dtype: bool,
    ) -> tuple:
        args = self.__get_size_args(*size, num_solutions=num_solutions, out=out)
        kwargs = self.__get_dtype_and_device_kwargs(dtype=dtype, device=device, use_eval_dtype=use_eval_dtype, out=out)
        if out is not None:
            kwargs["out"] = out
        return args, kwargs

    def __get_all_args_for_random_maker(
        self,
        *size: Size,
        num_solutions: Optional[int],
        out: Optional[Iterable],
        dtype: Optional[DType],
        device: Optional[Device],
        use_eval_dtype: bool,
        generator: Any,
    ):
        args = self.__get_size_args(*size, num_solutions=num_solutions, out=out)

        kwargs = {}
        kwargs.update(
            self.__get_dtype_and_device_kwargs(dtype=dtype, device=device, use_eval_dtype=use_eval_dtype, out=out)
        )
        kwargs.update(self.__get_generator_kwargs(generator=generator))
        if out is not None:
            kwargs["out"] = out

        return args, kwargs

    def make_tensor(
        self,
        data: Any,
        *,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
        read_only: bool = False,
    ) -> Iterable:
        """
        Make a new tensor.

        When not explicitly specified via arguments, the dtype and the device
        of the resulting tensor is determined by this method's parent object.

        Args:
            data: The data to be converted to a tensor.
                If one wishes to create a PyTorch tensor, this can be anything
                that can be stored by a PyTorch tensor.
                If one wishes to create an `ObjectArray` and therefore passes
                `dtype=object`, then the provided `data` is expected as an
                `Iterable`.
            dtype: Optionally a string (e.g. "float32"), or a PyTorch dtype
                (e.g. torch.float32), or `object` or "object" (as a string)
                or `Any` if one wishes to create an `ObjectArray`.
                If `dtype` is not specified it will be assumed that the user
                wishes to create a tensor using the dtype of this method's
                parent object.
            device: The device in which the tensor will be stored.
                If `device` is not specified, it will be assumed that the user
                wishes to create a tensor on the device of this method's
                parent object.
            use_eval_dtype: If this is given as True and a `dtype` is not
                specified, then the `dtype` of the result will be taken
                from the `eval_dtype` attribute of this method's parent
                object.
            read_only: Whether or not the created tensor will be read-only.
                By default, this is False.
        Returns:
            A PyTorch tensor or an ObjectArray.
        """
        kwargs = self.__get_dtype_and_device_kwargs(dtype=dtype, device=device, use_eval_dtype=use_eval_dtype, out=None)
        return misc.make_tensor(data, read_only=read_only, **kwargs)

    def make_empty(
        self,
        *size: Size,
        num_solutions: Optional[int] = None,
        out: Optional[Iterable] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
    ) -> Iterable:
        """
        Make an empty tensor.

        When not explicitly specified via arguments, the dtype and the device
        of the resulting tensor is determined by this method's parent object.

        Args:
            size: Shape of the empty tensor to be created.
                expected as multiple positional arguments of integers,
                or as a single positional argument containing a tuple of
                integers.
                Note that when the user wishes to create an `ObjectArray`
                (i.e. when `dtype` is given as `object`), then the size
                is expected as a single integer, or as a single-element
                tuple containing an integer (because `ObjectArray` can only
                be one-dimensional).
            num_solutions: This can be used instead of the `size` arguments
                for specifying the shape of the target tensor.
                Expected as an integer, when `num_solutions` is specified
                as `n`, the shape of the resulting tensor will be
                `(n, m)` where `m` is the solution length reported by this
                method's parent object's `solution_length` attribute.
            dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
                (e.g. torch.float32) or, for creating an `ObjectArray`,
                "object" (as string) or `object` or `Any`.
                If `dtype` is not specified (and also `out` is None),
                it will be assumed that the user wishes to create a tensor
                using the dtype of this method's parent object.
            device: The device in which the new empty tensor will be stored.
                If not specified (and also `out` is None), it will be
                assumed that the user wishes to create a tensor on the
                same device with this method's parent object.
            use_eval_dtype: If this is given as True and a `dtype` is not
                specified, then the `dtype` of the result will be taken
                from the `eval_dtype` attribute of this method's parent
                object.
        Returns:
            The new empty tensor, which can be a PyTorch tensor or an
            `ObjectArray`.
        """
        args, kwargs = self.__get_all_args_for_maker(
            *size,
            num_solutions=num_solutions,
            out=out,
            dtype=dtype,
            device=device,
            use_eval_dtype=use_eval_dtype,
        )
        return misc.make_empty(*args, **kwargs)

    def make_zeros(
        self,
        *size: Size,
        num_solutions: Optional[int] = None,
        out: Optional[torch.Tensor] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
    ) -> torch.Tensor:
        """
        Make a new tensor filled with 0, or fill an existing tensor with 0.

        When not explicitly specified via arguments, the dtype and the device
        of the resulting tensor is determined by this method's parent object.

        Args:
            size: Size of the new tensor to be filled with 0.
                This can be given as multiple positional arguments, each such
                positional argument being an integer, or as a single positional
                argument of a tuple, the tuple containing multiple integers.
                Note that, if the user wishes to fill an existing tensor with
                0 values, then no positional argument is expected.
            num_solutions: This can be used instead of the `size` arguments
                for specifying the shape of the target tensor.
                Expected as an integer, when `num_solutions` is specified
                as `n`, the shape of the resulting tensor will be
                `(n, m)` where `m` is the solution length reported by this
                method's parent object's `solution_length` attribute.
            out: Optionally, the tensor to be filled by 0 values.
                If an `out` tensor is given, then no `size` argument is expected.
            dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
                (e.g. torch.float32).
                If `dtype` is not specified (and also `out` is None),
                it will be assumed that the user wishes to create a tensor
                using the dtype of this method's parent object.
                If an `out` tensor is specified, then `dtype` is expected
                as None.
            device: The device in which the new empty tensor will be stored.
                If not specified (and also `out` is None), it will be
                assumed that the user wishes to create a tensor on the
                same device with this method's parent object.
                If an `out` tensor is specified, then `device` is expected
                as None.
            use_eval_dtype: If this is given as True and a `dtype` is not
                specified, then the `dtype` of the result will be taken
                from the `eval_dtype` attribute of this method's parent
                object.
        Returns:
            The created or modified tensor after placing 0 values.
        """

        args, kwargs = self.__get_all_args_for_maker(
            *size,
            num_solutions=num_solutions,
            out=out,
            dtype=dtype,
            device=device,
            use_eval_dtype=use_eval_dtype,
        )
        return misc.make_zeros(*args, **kwargs)

    def make_ones(
        self,
        *size: Size,
        num_solutions: Optional[int] = None,
        out: Optional[torch.Tensor] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
    ) -> torch.Tensor:
        """
        Make a new tensor filled with 1, or fill an existing tensor with 1.

        When not explicitly specified via arguments, the dtype and the device
        of the resulting tensor is determined by this method's parent object.

        Args:
            size: Size of the new tensor to be filled with 1.
                This can be given as multiple positional arguments, each such
                positional argument being an integer, or as a single positional
                argument of a tuple, the tuple containing multiple integers.
                Note that, if the user wishes to fill an existing tensor with
                1 values, then no positional argument is expected.
            num_solutions: This can be used instead of the `size` arguments
                for specifying the shape of the target tensor.
                Expected as an integer, when `num_solutions` is specified
                as `n`, the shape of the resulting tensor will be
                `(n, m)` where `m` is the solution length reported by this
                method's parent object's `solution_length` attribute.
            out: Optionally, the tensor to be filled by 1 values.
                If an `out` tensor is given, then no `size` argument is expected.
            dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
                (e.g. torch.float32).
                If `dtype` is not specified (and also `out` is None),
                it will be assumed that the user wishes to create a tensor
                using the dtype of this method's parent object.
                If an `out` tensor is specified, then `dtype` is expected
                as None.
            device: The device in which the new empty tensor will be stored.
                If not specified (and also `out` is None), it will be
                assumed that the user wishes to create a tensor on the
                same device with this method's parent object.
                If an `out` tensor is specified, then `device` is expected
                as None.
            use_eval_dtype: If this is given as True and a `dtype` is not
                specified, then the `dtype` of the result will be taken
                from the `eval_dtype` attribute of this method's parent
                object.
        Returns:
            The created or modified tensor after placing 1 values.
        """
        args, kwargs = self.__get_all_args_for_maker(
            *size,
            num_solutions=num_solutions,
            out=out,
            dtype=dtype,
            device=device,
            use_eval_dtype=use_eval_dtype,
        )
        return misc.make_ones(*args, **kwargs)

    def make_nan(
        self,
        *size: Size,
        num_solutions: Optional[int] = None,
        out: Optional[torch.Tensor] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
    ) -> torch.Tensor:
        """
        Make a new tensor filled with NaN values, or fill an existing tensor
        with NaN values.

        When not explicitly specified via arguments, the dtype and the device
        of the resulting tensor is determined by this method's parent object.

        Args:
            size: Size of the new tensor to be filled with NaN.
                This can be given as multiple positional arguments, each such
                positional argument being an integer, or as a single positional
                argument of a tuple, the tuple containing multiple integers.
                Note that, if the user wishes to fill an existing tensor with
                NaN values, then no positional argument is expected.
            num_solutions: This can be used instead of the `size` arguments
                for specifying the shape of the target tensor.
                Expected as an integer, when `num_solutions` is specified
                as `n`, the shape of the resulting tensor will be
                `(n, m)` where `m` is the solution length reported by this
                method's parent object's `solution_length` attribute.
            out: Optionally, the tensor to be filled by NaN values.
                If an `out` tensor is given, then no `size` argument is expected.
            dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
                (e.g. torch.float32).
                If `dtype` is not specified (and also `out` is None),
                it will be assumed that the user wishes to create a tensor
                using the dtype of this method's parent object.
                If an `out` tensor is specified, then `dtype` is expected
                as None.
            device: The device in which the new empty tensor will be stored.
                If not specified (and also `out` is None), it will be
                assumed that the user wishes to create a tensor on the
                same device with this method's parent object.
                If an `out` tensor is specified, then `device` is expected
                as None.
            use_eval_dtype: If this is given as True and a `dtype` is not
                specified, then the `dtype` of the result will be taken
                from the `eval_dtype` attribute of this method's parent
                object.
        Returns:
            The created or modified tensor after placing NaN values.
        """
        args, kwargs = self.__get_all_args_for_maker(
            *size,
            num_solutions=num_solutions,
            out=out,
            dtype=dtype,
            device=device,
            use_eval_dtype=use_eval_dtype,
        )
        return misc.make_nan(*args, **kwargs)

    def make_I(
        self,
        size: Optional[Union[int, tuple]] = None,
        *,
        out: Optional[torch.Tensor] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
    ) -> torch.Tensor:
        """
        Make a new identity matrix (I), or change an existing tensor so that
        it expresses the identity matrix.

        When not explicitly specified via arguments, the dtype and the device
        of the resulting tensor is determined by this method's parent object.

        Args:
            size: A single integer or a tuple containing a single integer,
                where the integer specifies the length of the target square
                matrix. In this context, "length" means both rowwise length
                and columnwise length, since the target is a square matrix.
                Note that, if the user wishes to fill an existing tensor with
                identity values, then `size` is expected to be left as None.
            out: Optionally, the existing tensor whose values will be changed
                so that they represent an identity matrix.
                If an `out` tensor is given, then `size` is expected as None.
            dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
                (e.g. torch.float32).
                If `dtype` is not specified (and also `out` is None),
                it will be assumed that the user wishes to create a tensor
                using the dtype of this method's parent object.
                If an `out` tensor is specified, then `dtype` is expected
                as None.
            device: The device in which the new empty tensor will be stored.
                If not specified (and also `out` is None), it will be
                assumed that the user wishes to create a tensor on the
                same device with this method's parent object.
                If an `out` tensor is specified, then `device` is expected
                as None.
            use_eval_dtype: If this is given as True and a `dtype` is not
                specified, then the `dtype` of the result will be taken
                from the `eval_dtype` attribute of this method's parent
                object.
        Returns:
            The created or modified tensor after placing the I matrix values
        """

        if size is None:
            if out is None:
                if hasattr(self, "solution_length"):
                    size_args = (self.solution_length,)
                else:
                    raise AttributeError(
                        "The method `.make_I(...)` was used without any `size`"
                        " arguments."
                        " When the `size` argument is missing, the default"
                        " behavior of this method is to create an identity matrix"
                        " of size (n, n), n being the length of a solution."
                        " However, the parent object of this method does not have"
                        " an attribute name `solution_length`."
                    )
            else:
                size_args = tuple()
        elif isinstance(size, tuple):
            if len(size) != 1:
                raise ValueError(
                    f"When the size argument is given as a tuple, the method `make_I(...)` expects the tuple to have"
                    f" only one element. The given tuple is {size}."
                )
            size_args = size
        else:
            size_args = (int(size),)

        args, kwargs = self.__get_all_args_for_maker(
            *size_args,
            num_solutions=None,
            out=out,
            dtype=dtype,
            device=device,
            use_eval_dtype=use_eval_dtype,
        )
        return misc.make_I(*args, **kwargs)

    def make_uniform(
        self,
        *size: Size,
        num_solutions: Optional[int] = None,
        lb: Optional[RealOrVector] = None,
        ub: Optional[RealOrVector] = None,
        out: Optional[torch.Tensor] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
        generator: Any = None,
    ) -> torch.Tensor:
        """
        Make a new or existing tensor filled by uniformly distributed values.
        Both lower and upper bounds are inclusive.
        This function can work with both float and int dtypes.

        When not explicitly specified via arguments, the dtype and the device
        of the resulting tensor is determined by this method's parent object.

        Args:
            size: Size of the new tensor to be filled with uniformly distributed
                values. This can be given as multiple positional arguments, each
                such positional argument being an integer, or as a single
                positional argument of a tuple, the tuple containing multiple
                integers. Note that, if the user wishes to fill an existing
                tensor instead, then no positional argument is expected.
            num_solutions: This can be used instead of the `size` arguments
                for specifying the shape of the target tensor.
                Expected as an integer, when `num_solutions` is specified
                as `n`, the shape of the resulting tensor will be
                `(n, m)` where `m` is the solution length reported by this
                method's parent object's `solution_length` attribute.
            lb: Lower bound for the uniformly distributed values.
                Can be a scalar, or a tensor.
                If not specified, the lower bound will be taken as 0.
                Note that, if one specifies `lb`, then `ub` is also expected to
                be explicitly specified.
            ub: Upper bound for the uniformly distributed values.
                Can be a scalar, or a tensor.
                If not specified, the upper bound will be taken as 1.
                Note that, if one specifies `ub`, then `lb` is also expected to
                be explicitly specified.
            out: Optionally, the tensor to be filled by uniformly distributed
                values. If an `out` tensor is given, then no `size` argument is
                expected.
            dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
                (e.g. torch.float32).
                If `dtype` is not specified (and also `out` is None),
                it will be assumed that the user wishes to create a tensor
                using the dtype of this method's parent object.
                If an `out` tensor is specified, then `dtype` is expected
                as None.
            device: The device in which the new empty tensor will be stored.
                If not specified (and also `out` is None), it will be
                assumed that the user wishes to create a tensor on the
                same device with this method's parent object.
                If an `out` tensor is specified, then `device` is expected
                as None.
            use_eval_dtype: If this is given as True and a `dtype` is not
                specified, then the `dtype` of the result will be taken
                from the `eval_dtype` attribute of this method's parent
                object.
            generator: Pseudo-random generator to be used when sampling
                the values. Can be a `torch.Generator` or any object with
                a `generator` attribute (e.g. a Problem object).
                If not given, then this method's parent object will be
                analyzed whether or not it has its own generator.
                If it does, that generator will be used.
                If not, the global generator of PyTorch will be used.
        Returns:
            The created or modified tensor after placing the uniformly
            distributed values.
        """
        args, kwargs = self.__get_all_args_for_random_maker(
            *size,
            num_solutions=num_solutions,
            out=out,
            dtype=dtype,
            device=device,
            use_eval_dtype=use_eval_dtype,
            generator=generator,
        )
        return misc.make_uniform(*args, lb=lb, ub=ub, **kwargs)

    def make_gaussian(
        self,
        *size: Size,
        num_solutions: Optional[int] = None,
        center: Optional[RealOrVector] = None,
        stdev: Optional[RealOrVector] = None,
        symmetric: bool = False,
        out: Optional[torch.Tensor] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
        generator: Any = None,
    ) -> torch.Tensor:
        """
        Make a new or existing tensor filled by Gaussian distributed values.
        This function can work only with float dtypes.

        Args:
            size: Size of the new tensor to be filled with Gaussian distributed
                values. This can be given as multiple positional arguments, each
                such positional argument being an integer, or as a single
                positional argument of a tuple, the tuple containing multiple
                integers. Note that, if the user wishes to fill an existing
                tensor instead, then no positional argument is expected.
            num_solutions: This can be used instead of the `size` arguments
                for specifying the shape of the target tensor.
                Expected as an integer, when `num_solutions` is specified
                as `n`, the shape of the resulting tensor will be
                `(n, m)` where `m` is the solution length reported by this
                method's parent object's `solution_length` attribute.
            center: Center point (i.e. mean) of the Gaussian distribution.
                Can be a scalar, or a tensor.
                If not specified, the center point will be taken as 0.
                Note that, if one specifies `center`, then `stdev` is also
                expected to be explicitly specified.
            stdev: Standard deviation for the Gaussian distributed values.
                Can be a scalar, or a tensor.
                If not specified, the standard deviation will be taken as 1.
                Note that, if one specifies `stdev`, then `center` is also
                expected to be explicitly specified.
            symmetric: Whether or not the values should be sampled in a
                symmetric (i.e. antithetic) manner.
                The default is False.
            out: Optionally, the tensor to be filled by Gaussian distributed
                values. If an `out` tensor is given, then no `size` argument is
                expected.
            dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
                (e.g. torch.float32).
                If `dtype` is not specified (and also `out` is None),
                it will be assumed that the user wishes to create a tensor
                using the dtype of this method's parent object.
                If an `out` tensor is specified, then `dtype` is expected
                as None.
            device: The device in which the new empty tensor will be stored.
                If not specified (and also `out` is None), it will be
                assumed that the user wishes to create a tensor on the
                same device with this method's parent object.
                If an `out` tensor is specified, then `device` is expected
                as None.
            use_eval_dtype: If this is given as True and a `dtype` is not
                specified, then the `dtype` of the result will be taken
                from the `eval_dtype` attribute of this method's parent
                object.
            generator: Pseudo-random generator to be used when sampling
                the values. Can be a `torch.Generator` or any object with
                a `generator` attribute (e.g. a Problem object).
                If not given, then this method's parent object will be
                analyzed whether or not it has its own generator.
                If it does, that generator will be used.
                If not, the global generator of PyTorch will be used.
        Returns:
            The created or modified tensor after placing the Gaussian
            distributed values.
        """

        args, kwargs = self.__get_all_args_for_random_maker(
            *size,
            num_solutions=num_solutions,
            out=out,
            dtype=dtype,
            device=device,
            use_eval_dtype=use_eval_dtype,
            generator=generator,
        )
        return misc.make_gaussian(*args, center=center, stdev=stdev, symmetric=symmetric, **kwargs)

    def make_randint(
        self,
        *size: Size,
        n: Union[int, float, torch.Tensor],
        num_solutions: Optional[int] = None,
        out: Optional[torch.Tensor] = None,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
        generator: Any = None,
    ) -> torch.Tensor:
        """
        Make a new or existing tensor filled by random integers.
        The integers are uniformly distributed within `[0 ... n-1]`.
        This function can be used with integer or float dtypes.

        Args:
            size: Size of the new tensor to be filled with uniformly distributed
                values. This can be given as multiple positional arguments, each
                such positional argument being an integer, or as a single
                positional argument of a tuple, the tuple containing multiple
                integers. Note that, if the user wishes to fill an existing
                tensor instead, then no positional argument is expected.
            n: Number of choice(s) for integer sampling.
                The lowest possible value will be 0, and the highest possible
                value will be n - 1.
                `n` can be a scalar, or a tensor.
            out: Optionally, the tensor to be filled by the random integers.
                If an `out` tensor is given, then no `size` argument is
                expected.
            dtype: Optionally a string (e.g. "int64") or a PyTorch dtype
                (e.g. torch.int64).
                If `dtype` is not specified (and also `out` is None),
                `torch.int64` will be used.
                If an `out` tensor is specified, then `dtype` is expected
                as None.
            device: The device in which the new empty tensor will be stored.
                If not specified (and also `out` is None), it will be
                assumed that the user wishes to create a tensor on the
                same device with this method's parent object.
                If an `out` tensor is specified, then `device` is expected
                as None.
            use_eval_dtype: If this is given as True and a `dtype` is not
                specified, then the `dtype` of the result will be taken
                from the `eval_dtype` attribute of this method's parent
                object.
            generator: Pseudo-random generator to be used when sampling
                the values. Can be a `torch.Generator` or any object with
                a `generator` attribute (e.g. a Problem object).
                If not given, then this method's parent object will be
                analyzed whether or not it has its own generator.
                If it does, that generator will be used.
                If not, the global generator of PyTorch will be used.
        Returns:
            The created or modified tensor after placing the uniformly
            distributed values.
        """

        if (dtype is None) and (out is None):
            dtype = torch.int64

        args, kwargs = self.__get_all_args_for_random_maker(
            *size,
            num_solutions=num_solutions,
            out=out,
            dtype=dtype,
            device=device,
            use_eval_dtype=use_eval_dtype,
            generator=generator,
        )
        return misc.make_randint(*args, n=n, **kwargs)

    def as_tensor(
        self,
        x: Any,
        dtype: Optional[DType] = None,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
    ) -> torch.Tensor:
        """
        Get the tensor counterpart of the given object `x`.

        Args:
            x: Any object to be converted to a tensor.
            dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
                (e.g. torch.float32) or, for creating an `ObjectArray`,
                "object" (as string) or `object` or `Any`.
                If `dtype` is not specified, the dtype of this method's
                parent object will be used.
            device: The device in which the resulting tensor will be stored.
                If `device` is not specified, the device of this method's
                parent object will be used.
            use_eval_dtype: If this is given as True and a `dtype` is not
                specified, then the `dtype` of the result will be taken
                from the `eval_dtype` attribute of this method's parent
                object.
        Returns:
            The tensor counterpart of the given object `x`.
        """
        kwargs = self.__get_dtype_and_device_kwargs(dtype=dtype, device=device, use_eval_dtype=use_eval_dtype, out=None)
        return misc.as_tensor(x, **kwargs)

    def ensure_tensor_length_and_dtype(
        self,
        t: Any,
        length: Optional[int] = None,
        dtype: Optional[DType] = None,
        about: Optional[str] = None,
        *,
        allow_scalar: bool = False,
        device: Optional[Device] = None,
        use_eval_dtype: bool = False,
    ) -> Iterable:
        """
        Return the given sequence as a tensor while also confirming its
        length, dtype, and device.

        Default length, dtype, device are taken from this method's
        parent object.
        In more details, these attributes belonging to this method's parent
        object will be used for determining the the defaults:
        `solution_length`, `dtype`, and `device`.

        Args:
            t: The tensor, or a sequence which is convertible to a tensor.
            length: The length to which the tensor is expected to conform.
                If missing, the `solution_length` attribute of this method's
                parent object will be used as the default value.
            dtype: The dtype to which the tensor is expected to conform.
                If `dtype` argument is missing and `use_eval_dtype` is False,
                then the default dtype will be determined by the `dtype`
                attribute of this method's parent object.
                If `dtype` argument is missing and `use_eval_dtype` is True,
                then the default dtype will be determined by the `eval_dtype`
                attribute of this method's parent object.
            about: The prefix for the error message. Can be left as None.
            allow_scalar: Whether or not to accept scalars in addition
                to vector of the desired length.
                If `allow_scalar` is False, then scalars will be converted
                to sequences of the desired length. The sequence will contain
                the same scalar, repeated.
                If `allow_scalar` is True, then the scalar itself will be
                converted to a PyTorch scalar, and then will be returned.
            device: The device in which the sequence is to be stored.
                If the given sequence is on a different device than the
                desired device, a copy on the correct device will be made.
                If device is None, the default behavior of `torch.tensor(...)`
                will be used, that is: if `t` is already a tensor, the result
                will be on the same device, otherwise, the result will be on
                the cpu.
            use_eval_dtype: Whether or not to use the evaluation dtype
                (instead of the dtype of decision values).
                If this is given as True, the `dtype` argument is expected
                as None.
                If `dtype` argument is missing and `use_eval_dtype` is False,
                then the default dtype will be determined by the `dtype`
                attribute of this method's parent object.
                If `dtype` argument is missing and `use_eval_dtype` is True,
                then the default dtype will be determined by the `eval_dtype`
                attribute of this method's parent object.
        Returns:
            The sequence whose correctness in terms of length, dtype, and
            device is ensured.
        Raises:
            ValueError: if there is a length mismatch.
        """

        if length is None:
            if hasattr(self, "solution_length"):
                length = self.solution_length
            else:
                raise AttributeError(
                    f"{about}: The argument `length` was found to be None."
                    f" When the `length` argument is None, the default behavior is to use the `solution_length`"
                    f" attribute of this method's parent object."
                    f" However, this method's parent object does NOT have a `solution_length` attribute."
                )
        dtype_and_device = self.__get_dtype_and_device_kwargs(
            dtype=dtype, device=device, use_eval_dtype=use_eval_dtype, out=None
        )

        return misc.ensure_tensor_length_and_dtype(
            t, length=length, about=about, allow_scalar=allow_scalar, **dtype_and_device
        )

    def make_uniform_shaped_like(
        self,
        t: torch.Tensor,
        *,
        lb: Optional[RealOrVector] = None,
        ub: Optional[RealOrVector] = None,
    ) -> torch.Tensor:
        """
        Make a new uniformly-filled tensor, shaped like the given tensor.
        The `dtype` and `device` will be determined by the parent of this
        method (not by the given tensor).
        If the parent of this method has its own random generator, then that
        generator will be used.

        Args:
            t: The tensor according to which the result will be shaped.
            lb: The inclusive lower bounds for the uniform distribution.
                Can be a scalar or a tensor.
                If left as None, 0.0 will be used as the upper bound.
            ub: The inclusive upper bounds for the uniform distribution.
                Can be a scalar or a tensor.
                If left as None, 1.0 will be used as the upper bound.
        Returns:
            A new tensor whose shape is the same with the given tensor.
        """
        return self.make_uniform(t.shape, lb=lb, ub=ub)

    def make_gaussian_shaped_like(
        self,
        t: torch.Tensor,
        *,
        center: Optional[RealOrVector] = None,
        stdev: Optional[RealOrVector] = None,
    ) -> torch.Tensor:
        """
        Make a new tensor, shaped like the given tensor, with its values
        filled by the Gaussian distribution.

        The `dtype` and `device` will be determined by the parent of this
        method (not by the given tensor).
        If the parent of this method has its own random generator, then that
        generator will be used.

        Args:
            t: The tensor according to which the result will be shaped.
            center: Center point for the Gaussian distribution.
                Can be a scalar or a tensor.
                If left as None, 0.0 will be used as the center point.
            stdev: The standard deviation for the Gaussian distribution.
                Can be a scalar or a tensor.
                If left as None, 1.0 will be used as the standard deviation.
        Returns:
            A new tensor whose shape is the same with the given tensor.
        """
        return self.make_gaussian(t.shape, center=center, stdev=stdev)
as_tensor(self, x, dtype=None, device=None, use_eval_dtype=False)

Get the tensor counterpart of the given object x.

Parameters:

Name Type Description Default
x Any

Any object to be converted to a tensor.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32) or, for creating an ObjectArray, "object" (as string) or object or Any. If dtype is not specified, the dtype of this method's parent object will be used.

None
device Union[str, torch.device]

The device in which the resulting tensor will be stored. If device is not specified, the device of this method's parent object will be used.

None
use_eval_dtype bool

If this is given as True and a dtype is not specified, then the dtype of the result will be taken from the eval_dtype attribute of this method's parent object.

False

Returns:

Type Description
Tensor

The tensor counterpart of the given object x.

Source code in evotorch/tools/tensormaker.py
def as_tensor(
    self,
    x: Any,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
) -> torch.Tensor:
    """
    Get the tensor counterpart of the given object `x`.

    Args:
        x: Any object to be converted to a tensor.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32) or, for creating an `ObjectArray`,
            "object" (as string) or `object` or `Any`.
            If `dtype` is not specified, the dtype of this method's
            parent object will be used.
        device: The device in which the resulting tensor will be stored.
            If `device` is not specified, the device of this method's
            parent object will be used.
        use_eval_dtype: If this is given as True and a `dtype` is not
            specified, then the `dtype` of the result will be taken
            from the `eval_dtype` attribute of this method's parent
            object.
    Returns:
        The tensor counterpart of the given object `x`.
    """
    kwargs = self.__get_dtype_and_device_kwargs(dtype=dtype, device=device, use_eval_dtype=use_eval_dtype, out=None)
    return misc.as_tensor(x, **kwargs)
ensure_tensor_length_and_dtype(self, t, length=None, dtype=None, about=None, *, allow_scalar=False, device=None, use_eval_dtype=False)

Return the given sequence as a tensor while also confirming its length, dtype, and device.

Default length, dtype, device are taken from this method's parent object. In more details, these attributes belonging to this method's parent object will be used for determining the the defaults: solution_length, dtype, and device.

Parameters:

Name Type Description Default
t Any

The tensor, or a sequence which is convertible to a tensor.

required
length Optional[int]

The length to which the tensor is expected to conform. If missing, the solution_length attribute of this method's parent object will be used as the default value.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

The dtype to which the tensor is expected to conform. If dtype argument is missing and use_eval_dtype is False, then the default dtype will be determined by the dtype attribute of this method's parent object. If dtype argument is missing and use_eval_dtype is True, then the default dtype will be determined by the eval_dtype attribute of this method's parent object.

None
about Optional[str]

The prefix for the error message. Can be left as None.

None
allow_scalar bool

Whether or not to accept scalars in addition to vector of the desired length. If allow_scalar is False, then scalars will be converted to sequences of the desired length. The sequence will contain the same scalar, repeated. If allow_scalar is True, then the scalar itself will be converted to a PyTorch scalar, and then will be returned.

False
device Union[str, torch.device]

The device in which the sequence is to be stored. If the given sequence is on a different device than the desired device, a copy on the correct device will be made. If device is None, the default behavior of torch.tensor(...) will be used, that is: if t is already a tensor, the result will be on the same device, otherwise, the result will be on the cpu.

None
use_eval_dtype bool

Whether or not to use the evaluation dtype (instead of the dtype of decision values). If this is given as True, the dtype argument is expected as None. If dtype argument is missing and use_eval_dtype is False, then the default dtype will be determined by the dtype attribute of this method's parent object. If dtype argument is missing and use_eval_dtype is True, then the default dtype will be determined by the eval_dtype attribute of this method's parent object.

False

Returns:

Type Description
Iterable

The sequence whose correctness in terms of length, dtype, and device is ensured.

Exceptions:

Type Description
ValueError

if there is a length mismatch.

Source code in evotorch/tools/tensormaker.py
def ensure_tensor_length_and_dtype(
    self,
    t: Any,
    length: Optional[int] = None,
    dtype: Optional[DType] = None,
    about: Optional[str] = None,
    *,
    allow_scalar: bool = False,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
) -> Iterable:
    """
    Return the given sequence as a tensor while also confirming its
    length, dtype, and device.

    Default length, dtype, device are taken from this method's
    parent object.
    In more details, these attributes belonging to this method's parent
    object will be used for determining the the defaults:
    `solution_length`, `dtype`, and `device`.

    Args:
        t: The tensor, or a sequence which is convertible to a tensor.
        length: The length to which the tensor is expected to conform.
            If missing, the `solution_length` attribute of this method's
            parent object will be used as the default value.
        dtype: The dtype to which the tensor is expected to conform.
            If `dtype` argument is missing and `use_eval_dtype` is False,
            then the default dtype will be determined by the `dtype`
            attribute of this method's parent object.
            If `dtype` argument is missing and `use_eval_dtype` is True,
            then the default dtype will be determined by the `eval_dtype`
            attribute of this method's parent object.
        about: The prefix for the error message. Can be left as None.
        allow_scalar: Whether or not to accept scalars in addition
            to vector of the desired length.
            If `allow_scalar` is False, then scalars will be converted
            to sequences of the desired length. The sequence will contain
            the same scalar, repeated.
            If `allow_scalar` is True, then the scalar itself will be
            converted to a PyTorch scalar, and then will be returned.
        device: The device in which the sequence is to be stored.
            If the given sequence is on a different device than the
            desired device, a copy on the correct device will be made.
            If device is None, the default behavior of `torch.tensor(...)`
            will be used, that is: if `t` is already a tensor, the result
            will be on the same device, otherwise, the result will be on
            the cpu.
        use_eval_dtype: Whether or not to use the evaluation dtype
            (instead of the dtype of decision values).
            If this is given as True, the `dtype` argument is expected
            as None.
            If `dtype` argument is missing and `use_eval_dtype` is False,
            then the default dtype will be determined by the `dtype`
            attribute of this method's parent object.
            If `dtype` argument is missing and `use_eval_dtype` is True,
            then the default dtype will be determined by the `eval_dtype`
            attribute of this method's parent object.
    Returns:
        The sequence whose correctness in terms of length, dtype, and
        device is ensured.
    Raises:
        ValueError: if there is a length mismatch.
    """

    if length is None:
        if hasattr(self, "solution_length"):
            length = self.solution_length
        else:
            raise AttributeError(
                f"{about}: The argument `length` was found to be None."
                f" When the `length` argument is None, the default behavior is to use the `solution_length`"
                f" attribute of this method's parent object."
                f" However, this method's parent object does NOT have a `solution_length` attribute."
            )
    dtype_and_device = self.__get_dtype_and_device_kwargs(
        dtype=dtype, device=device, use_eval_dtype=use_eval_dtype, out=None
    )

    return misc.ensure_tensor_length_and_dtype(
        t, length=length, about=about, allow_scalar=allow_scalar, **dtype_and_device
    )
make_I(self, size=None, *, out=None, dtype=None, device=None, use_eval_dtype=False)

Make a new identity matrix (I), or change an existing tensor so that it expresses the identity matrix.

When not explicitly specified via arguments, the dtype and the device of the resulting tensor is determined by this method's parent object.

Parameters:

Name Type Description Default
size Union[tuple, int]

A single integer or a tuple containing a single integer, where the integer specifies the length of the target square matrix. In this context, "length" means both rowwise length and columnwise length, since the target is a square matrix. Note that, if the user wishes to fill an existing tensor with identity values, then size is expected to be left as None.

None
out Optional[torch.Tensor]

Optionally, the existing tensor whose values will be changed so that they represent an identity matrix. If an out tensor is given, then size is expected as None.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified (and also out is None), it will be assumed that the user wishes to create a tensor using the dtype of this method's parent object. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new empty tensor will be stored. If not specified (and also out is None), it will be assumed that the user wishes to create a tensor on the same device with this method's parent object. If an out tensor is specified, then device is expected as None.

None
use_eval_dtype bool

If this is given as True and a dtype is not specified, then the dtype of the result will be taken from the eval_dtype attribute of this method's parent object.

False

Returns:

Type Description
Tensor

The created or modified tensor after placing the I matrix values

Source code in evotorch/tools/tensormaker.py
def make_I(
    self,
    size: Optional[Union[int, tuple]] = None,
    *,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
) -> torch.Tensor:
    """
    Make a new identity matrix (I), or change an existing tensor so that
    it expresses the identity matrix.

    When not explicitly specified via arguments, the dtype and the device
    of the resulting tensor is determined by this method's parent object.

    Args:
        size: A single integer or a tuple containing a single integer,
            where the integer specifies the length of the target square
            matrix. In this context, "length" means both rowwise length
            and columnwise length, since the target is a square matrix.
            Note that, if the user wishes to fill an existing tensor with
            identity values, then `size` is expected to be left as None.
        out: Optionally, the existing tensor whose values will be changed
            so that they represent an identity matrix.
            If an `out` tensor is given, then `size` is expected as None.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified (and also `out` is None),
            it will be assumed that the user wishes to create a tensor
            using the dtype of this method's parent object.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new empty tensor will be stored.
            If not specified (and also `out` is None), it will be
            assumed that the user wishes to create a tensor on the
            same device with this method's parent object.
            If an `out` tensor is specified, then `device` is expected
            as None.
        use_eval_dtype: If this is given as True and a `dtype` is not
            specified, then the `dtype` of the result will be taken
            from the `eval_dtype` attribute of this method's parent
            object.
    Returns:
        The created or modified tensor after placing the I matrix values
    """

    if size is None:
        if out is None:
            if hasattr(self, "solution_length"):
                size_args = (self.solution_length,)
            else:
                raise AttributeError(
                    "The method `.make_I(...)` was used without any `size`"
                    " arguments."
                    " When the `size` argument is missing, the default"
                    " behavior of this method is to create an identity matrix"
                    " of size (n, n), n being the length of a solution."
                    " However, the parent object of this method does not have"
                    " an attribute name `solution_length`."
                )
        else:
            size_args = tuple()
    elif isinstance(size, tuple):
        if len(size) != 1:
            raise ValueError(
                f"When the size argument is given as a tuple, the method `make_I(...)` expects the tuple to have"
                f" only one element. The given tuple is {size}."
            )
        size_args = size
    else:
        size_args = (int(size),)

    args, kwargs = self.__get_all_args_for_maker(
        *size_args,
        num_solutions=None,
        out=out,
        dtype=dtype,
        device=device,
        use_eval_dtype=use_eval_dtype,
    )
    return misc.make_I(*args, **kwargs)
make_empty(self, *size, *, num_solutions=None, out=None, dtype=None, device=None, use_eval_dtype=False)

Make an empty tensor.

When not explicitly specified via arguments, the dtype and the device of the resulting tensor is determined by this method's parent object.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Shape of the empty tensor to be created. expected as multiple positional arguments of integers, or as a single positional argument containing a tuple of integers. Note that when the user wishes to create an ObjectArray (i.e. when dtype is given as object), then the size is expected as a single integer, or as a single-element tuple containing an integer (because ObjectArray can only be one-dimensional).

()
num_solutions Optional[int]

This can be used instead of the size arguments for specifying the shape of the target tensor. Expected as an integer, when num_solutions is specified as n, the shape of the resulting tensor will be (n, m) where m is the solution length reported by this method's parent object's solution_length attribute.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32) or, for creating an ObjectArray, "object" (as string) or object or Any. If dtype is not specified (and also out is None), it will be assumed that the user wishes to create a tensor using the dtype of this method's parent object.

None
device Union[str, torch.device]

The device in which the new empty tensor will be stored. If not specified (and also out is None), it will be assumed that the user wishes to create a tensor on the same device with this method's parent object.

None
use_eval_dtype bool

If this is given as True and a dtype is not specified, then the dtype of the result will be taken from the eval_dtype attribute of this method's parent object.

False

Returns:

Type Description
Iterable

The new empty tensor, which can be a PyTorch tensor or an ObjectArray.

Source code in evotorch/tools/tensormaker.py
def make_empty(
    self,
    *size: Size,
    num_solutions: Optional[int] = None,
    out: Optional[Iterable] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
) -> Iterable:
    """
    Make an empty tensor.

    When not explicitly specified via arguments, the dtype and the device
    of the resulting tensor is determined by this method's parent object.

    Args:
        size: Shape of the empty tensor to be created.
            expected as multiple positional arguments of integers,
            or as a single positional argument containing a tuple of
            integers.
            Note that when the user wishes to create an `ObjectArray`
            (i.e. when `dtype` is given as `object`), then the size
            is expected as a single integer, or as a single-element
            tuple containing an integer (because `ObjectArray` can only
            be one-dimensional).
        num_solutions: This can be used instead of the `size` arguments
            for specifying the shape of the target tensor.
            Expected as an integer, when `num_solutions` is specified
            as `n`, the shape of the resulting tensor will be
            `(n, m)` where `m` is the solution length reported by this
            method's parent object's `solution_length` attribute.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32) or, for creating an `ObjectArray`,
            "object" (as string) or `object` or `Any`.
            If `dtype` is not specified (and also `out` is None),
            it will be assumed that the user wishes to create a tensor
            using the dtype of this method's parent object.
        device: The device in which the new empty tensor will be stored.
            If not specified (and also `out` is None), it will be
            assumed that the user wishes to create a tensor on the
            same device with this method's parent object.
        use_eval_dtype: If this is given as True and a `dtype` is not
            specified, then the `dtype` of the result will be taken
            from the `eval_dtype` attribute of this method's parent
            object.
    Returns:
        The new empty tensor, which can be a PyTorch tensor or an
        `ObjectArray`.
    """
    args, kwargs = self.__get_all_args_for_maker(
        *size,
        num_solutions=num_solutions,
        out=out,
        dtype=dtype,
        device=device,
        use_eval_dtype=use_eval_dtype,
    )
    return misc.make_empty(*args, **kwargs)
make_gaussian(self, *size, *, num_solutions=None, center=None, stdev=None, symmetric=False, out=None, dtype=None, device=None, use_eval_dtype=False, generator=None)

Make a new or existing tensor filled by Gaussian distributed values. This function can work only with float dtypes.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with Gaussian distributed values. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor instead, then no positional argument is expected.

()
num_solutions Optional[int]

This can be used instead of the size arguments for specifying the shape of the target tensor. Expected as an integer, when num_solutions is specified as n, the shape of the resulting tensor will be (n, m) where m is the solution length reported by this method's parent object's solution_length attribute.

None
center Union[float, Iterable[float], torch.Tensor]

Center point (i.e. mean) of the Gaussian distribution. Can be a scalar, or a tensor. If not specified, the center point will be taken as 0. Note that, if one specifies center, then stdev is also expected to be explicitly specified.

None
stdev Union[float, Iterable[float], torch.Tensor]

Standard deviation for the Gaussian distributed values. Can be a scalar, or a tensor. If not specified, the standard deviation will be taken as 1. Note that, if one specifies stdev, then center is also expected to be explicitly specified.

None
symmetric bool

Whether or not the values should be sampled in a symmetric (i.e. antithetic) manner. The default is False.

False
out Optional[torch.Tensor]

Optionally, the tensor to be filled by Gaussian distributed values. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified (and also out is None), it will be assumed that the user wishes to create a tensor using the dtype of this method's parent object. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new empty tensor will be stored. If not specified (and also out is None), it will be assumed that the user wishes to create a tensor on the same device with this method's parent object. If an out tensor is specified, then device is expected as None.

None
use_eval_dtype bool

If this is given as True and a dtype is not specified, then the dtype of the result will be taken from the eval_dtype attribute of this method's parent object.

False
generator Any

Pseudo-random generator to be used when sampling the values. Can be a torch.Generator or any object with a generator attribute (e.g. a Problem object). If not given, then this method's parent object will be analyzed whether or not it has its own generator. If it does, that generator will be used. If not, the global generator of PyTorch will be used.

None

Returns:

Type Description
Tensor

The created or modified tensor after placing the Gaussian distributed values.

Source code in evotorch/tools/tensormaker.py
def make_gaussian(
    self,
    *size: Size,
    num_solutions: Optional[int] = None,
    center: Optional[RealOrVector] = None,
    stdev: Optional[RealOrVector] = None,
    symmetric: bool = False,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
    generator: Any = None,
) -> torch.Tensor:
    """
    Make a new or existing tensor filled by Gaussian distributed values.
    This function can work only with float dtypes.

    Args:
        size: Size of the new tensor to be filled with Gaussian distributed
            values. This can be given as multiple positional arguments, each
            such positional argument being an integer, or as a single
            positional argument of a tuple, the tuple containing multiple
            integers. Note that, if the user wishes to fill an existing
            tensor instead, then no positional argument is expected.
        num_solutions: This can be used instead of the `size` arguments
            for specifying the shape of the target tensor.
            Expected as an integer, when `num_solutions` is specified
            as `n`, the shape of the resulting tensor will be
            `(n, m)` where `m` is the solution length reported by this
            method's parent object's `solution_length` attribute.
        center: Center point (i.e. mean) of the Gaussian distribution.
            Can be a scalar, or a tensor.
            If not specified, the center point will be taken as 0.
            Note that, if one specifies `center`, then `stdev` is also
            expected to be explicitly specified.
        stdev: Standard deviation for the Gaussian distributed values.
            Can be a scalar, or a tensor.
            If not specified, the standard deviation will be taken as 1.
            Note that, if one specifies `stdev`, then `center` is also
            expected to be explicitly specified.
        symmetric: Whether or not the values should be sampled in a
            symmetric (i.e. antithetic) manner.
            The default is False.
        out: Optionally, the tensor to be filled by Gaussian distributed
            values. If an `out` tensor is given, then no `size` argument is
            expected.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified (and also `out` is None),
            it will be assumed that the user wishes to create a tensor
            using the dtype of this method's parent object.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new empty tensor will be stored.
            If not specified (and also `out` is None), it will be
            assumed that the user wishes to create a tensor on the
            same device with this method's parent object.
            If an `out` tensor is specified, then `device` is expected
            as None.
        use_eval_dtype: If this is given as True and a `dtype` is not
            specified, then the `dtype` of the result will be taken
            from the `eval_dtype` attribute of this method's parent
            object.
        generator: Pseudo-random generator to be used when sampling
            the values. Can be a `torch.Generator` or any object with
            a `generator` attribute (e.g. a Problem object).
            If not given, then this method's parent object will be
            analyzed whether or not it has its own generator.
            If it does, that generator will be used.
            If not, the global generator of PyTorch will be used.
    Returns:
        The created or modified tensor after placing the Gaussian
        distributed values.
    """

    args, kwargs = self.__get_all_args_for_random_maker(
        *size,
        num_solutions=num_solutions,
        out=out,
        dtype=dtype,
        device=device,
        use_eval_dtype=use_eval_dtype,
        generator=generator,
    )
    return misc.make_gaussian(*args, center=center, stdev=stdev, symmetric=symmetric, **kwargs)
make_gaussian_shaped_like(self, t, *, center=None, stdev=None)

Make a new tensor, shaped like the given tensor, with its values filled by the Gaussian distribution.

The dtype and device will be determined by the parent of this method (not by the given tensor). If the parent of this method has its own random generator, then that generator will be used.

Parameters:

Name Type Description Default
t Tensor

The tensor according to which the result will be shaped.

required
center Union[float, Iterable[float], torch.Tensor]

Center point for the Gaussian distribution. Can be a scalar or a tensor. If left as None, 0.0 will be used as the center point.

None
stdev Union[float, Iterable[float], torch.Tensor]

The standard deviation for the Gaussian distribution. Can be a scalar or a tensor. If left as None, 1.0 will be used as the standard deviation.

None

Returns:

Type Description
Tensor

A new tensor whose shape is the same with the given tensor.

Source code in evotorch/tools/tensormaker.py
def make_gaussian_shaped_like(
    self,
    t: torch.Tensor,
    *,
    center: Optional[RealOrVector] = None,
    stdev: Optional[RealOrVector] = None,
) -> torch.Tensor:
    """
    Make a new tensor, shaped like the given tensor, with its values
    filled by the Gaussian distribution.

    The `dtype` and `device` will be determined by the parent of this
    method (not by the given tensor).
    If the parent of this method has its own random generator, then that
    generator will be used.

    Args:
        t: The tensor according to which the result will be shaped.
        center: Center point for the Gaussian distribution.
            Can be a scalar or a tensor.
            If left as None, 0.0 will be used as the center point.
        stdev: The standard deviation for the Gaussian distribution.
            Can be a scalar or a tensor.
            If left as None, 1.0 will be used as the standard deviation.
    Returns:
        A new tensor whose shape is the same with the given tensor.
    """
    return self.make_gaussian(t.shape, center=center, stdev=stdev)
make_nan(self, *size, *, num_solutions=None, out=None, dtype=None, device=None, use_eval_dtype=False)

Make a new tensor filled with NaN values, or fill an existing tensor with NaN values.

When not explicitly specified via arguments, the dtype and the device of the resulting tensor is determined by this method's parent object.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with NaN. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor with NaN values, then no positional argument is expected.

()
num_solutions Optional[int]

This can be used instead of the size arguments for specifying the shape of the target tensor. Expected as an integer, when num_solutions is specified as n, the shape of the resulting tensor will be (n, m) where m is the solution length reported by this method's parent object's solution_length attribute.

None
out Optional[torch.Tensor]

Optionally, the tensor to be filled by NaN values. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified (and also out is None), it will be assumed that the user wishes to create a tensor using the dtype of this method's parent object. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new empty tensor will be stored. If not specified (and also out is None), it will be assumed that the user wishes to create a tensor on the same device with this method's parent object. If an out tensor is specified, then device is expected as None.

None
use_eval_dtype bool

If this is given as True and a dtype is not specified, then the dtype of the result will be taken from the eval_dtype attribute of this method's parent object.

False

Returns:

Type Description
Tensor

The created or modified tensor after placing NaN values.

Source code in evotorch/tools/tensormaker.py
def make_nan(
    self,
    *size: Size,
    num_solutions: Optional[int] = None,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
) -> torch.Tensor:
    """
    Make a new tensor filled with NaN values, or fill an existing tensor
    with NaN values.

    When not explicitly specified via arguments, the dtype and the device
    of the resulting tensor is determined by this method's parent object.

    Args:
        size: Size of the new tensor to be filled with NaN.
            This can be given as multiple positional arguments, each such
            positional argument being an integer, or as a single positional
            argument of a tuple, the tuple containing multiple integers.
            Note that, if the user wishes to fill an existing tensor with
            NaN values, then no positional argument is expected.
        num_solutions: This can be used instead of the `size` arguments
            for specifying the shape of the target tensor.
            Expected as an integer, when `num_solutions` is specified
            as `n`, the shape of the resulting tensor will be
            `(n, m)` where `m` is the solution length reported by this
            method's parent object's `solution_length` attribute.
        out: Optionally, the tensor to be filled by NaN values.
            If an `out` tensor is given, then no `size` argument is expected.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified (and also `out` is None),
            it will be assumed that the user wishes to create a tensor
            using the dtype of this method's parent object.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new empty tensor will be stored.
            If not specified (and also `out` is None), it will be
            assumed that the user wishes to create a tensor on the
            same device with this method's parent object.
            If an `out` tensor is specified, then `device` is expected
            as None.
        use_eval_dtype: If this is given as True and a `dtype` is not
            specified, then the `dtype` of the result will be taken
            from the `eval_dtype` attribute of this method's parent
            object.
    Returns:
        The created or modified tensor after placing NaN values.
    """
    args, kwargs = self.__get_all_args_for_maker(
        *size,
        num_solutions=num_solutions,
        out=out,
        dtype=dtype,
        device=device,
        use_eval_dtype=use_eval_dtype,
    )
    return misc.make_nan(*args, **kwargs)
make_ones(self, *size, *, num_solutions=None, out=None, dtype=None, device=None, use_eval_dtype=False)

Make a new tensor filled with 1, or fill an existing tensor with 1.

When not explicitly specified via arguments, the dtype and the device of the resulting tensor is determined by this method's parent object.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with 1. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor with 1 values, then no positional argument is expected.

()
num_solutions Optional[int]

This can be used instead of the size arguments for specifying the shape of the target tensor. Expected as an integer, when num_solutions is specified as n, the shape of the resulting tensor will be (n, m) where m is the solution length reported by this method's parent object's solution_length attribute.

None
out Optional[torch.Tensor]

Optionally, the tensor to be filled by 1 values. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified (and also out is None), it will be assumed that the user wishes to create a tensor using the dtype of this method's parent object. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new empty tensor will be stored. If not specified (and also out is None), it will be assumed that the user wishes to create a tensor on the same device with this method's parent object. If an out tensor is specified, then device is expected as None.

None
use_eval_dtype bool

If this is given as True and a dtype is not specified, then the dtype of the result will be taken from the eval_dtype attribute of this method's parent object.

False

Returns:

Type Description
Tensor

The created or modified tensor after placing 1 values.

Source code in evotorch/tools/tensormaker.py
def make_ones(
    self,
    *size: Size,
    num_solutions: Optional[int] = None,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
) -> torch.Tensor:
    """
    Make a new tensor filled with 1, or fill an existing tensor with 1.

    When not explicitly specified via arguments, the dtype and the device
    of the resulting tensor is determined by this method's parent object.

    Args:
        size: Size of the new tensor to be filled with 1.
            This can be given as multiple positional arguments, each such
            positional argument being an integer, or as a single positional
            argument of a tuple, the tuple containing multiple integers.
            Note that, if the user wishes to fill an existing tensor with
            1 values, then no positional argument is expected.
        num_solutions: This can be used instead of the `size` arguments
            for specifying the shape of the target tensor.
            Expected as an integer, when `num_solutions` is specified
            as `n`, the shape of the resulting tensor will be
            `(n, m)` where `m` is the solution length reported by this
            method's parent object's `solution_length` attribute.
        out: Optionally, the tensor to be filled by 1 values.
            If an `out` tensor is given, then no `size` argument is expected.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified (and also `out` is None),
            it will be assumed that the user wishes to create a tensor
            using the dtype of this method's parent object.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new empty tensor will be stored.
            If not specified (and also `out` is None), it will be
            assumed that the user wishes to create a tensor on the
            same device with this method's parent object.
            If an `out` tensor is specified, then `device` is expected
            as None.
        use_eval_dtype: If this is given as True and a `dtype` is not
            specified, then the `dtype` of the result will be taken
            from the `eval_dtype` attribute of this method's parent
            object.
    Returns:
        The created or modified tensor after placing 1 values.
    """
    args, kwargs = self.__get_all_args_for_maker(
        *size,
        num_solutions=num_solutions,
        out=out,
        dtype=dtype,
        device=device,
        use_eval_dtype=use_eval_dtype,
    )
    return misc.make_ones(*args, **kwargs)
make_randint(self, *size, *, n, num_solutions=None, out=None, dtype=None, device=None, use_eval_dtype=False, generator=None)

Make a new or existing tensor filled by random integers. The integers are uniformly distributed within [0 ... n-1]. This function can be used with integer or float dtypes.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with uniformly distributed values. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor instead, then no positional argument is expected.

()
n Union[int, float, torch.Tensor]

Number of choice(s) for integer sampling. The lowest possible value will be 0, and the highest possible value will be n - 1. n can be a scalar, or a tensor.

required
out Optional[torch.Tensor]

Optionally, the tensor to be filled by the random integers. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "int64") or a PyTorch dtype (e.g. torch.int64). If dtype is not specified (and also out is None), torch.int64 will be used. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new empty tensor will be stored. If not specified (and also out is None), it will be assumed that the user wishes to create a tensor on the same device with this method's parent object. If an out tensor is specified, then device is expected as None.

None
use_eval_dtype bool

If this is given as True and a dtype is not specified, then the dtype of the result will be taken from the eval_dtype attribute of this method's parent object.

False
generator Any

Pseudo-random generator to be used when sampling the values. Can be a torch.Generator or any object with a generator attribute (e.g. a Problem object). If not given, then this method's parent object will be analyzed whether or not it has its own generator. If it does, that generator will be used. If not, the global generator of PyTorch will be used.

None

Returns:

Type Description
Tensor

The created or modified tensor after placing the uniformly distributed values.

Source code in evotorch/tools/tensormaker.py
def make_randint(
    self,
    *size: Size,
    n: Union[int, float, torch.Tensor],
    num_solutions: Optional[int] = None,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
    generator: Any = None,
) -> torch.Tensor:
    """
    Make a new or existing tensor filled by random integers.
    The integers are uniformly distributed within `[0 ... n-1]`.
    This function can be used with integer or float dtypes.

    Args:
        size: Size of the new tensor to be filled with uniformly distributed
            values. This can be given as multiple positional arguments, each
            such positional argument being an integer, or as a single
            positional argument of a tuple, the tuple containing multiple
            integers. Note that, if the user wishes to fill an existing
            tensor instead, then no positional argument is expected.
        n: Number of choice(s) for integer sampling.
            The lowest possible value will be 0, and the highest possible
            value will be n - 1.
            `n` can be a scalar, or a tensor.
        out: Optionally, the tensor to be filled by the random integers.
            If an `out` tensor is given, then no `size` argument is
            expected.
        dtype: Optionally a string (e.g. "int64") or a PyTorch dtype
            (e.g. torch.int64).
            If `dtype` is not specified (and also `out` is None),
            `torch.int64` will be used.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new empty tensor will be stored.
            If not specified (and also `out` is None), it will be
            assumed that the user wishes to create a tensor on the
            same device with this method's parent object.
            If an `out` tensor is specified, then `device` is expected
            as None.
        use_eval_dtype: If this is given as True and a `dtype` is not
            specified, then the `dtype` of the result will be taken
            from the `eval_dtype` attribute of this method's parent
            object.
        generator: Pseudo-random generator to be used when sampling
            the values. Can be a `torch.Generator` or any object with
            a `generator` attribute (e.g. a Problem object).
            If not given, then this method's parent object will be
            analyzed whether or not it has its own generator.
            If it does, that generator will be used.
            If not, the global generator of PyTorch will be used.
    Returns:
        The created or modified tensor after placing the uniformly
        distributed values.
    """

    if (dtype is None) and (out is None):
        dtype = torch.int64

    args, kwargs = self.__get_all_args_for_random_maker(
        *size,
        num_solutions=num_solutions,
        out=out,
        dtype=dtype,
        device=device,
        use_eval_dtype=use_eval_dtype,
        generator=generator,
    )
    return misc.make_randint(*args, n=n, **kwargs)
make_tensor(self, data, *, dtype=None, device=None, use_eval_dtype=False, read_only=False)

Make a new tensor.

When not explicitly specified via arguments, the dtype and the device of the resulting tensor is determined by this method's parent object.

Parameters:

Name Type Description Default
data Any

The data to be converted to a tensor. If one wishes to create a PyTorch tensor, this can be anything that can be stored by a PyTorch tensor. If one wishes to create an ObjectArray and therefore passes dtype=object, then the provided data is expected as an Iterable.

required
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32"), or a PyTorch dtype (e.g. torch.float32), or object or "object" (as a string) or Any if one wishes to create an ObjectArray. If dtype is not specified it will be assumed that the user wishes to create a tensor using the dtype of this method's parent object.

None
device Union[str, torch.device]

The device in which the tensor will be stored. If device is not specified, it will be assumed that the user wishes to create a tensor on the device of this method's parent object.

None
use_eval_dtype bool

If this is given as True and a dtype is not specified, then the dtype of the result will be taken from the eval_dtype attribute of this method's parent object.

False
read_only bool

Whether or not the created tensor will be read-only. By default, this is False.

False

Returns:

Type Description
Iterable

A PyTorch tensor or an ObjectArray.

Source code in evotorch/tools/tensormaker.py
def make_tensor(
    self,
    data: Any,
    *,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
    read_only: bool = False,
) -> Iterable:
    """
    Make a new tensor.

    When not explicitly specified via arguments, the dtype and the device
    of the resulting tensor is determined by this method's parent object.

    Args:
        data: The data to be converted to a tensor.
            If one wishes to create a PyTorch tensor, this can be anything
            that can be stored by a PyTorch tensor.
            If one wishes to create an `ObjectArray` and therefore passes
            `dtype=object`, then the provided `data` is expected as an
            `Iterable`.
        dtype: Optionally a string (e.g. "float32"), or a PyTorch dtype
            (e.g. torch.float32), or `object` or "object" (as a string)
            or `Any` if one wishes to create an `ObjectArray`.
            If `dtype` is not specified it will be assumed that the user
            wishes to create a tensor using the dtype of this method's
            parent object.
        device: The device in which the tensor will be stored.
            If `device` is not specified, it will be assumed that the user
            wishes to create a tensor on the device of this method's
            parent object.
        use_eval_dtype: If this is given as True and a `dtype` is not
            specified, then the `dtype` of the result will be taken
            from the `eval_dtype` attribute of this method's parent
            object.
        read_only: Whether or not the created tensor will be read-only.
            By default, this is False.
    Returns:
        A PyTorch tensor or an ObjectArray.
    """
    kwargs = self.__get_dtype_and_device_kwargs(dtype=dtype, device=device, use_eval_dtype=use_eval_dtype, out=None)
    return misc.make_tensor(data, read_only=read_only, **kwargs)
make_uniform(self, *size, *, num_solutions=None, lb=None, ub=None, out=None, dtype=None, device=None, use_eval_dtype=False, generator=None)

Make a new or existing tensor filled by uniformly distributed values. Both lower and upper bounds are inclusive. This function can work with both float and int dtypes.

When not explicitly specified via arguments, the dtype and the device of the resulting tensor is determined by this method's parent object.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with uniformly distributed values. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor instead, then no positional argument is expected.

()
num_solutions Optional[int]

This can be used instead of the size arguments for specifying the shape of the target tensor. Expected as an integer, when num_solutions is specified as n, the shape of the resulting tensor will be (n, m) where m is the solution length reported by this method's parent object's solution_length attribute.

None
lb Union[float, Iterable[float], torch.Tensor]

Lower bound for the uniformly distributed values. Can be a scalar, or a tensor. If not specified, the lower bound will be taken as 0. Note that, if one specifies lb, then ub is also expected to be explicitly specified.

None
ub Union[float, Iterable[float], torch.Tensor]

Upper bound for the uniformly distributed values. Can be a scalar, or a tensor. If not specified, the upper bound will be taken as 1. Note that, if one specifies ub, then lb is also expected to be explicitly specified.

None
out Optional[torch.Tensor]

Optionally, the tensor to be filled by uniformly distributed values. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified (and also out is None), it will be assumed that the user wishes to create a tensor using the dtype of this method's parent object. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new empty tensor will be stored. If not specified (and also out is None), it will be assumed that the user wishes to create a tensor on the same device with this method's parent object. If an out tensor is specified, then device is expected as None.

None
use_eval_dtype bool

If this is given as True and a dtype is not specified, then the dtype of the result will be taken from the eval_dtype attribute of this method's parent object.

False
generator Any

Pseudo-random generator to be used when sampling the values. Can be a torch.Generator or any object with a generator attribute (e.g. a Problem object). If not given, then this method's parent object will be analyzed whether or not it has its own generator. If it does, that generator will be used. If not, the global generator of PyTorch will be used.

None

Returns:

Type Description
Tensor

The created or modified tensor after placing the uniformly distributed values.

Source code in evotorch/tools/tensormaker.py
def make_uniform(
    self,
    *size: Size,
    num_solutions: Optional[int] = None,
    lb: Optional[RealOrVector] = None,
    ub: Optional[RealOrVector] = None,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
    generator: Any = None,
) -> torch.Tensor:
    """
    Make a new or existing tensor filled by uniformly distributed values.
    Both lower and upper bounds are inclusive.
    This function can work with both float and int dtypes.

    When not explicitly specified via arguments, the dtype and the device
    of the resulting tensor is determined by this method's parent object.

    Args:
        size: Size of the new tensor to be filled with uniformly distributed
            values. This can be given as multiple positional arguments, each
            such positional argument being an integer, or as a single
            positional argument of a tuple, the tuple containing multiple
            integers. Note that, if the user wishes to fill an existing
            tensor instead, then no positional argument is expected.
        num_solutions: This can be used instead of the `size` arguments
            for specifying the shape of the target tensor.
            Expected as an integer, when `num_solutions` is specified
            as `n`, the shape of the resulting tensor will be
            `(n, m)` where `m` is the solution length reported by this
            method's parent object's `solution_length` attribute.
        lb: Lower bound for the uniformly distributed values.
            Can be a scalar, or a tensor.
            If not specified, the lower bound will be taken as 0.
            Note that, if one specifies `lb`, then `ub` is also expected to
            be explicitly specified.
        ub: Upper bound for the uniformly distributed values.
            Can be a scalar, or a tensor.
            If not specified, the upper bound will be taken as 1.
            Note that, if one specifies `ub`, then `lb` is also expected to
            be explicitly specified.
        out: Optionally, the tensor to be filled by uniformly distributed
            values. If an `out` tensor is given, then no `size` argument is
            expected.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified (and also `out` is None),
            it will be assumed that the user wishes to create a tensor
            using the dtype of this method's parent object.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new empty tensor will be stored.
            If not specified (and also `out` is None), it will be
            assumed that the user wishes to create a tensor on the
            same device with this method's parent object.
            If an `out` tensor is specified, then `device` is expected
            as None.
        use_eval_dtype: If this is given as True and a `dtype` is not
            specified, then the `dtype` of the result will be taken
            from the `eval_dtype` attribute of this method's parent
            object.
        generator: Pseudo-random generator to be used when sampling
            the values. Can be a `torch.Generator` or any object with
            a `generator` attribute (e.g. a Problem object).
            If not given, then this method's parent object will be
            analyzed whether or not it has its own generator.
            If it does, that generator will be used.
            If not, the global generator of PyTorch will be used.
    Returns:
        The created or modified tensor after placing the uniformly
        distributed values.
    """
    args, kwargs = self.__get_all_args_for_random_maker(
        *size,
        num_solutions=num_solutions,
        out=out,
        dtype=dtype,
        device=device,
        use_eval_dtype=use_eval_dtype,
        generator=generator,
    )
    return misc.make_uniform(*args, lb=lb, ub=ub, **kwargs)
make_uniform_shaped_like(self, t, *, lb=None, ub=None)

Make a new uniformly-filled tensor, shaped like the given tensor. The dtype and device will be determined by the parent of this method (not by the given tensor). If the parent of this method has its own random generator, then that generator will be used.

Parameters:

Name Type Description Default
t Tensor

The tensor according to which the result will be shaped.

required
lb Union[float, Iterable[float], torch.Tensor]

The inclusive lower bounds for the uniform distribution. Can be a scalar or a tensor. If left as None, 0.0 will be used as the upper bound.

None
ub Union[float, Iterable[float], torch.Tensor]

The inclusive upper bounds for the uniform distribution. Can be a scalar or a tensor. If left as None, 1.0 will be used as the upper bound.

None

Returns:

Type Description
Tensor

A new tensor whose shape is the same with the given tensor.

Source code in evotorch/tools/tensormaker.py
def make_uniform_shaped_like(
    self,
    t: torch.Tensor,
    *,
    lb: Optional[RealOrVector] = None,
    ub: Optional[RealOrVector] = None,
) -> torch.Tensor:
    """
    Make a new uniformly-filled tensor, shaped like the given tensor.
    The `dtype` and `device` will be determined by the parent of this
    method (not by the given tensor).
    If the parent of this method has its own random generator, then that
    generator will be used.

    Args:
        t: The tensor according to which the result will be shaped.
        lb: The inclusive lower bounds for the uniform distribution.
            Can be a scalar or a tensor.
            If left as None, 0.0 will be used as the upper bound.
        ub: The inclusive upper bounds for the uniform distribution.
            Can be a scalar or a tensor.
            If left as None, 1.0 will be used as the upper bound.
    Returns:
        A new tensor whose shape is the same with the given tensor.
    """
    return self.make_uniform(t.shape, lb=lb, ub=ub)
make_zeros(self, *size, *, num_solutions=None, out=None, dtype=None, device=None, use_eval_dtype=False)

Make a new tensor filled with 0, or fill an existing tensor with 0.

When not explicitly specified via arguments, the dtype and the device of the resulting tensor is determined by this method's parent object.

Parameters:

Name Type Description Default
size Union[int, torch.Size]

Size of the new tensor to be filled with 0. This can be given as multiple positional arguments, each such positional argument being an integer, or as a single positional argument of a tuple, the tuple containing multiple integers. Note that, if the user wishes to fill an existing tensor with 0 values, then no positional argument is expected.

()
num_solutions Optional[int]

This can be used instead of the size arguments for specifying the shape of the target tensor. Expected as an integer, when num_solutions is specified as n, the shape of the resulting tensor will be (n, m) where m is the solution length reported by this method's parent object's solution_length attribute.

None
out Optional[torch.Tensor]

Optionally, the tensor to be filled by 0 values. If an out tensor is given, then no size argument is expected.

None
dtype Union[str, torch.dtype, numpy.dtype, Type]

Optionally a string (e.g. "float32") or a PyTorch dtype (e.g. torch.float32). If dtype is not specified (and also out is None), it will be assumed that the user wishes to create a tensor using the dtype of this method's parent object. If an out tensor is specified, then dtype is expected as None.

None
device Union[str, torch.device]

The device in which the new empty tensor will be stored. If not specified (and also out is None), it will be assumed that the user wishes to create a tensor on the same device with this method's parent object. If an out tensor is specified, then device is expected as None.

None
use_eval_dtype bool

If this is given as True and a dtype is not specified, then the dtype of the result will be taken from the eval_dtype attribute of this method's parent object.

False

Returns:

Type Description
Tensor

The created or modified tensor after placing 0 values.

Source code in evotorch/tools/tensormaker.py
def make_zeros(
    self,
    *size: Size,
    num_solutions: Optional[int] = None,
    out: Optional[torch.Tensor] = None,
    dtype: Optional[DType] = None,
    device: Optional[Device] = None,
    use_eval_dtype: bool = False,
) -> torch.Tensor:
    """
    Make a new tensor filled with 0, or fill an existing tensor with 0.

    When not explicitly specified via arguments, the dtype and the device
    of the resulting tensor is determined by this method's parent object.

    Args:
        size: Size of the new tensor to be filled with 0.
            This can be given as multiple positional arguments, each such
            positional argument being an integer, or as a single positional
            argument of a tuple, the tuple containing multiple integers.
            Note that, if the user wishes to fill an existing tensor with
            0 values, then no positional argument is expected.
        num_solutions: This can be used instead of the `size` arguments
            for specifying the shape of the target tensor.
            Expected as an integer, when `num_solutions` is specified
            as `n`, the shape of the resulting tensor will be
            `(n, m)` where `m` is the solution length reported by this
            method's parent object's `solution_length` attribute.
        out: Optionally, the tensor to be filled by 0 values.
            If an `out` tensor is given, then no `size` argument is expected.
        dtype: Optionally a string (e.g. "float32") or a PyTorch dtype
            (e.g. torch.float32).
            If `dtype` is not specified (and also `out` is None),
            it will be assumed that the user wishes to create a tensor
            using the dtype of this method's parent object.
            If an `out` tensor is specified, then `dtype` is expected
            as None.
        device: The device in which the new empty tensor will be stored.
            If not specified (and also `out` is None), it will be
            assumed that the user wishes to create a tensor on the
            same device with this method's parent object.
            If an `out` tensor is specified, then `device` is expected
            as None.
        use_eval_dtype: If this is given as True and a `dtype` is not
            specified, then the `dtype` of the result will be taken
            from the `eval_dtype` attribute of this method's parent
            object.
    Returns:
        The created or modified tensor after placing 0 values.
    """

    args, kwargs = self.__get_all_args_for_maker(
        *size,
        num_solutions=num_solutions,
        out=out,
        dtype=dtype,
        device=device,
        use_eval_dtype=use_eval_dtype,
    )
    return misc.make_zeros(*args, **kwargs)