Join%20Chat

logo

One To Rule Them All

This tutorial

  • Applies a new combination of optimization algorithms, CR-FM-NES and BiteOpt, in sequence to different scheduling and packing problems of practical relevance: MMKP and VRPTW.

  • Compares the results for typical benchmark problems to reference solutions.

  • Shows how to define efficient fitness functions for these problems in Python.

  • Discusses when to apply the new method.

Motivation

While experimenting with problems that use discrete decision variables, such as scheduling and packing, two optimization algorithms stood out: CR-FM-NES and BiteOpt.

  • Minimal algorithmic overhead, even with a large number of decision variables (> 1000).

  • High diversity across multiple runs.

  • Good resilience on discontinuous, multi-modal, or noisy objective functions.

  • Good support for integer decision variables. We convert continuous variables using numpy’s argsort or astype(int).

CR-FM-NES converges faster. BiteOpt is better at escaping local minima. In a parallel retry setting, it is therefore natural to run CR-FM-NES first and then use its result as the initial guess for BiteOpt.

We tried this CR-FM-NES → BiteOpt sequence for Multi-UAV Task Assignment. There it outperformed several problem-specific algorithm implementations such as GA, PSO, and ACO.

In this tutorial, we extend that idea to MMKP and VRPTW, two very different problems with high practical relevance.

Do we need a problem specific optimization algorithm

Traditionally, even when heuristic methods are used, the optimizer is designed for one specific problem. See UAV1, for example, where three heuristic approaches, GA, ACO, and PSO, are compared. We already showed in a fork, UAV2, that results may improve when we give up this idea and apply a generic algorithm instead.

Packing, scheduling, and vehicle routing all have many practically relevant variants. Adapting and reimplementing successful algorithms for each new variant is demanding. In vehicle routing, more generic approaches already exist that cover multiple variants: A generic exact solver for vehicle routing and related problems. Better still would be to avoid creating a problem-specific algorithm entirely.

In that case, we only need an efficient implementation of the objective function, the "scoring" of a solution. That part can often be adapted more easily to many variants. Such a "one for all" approach is useful in several situations:

  • We face a very specific problem variant and do not have a good algorithm for it.

  • Our programming skills are sufficient to code an objective function in Python, but not to build a problem-specific solver.

  • We want to avoid learning the complex API of a problem-specific solver such as or-tools or OptaPlanner.

  • There is no good open source solution for our specific problem in the programming language we want to use.

  • We have multiple competing objectives and want the pareto front of non-dominated choices.

To study the tradeoff, "how much do we lose with the generic approach", we can test it on well-known problem variants where researchers had decades to improve specialized algorithms. MMKP and VRPTW are good candidates because they are important in practice and heavily studied. Still, we do not see these benchmark problems as the main application area of the approach.

Optimization needs to be "tweaked" to support parallelism

CPU vendors are investing in many-core architectures, and cloud computing offers affordable large-scale parallelism. This trend extends the reach of continuous optimization far beyond its traditional application areas. In this setting, optimization algorithms need different tuning. High diversity across repeated runs becomes much more important. This may explain why the CR-FM-NES → BiteOpt sequence performs better than the alternatives we tried.

Note: CR-FM-NES does not have this property in general. We need to lower the initial step size parameter input_sigma.

Note: The fcmaes implementation of parallelism works much better on Linux. On Windows, consider using the Linux subsystem for Windows WSL.

MMKP Multiple-choice Multidimensional Knapsack Problem

MMKP is a knapsack and packing problem of practical importance because several real-world optimization problems can be mapped to it. Examples are:

Problem description

We have n sets composed of mutually exclusive items. The goal is to select exactly one item from each set and maximize the overall value without violating a family of resource constraints. For each resource, the sum of the resource values of the chosen items must not exceed a resource-specific threshold.

See MMKP-Ants for the mathematical details.

Note: Hard multidimensional multiple choice knapsack problems states: "An instance is hard to solve when all classes contain the same profit vector and the weights are correlated with the profits". The selection of benchmark problems may influence the results.

Reference solutions

We use the well-known MMKP benchmark problems "I01-I13", which are cited in most related publications. The literature reports excellent results (see hybrid algorithm), but we did not find the corresponding solution instances. We therefore applied Simulated Annealing for 1E8 iterations with multiple retries to produce at least some kind of "reference solutions", which can be verified:

