Iterated Local Search (ILS)

Iterated Local Search (ILS) is a meta-heuristic designed to overcome the problem of hill-climbing algorithms becoming stuck in local optima (good solutions, that are not the global optimum or best). ILS runs hill-climbing algorithms multiple times and stochastically climbs (or descends) the hill of local-optima. ILS has proven to be a highly effective meta-heuristic for the TSP.

We will implement ILS in Julia, enable parallel running of the algorithm and test it on a few different sized problems from TSPLib. You will see that it performs quite well! The problems we will test are: berlin52, st70 and ch150. All of these problem have known optimal solutions. This means we can take a look at how average performance of our ILS implementation.

A Jupyter notebook containing the full implementation is available at our Github repo

ILS Psuedo code

The code for ILS is fairly straightforward, but there is a couple of concepts you need to understand.

function iterated_local_search(init_solution)
    best = copy(init_solution)
    home = copy(init_solution)
    candidate = copy(init_solution)
    history = [home]

    while time_remains

        candidate = local_search(candidate)

        if quality(candidate) > quality(best)
            best = copy(candidate)
        end

        home = update_home(home, candidate)

        candidate, history = perturb(home, history)

    end

    return best
end

For an in-depth look a local_search see our snippets on routing and scheduling, first improvement and steepest ascent

Updating the home base

At the extreme’s update_home(home, candidate) might always return the current local optima (which is favouring exploration) or it might only accept a change if the current candidate is of a higher quality than the current home base (which is favouring exploitation). There are then many options in between. For example, epsilon-greedy where the algorithm explores 20% of the time and exploits 80% of the time. These three examples are coded below:

#exploration focussed
function random_homebase(home_base, home_cost, candidate, 
                         candidate_cost)
    return candidate, candidate_cost
end

#exploitation focussed
function higher_quality_homebase(home_base, home_cost, candidate, 
                                 candidate_cost)
    if candidate_cost > home_cost
        return candidate, candidate_cost
    else
        return home_base, home_cost
    end
end

#attempting to balance exploration and exploitation
function epsilon_greedy_homebase(home_base, home_cost, candidate, 
                                 candidate_cost)
    epsilon = 0.2
    u = rand(0:1)
    if u > epsilon
        return higher_quality_homebase(home_base, home_cost, candidate, 
                                       candidate_cost)
    else
        return random_homebase(home_base, home_cost, candidate, 
                               candidate_cost)
    end
end

Purtubation

In each iteration of ILS the algorithm needs to perturb it homebase. In conceptual terms, the idea is that this pertubation ‘jumps’ the starting solution over the current local optima (or hill) and provides an opportunity to find a new local optima on the other side. A good approach to handle this in the TSP is a 4-opt tweak known as the ‘double bridge’ move. In the TSP, a 4-opt move breaks four links. We then have four segments that we can recombine. There’s multiple ways to do this recombining and one easy to remember on is called the double bridge.

Conceptually you can think of a tour in 4 segments: A,B,C and D. If you tour was [1, 2, 3, 4, 5, 6, 7, 8] then these segments are A = [1, 2]; B = [3, 4]; C = [5, 6] and D = [7, 8]. A double bridge move recombines the tour to ADCB. To add a bit more exploration the implementation below varies the length of the segments (a bit).

"""
    double_bridge_tweak(tour)

Perform a 4-opt ("double bridge") move on a tour.  
Note: length of segments is stochastic.

Deterministic example:
If we pass the tour [1, 2, 3, 4, 5, 6, 7, 8]
    
A = [1, 2]
B = [3, 4]
C = [5, 6]
D = [7, 8]
    
original_tour = ABCD
double_bridge = ADCB
    
original_tour = [1, 2, 3, 4, 5, 6, 7, 8]
double_bridge = [1, 2, 7, 8, 5, 6, 3, 4]
    
# Arguments:

- tour: vector representing tour between cities e.g.
        [1, 2, 3, 4, 5, 6, 7, 8]

Returns:
--------
vector. representing the tour after stochastic double bridge swap
"""
function double_bridge_tweak(tour)
    
    n = length(tour)
    max_segment_length = convert(Int, floor(n/3))
    
    end1 = rand(1:max_segment_length) 
    end2 = end1 + rand(1:max_segment_length)
    end3 = end2 + rand(1:max_segment_length) 
        
    a = tour[1:end1]
    b = tour[end1+1:end2]
    c = tour[end2+1:end3]
    d = tour[end3+1:end]
        
    #useful to see how the function is splitting the array
    #println(a, b, c, d)
        
    return vcat(a, d, c, b)

end

Using history to increase the effectiveness of pertubation

