For a problem I’m working on I got stuck onto the classical situation of local maximum. After trying to work around the problem in several more or less creative ways, I thought of the simulated annealing algorithm. Considering it’s been a while since I last saw it I tried to search for it on the web and surprisingly there is not much stuff about it, and the few bits I found are often contraddictory. After quite a lot of digging I decided to write about it here. As a warning I should probably say that there will be digging into some basic statistic and complexity analysis, as well as a quick formal introduction to the problem of the knapsack. You should be able to follow even if you don’t know nothing about those topics, but having some foundations in these areas would be of great help.
Let begin with the knapsack problem. This is a classic combinatorial computer science problem known to be NP-complete, meaning that the exact optimal solution cannot be found in polynomial time. This often means that most of the times we are happy of a good solution, assuming it’s not so far from the optimal one. In the simpliest possible terms you are a thief and you’re in a room with a set of objects that are worth something but you have only one knapsack, and that knapsack can carry at most a certain weight, so you have to choose carefully what objects to steal in order to maximize the earnings. For example consider the following situation: you can carry at most 5kg, and there is one laptop and a 4kg safe with pure diamonds within. You can’t carry both of them so you have to choose what’s better to carry on, the diamonds or the laptop. A smart thief would choose the diamonds since their value is considerably higher than the laptop.
There are few variations of the same problem but most common one is named “0-1”: you can’t split the weight over two or more carriers or bags but either you take the whole weight or you leave the object where it is. Mathematically talking, consider a set of objects, each item
is worth
and weights
with
. Then the goal is to maximize the following function:
But keeping the following constraint (being the maximum weight we can carry):
The first problem we have to face then is how we should generate the items and how the value of a solution should be calculated. The following example, as the other that will follow, it’s written in Python but it’s quite easy to understand so porting to another language wouldn’t be that hard. I chosen to generate 50 objects with values that range from 1 to 99$ (both ends included) using a uniform distribution (if you don’t know much about statistic, it means that all the values are equally distributed among the objects). The same with the weights except they range from 1 to 20 (the choice of the weight’s unit measure is left to you).
def generate_items(n_items=100): "Generate a list of items that could be stealed" items = [] for n in range(0, n_items): # use a uniform distribution both for values and for weights cost = random.randint(1, 100) weight = random.uniform(1, 20) items.append((cost, weight)) return items
So the items are nothing other than a list of pairs in the format for every object
. Probably a better and more realistic dataset would have used a normal distribution for the weights but it’s trivial to change the generation to function to work in that way. For our purposes the uniform distribution does its job.
As we said above the problem is NP-complete so we usually need to visit the whole search space to get the optimal solution which can be quite big when as the number of objects grows. Here comes the simulated annealing. We won’t visit the search space extensively, but we’d rather generate solutions. Indeed, it is a stochastic heuristic search algorithm. An heuristic is a function that measures how much something is good or bad, and stochastic means that we move more or less in a random way into the search space. In practical terms it’s not greedy as that it doesn’t always follow what the heuristic says but rather randomly search where the heuristic function points to. For example consider the needle in the haystack situation: an exaustive search method would take every straw piece, check that it’s not a needle, put it apart and repeat those moves until you don’t find the needle. You don’t want to proceed in this way, you’re more likely to end in less time if you look randomly in the haystack and if somethings stings you while you’re holding straw then search into that straw piece, because there may be the needle in there. In the knapsack problem we do have the heuristic, and it’s the function above that, for each solution (admissible or not), says how much it’s worth. In this case we define an admissible solution as one that it’s not too heavy.
An useful (and classic as well) example is the one of the blind hill climbing. You’re blind and stuck on a hill and you need to reach the top. A good principle that could lead you to the top is to touch the terrain and always follow the rising path. It could, because if you’re on a rock than you surely haven’t reached the top but the principle above doesn’t apply: you reached a local maximum (invert the things and you get the same thing for a local minimum). Simulated annealing avoids these problems by trying worsening moves from time to time: even if this may not sound like a good move it helps avoiding the problems we described above.