Table 1. MMKP benchmark results for simulated annealing
problem score deviation from optimum

I01

173.0

0.0

I02

364.0

0.0

I03

1597.0

0.31

I04

3581.0

0.44

I05

3905.7

0.0

I06

4799.3

0.0

I07

24410.0

0.8

I08

36577.0

0.89

I09

48742.0

0.92

I10

60902.0

0.95

I11

73139.0

0.89

I12

85261.0

0.97

I13

97712.0

0.75

Note that both hybrid algorithm and Hifi report better solutions. The latter used a 250 Mhz CPU with 128 Mb of RAM and generated an I13 result of 98429 in 160 seconds single-threaded. Please contact me if you know where the corresponding code can be found.

Benchmark results for continuous optimization

These benchmark results for continuous optimization were computed on a 16 core AMD 5950x CPU using 32 parallel threads with the code at mmkp.py:

Table 2. MMKP benchmark results for continuous optimization
problem score deviation from optimum time in seconds time to reach 2% deviation

I01

173.0

0.0

0.59

0.59

I02

364.0

0.0

0.22

0.22

I03

1602.0

0.0

124

20

I04

3572.0

0.7

416

21

I05

3905.7

0.0

0.67

0.57

I06

4799.3

0.0

0.95

0.8

I07

24232.0

1.53

451

8

I08

36411.0

1.34

1800

18

I09

48503.0

1.4

1859

23

I10

60611.0

1.42

1727

35

I11

72745.0

1.43

5713

51

I12

84928.0

1.36

3983

74

I13

97077.0

1.39

2342

95

As you can see, we lose about 0.5% accuracy compared to the reference solutions above. We still reach 2% accuracy in less than a minute, even for the larger instances. The following diagram shows the relation between the number of groups or decision variables and the time to reach 2% accuracy:

MMKP time

The relation is almost linear. That suggests we can handle even larger instances with reasonable effort.

Alternative approaches

Github repositories related to MMKP are:

  • Simulated Annealing C++ algorithm solving the problem slightly better than our approach. But it is MMKP-specific, and you need to implement some interface if you want to use it from Python.

  • MMKP Heuristics. A nice comparison of different older C++ algorithms, none of which seems to work better than Simulated Annealing.

Both hybrid algorithm and Hifi report better results, but there seems to be no related open source code available.

Implementation

The complete code for the MMKP problem is at mmkp.py. To apply continuous optimization, we represent a problem instance as Python class MMKP:

class MMKP():
    def __init__(self, problem):
        self.problem = problem
        filename = 'problems/' + problem
        self.n, self.l, self.m, self.best_val, self.best_sol,\
                self.avail, self.values, self.resources = parse(filename)
        self.dim = self.n
        self.bounds = Bounds([0]*self.dim, [self.l-1E-12]*self.dim)

    def fitness(self, x):
        vsum, penalty = fitness_(x.astype(int), self.n, self.l, self.avail, \
                self.values, self.resources)
        if penalty > 0:
            penalty += 100
        return self.deviation(vsum) + penalty

We extract the problem parameters by parsing the instance file:

  • Available resources: self.avail

  • Resource consumption for each item: self.resources

  • Value of each item: self.values

  • Reference solution value: self.best_val

  • Number of groups: self.n

  • Number of items to choose from per group: self.l

The number of decision variables, self.dim, is equal to the number of groups. The boundaries are [0, self.l-1E-12]. The fitness function maps each continuous decision vector to a vector of integers in the [0, self.l-1] interval using numpy’s astype(int). These integers represent the selected item for each group. fitness delegates to a fast numba function, fitness_, which checks resource consumption and returns the overall value together with a penalty value for resource violations.

@njit(fastmath=True)
def fitness_(x, n, l, avail, values, resources):
    vsum = 0
    rsum = np.zeros(l, dtype=numba.int32)
    for i in range(n):
        vsum += values[i][x[i]]
        rsum += resources[i][x[i]]
    rsum = np.maximum(rsum - avail, np.zeros(l, dtype=numba.int32))
    pen = np.sum(rsum)
    return vsum, pen

Problem variants

This function is much easier to adapt to problem variants than optimization algorithms that use the "internal structure" of a problem instance. Those algorithms usually support incremental changes of a solution by computing the score delta. See for instance saMultiChoiceKnapsack.cpp.

