Simulated Annealing Lab
While the search is “hot,” simulated annealing is willing to climb hills to see what lies behind them; as it cools, only downhill steps remain.
Based on Chapter 5, “Simulated annealing,” in Alaa Khamis, Optimization Algorithms: AI techniques for design, planning, and control problems, Manning Publications, 2024 (ISBN 978-1633438835).
Simulated annealing on a rugged landscape.
Trajectory-based search and physical annealing
Simulated annealing (SA) sometimes accepts worse moves, less often as it cools, so it can leave a local valley.
Section 5.1 places SA among the metaheuristics (Figure 5.1), and Table 5.1 maps physical annealing onto the algorithm.
Physical annealing: slow cooling vs. quenching
Press Start: the left box cools slowly, the right one at once. Which reaches crystal order (gold) and lower energy?
Figures 5.2 and 5.3 in Section 5.2.1 explain why slow cooling yields a crystal and quenching does not.
Slow cooling vs. fast cooling
64 atoms · Metropolis ruleTemperature over time. The quench drops to cold almost at once.
Energy per atom over time (lower is more stable).
Press Start to heat both boxes and cool them.
The SA algorithm
Each iteration tries a random neighbor, always keeps it if it is better, and otherwise keeps it with probability e−Δf/T; then the temperature drops.
Section 5.2.2 explains SA as a Markov chain (Figure 5.4) and surveys its variants, including quantum annealing.
Acceptance probability
A worse move is accepted when
p = e−Δf/T; accept a worse move if p > r, r uniform in [0, 1]
Move the sliders, then draw random numbers to see which moves pass.
Section 5.2.3 derives this rule from physics and shows cheaper forms (Figure 5.6).
To accept or not to accept?
MinimizationThe annealing schedule
The schedule sets the starting temperature, how it decreases, the iterations per temperature and the final temperature.
Figure 5.7 and Section 5.2.4 discuss how to pick each of these four settings.
Initial temperature
T0 ≈ −max(Δf) / ln p0
Set max(Δf) and p0, or run the warm-up on Berlin52.
Equation 5.5 and the text around it explain both ways of choosing T0.
Choosing T₀
Share of worse 2-opt moves accepted at each temperature (log scale on x).
Cooling schedules
Tune T0 and each schedule’s factor to compare how fast they cool.
Cooling schedule explorer
Figure 5.8Adaptation in SA
SA can adapt its schedule, for example by reheating when it stalls; try it in the Berlin52 lab.
Section 5.2.5 covers reheating, nonmonotonic cooling (equation 5.12) and Figure 5.9.
Function optimization
Hand iterations: minimize (x − 6)² on [0, 31]
Press Next iteration to follow each candidate, its Δf and the accept or reject decision.
Section 5.3 works these first iterations out by hand, with every random number shown.
Step through SA
T₀ = 5 · α = 0.85 · 2 iterations per TFunction lab
Pick a function, then Run or step one temperature at a time.
Listings 5.1 and 5.2 solve these functions with SciPy, MEALPY and the book’s solver.
Continuous SA lab
Minimize fTemperature at each level.
Current (faint) and best value.
Sudoku
A move swaps two cells inside a box; the cost counts digits missing from rows and columns. Pick a puzzle and press Run.
Listing 5.3 solves Sudoku with SA, and Table 5.2 compares it with backtracking and PuLP.
Sudoku with SA
Minimize row and column conflictsCost (missing digits in rows and columns) as SA runs.
Bold digits on gray are given. Highlighted cells repeat a digit in their row or column.
The traveling salesman problem
Five cities (Figures 5.11 to 5.14)
Step through SA with swap moves on a five-city tour and watch which swaps are accepted.
Figures 5.11 to 5.14 work this example through by hand, iteration by iteration.
5-city walkthrough
T₀ = 500 · T = T − 50 · swap movesBerlin52 lab (Listing 5.5)
Choose a neighborhood and schedule, then run SA on the 52 Berlin locations.
Listing 5.5 gives the book’s Berlin52 settings and compares its result with the optimum.
Berlin52 SA lab
Minimize tour lengthCurrent (faint) and best tour per level; dashed: the optimum and the book’s result.
Plant layout
Place six departments on a 2 × 3 grid to minimize TMHC = Σ flow × distance. A move swaps two departments.
The book works the first swaps of this example by hand.
Plant layout with SA
Minimize TMHCScheduling
Minimize weighted tardiness on one machine and the makespan on three; press Next iteration in each.
The book defines the main scheduling objectives and solves both examples by hand.
One machine, weighted tardiness
Three machines, makespan
Six jobs (times 2, 3, 4, 6, 2, 2); each job gets a machine, and every machine at least one job.
More applications
Section 5.6 solves the delivery routing case on real road data, and appendix C adds the US-cities TSP examples.
Delivery semi-truck routing
Route a truck to 18 Walmart stores in the Greater Toronto Area on real roads.
Python in Google Colab
Open the book’s notebooks in Colab, or the companion notebook with the from-scratch code below.
Notebooks
From-scratch implementations
# Acceptance probability, initial temperature and cooling schedules (Chapter 5, Sections 5.2.3 and 5.2.4) import math import numpy as np import matplotlib.pyplot as plt def accept_probability(delta_f, T): """Metropolis criterion: improving moves (delta_f < 0) are always accepted.""" return 1.0 if delta_f < 0 else math.exp(-delta_f / T) print("p(9.17, T=5) =", round(accept_probability(23.23 - 14.06, 5), 4)) # 0.1597, the book's hand iteration # Initial temperature for a target acceptance probability p0 of the largest expected increase (equation 5.5) max_delta, p0 = 50, 0.8 print("T0 =", round(-max_delta / math.log(p0), 1)) def temperature(i, schedule, T0=100, Tf=0.001, N=100, alpha=0.9, n=1): if schedule == "linear": # T = T0 - beta i, beta = (T0 - Tf) / N return T0 - (T0 - Tf) / N * i if schedule == "linear_inverse": # T = T0 / (1 + alpha i) return T0 / (1 + alpha * i) if schedule == "geometric": # T = T0 alpha^i return T0 * alpha ** i if schedule == "logarithmic": # T = T0 / (1 + alpha ln(1 + i)), alpha > 1 return T0 / (1 + alpha * math.log(1 + i)) if schedule == "exponential": # T = T0 exp(-alpha i^(1/n)), n = dimension return T0 * math.exp(-alpha * i ** (1 / n)) raise ValueError(schedule) i = np.arange(101) for s, a in [("linear", 0), ("geometric", 0.7), ("geometric", 0.9), ("logarithmic", 10), ("exponential", 0.9), ("linear_inverse", 0.9)]: plt.plot(i, [temperature(k, s, alpha=a) for k in i], label=f"{s}" + (f", alpha = {a}" if a else "")) plt.xlabel("iteration"); plt.ylabel("temperature"); plt.legend(); plt.title("SA cooling schedules (Figure 5.8)"); plt.show() # Acceptance probability against the objective change for several temperatures (Figure 5.5) d = np.linspace(0, 10, 200) for T in [0.5, 1, 2, 5, 10]: plt.plot(d, np.exp(-d / T), label=f"T = {T}") plt.xlabel("objective change Δf"); plt.ylabel("acceptance probability"); plt.legend(); plt.show()
# Simulated annealing for continuous functions (Chapter 5, Section 5.3) import math import numpy as np def simulated_annealing(f, bounds, step, T0=1000, Tf=1e-4, alpha=0.9, iters_per_temp=100, max_temps=1000, x0=None, seed=0): """Geometric cooling; Gaussian neighbors of standard deviation `step`, clipped to the bounds.""" rng = np.random.default_rng(seed) lo, hi = np.array(bounds, float).T x = rng.uniform(lo, hi) if x0 is None else np.array(x0, float) fx = f(*x) best, best_f = x.copy(), fx T, k = T0, 0 while T > Tf and k < max_temps: for _ in range(iters_per_temp): cand = np.clip(x + rng.normal(0, step, len(x)), lo, hi) fc = f(*cand) if fc < fx or rng.random() < math.exp(-(fc - fx) / T): # Metropolis acceptance x, fx = cand, fc if fx < best_f: best, best_f = x.copy(), fx k += 1 T = T0 * alpha ** k # geometric cooling return best, best_f # The book's hand iterations: f(x) = (x - 6)^2, T0 = 5, two iterations per temperature, alpha = 0.85 f = lambda x: (x - 6) ** 2 T, x = 5, 2.0 for k, (cand, r) in enumerate([(2.25, None), (1.18, 0.37), (3.82, None)]): if k == 2: T = 5 * 0.85 # cool after two iterations: 4.25 d = f(cand) - f(x) p = 1 if d < 0 else math.exp(-d / T) accepted = d < 0 or p > r print(f"iteration {k + 1}: x' = {cand}, f = {f(cand):.2f}, p = {p:.4f}, accepted = {accepted}") if accepted: x = cand bohachevsky = lambda x1, x2: x1**2 + 2 * x2**2 - 0.3 * np.cos(3 * np.pi * x1) - 0.4 * np.cos(4 * np.pi * x2) + 0.7 bukin = lambda x1, x2: 100 * math.sqrt(abs(x2 - 0.01 * x1**2)) + 0.01 * abs(x1 + 10) gramacy_lee = lambda x: math.sin(10 * math.pi * x) / (2 * x) + (x - 1) ** 4 for name, fn, b, s in [("quadratic", f, [[0, 31]], 1), ("Bohachevsky", bohachevsky, [[-100, 100]] * 2, 5), ("Bukin", bukin, [[-15, -5], [-3, 3]], 5), ("Gramacy & Lee", gramacy_lee, [[0.5, 2.5]], 0.1)]: x, fx = simulated_annealing(fn, b, s) print(f"{name}: x = {np.round(x, 4)}, f = {fx:.4f}")
# Simulated annealing for Sudoku (Chapter 5, Section 5.4) # Each 3x3 box is filled with its missing digits, so boxes are always valid. A move swaps two non-fixed # cells in the same box; the cost counts the digits missing from every row and column (0 = solved). import math import numpy as np PUZZLE = np.array([ # the "hard" puzzle of Listing 5.3 [9, 0, 0, 1, 0, 0, 0, 5, 4], [0, 0, 0, 0, 8, 0, 0, 0, 0], [0, 0, 5, 0, 0, 9, 0, 0, 3], [0, 9, 0, 0, 3, 5, 0, 4, 1], [0, 0, 0, 0, 1, 0, 0, 0, 0], [4, 1, 0, 2, 6, 0, 0, 8, 0], [7, 0, 0, 3, 0, 0, 1, 0, 0], [0, 0, 0, 0, 4, 0, 0, 0, 0], [3, 5, 0, 0, 0, 1, 0, 0, 6]]) BOXES = [[(r, c) for r in range(br, br + 3) for c in range(bc, bc + 3)] for br in (0, 3, 6) for bc in (0, 3, 6)] def cost(g): return sum(9 - len(set(g[i, :])) + 9 - len(set(g[:, i])) for i in range(9)) def fill_boxes(puzzle, rng): g = puzzle.copy() for box in BOXES: missing = list(set(range(1, 10)) - {g[r, c] for r, c in box}) rng.shuffle(missing) for r, c in box: if g[r, c] == 0: g[r, c] = missing.pop() return g def solve(puzzle, T0=0.5, alpha=0.99999, max_iter=400_000, restart_after=40_000, seed=0): rng = np.random.default_rng(seed) free = [[cell for cell in box if puzzle[cell] == 0] for box in BOXES] g = fill_boxes(puzzle, rng) c, T, stall = cost(g), T0, 0 for it in range(max_iter): if c == 0: return g, it box = free[rng.integers(9)] if len(box) < 2: continue a, b = rng.choice(len(box), 2, replace=False) (r1, c1), (r2, c2) = box[a], box[b] g[r1, c1], g[r2, c2] = g[r2, c2], g[r1, c1] new = cost(g) if new <= c or rng.random() < math.exp(-(new - c) / T): stall = stall + 1 if new >= c else 0 c = new else: g[r1, c1], g[r2, c2] = g[r2, c2], g[r1, c1] # undo the swap stall += 1 T *= alpha if stall > restart_after: # reheat when the search is frozen T, stall = T0, 0 return g, max_iter g, it = solve(PUZZLE) print("solved" if cost(g) == 0 else f"cost {cost(g)}", "after", it, "iterations") print(g)
# Simulated annealing for the TSP (Chapter 5, Section 5.5) import itertools import math import numpy as np import matplotlib.pyplot as plt # The 5-city example of Figure 5.11 d = {(1, 2): 4, (1, 3): 2, (1, 4): 9, (1, 5): 12, (2, 3): 7, (2, 4): 5, (2, 5): 5, (3, 4): 4, (3, 5): 10, (4, 5): 3} dist = lambda a, b: d[(min(a, b), max(a, b))] tour_len = lambda t: sum(dist(t[k], t[(k + 1) % len(t)]) for k in range(len(t))) print("[1, 3, 4, 2, 5]:", tour_len([1, 3, 4, 2, 5]), " swap 2-3:", tour_len([1, 2, 4, 3, 5]), " swap 2-5:", tour_len([1, 3, 4, 5, 2])) # 28, 35, 18 print("optimum:", min(tour_len((1,) + p) for p in itertools.permutations([2, 3, 4, 5]))) # Berlin52 (optimal tour 7,542) XY = np.array([[565, 575], [25, 185], [345, 750], [945, 685], [845, 655], [880, 660], [25, 230], [525, 1000], [580, 1175], [650, 1130], [1605, 620], [1220, 580], [1465, 200], [1530, 5], [845, 680], [725, 370], [145, 665], [415, 635], [510, 875], [560, 365], [300, 465], [520, 585], [480, 415], [835, 625], [975, 580], [1215, 245], [1320, 315], [1250, 400], [660, 180], [410, 250], [420, 555], [575, 665], [1150, 1160], [700, 580], [685, 595], [685, 610], [770, 610], [795, 645], [720, 635], [760, 650], [475, 960], [95, 260], [875, 920], [700, 500], [555, 815], [830, 485], [1170, 65], [830, 610], [605, 625], [595, 360], [1340, 725], [1740, 245]], float) n = len(XY) D = np.rint(np.hypot(*(XY[:, None, :] - XY[None, :, :]).transpose(2, 0, 1))) def anneal(T0=150, Tf=0.01, alpha=0.995, iters_per_temp=500, move="reverse", seed=0): rng = np.random.default_rng(seed) t = list(rng.permutation(n)) L = sum(D[t[k], t[(k + 1) % n]] for k in range(n)) best, best_L, T, hist = t[:], L, T0, [] while T > Tf: for _ in range(iters_per_temp): i, j = sorted(rng.choice(n, 2, replace=False)) if move == "reverse": # 2-opt: reverse t[i..j] a, b, c, e = t[i - 1], t[i], t[j], t[(j + 1) % n] delta = D[a, c] + D[b, e] - D[a, b] - D[c, e] if (i, j) != (0, n - 1) else 0 else: # swap two cities s = t[:]; s[i], s[j] = s[j], s[i] delta = sum(D[s[k], s[(k + 1) % n]] for k in range(n)) - L if delta < 0 or rng.random() < math.exp(-delta / T): if move == "reverse": t[i:j + 1] = t[i:j + 1][::-1] else: t = s L += delta if L < best_L: best, best_L = t[:], L hist.append(best_L) T *= alpha # geometric cooling return best, best_L, hist best, L, hist = anneal() print(f"Berlin52 best tour: {L:.0f} (optimum 7,542; the book's run gives 8,106.88)") plt.plot(*XY[best + best[:1]].T, "o-", ms=3); plt.title(f"Berlin52, length {L:.0f}"); plt.show()
# Simulated annealing for a plant layout problem: six departments A-F on six locations of a 2 x 3 grid # Total material handling cost TMHC = sum of flow(i, j) * distance(location(i), location(j)). import itertools import math import numpy as np FLOW = np.array([ # from (rows) / to (columns) departments A..F [0, 50, 100, 0, 0, 0], [0, 0, 35, 25, 100, 90], [0, 75, 0, 60, 75, 25], [0, 50, 50, 0, 100, 25], [0, 75, 50, 140, 0, 10], [0, 0, 0, 0, 0, 0]]) POS = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)] # locations 1-3 on top, 4-6 below DIST = np.array([[abs(a[0] - b[0]) + abs(a[1] - b[1]) for b in POS] for a in POS]) # rectilinear distances NAMES = "ABCDEF" def tmhc(loc): # loc[i] = location index of department i return int((FLOW * DIST[np.ix_(loc, loc)]).sum()) init = [0, 1, 2, 3, 4, 5] # A on location 1, B on 2, ... print("initial TMHC:", tmhc(init)) # 1770 def swap(loc, a, b): loc = loc.copy(); loc[a], loc[b] = loc[b], loc[a] return loc # The worked iterations: T0 = 100, alpha = 0.85, two iterations per temperature T, cur = 100, init for a, b, r in [("B", "D", 0.2), ("A", "D", 0.6)]: cand = swap(cur, NAMES.index(a), NAMES.index(b)) delta = tmhc(cand) - tmhc(cur) p = 1 if delta < 0 else math.exp(-delta / T) print(f"swap {a}-{b}: TMHC {tmhc(cand)}, p = {p:.3f}, r = {r} ->", "accept" if p > r else "reject") if p > r: cur = cand def anneal(T0=100, Tf=0.01, alpha=0.85, iters_per_temp=20, seed=0): rng = np.random.default_rng(seed) cur = list(rng.permutation(6)); c = tmhc(cur) best, best_c, T = cur, c, T0 while T > Tf: for _ in range(iters_per_temp): a, b = rng.choice(6, 2, replace=False) cand = swap(cur, a, b); cc = tmhc(cand) if cc < c or rng.random() < math.exp(-(cc - c) / T): cur, c = cand, cc if c < best_c: best, best_c = cur, c T *= alpha return best, best_c best, c = anneal() print("SA layout:", {NAMES[i]: best[i] + 1 for i in range(6)}, "TMHC:", c) print("optimum by enumeration:", min(tmhc(list(p)) for p in itertools.permutations(range(6))))
# Simulated annealing for two scheduling problems import itertools import math import numpy as np # 1. Single machine, minimize the total weighted tardiness sum w_j max(0, C_j - d_j) P, DUE, W = [9, 9, 12, 3], [10, 8, 5, 28], [14, 12, 1, 12] def weighted_tardiness(seq): # seq holds job numbers 1..4 t = total = 0 for j in seq: t += P[j - 1] total += W[j - 1] * max(0, t - DUE[j - 1]) return total print("3-1-4-2:", weighted_tardiness([3, 1, 4, 2]), " 1-3-4-2:", weighted_tardiness([1, 3, 4, 2]), " 1-4-3-2:", weighted_tardiness([1, 4, 3, 2])) # 461, 316, 319 print("p(+3, T = 0.9) =", round(math.exp(-3 / 0.9), 4)) # 0.0357 best = min(itertools.permutations([1, 2, 3, 4]), key=weighted_tardiness) print("optimal sequence:", best, "weighted tardiness:", weighted_tardiness(best)) # 2. Three identical machines, six jobs, minimize the makespan PT = [2, 3, 4, 6, 2, 2] makespan = lambda a: max(sum(p for p, m in zip(PT, a) if m == k) for k in (1, 2, 3)) print("(1,1,2,2,3,3):", makespan((1, 1, 2, 2, 3, 3)), " (1,1,1,1,2,3):", makespan((1, 1, 1, 1, 2, 3)), " (1,2,3,1,2,3):", makespan((1, 2, 3, 1, 2, 3))) # 10, 15, 8 def anneal_makespan(T0=0.9, alpha=0.9, iters_per_temp=2, n_temps=60, seed=0): rng = np.random.default_rng(seed) a = [1, 1, 2, 2, 3, 3]; c = makespan(a) best, best_c, T = a[:], c, T0 for _ in range(n_temps): for _ in range(iters_per_temp): b = a[:]; j = rng.integers(6); b[j] = rng.choice([m for m in (1, 2, 3) if m != a[j]]) # move one job if len(set(b)) < 3: # every machine must get a job continue cb = makespan(b) if cb < c or rng.random() < math.exp(-(cb - c) / T): a, c = b, cb if c < best_c: best, best_c = a[:], c T *= alpha return best, best_c print("SA assignment:", anneal_makespan(), " (optimal makespan 7)")
To run: open the companion notebook in Colab and press Shift+Enter in each cell, or paste a tab’s code into any notebook.
What to remember
Trajectory-based
One agent always takes better moves and sometimes worse ones.
Temperature
p = e−Δf/T: hot explores, cold exploits.
Schedule
Temperatures, cooling and iterations per level shape the result.
Derivative-free
SA needs only objective values but guarantees no optimum.
References: N. Metropolis et al., “Equation of state calculations by fast computing machines,” 1953. S. Kirkpatrick, C. D. Gelatt and M. P. Vecchi, “Optimization by simulated annealing,” Science, 1983. L. Ingber, “Adaptive simulated annealing (ASA): Lessons learned,” 1996. X. Geng et al., “Solving the traveling salesman problem based on an adaptive simulated annealing algorithm with greedy search,” 2011. A. Khamis, Optimization Algorithms: AI techniques for design, planning, and control problems, Manning, 2024, Chapter 5.
Check your understanding
Two quick questions on Chapter 5. Pick an answer to check it.
Q1. In simulated annealing, a worse move with objective change Δf > 0 is accepted with probability e−Δf/T. What happens as the temperature T approaches 0?
As T goes to 0, e−Δf/T goes to 0 for any worse move, so only improving moves remain, which is hill climbing. A very high temperature gives the random walk.Q2. In the slow cooling vs. quenching experiment, why does slow cooling reach a lower-energy state?
Gradual cooling keeps occasional uphill moves possible long enough to leave poor local minima. Quenching drops the temperature at once, so the system freezes with defects, like a greedy search stuck in a local optimum.For more exercises with full solutions, see Appendix C, “Exercises and solutions,” in Optimization Algorithms. The coding solutions are also on GitHub (Chapter 5).