Genetic Algorithms › Implementation in NumPy
Machine Learning from ScratchAll chapters 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
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]Expand
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]Expand
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))| 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]Expand
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]Expand
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 valueThe 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]Expand
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]Expand
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 before proposal , for . 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]Expand
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, evGenetic 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]Expand
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(, here 20): the number of retained candidates. Complete generations number 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(, here 3): the tournament width. A unique best individual is sampled with probability under distinct costs, compared with 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 uniformly from 0 through 6, then uniformly from 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 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]Expand
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, historyChecking 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]Expand
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]Expand
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 and , the crossover child costs , and the swapped version costs . 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]Expand
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]Expand
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]Expand
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]Expand
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]Expand
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_BESTConsoleOut [23]Expand
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.807Independently 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]Expand
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]Expand
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!