Join%20Chat

logo

Vehicle Routing

This tutorial

  • Is related to Vehicle Routing

  • Shows an alternative implementation using numba.

  • Compares different optimization algorithms applied to this problem.

  • Extends the problem by adding a second objective: The maximal vehicle capacity.

All results were produced on a 16 core / 32 thread AMD CPU 5950x. The code for this tutorial is here: routing.py

Problem Statement

The original problem statement from Vehicle Routing:

"Delivery companies every day need to deliver packages to many different clients. The deliveries are accomplished using an available fleet of vehicles from a central warehouse. The goal of this exercise is to design a route for each vehicle so that all customers are served, and the number of vehicles (objective 1) along with the total traveled distance (objective 2) by all the vehicles are minimized. In addition, the capacity of each vehicle should not be exceeded (constraint 1)"

Due date, service time, and ready time are ignored.

Minimizing the number of vehicles and minimizing total travel distance are not competing objectives here. As a result, the Pareto front always contains a single element. Therefore we

  • Solve the single-objective variant independent of the vehicle number.

  • Define a 2-objective variant maximizing the vehicle number. This works, but it does not make much sense.

  • Define a useful 2-objective variant optimizing total travel distance and vehicle capacity.

Motivation

Vehicle Routing implements the vehicle routing problem with a custom individual encoding and decoding, together with custom mutation and crossover operations. This approach has several drawbacks:

  • All custom code must be adapted when the problem changes or is extended, for example to include due date, service time, or ready time. If we reduce the amount of problem-specific code, adaptation becomes much easier.

  • Defining additional / alternative objectives is difficult.

  • Low performance of the fitness function evaluation.

  • No automated parallelization.

If you execute the given code runAlgo.py with the provided parameterization, you do not get good results for the predefined vehicle capacity=70. I therefore changed numGen = 20000 (default is 200) and ran the code manually 16 times in parallel to use the CPU cores. After a few minutes I got:

Best individual is [12, 14, 16, 15, 19, 18, 23, 25, 24, 20, 17, 13, 11, 10, 1, 2, 4, 3, 5, 8, 9, 6, 7, 21, 22]
Number of vechicles required are 7.0
Cost required for the transportation is 378.5564153198204
  Vehicle 1's route: 0 - 12 - 14 - 16 - 0
  Vehicle 2's route: 0 - 15 - 19 - 18 - 0
  Vehicle 3's route: 0 - 23 - 25 - 24 - 20 - 0
  Vehicle 4's route: 0 - 17 - 13 - 11 - 10 - 0
  Vehicle 5's route: 0 - 1 - 2 - 4 - 3 - 5 - 0
  Vehicle 6's route: 0 - 8 - 9 - 6 - 7 - 0
  Vehicle 7's route: 0 - 21 - 22 - 0

...

Best individual is [5, 3, 4, 7, 13, 17, 22, 12, 14, 16, 15, 19, 18, 1, 2, 6, 21, 10, 11, 9, 8, 23, 25, 24, 20]
Number of vechicles required are 7.0
Cost required for the transportation is 404.1737778339401
  Vehicle 1's route: 0 - 5 - 3 - 4 - 7 - 0
  Vehicle 2's route: 0 - 13 - 17 - 22 - 0
  Vehicle 3's route: 0 - 12 - 14 - 16 - 0
  Vehicle 4's route: 0 - 15 - 19 - 18 - 0
  Vehicle 5's route: 0 - 1 - 2 - 6 - 0
  Vehicle 6's route: 0 - 21 - 10 - 11 - 9 - 8 - 0
  Vehicle 7's route: 0 - 23 - 25 - 24 - 20 - 0

The standard deviation of the results was high. I got several results > 400. This means:

  • Even with 20000 generations you have to be lucky.

  • Parallelization is manual, and you have to inspect all results to find a good one.

  • But at least I found a good result this way.

Let us see if we can do better.

Fast Fitness Function

The first step is a fast fitness function using numba. In combination with parallelization, this enables an evaluation rate > 1E6 / second. That makes single-objective optimization possible in less than 2 seconds, and multi-objective optimization in less than a minute.

