Genetic Algorithms › Implementation in NumPy

Michael BrenndoerferOctober 29, 202617 min read
Machine Learning from ScratchAll chapters
Part 15 of 15Heuristic and Evolutionary Optimization
Chapter 91Genetic Algorithms › Implementation in NumPy
Reading section 6 of 10 · Implementation in NumPy · 17 minSection 6 of 10 · 17 min

Section 6 of 10 · 17 min read

Genetic Algorithms › Implementation in NumPySection view · Sections in this chapter6 / 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

We use NumPy arrays to expose the population, variation, and selection operations directly. The implementation has four parts: the instance and its independent validator, the evaluation account, the operators, and the comparison harness that runs every method under the same 300-evaluation budget.

PythonIn [6]
CollapseExpand
import time
from itertools import permutations

import numpy as np
import pandas as pd
from book_plot_style import seed_book_plots

seed_book_plots()

BUDGET = 300
SEEDS = list(range(20))
CUSTOMERS = tuple(range(1, 9))

The routing instance

Coordinates, demands, capacities, windows, and service times are the shared instance from the preceding chapter. The distance matrix is the same matrix used for both the objective and the schedule, so there is no second notion of "cost". Keeping one matrix for both purposes is important: if the objective and the feasibility check used different distance conventions, a route could be scored with one length and validated with another.

PythonIn [7]
CollapseExpand
COORDS = np.array(
    [
        [0.0, 0.0],  # depot
        [1.0, 3.0],  # 1
        [2.0, 6.0],  # 2
        [4.0, 5.0],  # 3
        [7.0, 6.0],  # 4
        [8.0, 3.0],  # 5
        [6.0, 1.0],  # 6
        [3.0, 1.0],  # 7
        [5.0, 3.0],  # 8
    ]
)
N_NODES = len(COORDS)
DEPOT = 0
DEMAND = np.array([0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0])
CAPACITY = 4.0
WINDOW_OPEN = np.zeros(N_NODES)
WINDOW_CLOSE = np.full(N_NODES, 80.0)
SERVICE = np.array([0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0])

_pairs = COORDS[:, None, :] - COORDS[None, :, :]
DIST = np.sqrt((_pairs**2).sum(axis=2))
Out[8]:
node 0 node 1 node 2 node 3 node 4 node 5 node 6 node 7 node 8
node 0 0.000 3.162 6.325 6.403 9.220 8.544 6.083 3.162 5.831
node 1 3.162 0.000 3.162 3.606 6.708 7.000 5.385 2.828 4.000
node 2 6.325 3.162 0.000 2.236 5.000 6.708 6.403 5.099 4.243
node 3 6.403 3.606 2.236 0.000 3.162 4.472 4.472 4.123 2.236
node 4 9.220 6.708 5.000 3.162 0.000 3.162 5.099 6.403 3.606
node 5 8.544 7.000 6.708 4.472 3.162 0.000 2.828 5.385 3.000
node 6 6.083 5.385 6.403 4.472 5.099 2.828 0.000 3.000 2.236
node 7 3.162 2.828 5.099 4.123 6.403 5.385 3.000 0.000 2.828
node 8 5.831 4.000 4.243 2.236 3.606 3.000 2.236 2.828 0.000

Decoder, objective, and an independent validator

The decoder is a slice; the objective is a simulation of two tours. The validator is deliberately written a second time from raw coordinates with np.hypot, so that a reported route is checked by code that does not share the objective's distance matrix. That redundancy is intentional: it catches mistakes in the objective rather than merely repeating them.

PythonIn [9]
CollapseExpand
def decode(perm):
    """Split an 8-customer permutation into two four-customer depot tours."""
    values = np.asarray(perm)
    if (
        values.shape != (8,)
        or values.dtype.kind not in "iu"
        or sorted(values.tolist()) != list(CUSTOMERS)
    ):
        raise ValueError("Expected one integer permutation of customers1..8")
    perm = values.tolist()
    return [[DEPOT, *perm[0:4], DEPOT], [DEPOT, *perm[4:8], DEPOT]]