As an example, assume we want not only to maximize the sum of item values, but also to achieve balanced resource consumption. We can express this by the standard deviation of the consumed resources divided by their availability. Only a small modification of the fitness or objective function is required:

@njit(fastmath=True)
def fitness_(x, n, l, avail, values, resources):
    vsum = 0
    rsum = np.zeros(l, dtype=numba.int32)
    for i in range(n):
        vsum += values[i][x[i]]
        rsum += resources[i][x[i]]
    sdev = np.std(rsum/avail)
    rsum = np.maximum(rsum - avail, np.zeros(l, dtype=numba.int32))
    pen = np.sum(rsum)
    return vsum, pen, sdev

...
    def fitness(self, x):
        vsum, penalty, sdev = fitness_(x.astype(int), self.n, self.l, self.avail, \
                self.values, self.resources)
        if penalty > 0:
            penalty += 100
        return self.deviation(vsum) + penalty + 10*sdev

We applied a specific weight 10 to the standard deviation. For smaller problem instances, we could instead apply a multi-objective algorithm to generate the complete pareto front. That algorithm also comes with fcmaes and is used in many other tutorials.

Exercise

Apply the same modification to saMultiChoiceKnapsack.cpp. Hint: This may be a bit tricky. Note that this optimization algorithm is not only problem-specific, it is benchmark-specific. It exploits the fact that for most benchmarks the resource limit for all resources is equal. Here double ratio = value/weight the resource consumption weight is not normalized using the resource availability as it should be.

MMKP Optimization

Parallelization of optimization runs and of the optimization algorithm comes for free if we use the fcmaes library:

stop_fitness = 2.0
popsize = 500

opt = crfmnes_bite(max_evaluations, popsize=popsize, M=4, stop_fitness = stop_fitness)

def optimize(mmkp, opt, num_retries = 32):
    ret = retry.minimize(wrapper(mmkp.fitness),
                               mmkp.bounds, num_retries = num_retries,
                               stop_fitness = stop_fitness, optimizer=opt)
  • crfmnes_bite represents a sequence of CR-FM-NES and BiteOpt.

  • wrapper monitors and logs the progress for all parallel runs.

  • stop_fitness tells the algorithm to stop when a specific value or deviation is reached.

  • popsize and M are configuration parameters of the used optimizers.

These settings are sufficient if you aim for a 2% deviation from the optimum (stop_fitness = 2.0). A 1.5% deviation is much harder to achieve, so you may want to increase the number of retries, num_retries. The number of parallel retries depends on the CPU. For the AMD 5950x it is 32, and this can be overwritten using the workers argument. We experimented with smaller population sizes for smaller instances, but there is not much to gain. Both CR-FM-NES and BiteOpt are largely self-adapting.

VRPTW capacitated Vehicle Routing Problem with Time Windows

VRPTW is a variant of the vehicle routing problem for multiple vehicles with customer demands, capacity constraints, time windows, and customer service times. Because of its practical relevance, it is one of the best-studied optimization problems in computer science. There is a large body of literature, many sophisticated problem-specific algorithms, and benchmark sets with reference solutions proven to be optimal. See https://developers.google.com/optimization/routing/vrp for a nice introduction.

Open source libraries such as or-tools provide a vehicle-routing-specific API and produce nearly perfect results (see VRPTW results).

The "optimization without a problem-specific optimizer" approach should not be applied here if the standard problem is all you need. Still, it is interesting to measure how much we lose when we try it anyway. The code can also be adapted easily to other variants with additional constraints or different objectives. That is where this approach is most useful.

Problem description

We plan routes for a fleet of vehicles to serve a set of customer demands. Each vehicle has a capacity constraint on the total demand it serves. Each customer node has a time window, and customer-specific service times must be considered. Possible objectives are the number of vehicles used and the overall distance traveled by all vehicles. If only one vehicle is available, the problem becomes a variant of TSP, the Traveling Salesman Problem.

Possible variants include variable vehicle speed, noisy distances or demands, and additional constraints.

Benchmarks

Many different benchmarks are used in the literature. We choose the 100-customer instances of Solomon’s benchmark http://web.cba.neu.edu/~msolomon/problems.htm because reference solutions are available and the benchmark is cited in most related publications.