@njit(fastmath=True)
def fitness_(seq, distance, demands, capacity):
    n = len(seq)
    seq += 1
    sum_demand = 0
    sum_dist = 0
    last = 0
    vehicles = 1
    for i in range(n+1):
        customer = seq[i] if i < n else 0
        demand = demands[customer]
        if sum_demand + demand > capacity:
            # end vehicle tour, return to base
            sum_dist += distance[last, 0]
            sum_demand = 0
            vehicles += 1
            last = 0
        # go to customer
        sum_demand += demand
        sum_dist += distance[last, customer]
        last = customer
    return np.array([-float(vehicles), sum_dist])

We use an encoding very similar to Vehicle Routing. The input is a sequence of unique integer vehicle indices. The vehicle tours are then determined "on the fly" when the capacity limit is exceeded. Up to this point we do not need classes, because this is easier with numba. We also leave all optimization-related work to predefined algorithms from the fcmaes library. There is no need to define problem-specific mutation and crossover operations.

We define a Routing class that hosts both the multi-objective and the single-objective variants of the fitness function, together with the demands array and the distance matrix. As boundaries we use the [0,1] interval and the np.argsort(x) trick to convert the continuous argument vector x into a sequence of unique integer indices. This lets us apply standard continuous optimization algorithms.

class Routing():
    def __init__(self, filename, capacity):
        self.capacity = capacity
        self.demands, self.distance = parse(filename)
        self.dim = len(self.demands) - 1
        self.bounds = Bounds([0]*self.dim, [1]*self.dim)

    def fitness(self, x):  # returns number of vehicles and cost
        return fitness_(np.argsort(x), self.distance, self.demands, self.capacity)

    def fitness_so(self, x): # returns the cost only
        return fitness_(np.argsort(x), self.distance, self.demands, self.capacity)[1]

This is much simpler than defining problem-specific crossover and mutation operations. So it is fair to ask: why use this approach at all? One reason is pedagogical. You get involved in the inner workings of the optimization process. Another is that the power of continuous optimization for discrete problems is often underestimated. There are not many open-source libraries that provide fast implementations of state-of-the-art algorithms and also parallelize well across many CPU cores.

For larger instances or more complex problem variants, you should definitely use a fast fitness function implementation and an optimizer that uses all CPU cores.

Single-Objective Optimization

We use a standard wrapper around the single-objective fitness function to monitor the progress of the parallel optimization. We then use the minimize_plot function, which not only optimizes, but also produces a plot of the progress over time.

def optimize_so(filename, capacity, opt, num_retries = 320):
    routing = Routing(filename, capacity)
    name = "routing." + str(opt.max_evaluations)
    ret = retry.minimize_plot(name, opt, wrapper(routing.fitness_so),
                               routing.bounds, num_retries = num_retries, logger=logger())
    routing.dump(np.argsort(ret.x), ret.fun)

capacity = 70
popsize = 128
max_evaluations = 100000

optimize_so(filename, capacity, Bite_cpp(max_evaluations))
#optimize_so(filename, capacity, Crfmnes_cpp(max_evaluations,popsize=popsize))
#optimize_so(filename, capacity, de_cma(max_evaluations,popsize=popsize))
#optimize_so(filename, capacity, De_cpp(max_evaluations,popsize=popsize))

We propose four different single-objective algorithms. BiteOpt is the best choice here. You do not need to tune the algorithm, because it supports "auto-configuration". The three other choices also produce good results very quickly.

Even when coding your problem-specific operations, such as crossover and mutation, in Assembler, it will not be easy to beat this result with capacity=70:

progress ret.routing.100000 bite cpp
tour  [23, 25, 24, 20, 22, 21, 16, 14, 12, 17, 13, 11, 10, 1, 2, 4, 3, 5, 7, 6, 9, 8, 15, 19, 18]
y  378.55641531982036
vehicle  1 tour [0, 23, 25, 24, 20, 0] demands 70.0 distance 34.242640687119284
vehicle  2 tour [0, 22, 21, 0] demands 40.0 distance 58.606204774901286
vehicle  3 tour [0, 16, 14, 12, 0] demands 70.0 distance 141.99635904571358
vehicle  4 tour [0, 17, 13, 11, 10, 0] demands 70.0 distance 213.37888633392356
vehicle  5 tour [0, 1, 2, 4, 3, 5, 0] demands 70.0 distance 255.79872525207853
vehicle  6 tour [0, 7, 6, 9, 8, 0] demands 70.0 distance 297.1455635058531
vehicle  7 tour [0, 15, 19, 18, 0] demands 70.0 distance 378.55641531982036