def schedule(route):
    """Simulate one depot tour: arc lengths, load, service starts, window checks."""
    load = 0.0
    clock = 0.0
    length = 0.0
    feasible = True
    for a, b in zip(route[:-1], route[1:]):
        load += float(DEMAND[a])
        travel = float(DIST[a, b])
        length += travel
        arrival = clock + float(SERVICE[a]) + travel
        start = max(arrival, float(WINDOW_OPEN[b]))
        if start > float(WINDOW_CLOSE[b]) + 1e-9:
            feasible = False
        clock = start
    if load > CAPACITY + 1e-9:
        feasible = False
    return length, load, clock, feasible


def objective(perm):
    """Complete-route objective: summed Euclidean length of both closed tours."""
    total = 0.0
    for route in decode(perm):
        length, _, _, feasible = schedule(route)
        if not feasible:
            return float("inf")
        # Match the preceding chapter's sequential10-arc accumulation.
        for a, b in zip(route[:-1], route[1:]):
            total += float(DIST[a, b])
    return total


def validate_solution(perm):
    """Independent check that recomputes distances from coordinates, not DIST."""
    values = np.asarray(perm)
    if (
        values.shape != (8,)
        or values.dtype.kind not in "iu"
        or sorted(values.tolist()) != list(CUSTOMERS)
    ):
        return {
            "routes": [],
            "total": float("inf"),
            "feasible": False,
            "permutation": values.tolist(),
        }
    perm = values.tolist()
    # Reconstruct the two routes independently of decode and DIST.
    routes = [[0, *perm[:4], 0], [0, *perm[4:], 0]]
    rows = []
    total = 0.0
    feasible = sorted(perm) == list(CUSTOMERS)
    for route in routes:
        load = 0.0
        clock = 0.0
        length = 0.0
        windows_ok = True
        for a, b in zip(route[:-1], route[1:]):
            load += float(DEMAND[a])
            travel = float(
                np.hypot(
                    COORDS[a, 0] - COORDS[b, 0], COORDS[a, 1] - COORDS[b, 1]
                )
            )
            length += travel
            arrival = clock + float(SERVICE[a]) + travel
            start = max(arrival, float(WINDOW_OPEN[b]))
            if start > float(WINDOW_CLOSE[b]) + 1e-9:
                windows_ok = False
            clock = start
        capacity_ok = load <= CAPACITY + 1e-9
        rows.append(
            {
                "route": route,
                "load": load,
                "length": length,
                "return_to_depot": clock,
                "capacity_ok": capacity_ok,
                "windows_ok": windows_ok,
            }
        )
        total += length
        feasible = feasible and capacity_ok and windows_ok
    return {
        "routes": rows,
        "total": total,
        "feasible": feasible,
        "permutation": perm,
    }

Counting every evaluation

Every scoring call for a complete permutation passes through one counter. The counter also stores the running best, which is what lets us plot evaluation-indexed incumbent traces instead of wall-clock curves. There is no memoisation anywhere: if a child duplicates an existing individual it is scored again and charged again. This is the implementation of the budget identity from the formula section.

PythonIn [10]
CollapseExpand
class BudgetedObjective:
    """Counts every complete-route evaluation and records the incumbent trace."""

    def __init__(self, budget):
        if (
            isinstance(budget, (bool, np.bool_))
            or not isinstance(budget, (int, np.integer))
            or budget < 1
        ):
            raise ValueError("budget must be a positive integer")
        self.budget = int(budget)
        self.used = 0
        self.best = float("inf")
        self.trace = []

    def __call__(self, perm):
        if self.used >= self.budget:
            raise RuntimeError("Objective evaluation budget exhausted")
        self.used += 1
        value = objective(perm)
        if value < self.best:
            self.best = value
        self.trace.append(self.best)
        return value

The shared starting point

Nearest-neighbour construction scans 36 candidate arcs to build the permutation: it considers 8+7+6+5+4+3+2+1 remaining customers, resetting to the depot after the first four. These arc lookups are extra construction work, not complete-route evaluations. The diagnostic NN cost below is one separate score. Each search run scores the NN permutation again through its own counter as evaluation 1, then spends its remaining budget. No cached NN objective value is inserted into a run.

PythonIn [11]
CollapseExpand
def nearest_neighbor_perm():
    """Greedy construction: nearest remaining customer within each vehicle block."""
    perm = []
    remaining = list(CUSTOMERS)
    current = DEPOT
    while remaining:
        if len(perm) % 4 == 0:
            current = DEPOT
        nxt = min(remaining, key=lambda c: (float(DIST[current, c]), c))
        perm.append(nxt)
        remaining.remove(nxt)
        current = nxt
    return np.array(perm, dtype=int)


