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
which is implemented in PyTorch as,
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,
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
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'\),
where
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.
- This method receives an instance of Solution, the data type used to store and manipulate individual members of the population in EvoTorch.
- The Solution instance's
values
method is called, which returns the \(d\)-dimensional vectorx
that represents the solution. - This vector
x
is evaluated according to the above formula to give fitnessf
. - The Solution instance's
set_evals
method is called with argumentf
. This stores the fitness valuef
within the Solution instance so that it can be used by thesearcher
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.