Multi-Objective Optimization

Minimizing the number of vehicles is not a competing goal here, so it makes more sense to compare results for different vehicle capacity settings. We add a new input variable that defines the capacity of all vehicles, set its boundaries to [40, 500], and return it as an objective together with the cost.

class Routing():
    def __init__(self, filename, capacity):
        ..
        self.bounds_capacity = Bounds([40] + [0]*(self.dim), [500] + [1]*self.dim)

    def fitness_capacity(self, x):
        y = fitness_(np.argsort(x[1:]), self.distance, self.demands, x[0])
        return np.array([x[0], y[1]])

def optimize_capacity(filename, popsize, max_evaluations, num_retries = 640):
    routing = Routing(filename, 0)
    x, y = modecpp.retry(mode.wrapper(routing.fitness_capacity, 2, interval = 10000000), 2, 0,
                 routing.bounds_capacity, popsize = popsize,
                 max_evaluations = max_evaluations,
                 nsga_update=True, num_retries = num_retries)
    pname = "routing." + str(popsize) + "." + str(max_evaluations)
    np.savez_compressed(pname, xs=x, ys=y)
    moretry.plot(pname, 0, x, y, all=False, interp=True)#, plot3d=True)
    routing.dump(np.argsort(x[-1][1:]), y[-1], y[-1][0])

The Pareto front shows that we could save some cost by increasing vehicle capacity.

front routing.256.200000

Pareto front:

[40.0, 618.3907115437548] [40.0, 0.47093, 0.49844, 0.39375, 0.49976, 0.40218, 0.55715, 0.35621, 0.64084, 0.58421, 0.67753, 0.65804, 0.14671, 0.69748, 0.13105, 0.17688, 0.19121, 0.00453, 0.05082, 0.05447, 0.94852, 0.74455, 0.83638, 0.73859, 0.88992, 0.78497]
...
[70.00009380697202, 378.55641531982036] [70.00009, 0.36217, 0.36317, 0.43649, 0.40161, 0.45219, 0.48159, 0.46036, 0.57442, 0.5652, 0.34037, 0.31426, 0.00848, 0.27692, 0.09889, 0.15106, 0.13469, 0.2391, 0.22958, 0.20722, 0.77681, 0.82719, 0.79605, 0.60043, 0.74514, 0.68503]
[80.0000014820427, 366.38436101504595] [80.0, 0.65229, 0.63874, 0.66709, 0.63649, 0.68901, 0.52217, 0.76201, 0.5186, 0.47152, 0.35066, 0.41905, 0.34831, 0.38981, 0.32899, 0.21383, 0.31939, 0.26637, 0.2593, 0.29995, 0.99657, 0.84268, 0.96297, 0.80748, 0.93927, 0.90448]
...
460.01098236218775, 132.12162500340892] [460.01098, 0.74831, 0.73675, 0.88968, 0.72019, 0.89847, 0.7128, 0.91608, 0.68842, 0.70344, 0.51361, 0.51281, 0.49429, 0.19855, 0.38483, 0.28595, 0.309, 0.24909, 0.26547, 0.27641, 0.00253, 0.02819, 0.0651, 0.1843, 0.11321, 0.14182]

tour  [20, 21, 22, 24, 25, 23, 13, 17, 18, 19, 15, 16, 14, 12, 11, 10, 8, 9, 6, 4, 2, 1, 3, 5, 7]
y  [460.01098236 132.121625  ]
vehicle  1 tour [0, 20, 21, 22, 24, 25, 23, 13, 17, 18, 19, 15, 16, 14, 12, 11, 10, 8, 9, 6, 4, 2, 1, 3, 5, 7, 0] demands 460.0 distance 132.12162500340892

Conclusion

A continuous optimizer can solve both the single- and multi-objective vehicle routing problem in a few seconds. Both coding and adaptation to more complex problem variants are much easier than with problem-specific crossover / mutation operations.