NN_PERM = nearest_neighbor_perm()
NN_COST = float(objective(NN_PERM))
ConsoleOut [12]
CollapseExpand
nearest-neighbour permutation: (1, 7, 8, 3, 6, 5, 4, 2)
nearest-neighbour cost: 40.85635

Baselines: NN-seeded random search, hill climbing, annealing

The three baselines follow the shared comparison contract. Each performs one actual nearest-neighbour scoring call per run and then spends the remaining 299 evaluations. The SA schedule is Tm=2(0.01/2)m/299T_m=2(0.01/2)^{m/299} before proposal mm, for m=1,…,299m=1,\ldots,299. It skips an acceptance draw for a strict improvement; ties consume a draw and are accepted. The objective also accumulates all ten arcs in the same sequential order as the preceding chapter. Even mathematically equivalent summation can change a floating-point tie and therefore the later random-call order; matching these details makes the baseline reproducible across the two chapters. The random baseline is explicitly NN-seeded random search, not 300 uniform draws.

Before scoring, the implementation rejects a represented t_end / t_start ratio below the smallest positive normal value of its floating-point dtype. This catches zero and subnormal quotients, which can erase or distort the cooling schedule. A constant positive subnormal temperature remains allowed because its ratio is one. The temperatures still approximate the geometric schedule in floating-point arithmetic; this guard does not guarantee exact endpoints or a bound on relative error.

PythonIn [13]
CollapseExpand
def nn_seeded_random_search(rng, budget, nn_perm):
    """Nearest-neighbour score, then uniform draws to the remaining budget."""
    ev = BudgetedObjective(budget)
    initial_cost = ev(nn_perm)

    best_perm = np.asarray(nn_perm, dtype=int).copy()
    best_cost = float(initial_cost)
    while ev.used < budget:
        perm = rng.permutation(CUSTOMERS)
        value = ev(perm)
        if value < best_cost:
            best_perm, best_cost = perm.copy(), value
    return best_perm, best_cost, ev


def hill_climbing(rng, budget, nn_perm):
    """Tolerance-aware scans of all 28 segment reversals, with restarts."""
    ev = BudgetedObjective(budget)
    initial_cost = ev(nn_perm)

    current = np.asarray(nn_perm, dtype=int).copy()
    current_cost = float(initial_cost)
    best_perm, best_cost = current.copy(), current_cost

    while ev.used < budget:
        neighbor_cost = float("inf")
        neighbor = None
        completed = True
        for i in range(8):
            for j in range(i + 1, 8):
                if ev.used >= budget:
                    completed = False
                    break
                trial = current.copy()
                trial[i : j + 1] = trial[i : j + 1][::-1]
                value = ev(trial)
                if (
                    value < neighbor_cost - 1e-12
                ):  # retain first on numerical ties
                    neighbor_cost = value
                    neighbor = trial
                if value < best_cost:
                    best_perm, best_cost = trial.copy(), value
            if not completed:
                break
        if not completed:
            break  # partial sweep at exhaustion
        if neighbor_cost < current_cost - 1e-12:
            current, current_cost = neighbor, neighbor_cost
        else:
            if ev.used >= budget:
                break
            current = rng.permutation(CUSTOMERS)  # restart, one counted call
            current_cost = ev(current)
            if current_cost < best_cost:
                best_perm, best_cost = current.copy(), current_cost
    return best_perm, best_cost, ev