Optionally ILS can include a fixed size memory of starting points for local_search. You can think of this memory as a ‘tabu list’ i.e. a list of tours that we are forbidden from using as an initial solution for local_search. In Julia we will implement this as a capacity constrained DataStructures.Deque. When our memory reaches capacity the oldest tabu tour will be forgotten. We need some custom code to implement this in Julia, but it is relatively simple:

"""
push history to Deque and pop old history

"""
function push_history!(tour_to_add, history, max_length)
    if length(history) == max_length
        removed = popfirst!(history)
    end

    push!(history, tour_to_add)
end

We will then create a function that calls both the double bridge and push history functions that our ILS code will interact with:

function tabu_double_bridge_tweak(tour, history, tabu_size)
    # perform stochastic double bridge
    candidate = four_opt_tweak(tour)
    
    #perturb until init solution is not in tabu list
    while candidate in history
        candidate = four_opt_tweak(tour)
    end
    
    #add to memory
    push_history!(candidate, history, tabu_size)
        
    return candidate, history
end

What size should the tabu list be? I’m afraid there are no hard and fast answers. It is a hyperparameter that needs to be tuned for your specific problem. You may even find that you don’t need to include memory.

Final ILS code

We now have all of the ingredients for our full ILS algorithm. The final Julia code listing is below. Note that the function accepts arguments that will vary its behaviour. For example, we can vary local_search, local_search_time_limit and update_home. The function has been setup to work on a maximum number of iterations, but it would be simple to adapt it to work for a given time limit instead.

function iterated_local_search(init_solution, matrix;
                               update_home=random_homebase, 
                               perturb=tabu_double_bridge_tweak, maxiter=50,
                               local_search=local_search,
                               local_search_tweak=simple_tweak!,
                               local_search_time_limit=2.0,
                               tabu_size=10)

    candidate = copy(init_solution)
    home =  copy(candidate)
    best = copy(candidate)
    history = DataStructures.Deque{Array}()
    
    home_cost = -tour_cost(init_solution, matrix)
    best_cost = home_cost
    
    for i in 1:maxiter
        
        #local search algorithm
        candidate_cost, candidate = local_search(candidate, matrix, 
                                                 tweak=local_search_tweak,
                                                 time_limit=local_search_time_limit)
        #is current iteration local optimum best result found?
        if candidate_cost > best_cost
            best_cost = candidate_cost
            best = copy(candidate)
        end
        
        # update homebase
        home, home_cost = update_home(home, home_cost, candidate, 
                                      candidate_cost)
        
        #take a big step away from homebase
        candidate, history = perturb(home, history, tabu_size)
    end
    
    return -best_cost, best
end

Testing out ILS

The data file and code listing are available in our Github repo. Note TSPLib’s distance matrix is integer values, ours are floats so we expect slight differences in answers.

Berlin52

TSPLib’s optimal solution for Berlin52 is 7542.

Random.seed!(42);

coords = trim_cities(berlin52, 52);
matrix = euclidean_distance_matrix(coords)
tour = [i for i in 1:52]
iter = 500

@time cost, solution = iterated_local_search(shuffle(tour), matrix, 
                                             local_search_tweak=tweak_two_opt!, 
                                             maxiter=iter);

println("Best tour length: $cost")

out:

0.205549 seconds (25.86 k allocations: 1.971 MiB, 3.82% gc time)
Best tour length: 7544.365901904087

st70

TSPLib’s optimal tour length for st70 is 675

Random.seed!(42);

coords = trim_cities(st70, 70);
matrix = euclidean_distance_matrix(coords)
tour = [i for i in 1:70]
iter = 2_000

@time cost, solution = iterated_local_search(shuffle(tour), matrix, 
                                             local_search_tweak=tweak_two_opt!, 
                                             maxiter=iter,
                                             update_home=epsilon_greedy_homebase);

println("Best tour length: $cost")

out:

 1.906342 seconds (108.84 k allocations: 9.561 MiB)
Best tour length: 677.1096092748469

ch150

TSPLib’s optimal tour length for ch150 is 6528

Random.seed!(42);

coords = trim_cities(ch150, 150);
matrix = euclidean_distance_matrix(coords)
tour = [i for i in 1:150]
iter = 5_000
tabu_size = 200

@time cost, solution = iterated_local_search(shuffle(tour), matrix, 
                                             local_search_tweak=tweak_two_opt!, 
                                             maxiter=iter,
                                             update_home=epsilon_greedy_homebase,
                                             tabu_size=200);

println("Best tour length: $cost")

out:

 51.499881 seconds (3.06 M allocations: 125.292 MiB, 0.03% gc time)
Best tour length: 6613.48777525181