Local Search and Simulated Annealing › Implementation in NumPy

Michael BrenndoerferOctober 29, 20268 min read
Machine Learning from ScratchAll chapters
Part 15 of 15Heuristic and Evolutionary Optimization
Chapter 90Local Search and Simulated Annealing › Implementation in NumPy
Reading section 7 of 10 · Implementation in NumPy · 8 minSection 7 of 10 · 8 min

Section 7 of 10 · 8 min read

Local Search and Simulated Annealing › Implementation in NumPySection view · Sections in this chapter7 / 10 ↓
Reading tools Definitions, links & notes

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Notes & comments

The design center is a tiny Budget object that counts every objective evaluation and remembers the best permutation it has scored. This centralizes the accounting, so that hill climbing, annealing, and random search all spend budget by the same rule.

We now package the pieces into runnable algorithms. The search implementation uses NumPy and standard Python; figures use Matplotlib and the shared book style. We implement the acceptance and budgeting rules directly so their behavior is inspectable. The budget accounting, the fact that a scoring call is the currency of comparison, is what we want to control ourselves. It also makes the comparison honest: a method cannot quietly spend extra evaluations on rejected proposals or on restart initialization without those calls showing up in the trace.

PythonIn [21]
CollapseExpand
class Budget:
    def __init__(self, limit):
        if (
            isinstance(limit, (bool, np.bool_))
            or not isinstance(limit, (int, np.integer))
            or limit < 1
        ):
            raise ValueError("limit must be a positive integer")
        self.limit = int(limit)
        self.evals = 0
        self.scores = []
        self.best_score = np.inf
        self.best_perm = None

    def score(self, perm):
        if self.evals >= self.limit:
            raise RuntimeError("budget exceeded")
        s = objective(perm)
        self.evals += 1
        self.scores.append(s)
        if s < self.best_score:
            self.best_score = s
            self.best_perm = perm
        return s

    def trace(self):
        return np.minimum.accumulate(np.array(self.scores))

The trace method returns the running best-so-far length after every evaluation, which is the incumbent curve we will plot. When we call it at the end of a run, it captures the entire trajectory rather than just the final number. That distinction matters: two methods can end at the same objective while one found it early and the other found it only at the last evaluation.

Key Methods

Hill climbing scans the 2828 reversals in the order shown, retaining a candidate through the tolerance-aware update described above. After a completed scan, it steps to that candidate only if the candidate beats the current length by more than 10−1210^{-12}; otherwise it restarts from a fresh random permutation if budget remains. Equal-cost neighbors still consume budget and the scan continues; they do not themselves authorize an installed step. If the budget interrupts a partial scan, the function returns the best permutation scored so far without spending another evaluation.

Simulated annealing proposes a uniformly random reversal, accepts it by the Metropolis rule, and updates the running best. Nothing about the acceptance uses the budget structures differently from hill climbing: every accepted and every rejected proposal costs one evaluation. Random search uses the same budget object but proposes uniformly random permutations, which makes it a baseline for how much structure the local moves add.

The implementation also rejects a zero or subnormal ratio T1/T0T_1/T_0 in the quotient's floating-point type. This range check leaves equal positive temperatures valid, even when their absolute values are subnormal. The executable schedule is a floating-point approximation to the ideal geometric sequence.

PythonIn [22]
CollapseExpand
def reverse_segment(perm, i, j):
    p = list(perm)
    p[i : j + 1] = p[i : j + 1][::-1]
    return tuple(p)


def hill_climb(limit, seed):
    rng = np.random.default_rng(seed)
    b = Budget(limit)
    current = nearest_neighbor()
    current_score = b.score(current)
    while b.evals < limit:
        best_local, best_local_score = None, np.inf
        for i in range(8):
            for j in range(i + 1, 8):
                if b.evals >= limit:
                    return b.best_perm, b.best_score, b.trace()
                n = reverse_segment(current, i, j)
                s = b.score(n)
                if best_local is None or s < best_local_score - 1e-12:
                    best_local, best_local_score = n, s
        if best_local_score < current_score - 1e-12:
            current, current_score = best_local, best_local_score
        else:
            if b.evals >= limit:
                break
            current = tuple(rng.permutation(np.arange(1, 9)))
            current_score = b.score(current)
    return b.best_perm, b.best_score, b.trace()


def simulated_annealing(limit, seed, T0=2.0, T1=0.01):
    if not (np.isfinite(T0) and np.isfinite(T1) and 0 < T1 <= T0):
        raise ValueError("temperatures must be finite with 0 < T1 <= T0")
    temperature_ratio = T1 / T0
    if temperature_ratio < np.finfo(np.asarray(temperature_ratio).dtype).tiny:
        raise ValueError(
            "temperature ratio must be normal in its floating-point dtype"
        )
    rng = np.random.default_rng(seed)
    b = Budget(limit)
    current = nearest_neighbor()
    current_score = b.score(current)
    while b.evals < limit:
        progress = b.evals / (limit - 1)
        temp = T0 * (T1 / T0) ** progress
        i, j = sorted(rng.choice(8, size=2, replace=False))
        n = reverse_segment(current, i, j)
        s = b.score(n)
        delta = s - current_score
        if delta < 0 or rng.random() < np.exp(-delta / temp):
            current, current_score = n, s
    return b.best_perm, b.best_score, b.trace()