def simulated_annealing(rng, budget, nn_perm, t_start=2.0, t_end=0.01):
    """Symmetric single-reversal proposal, geometric cooling, best-so-far reported."""
    if not (
        np.isfinite(t_start) and np.isfinite(t_end) and 0 < t_end <= t_start
    ):
        raise ValueError("Require finite temperatures with0<t_end<=t_start")
    temperature_ratio = t_end / t_start
    if temperature_ratio < np.finfo(np.asarray(temperature_ratio).dtype).tiny:
        raise ValueError(
            "temperature ratio must be normal in its floating-point dtype"
        )
    ev = BudgetedObjective(budget)
    initial_cost = ev(nn_perm)

    current = np.asarray(nn_perm, dtype=int).copy()
    current_cost = float(initial_cost)
    best_perm, best_cost = current.copy(), current_cost

    while ev.used < budget:
        fraction = ev.used / max(budget - 1, 1)
        temperature = t_start * (t_end / t_start) ** fraction
        i, j = sorted(int(v) for v in rng.choice(8, size=2, replace=False))
        trial = current.copy()
        trial[i : j + 1] = trial[i : j + 1][::-1]
        value = ev(trial)
        delta = value - current_cost
        if delta < 0.0 or rng.random() < np.exp(-delta / temperature):
            current, current_cost = trial, value
            if value < best_cost:
                best_perm, best_cost = trial.copy(), value
    return best_perm, best_cost, ev

Genetic operators

The operators are short enough to read line by line. order_crossover implements the rule derived in the formula section, using cyclic position indices so that a block ending at the last position wraps around correctly. swap_mutation is the permutation-space mutation, and tournament and elitist_survivors implement the selection and survival rules.

PythonIn [14]
CollapseExpand
def order_crossover(parent_one, parent_two, a, b):
    """Order crossover: copy one block, fill the rest in cyclic order from parent two."""
    n = len(parent_one)
    child = np.full(n, -1, dtype=int)
    child[a : b + 1] = parent_one[a : b + 1]
    used = set(int(v) for v in child[a : b + 1])

    empty_positions = [(b + 1 + k) % n for k in range(n - (b - a + 1))]
    scan_order = [(b + 1 + k) % n for k in range(n)]
    values = [
        int(parent_two[t]) for t in scan_order if int(parent_two[t]) not in used
    ]

    for position, value in zip(empty_positions, values):
        child[position] = value
    return child


def swap_mutation(perm, rng):
    """Exchange the values at two distinct positions; validity is automatic."""
    child = perm.copy()
    i, j = (int(v) for v in rng.choice(len(child), size=2, replace=False))
    child[i], child[j] = child[j], child[i]
    return child


def tournament(rng, population, size):
    """Sample distinct individuals and return the permutation with lowest cost."""
    picked = rng.choice(len(population), size=size, replace=False)
    contenders = [population[int(k)] for k in picked]
    return min(contenders, key=lambda individual: individual[1])[0]


def elitist_survivors(parents, children, population_size):
    """Mu-plus-lambda survival: keep the cheapest `size` of parents plus children."""
    pool = parents + children
    pool = sorted(
        pool, key=lambda individual: individual[1]
    )  # stable: parents win ties
    return [(perm.copy(), float(cost)) for perm, cost in pool[:population_size]]


def canonical_signature(perm):
    """Vehicle-symmetric signature: tours are unordered and reversals are equal."""
    signature = []
    for route in decode(perm):
        sequence = tuple(int(v) for v in route[1:-1])
        signature.append(min(sequence, sequence[::-1]))
    return tuple(sorted(signature))


def population_stats(population):
    """Diversity and cost summary of one generation."""
    costs = np.array([cost for _, cost in population], dtype=float)
    matrix = np.array([perm for perm, _ in population], dtype=int)
    hamming = (matrix[:, None, :] != matrix[None, :, :]).sum(axis=2)
    upper = np.triu_indices(len(matrix), k=1)
    return {
        "best": float(costs.min()),
        "median": float(np.median(costs)),
        "worst": float(costs.max()),
        "distinct": int(
            len({canonical_signature(perm) for perm, _ in population})
        ),
        "mean_hamming": float(hamming[upper].mean()),
    }

Key Parameters

The following settings define this comparison; their effects need to be measured under the chosen budget.

  • pop_size (μ\mu, here 20): the number of retained candidates. Complete generations number ⌊(B−N)/N⌋\lfloor(B-N)/N\rfloor after initialization. A larger population leaves no more complete generations at a fixed budget, and often fewer, but does not guarantee greater useful diversity.
  • tournament_size (kk, here 3): the tournament width. A unique best individual is sampled with probability 3/20=0.153/20=0.15 under distinct costs, compared with 1/201/20 for uniform parent choice. Ties and downstream diversity need separate interpretation.
  • crossover_rate (here 0.9): the chance of applying OX to two selected parents rather than copying the first. The implementation draws aa uniformly from 0 through 6, then bb uniformly from a+1a+1 through 7; these cut pairs are not uniformly distributed.
  • mutation_rate (here 0.5): the probability that a child receives one swap. This is the only operator that can create novelty once the population copies itself.
  • Budget handling: the NN permutation is scored once inside each run, then 19 random permutations complete the initial population. Every child is scored, including duplicates. A budget below the requested initial population is rejected before scoring.