There are two different objectives for Solomon’s VRPTW benchmark:

  • Minimizing the overall distance or time needed to serve all customers: solomon.

  • A hierarchical objective that minimizes the number of vehicles, with distance as the secondary objective: sintef.

The single-objective variant can be solved almost perfectly using or-tools, so we choose that one. See optimize_or.py for the or-tools implementation used to generate the reference results for comparison here. We found other reference solutions at galgos, but some of them did not pass our validation. These solutions assume rounding of the distances, which makes them incompatible with the interpretation of the problem used here.

Alternative implementations

Besides optimize_or.py, there are many implementations for this problem. http://vrp.galgos.inf.puc-rio.br/index.php/en/links links to some of them. That is not the case for optimization without a problem-specific algorithm. Maybe that is because most continuous optimization algorithms do not work well here.

Implementation

The complete code for the VRPTW problem is at vrptw.py. To apply continuous optimization, we represent a problem instance as Python class VRPTW:

class VRPTW():
    def __init__(self, problem):
        self.problem = problem
        filename = 'problems/' + problem + '.txt'
        self.vnumber, self.capacity, self.dtime, self.demand, self.ready,\
            self.due, self.service = parse_problem(filename)
        self.dim = len(self.demand) - 1
        self.bounds = Bounds([0]*self.dim, [1]*self.dim)

    def fitness(self, x):
        fit = fitness_(np.argsort(x), self.capacity, self.dtime, self.demand, \
                    self.ready, self.due, self.service)
        return 10*fit[0] + fit[1]

We extract the problem parameters by parsing the instance file:

  • Vehicle capacities: self.capacity

  • Distance matrix: self.dtime

  • Customer demand: self.demand

  • Customer is ready time: self.ready

  • Customer due time: self.due

  • Customer service time: self.service

The number of decision variables, self.dim, is equal to the number of customer locations. Note that all tables include a 0-entry for the start and end location. We use the [0, 1] interval as boundary. The fitness function converts the continuous argument vector into a list of unique integers using np.argsort(x). fitness then delegates to a fast numba function, fitness_, which executes all tours and evaluates both the objectives and the constraints.

Instead of ignoring the vehicle number, we include it with some weight, even though we are ultimately interested only in the overall distance. This acts as a heuristic that supports the optimization process. We observed that good solutions usually use a fairly low number of vehicles.

@njit(fastmath=True)
def fitness_(seq, capacity, dtime, demands, readys, dues, services):
    n = len(seq)
    seq += 1
    sum_demand = 0
    sum_dtime = 0
    time = 0
    last = 0
    vehicles = 1
    for i in range(0, n+1):
        customer = seq[i] if i < n else 0
        demand = demands[customer]
        ready = readys[customer]
        due = dues[customer]
        service = services[customer]
        if sum_demand + demand > capacity or \
                time + dtime[last, customer] > due:
            # end vehicle tour, return to base
            dt = dtime[last, 0]
            sum_dtime += dt
            time = 0
            sum_demand = 0
            vehicles += 1
            last = 0
        # go to customer
        dt = dtime[last, customer]
        time += dt
        if time < ready:
            time = ready
        time += service
        sum_demand += demand
        sum_dtime += dt
        last = customer
    return np.array([float(vehicles), sum_dtime])

VRPTW Optimization

You will probably notice that this code is almost exactly the same as for MMKP above. Even the popsize parameter is the same.

popsize = 500
opt = crfmnes_bite(max_evaluations, popsize=popsize, M=4)

def optimize(vrptw, opt, num_retries = 64):
    ret = retry.minimize(wrapper(vrptw.fitness),
                        vrptw.bounds, num_retries = num_retries, optimizer=opt)
  • crfmnes_bite represents a sequence of CR-FM-NES and BiteOpt.

  • wrapper monitors and logs the progress for all parallel runs.

  • popsize and M are configuration parameters of the used optimizers.

Exercise

Modify the fitness function to handle a problem variant with noisy demands. For example, when using demands[customer], multiply it with a random factor in the [0.8,1.2] interval. You then need to call fitness_ multiple times and use a "worst case" value as the fitness. See link:TSP.html where this was done for TSP. Can you do the same using or-tools by modifying optimize_or.py?

Results

