Skip to content

Defining Problems

Basic Usage

One of the most important components of EvoTorch is the definition of problems. To define problems, we use the Problem class, which provides various advanced functionality, including vectorization, GPU usage, Ray parallelisation and variable population sizes, out of the box. The most basic usage of the Problem class is simply to pass to it a function to minimize or maximize. In the following documentation, we will focus on minimization of the Sphere function

\[ f(x) = \sum_{i=1}^d x_i^2. \]

which is implemented in PyTorch as,

import torch


def sphere(x: torch.Tensor) -> torch.Tensor:
    return torch.sum(x.pow(2.0))

With only this function definition, we can create an EvoTorch Problem instance and start learning,

from evotorch import Problem
from evotorch.algorithms import SNES
from evotorch.logging import StdOutLogger

problem = Problem("min", sphere, solution_length=10, initial_bounds=(-1, 1))

searcher = SNES(problem, stdev_init=5)
logger = StdOutLogger(searcher)
searcher.run(10)

If we instead want to maximize the function, we can instead instantiate the Problem instance,

problem = Problem("max", sphere, solution_length=10, initial_bounds=(-1, 1))

and if we want to make the instantiation explicit, we can use the keyword arguments objective_sense and objective_func,

problem = Problem(
    objective_sense="min",
    objective_func=sphere,
    solution_length=10,
    initial_bounds=(-1, 1),
)

Vectorizing Problems

One of the most straight-forward ways to accelerate evolution is to use vectorized problems. In a typical EA implementation, the population is stored as a list of vectors, so that fitnesses can be evaluated with a simple for loop

fitnesses = [f(x) for x in population]

In EvoTorch, the population is stored as a torch tensor of shape \(N \times d\) (or, to use the PyTorch notation, of shape torch.Size([N, d])) where \(N\) is the population size and \(d\) is the problem dimensionality. If it is possible to define a fitness function that can evaluate all \(N\) solutions at once, as is often possible when the fitness function is defined in terms of PyTorch operators, then significant speedups can be achieved by letting the low-level C implementation of PyTorch do much of the work. To demonstrate this, let's vectorize the sphere function from earlier:

from evotorch.decorators import vectorized


@vectorized
def vectorized_sphere(xs: torch.Tensor) -> torch.Tensor:
    return torch.sum(xs.pow(2.0), dim=-1)

By specifying that we want to sum across the last dimension (via dim=-1), we return an \(N\) dimensional vector of fitnesses, rather than a single fitness value. The decorator @vectorized informs the Problem instance that this fitness function is vectorized, and therefore expects to receive multiple solutions and returns fitnesses for all those solutions.

We are now ready to make our problem description around our vectorized fitness function:

problem = Problem(
    objective_sense="min",
    objective_func=vectorized_sphere,
    solution_length=10,
    initial_bounds=(-1, 1),
)

Creating Custom Problem Classes

While many fitness functions can be expressed as a callable function \(f: \mathbb{R}^d \rightarrow \mathbb{R}\) which can be passed to a Problem instance at instantiation using the objective_func keyword-argument, there are also many cases were we wish to create custom Problem classes which can be stateful and parameterisable. In this case, we can create a new class that inherits from the Problem class.