In the function above once in the middle we could choose to take the left maximum (which is a local maximum). Using hill climbing we’d be stuck on that because we wouldn’t try other paths.
Simulated annealing takes its name from the same process that metals go through when cooling from a melting point. Indeed, the cooling process consists of several particles that changes energy states (this statement may not be accurate or be inexact at all, but please forgive me as I never studied those things and I all know in this field comes from simulated annealing algorithm itself), in particular we can calculate the transiction probability from one state to another. Considering two energy states and
and a temperature
, switching from
to
has probability:
Where is a constant called Boltzmann’s constant. But then, how do we apply those statements to our problem (or a combinatorial search problem, in general)? The most important concept to grasp is the energy switching one. As a particle change state, a solution might change. Indeed for the knapsack problem there are many admissible solution, each one with an associated earning. Of course we’d prefer the one with the higher earnings (and simulated annealing will help us find that) but still it’s perfectly acceptable to go from a solution to another as long as the other solution continues to be admissible.
So here it is what the simulated annealing does: if you find a better item go on an take that path (under this circumnstance, behaves just like the hill climbing), otherwise change state with probability (you may notice that the Boltzmann’s constant is missing, indeed that constant applies mostly to thermodynamic when dealing with different metals). The effect that the temperature scaling has is that at higher temperatures it’ll try worsening moves quite often while on lower temperatures the probability to do worsening moves is lower so when temperature tends towards 0 it behaves quite like the hill climbing algorithm.
def simulated_annealing(solution, items, max_weight): "Apply the simulated annealing for solving the knapsack problem" best = solution best_value = compute_cost(solution, items)[0] current_sol = solution temperature = 1.0 while True: current_value = compute_cost(best, items)[0] for i in range(0, COOLING_STEPS): moves = generate_moves(current_sol, items, max_weight) idx = random.randint(0, len(moves) - 1) random_move = moves[idx] delta = compute_cost(random_move, items)[0] - compute_cost(best, items)[0] if delta > 0: best = random_move best_value = compute_cost(best, items)[0] current_sol = random_move else: if math.exp(delta / float(temperature)) > random.random(): current_sol = random_move temperature = TEMP_ALPHA * temperature if current_value >= best_value or temperature <= 0: break
And finally, that is the simulated annealing. You start from a temperature of 1.0 then you have a certain number of cooling steps, in every one of them you extract a random item from the neighbours and, if the item is better than the current best item then it becomes the new best item (and the new local solution). If the new item’s value is worst than the current best then update the current local solution with the probability expressed above. After the cooling steps the temperature is decreased with an exponential decay (usually it is ).
In the example above you don’t wait for the temperature to be 0 but you leave the loop if after all the cooling steps there hasn’t been any improvement. How big must be the number of cooling steps and the value it’s a fine tuning problem. A different approach that allow to get rid of the cooling steps is to make the temperature get cold slowly (
and
is a very small value like 0.01). Besides, a common improvement is to cache the moves within the cooling steps unless you found a new best value or changed state.
You can see that the most critical points are the neighbour generation and the cost computation. While the neighbour generation could be cached like I said above, the cost computation could be replaced with a probability estimate in order to reduce the time per cooling step.
But how do you apply the algorithm? If an empty solution is acceptable then you can just start with that and let the neighbour’s generator to create a solution, but usually you start with a greedy solution (found through the hill climbing, for example) or from a random one.
Here follows the complete code. I used 1000 cooling steps and a value of 0.8. I start from a random solution whose behaviour is not that bad considering how much time it takes to compute the solution. Indeed often random algorithms perform really well on combinatorial algorithms, see stochastic search for some more informations.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | #!/usr/bin/env python import math import operator import pprint import random import sys COOLING_STEPS = 1000 TEMP_ALPHA = 0.8 random.seed() def generate_items(n_items=100): "Generate a list of items that could be stealed" items = [] for n in range(0, n_items): # use a uniform distribution both for values and for weights cost = random.randint(1, 100) weight = random.uniform(1, 20) items.append((cost, weight)) return items def main(args): items = generate_items() pprint.pprint(items) start_sol = generate_random_solution(items, max_weight=40) print "Random solution: %s" % start_sol print "value: (cost: %d, weight: %f)" % compute_cost(start_sol, items) solution = simulated_annealing(start_sol, items, max_weight=40) print "Final solution: %s" % solution print "value: (cost: %d, weight: %f)" % compute_cost(solution, items) return False def generate_random_solution(items, max_weight): "Generate a starting random solution" # generate a random solution by adding a random item # until we don't get over the weight solution = [] while compute_cost(solution, items)[1] <= max_weight: idx = random.randint(0, len(items) - 1) # skip duplicates if idx not in solution: solution.append(idx) # last item makes us get over the weight so simply remove it # we'll look for better results after solution = solution[:-1] return solution def simulated_annealing(solution, items, max_weight): "Apply the simulated annealing for solving the knapsack problem" best = solution best_value = compute_cost(solution, items)[0] current_sol = solution temperature = 1.0 while True: current_value = compute_cost(best, items)[0] for i in range(0, COOLING_STEPS): moves = generate_moves(current_sol, items, max_weight) idx = random.randint(0, len(moves) - 1) random_move = moves[idx] delta = compute_cost(random_move, items)[0] - \ compute_cost(best, items)[0] if delta > 0: best = random_move best_value = compute_cost(best, items)[0] current_sol = random_move else: if math.exp(delta / float(temperature)) > random.random(): current_sol = random_move temperature = TEMP_ALPHA * temperature if current_value >= best_value or temperature <= 0: break return best def generate_moves(solution, items, max_weight): """ Generate all the ammissible moves starting from the input solution """ moves = [] # try to add another item and save as a possible move for idx, item in enumerate(items): if idx not in solution: move = solution[::] move.append(idx) if compute_cost(move, items)[1] <= max_weight: moves.append(move) # try to remove one item for idx, item in enumerate(solution): move = solution[::] del move[idx] if move not in moves: moves.append(move) return moves def compute_cost(solution, items): """ Return a tuple in the format (id_item1, id_item2, ...) for the input solution """ cost, weight = 0, 0 for item in solution: cost += items[item][0] weight += items[item][1] return (cost, weight) if __name__ == '__main__': sys.exit(main(sys.argv)) |
The results are suprising. For three different set of 100 items, each one with its own value and weight, here are the results:
$ python sa.py Random solution: [98, 71, 95] value: (cost: 44, weight: 27.001685) Final solution: [71, 95, 67, 9, 41, 33, 27] value: (cost: 229, weight: 39.791386) $ python sa.py Random solution: [38, 16, 62, 31] value: (cost: 124, weight: 36.863846) Final solution: [38, 16, 62, 31, 5] value: (cost: 194, weight: 38.970745) Random solution: [61, 44, 48, 38] value: (cost: 293, weight: 30.357135) Final solution: [61, 44, 48, 38, 37, 5, 2] value: (cost: 421, weight: 39.331549)
We usually don’t leave much free weight and the quality of the solutions found is quite good considering that the time required to compute that is ~0.5s.
Thank you for the valuable introduction to simulated annealing. I like the easy way you explain complex material.
Cheers
boto