Continuous optimization is performed by a sequence of CR-FM-NES and BiteOpt, with 64 runs in total and 32 runs performed in parallel. On an AMD 5950x 16 core CPU, this takes about 7 minutes.

Compared to the or-tools result, which serves as the reference, we lose about 0.2% on the clustered problem instances and about 3% on the random problem instances.

Table 3. Average distance single objective
optimizer C1 C2 R1 R2 RC1 RC2

or-tools

828.4

589.9

1182.6

878.0

1360.9

1005.3

continuous

829.3

591.8

1221.6

909.7

1384.2

1035.2

%difference

0.11

0.34

3.3

3.61

1.71

2.97

Table 4. Average number of vehicles single objective
optimizer C1 C2 R1 R2 RC1 RC2

or-tools

10.0

3.0

13.33

5.45

13.12

6.25

continuous

10.0

3.0

14.0

5.36

13.88

6.75

%difference

0.0

0.0

5.0

-1.67

5.71

8.0

Table 5. Continuous single objective results compared to or-tools
problem vehicles distance % vehicles difference % distance difference

c101

10

828.9

0.0

-0.0

c102

10

828.9

0.0

0.0

c103

10

830.2

0.0

0.26

c104

10

831.1

0.0

0.77

c105

10

828.9

0.0

-0.0

c106

10

828.9

0.0

-0.0

c107

10

828.9

0.0

-0.0

c108

10

828.9

0.0

-0.0

c109

10

828.9

0.0

-0.0

c201

3

591.6

0.0

-0.0

c202

3

591.6

0.0

0.0

c203

3

594.7

0.0

0.6

c204

3

603.0

0.0

2.09

c205

3

588.9

0.0

-0.0

c206

3

588.5

0.0

-0.0

c207

3

588.3

0.0

0.0

c208

3

588.3

0.0

0.0

r101

20

1670.4

0.0

1.64

r102

18

1501.8

0.0

1.97

r103

15

1246.5

7.14

2.71

r104

12

1024.1

9.09

4.1

r105

16

1407.9

6.67

3.46

r106

14

1289.2

7.69

3.91

r107

12

1119.3

9.09

3.88

r108

11

990.1

0.0

3.87

r109

14

1202.8

7.69

4.42

r110

13

1116.0

8.33

3.0

r111

12

1083.0

0.0

2.69

r112

11

1008.6

10.0

5.53

r201

8

1188.0

0.0

3.49

r202

6

1067.7

-25.0

3.01

r203

6

908.7

0.0

3.78

r204

5

766.7

0.0

4.2

r205

5

978.8

0.0

2.38

r206

4

918.8

-20.0

4.23

r207

4

835.5

0.0

4.69

r208

4

741.4

0.0

4.99

r209

6

883.8

20.0

2.78

r210

7

934.6

16.67

3.29

r211

4

783.1

0.0

3.59

rc101

17

1673.5

0.0

1.59

rc102

15

1490.4

7.14

0.8

rc103

13

1312.2

8.33

-0.54

rc104

11

1190.5

10.0

3.45

rc105

17

1576.5

6.25

2.9

rc106

13

1401.8

0.0

1.15

rc107

13

1258.1

8.33

1.77

rc108

12

1170.6

9.09

2.96

rc201

9

1297.6

0.0

2.51

rc202

8

1124.3

0.0

2.54

rc203

6

974.5

20.0

4.2

rc204

5

828.5

25.0

5.35

rc205

7

1176.2

0.0

1.6

rc206

7

1092.5

0.0

3.59

rc207

7

982.9

16.67

1.71

rc208

5

805.4

25.0

3.25

Conclusion

From these results we can conclude:

  • Standard tools like or-tools are hard to beat on the problems they were designed for.

  • For variants with additional constraints, objectives, or noisiness, we first need to check whether those standard tools still apply.

  • Think twice before developing a problem-specific algorithm. It may not be worth the effort if you are not aiming for a perfect solution.

  • The penalty for using a generic optimization method that only requires a fast fitness implementation may be lower than expected, especially when parallelism and recent advances in continuous optimization are taken into account.

  • The CR-FM-NES and BiteOpt sequence proved to be an excellent choice for both vehicle routing (VRPTW) and packing (MMKP) if you want to use Python and take advantage of a modern many-core CPU or multi-node cloud computing resources.