The population budget and number of generations are coupled. Parent pressure and variation also interact through the candidates that survive. The explicit settings make those choices reproducible; they do not establish that more mutation, more population members, or larger tournaments improve performance.

Key Methods

The functions map onto the components of the algorithm. decode and schedule define the genotype-to-phenotype map and the feasibility check; objective is the complete-route cost that the counter charges for; validate_solution re-derives the same quantities independently from the raw coordinates and is used only on reported answers. On the search side, tournament implements selection, order_crossover and swap_mutation implement variation, elitist_survivors implements (μ+λ)(\mu+\lambda) survival with elitism, and population_stats records the diagnostics (number of distinct canonical solutions and mean pairwise Hamming distance), while canonical_signature computes the vehicle-symmetric tour signature used for those diagnostics, that we use to discuss diversity.

PythonIn [15]
CollapseExpand
def genetic_algorithm(
    rng,
    budget,
    nn_perm,
    pop_size=20,
    tournament_size=3,
    crossover_rate=0.9,
    mutation_rate=0.5,
):
    """Elitist generational GA over permutations. Returns (best_perm, best_cost, counter, history)."""
    n = len(CUSTOMERS)
    ev = BudgetedObjective(budget)
    if (
        isinstance(pop_size, (bool, np.bool_))
        or not isinstance(pop_size, (int, np.integer))
        or pop_size < 2
        or budget < pop_size
    ):
        raise ValueError(
            "Require population size>=2 and budget>=population size"
        )
    if (
        isinstance(tournament_size, (bool, np.bool_))
        or not isinstance(tournament_size, (int, np.integer))
        or not 1 <= tournament_size <= pop_size
    ):
        raise ValueError("Tournament size must be within the population")
    if not 0 <= crossover_rate <= 1 or not 0 <= mutation_rate <= 1:
        raise ValueError("Variation probabilities must lie in[0,1]")
    initial_cost = ev(nn_perm)
    population = [(np.asarray(nn_perm, dtype=int).copy(), initial_cost)]
    while len(population) < pop_size and ev.used < budget:
        perm = rng.permutation(CUSTOMERS)
        population.append((perm, ev(perm)))

    history = []
    while ev.used < budget:
        children = []
        for _ in range(pop_size):
            if ev.used >= budget:
                break
            parent_one = tournament(rng, population, tournament_size)
            parent_two = tournament(rng, population, tournament_size)
            if rng.random() < crossover_rate:
                a = int(rng.integers(0, n - 1))
                b = int(rng.integers(a + 1, n))
                child = order_crossover(parent_one, parent_two, a, b)
            else:
                child = parent_one.copy()
            if rng.random() < mutation_rate:
                child = swap_mutation(child, rng)
            children.append((child, ev(child)))
        population = elitist_survivors(population, children, pop_size)
        history.append(population_stats(population))

    best_perm, best_cost = min(population, key=lambda individual: individual[1])
    return best_perm.copy(), float(best_cost), ev, history

Checking the worked example in code

Before trusting the loop, we verify the hand arithmetic from the example. This cell also shows the operators producing exactly the child we computed by hand. If the code and the hand calculation disagree, the formula section and the implementation are not yet the same algorithm.

PythonIn [16]
CollapseExpand
example_perms = {
    "P1 nearest neighbour": [1, 7, 8, 3, 6, 5, 4, 2],
    "P2": [1, 2, 3, 8, 6, 5, 4, 7],
    "P3": [1, 2, 3, 7, 4, 5, 6, 8],
    "P4": [2, 6, 1, 5, 3, 7, 4, 8],
}
child = order_crossover(
    np.array(example_perms["P2"]), np.array(example_perms["P3"]), a=4, b=6
)
mutated = np.array([1, 2, 3, 7, 6, 4, 5, 8])

example_table = pd.DataFrame(
    [
        {
            "individual": name,
            "permutation": tuple(perm),
            "cost": objective(perm),
        }
        for name, perm in example_perms.items()
    ]
    + [
        {
            "individual": "OX child",
            "permutation": tuple(int(v) for v in child),
            "cost": objective(child),
        },
        {
            "individual": "OX child + swap",
            "permutation": tuple(int(v) for v in mutated),
            "cost": objective(mutated),
        },
    ]
)
ConsoleOut [17]
CollapseExpand
             individual               permutation     cost
0  P1 nearest neighbour  (1, 7, 8, 3, 6, 5, 4, 2)  40.8563
1                    P2  (1, 2, 3, 8, 6, 5, 4, 7)  38.2665
2                    P3  (1, 2, 3, 7, 4, 5, 6, 8)  39.1233
3                    P4  (2, 6, 1, 5, 3, 7, 4, 8)  60.0227
4              OX child  (1, 2, 3, 7, 6, 5, 4, 8)  37.3560
5       OX child + swap  (1, 2, 3, 7, 6, 4, 5, 8)  39.0210

The table reproduces the hand calculation: the parents cost 38.266538.2665 and 39.123339.1233, the crossover child costs 37.356037.3560, and the swapped version costs 39.021039.0210. Every value came from objective, so the arithmetic in the example section and the code agree.

Running the budget-matched comparison

Each method gets a separate generator initialized with the same integer for a given run index. Those labels organize the results; different algorithms consume the stream differently, so this alone does not establish statistical variance reduction. Every run is expected to finish with exactly 300 evaluations; the assertion enforces the accounting contract rather than trusting it.

PythonIn [18]
CollapseExpand
def run_ga(rng, budget, nn_perm):
    best_perm, best_cost, ev, _ = genetic_algorithm(rng, budget, nn_perm)
    return best_perm, best_cost, ev


RUNNERS = {
    "NN-seeded random search": nn_seeded_random_search,
    "2-opt hill climbing": hill_climbing,
    "Simulated annealing": simulated_annealing,
    "Genetic algorithm": run_ga,
}

records = []
traces = {name: [] for name in RUNNERS}
final_perms = {name: {} for name in RUNNERS}
ga_histories = {}

for seed in SEEDS:
    for name, runner in RUNNERS.items():
        rng = np.random.default_rng(seed)
        started = time.perf_counter()
        if name == "Genetic algorithm":
            best_perm, best_cost, ev, history = genetic_algorithm(
                rng, BUDGET, NN_PERM
            )
            ga_histories[seed] = history
        else:
            best_perm, best_cost, ev = runner(rng, BUDGET, NN_PERM)
        elapsed = time.perf_counter() - started
        records.append(
            {
                "method": name,
                "seed": seed,
                "best_cost": float(best_cost),
                "evaluations": int(ev.used),
                "seconds": elapsed,
            }
        )
        traces[name].append(np.asarray(ev.trace, dtype=float))
        final_perms[name][seed] = np.asarray(best_perm, dtype=int)

results = pd.DataFrame(records)
assert (results["evaluations"] == BUDGET).all(), "budget accounting violated"
ConsoleOut [19]
CollapseExpand
                         min  max  count
method                                  
2-opt hill climbing      300  300     20
Genetic algorithm        300  300     20
NN-seeded random search  300  300     20
Simulated annealing      300  300     20

Exact reference by enumeration

The reference is a separate computation, excluded from every search budget. With 40320 permutations the enumeration is cheap and it lets us report gaps instead of opinions. It also gives the number of distinct canonical solutions under the vehicle-swap and tour-reversal symmetries. Computations use double-precision distances; reference hits use an absolute tolerance of 1e-10 and zero relative tolerance, not symbolic equality.

PythonIn [20]
CollapseExpand
all_perms = np.array(list(permutations(CUSTOMERS)), dtype=int)
exact_costs = np.array([objective(perm) for perm in all_perms])
EXACT_BEST = float(exact_costs.min())
exact_best_perms = all_perms[
    np.isclose(exact_costs, EXACT_BEST, rtol=0.0, atol=1e-10)
]
distinct_signatures = len({canonical_signature(perm) for perm in all_perms})
ConsoleOut [21]
CollapseExpand
permutations enumerated: 40320
distinct canonical solutions: 5040
exact optimum total length: 37.35598
permutations attaining it: 8
nearest-neighbour gap: 3.50037

Summary table with exact gaps

The table reports the distribution of final costs across the 20 seeds, how often each method hit the exact optimum, and the median wall-clock time. The timing column is machine dependent and is included only to make the point that equal evaluation counts do not imply equal runtime.

PythonIn [22]
CollapseExpand
grouped = results.groupby("method")
summary = pd.DataFrame(
    {
        "mean": grouped["best_cost"].mean(),
        "std": grouped["best_cost"].std(ddof=1),
        "median": grouped["best_cost"].median(),
        "min": grouped["best_cost"].min(),
        "max": grouped["best_cost"].max(),
        "median_seconds": grouped["seconds"].median(),
    }
).reindex(list(RUNNERS))

summary["hits_exact_optimum"] = (
    results.assign(
        hit=np.isclose(results["best_cost"], EXACT_BEST, rtol=0.0, atol=1e-10)
    )
    .groupby("method")["hit"]
    .sum()
    .reindex(list(RUNNERS))
    .astype(int)
)
summary["mean_gap_pct"] = 100.0 * (summary["mean"] - EXACT_BEST) / EXACT_BEST
ConsoleOut [23]
CollapseExpand
                            mean     std   median      min      max  \
method                                                                
NN-seeded random search  39.2541  0.9189  39.0487  38.0002  40.7200   
2-opt hill climbing      37.8378  0.4685  38.0002  37.3560  38.3926   
Simulated annealing      37.8173  0.6421  37.3560  37.3560  38.9898   
Genetic algorithm        37.6574  0.5158  37.3560  37.3560  38.9898   

                         median_seconds  hits_exact_optimum  mean_gap_pct  
method                                                                     
NN-seeded random search          0.0023                   0         5.081  
2-opt hill climbing              0.0021                   9         1.290  
Simulated annealing              0.0037                  12         1.235  
Genetic algorithm                0.0081                  14         0.807

Independently validated final routes

Every reported final solution is re-checked by validate_solution, which recomputes distances from coordinates, sums loads, and re-simulates the schedule. All 80 final method/seed outcomes are checked, and the assertions require feasibility and agreement within 1e-10. The displayed table selects the best run per method. These 80 validation re-simulations are separate diagnostic work and do not provide candidates or scores to the searches.

PythonIn [24]
CollapseExpand
best_rows = results.loc[results.groupby("method")["best_cost"].idxmin()]
validation_rows = []
for _, row in results.iterrows():
    perm = final_perms[row["method"]][int(row["seed"])]
    report = validate_solution(perm)
    assert report["feasible"]
    assert np.isclose(report["total"], row["best_cost"], rtol=0.0, atol=1e-10)
    validation_rows.append(
        {
            "method": row["method"],
            "seed": int(row["seed"]),
            "tour 1": " - ".join(str(v) for v in report["routes"][0]["route"]),
            "tour 2": " - ".join(str(v) for v in report["routes"][1]["route"]),
            "length": report["total"],
            "feasible": report["feasible"],
            "matches report": bool(
                np.isclose(
                    report["total"], row["best_cost"], rtol=0.0, atol=1e-10
                )
            ),
        }
    )
validation_all = pd.DataFrame(validation_rows)
validation = validation_all.loc[best_rows.index].reset_index(drop=True)
ConsoleOut [25]
CollapseExpand
                    method  seed                 tour 1  \
0      2-opt hill climbing     0  0 - 7 - 3 - 2 - 1 - 0   
1        Genetic algorithm     4  0 - 7 - 3 - 2 - 1 - 0   
2  NN-seeded random search     1  0 - 4 - 5 - 6 - 7 - 0   
3      Simulated annealing     1  0 - 7 - 3 - 2 - 1 - 0   

                  tour 2   length  feasible  matches report  
0  0 - 8 - 4 - 5 - 6 - 0  37.3560      True            True  
1  0 - 6 - 5 - 4 - 8 - 0  37.3560      True            True  
2  0 - 1 - 2 - 3 - 8 - 0  38.0002      True            True  
3  0 - 8 - 4 - 5 - 6 - 0  37.3560      True            True

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