To demonstrate this, we will consider the \(d\)-dimensional Rastrigin problem, where the center of the function is offset by a randomly chosen vector \(x'\),

\[ f(x) = Ad + \sum_{i=1}^d z_i^2 - A \cos (2 \pi z_i). \]

where

\[ z = x - x' \]

We can create a new class which defines this problem and randomly chooses \(x'\) at instantiation. To do this, we only need to define the __init__ and _evaluate methods.

from evotorch import Problem, Solution
import torch
import math


class OffsetRastrigin(Problem):
    def __init__(self, d: int = 25, A: int = 10):
        super().__init__(
            objective_sense="min",
            solution_length=d,
            initial_bounds=(-1, 1),
        )

        # Store the A parameter for evaluation
        self._A = A
        # Generate a random offset with center 0 and standard deviation 1
        self._x_prime = self.make_gaussian(d, center=0.0, stdev=1.0)

    def _evaluate(self, solution: Solution):
        x = solution.values
        z = x - self._x_prime
        f = (self._A * self.solution_length) + torch.sum(
            z.pow(2.0) - self._A * torch.cos(2 * math.pi * z)
        )
        solution.set_evals(f)

This Problem class can be used just like any other, reparameterising it as needed

from evotorch.algorithms import SNES
from evotorch.logging import StdOutLogger

prob = OffsetRastrigin(d=14, A=5)
searcher = SNES(prob, stdev_init=5)
logger = StdOutLogger(searcher)
searcher.run(10)

Let's break down what is happening in the _evaluate method definition.

  1. This method receives an instance of Solution, the data type used to store and manipulate individual members of the population in EvoTorch.
  2. The Solution instance's values method is called, which returns the \(d\)-dimensional vector x that represents the solution.
  3. This vector x is evaluated according to the above formula to give fitness f.
  4. The Solution instance's set_evals method is called with argument f. This stores the fitness value f within the Solution instance so that it can be used by the searcher in the next iteration.

For more detail on interacting with Solution instances, please refer to the relevant advanced usage guide.

Vectorizing Custom Problems

Much like the base Problem class, it is straight-forward to introduce fitness vectorization when creating a custom Problem class. To do this, we simply override the _evaluate_batch method, rather than the _evaluate method.

from evotorch import SolutionBatch


class VecOffsetRastrigin(Problem):
    def __init__(self, d: int = 25, A: int = 10):
        super().__init__(
            objective_sense="min",
            solution_length=d,
            initial_bounds=(-1, 1),
        )

        # Store the A parameter for evaluation
        self._A = A
        # Generate a random offset with center 0 and standard deviation 1
        self._x_prime = self.make_gaussian((1, d), center=0.0, stdev=1.0)

    def _evaluate_batch(self, solutions: SolutionBatch):
        xs = solutions.values
        zs = xs - self._x_prime
        fs = (self._A * self.solution_length) + torch.sum(
            zs.pow(2.0) - self._A * torch.cos(2 * math.pi * zs), dim=-1
        )
        solutions.set_evals(fs)

All that has changed is that rather than receiving a Solution instance, we are now receiving a SolutionBatch instance which consists of \(N\) solutions. The call to values instead yields a \(N \times d\) tensor xs, and by appropriately rewriting the line that computes fs so that the result is a \(N\)-dimensional vector, we can straightforwardly set the fitness values of the entire batch of solutions with set_evals.

Working with Data Types and Devices

The Problem class supports different torch data types and devices, with the dtype and device keyword arguments, respectively. For example, we can specify that we wish to use 16-bit floating point values on the first available CUDA-capable device in the initialisation of the class.

One way to accelerate solution evaluation is to use CUDA-capable devices to compute the fitness values. In EvoTorch, this can be done easily using the device flag. By default, the device flag is set to 'cpu', so that the problem (and any searcher attached to it) will run everything on the CPU. Assuming there is at least one CUDA-capable device available, we can instead use,

problem = Problem(
    objective_sense="min",
    objective_func=vectorized_sphere,
    solution_length=10,
    initial_bounds=(-1, 1),
    dtype=torch.float16,
    device="cuda:0",
)

When working with different data types and device, EvoTorch searchers will use those data types and devices in their own computations to ensure that everything is compatible when the Problem instance is called. In practice, particularly in high dimensions and with large population sizes, using a CUDA-capable device can yield significant speedups. A particularly important example of this is in the case of neuroevolution.

Similarly, we can use different data types and devices within custom Problem classes:

class OffsetRastrigin16(Problem):
    def __init__(self, d: int = 25, A: int = 10):
        super().__init__(
            objective_sense="min",
            solution_length=d,
            initial_bounds=(-1, 1),
            dtype=torch.float16,
            device="cuda:0",
        )

        ...

For a similar reason, when we are creating torch tensors that we will use within the evaluation, it is also important to ensure they share the same data type and device. For this reason, Problem instances support a number of torch.Tensor generation methods out-of-the-box. Earlier, we used the method make_gaussian to generate a random center \(x'\) for the fitness evaluation to use. This method will generate a sample from a Gaussian distribution with the shape, center and standard deviation specified, but of particular relevance, it will ensure that the generated sample uses the device and data type associated with the Problem instance. There are a number of similar methods available, which can be found detailed in the API reference.