def random_search(limit, seed):
    rng = np.random.default_rng(seed)
    b = Budget(limit)
    b.score(nearest_neighbor())
    while b.evals < limit:
        p = tuple(rng.permutation(np.arange(1, 9)))
        b.score(p)
    return b.best_perm, b.best_score, b.trace()

Budgeted Comparisons and Repeated-Run Uncertainty

We now run each method on each of 2020 seeds with the same 300300-evaluation budget, and record both the final incumbent and the full incumbent trace. We also check every reported final solution: its identifiers must form the required permutation, it must pass the feasibility checks, and its recomputed objective must match the reported best score within tolerance. These assertions are part of the experiment, not decoration; they catch accounting mistakes before those mistakes become a figure.

PythonIn [23]
CollapseExpand
SEEDS = list(range(20))
BUDGET = 300
METHODS = {
    "Hill climbing": hill_climb,
    "Simulated annealing": simulated_annealing,
    "NN-seeded random search": random_search,
}
results = {name: {"final": [], "traces": []} for name in METHODS}
for seed in SEEDS:
    for name, fn in METHODS.items():
        perm, best, trace = fn(BUDGET, seed)
        assert is_feasible(perm), (
            f"infeasible final perm for {name}, seed {seed}"
        )
        assert np.isclose(objective(perm), best, rtol=0, atol=1e-12)
        assert len(trace) == BUDGET
        assert np.all(np.isfinite(trace))
        assert np.all(np.diff(trace) <= 0)
        assert np.isclose(trace[-1], best, rtol=0, atol=1e-12)
        results[name]["final"].append(best)
        results[name]["traces"].append(trace)
ConsoleOut [24]
CollapseExpand
Method                      mean       std       min       max
Hill climbing            37.8378    0.4567   37.3560   38.3926
Simulated annealing      37.8173    0.6258   37.3560   38.9898
NN-seeded random search   39.2541    0.8956   38.0002   40.7200

Exact optimum: 37.3560

The recorded traces show how quickly each run finds its incumbent. Compare the full distributions and repeated-run summaries rather than claiming universal superiority from this tiny instance. NN-seeded random search reports the best of its starting score and 299 random draws, so its final values do not have the distribution of one uniformly random permutation. The fixed instance, neighborhood, initialization, and temperature schedule all limit how these results generalize.

PythonIn [25]
CollapseExpand
trajectory_data = {}
for name in METHODS:
    traces = np.asarray(results[name]["traces"])
    trajectory_data[name] = (
        traces.mean(axis=0),
        *np.quantile(traces, [0.1, 0.9], axis=0),
    )
plot_positions = np.arange(1, BUDGET + 1)
rng_jitter = np.random.default_rng(0)
final_plot_data = {
    name: (
        np.asarray(results[name]["final"]),
        rng_jitter.uniform(-0.08, 0.08, len(SEEDS)),
    )
    for name in METHODS
}
VisualizationOut [26]
CollapseExpand
Three mean incumbent curves and descriptive percentile bands. Hill climbing drops quickly, then makes smaller later gains. Annealing finishes slightly lower on average; NN-seeded random search has a higher mean.
Mean incumbent distance over 20 seeds. Shading spans the pointwise 10th to 90th seed percentiles, describing observed variation rather than uncertainty in the mean. Hill climbing improves sharply at first and again after restarts; annealing's mean improves more gradually. The dashed line is the enumerated minimum.

The final distribution is the honest summary of quality. It shows not only how good each method is on average, but how much its answer depends on the seed. On this instance the exact optimum is attained by some seeds and missed by others; the spread of a method's final values is the practical price of stochasticity, and it is what we should report when we compare search heuristics.

VisualizationOut [27]
CollapseExpand
All 20 final values per method. Hill climbing and annealing reach the enumerated minimum on some seeds and miss it on others; NN-seeded random search stays above that minimum.
Final incumbent distance for 20 seeds per method under 300 complete objective evaluations. Every dot represents one seed and each dark bar is its method's mean. Annealing's slightly lower mean than hill climbing accompanies a larger descriptive standard deviation on this instance.

Key takeaways from the implementation:

  • The methods receive the same number of complete objective evaluations; this does not equalize runtime, construction work, or memory.
  • On these 20seeds, annealing has a slightly lower mean than hill climbing and a larger descriptive standard deviation. This is an observation about one instance and schedule.
  • NN-seeded random search has a higher final mean here, while providing a baseline with the same nearest-neighbor initial score.
  • Report seed variation and the experimental configuration alongside the mean.

Comments

Add to the conversation

A question, a correction, or an example from your own work.

No comments yet. Be the first to share your thoughts!

Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy