Derivative Free Optimization: Genetic Algorithm
Contents
Derivative Free Optimization: Genetic Algorithm#
Osvaldo Aldaz#
Here, we will explore a path optimization example, trying to minimize the distance traveled between 2 nodes. Since the distances are discrete, trying to take a derivative and setting it to 0 isn’t gonna work well here. So well explore the use of a derivative free optimize method known as a genetic algorithm structured similar to certain processes in nature.
Import Packages
import networkx as nx
import numpy as np
import matplotlib.pyplot as plt
import random
import imageio
import os
from IPython.display import Image, display
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
Cell In[1], line 1
----> 1 import networkx as nx
2 import numpy as np
3 import matplotlib.pyplot as plt
ModuleNotFoundError: No module named 'networkx'
Function to Generate Network:#
def generate_random_road_network(num_nodes=50, connection_radius=0.2, seed=None,
show_plot=True, show_dist=False, max_attempts=100,
start_node=None, goal_node=None):
"""
Generate a connected random geometric graph as a road network.
Parameters:
- num_nodes: number of intersections (nodes)
- connection_radius: maximum distance to connect nodes
- seed: random seed for reproducibility
- show_plot: whether to show a plot of the network
- max_attempts: max retries to generate a connected graph
Returns:
- G: a connected, weighted NetworkX graph with 'pos' attributes
"""
if seed is not None:
np.random.seed(seed)
for attempt in range(max_attempts):
# Generate random node positions in 2D
pos = {i: np.random.rand(2) for i in range(num_nodes)}
# Create graph
G = nx.Graph()
G.add_nodes_from(pos.keys())
nx.set_node_attributes(G, pos, "pos")
# Connect nodes within the radius
for i in G.nodes:
for j in G.nodes:
if i < j:
dist = np.linalg.norm(pos[i] - pos[j])
if dist < connection_radius:
G.add_edge(i, j, weight=dist)
# Check if graph is connected
if nx.is_connected(G):
if start_node is not None and goal_node is not None:
start, goal = start_node, goal_node
if start_node == goal_node or start_node >= num_nodes or goal_node >= num_nodes:
raise ValueError("Start and goal nodes must be different and within range.")
else:
start, goal = np.random.choice(num_nodes, 2, replace=False)
if show_plot:
plt.figure(figsize=(7, 6))
nx.draw(G, pos, with_labels=True, node_size=300, node_color="limegreen")
nx.draw_networkx_nodes(G, pos, nodelist=[start], node_color="skyblue", label="Start (A)")
nx.draw_networkx_nodes(G, pos, nodelist=[goal], node_color="red", label="Goal (B)")
label_offset = 0.05 # tweak this to move label further away if needed
pos_labels = {
start: (pos[start][0] + label_offset, pos[start][1] - 1.5*label_offset),
goal: (pos[goal][0] - label_offset, pos[goal][1] + 1.5*label_offset)
}
nx.draw_networkx_labels(G, pos_labels, labels={start:"Start", goal:"Goal"},
font_size= 28, font_color="black")
if show_dist:
labels = nx.get_edge_attributes(G, 'weight')
nx.draw_networkx_edge_labels(G, pos, edge_labels={e: f"{w:.2f}" for e, w in labels.items()})
plt.title("Random Road Network")
plt.axis("equal")
plt.grid(True, alpha=0.3)
plt.show()
return G, start, goal, pos
raise ValueError(f"Failed to generate a connected graph after {max_attempts} attempts.")
Generate Roads#
Use a seed to set a random network, leave it undefined to randomly generate upon each run. Start node is red, goal node to reach is Blue. The network generated here will be used for the rest of the run
G, A, B, pos = generate_random_road_network(num_nodes=64, connection_radius=0.25, seed=3438, start_node=1, goal_node=19)
#G, A, B = generate_random_road_network(num_nodes=16, connection_radius=0.25, show_plot=True)

Helper functions to generate and validate paths#
def random_valid_path(G, start, goal, max_steps=100):
"""
Random walk from start to goal to generate an initial genome (path).
May return invalid path if goal isn't reachable within max_steps.
"""
path = [start]
current = start
for _ in range(max_steps):
neighbors = list(G.neighbors(current))
if not neighbors:
break # dead end
next_node = random.choice(neighbors)
path.append(next_node)
current = next_node
if current == goal:
return path
return path # may not reach goal
def is_valid_path(path, G):
return all(G.has_edge(path[i], path[i+1]) for i in range(len(path)-1))
def path_length(path, G):
return sum(G[path[i]][path[i+1]]['weight'] for i in range(len(path)-1))
def evaluate_fitness(path, G, goal):
if not is_valid_path(path, G):
return float('inf') # invalid path
if path[-1] != goal:
return float('inf') # didn't reach goal
return path_length(path, G)
Path genetics#
The crossover function creates a new path by combining 2 valid paths, while the mutate function randomly changes a part of the path. Togther, they spiritaully combine to reflect nature, with 2 parent coming togther to produce a child that is very similar to both parents, with slight differences.
def crossover(parent1, parent2, start, goal):
common_nodes = set(parent1[1:-1]) & set(parent2[1:-1])
if not common_nodes:
return parent1 # fallback if no common node
crossover_node = random.choice(list(common_nodes))
i = parent1.index(crossover_node)
j = parent2.index(crossover_node)
child = parent1[:i] + parent2[j:]
return child
def mutate(path, G, mutation_rate=0.2):
if len(path) <= 2:
return path # nothing to mutate
if random.random() < mutation_rate:
# Randomly choose a node to mutate (not start/goal)
idx = random.randint(1, len(path)-2)
current = path[idx - 1]
# Replace the rest of the path with a new random walk
try:
new_path = random_valid_path(G, current, path[-1])
return path[:idx] + new_path[1:]
except:
return path # fallback
return path
Plotting Function#
Plots road network with paths highlighted
def plot_path(G, pos, path, start, goal, generation=None, save_path=None):
plt.figure(figsize=(7, 6))
nx.draw(G, pos, with_labels=True, node_size=300, node_color="lightgray")
nx.draw_networkx_nodes(G, pos, nodelist=[start], node_color="skyblue", label="Start (A)")
nx.draw_networkx_nodes(G, pos, nodelist=[goal], node_color="red", label="Goal (B)")
nx.draw_networkx_edges(G, pos)
# Highlight path
edge_list = [(path[i], path[i+1]) for i in range(len(path)-1)]
nx.draw_networkx_edges(G, pos, edgelist=edge_list, edge_color="blue", width=3)
# Dynamic title
if generation is not None:
plt.title(f"Generation {generation} - Best Path")
plt.text(0.01, 0.99, f"Gen {generation}", transform=plt.gca().transAxes,
fontsize=12, color='black', verticalalignment='top',
bbox=dict(facecolor='white', edgecolor='black', boxstyle='round,pad=0.3'))
else:
plt.title("Best Path Found")
plt.axis("equal")
plt.grid(True, alpha=0.3)
if save_path:
plt.savefig(save_path)
plt.close()
else:
plt.show()
Genetic Algorithm#
This is the bulk of the optimization, where the process seeks to improve over generations without the need of derivatives.
It starts by generating a batch of paths, known as a ‘population’. Then each path is scored, in this case the shortest the total distance of the path the better. Then the best performing paths are used to make “offspring”, in which the parent paths are spliced together with some mutations to produce a ‘child’ path. These children then make up the next generation, and the cycle restarts from path evaluation.
The algorithm stops after reaching a desired efficacy or after a set number of generations, with the optimal solution being approached over generations.
def genetic_algorithm(G, start, goal, pop_size=100, generations=100, mutation_rate=0.2,
save_frames=False, output_dir="frames", pos=None, Logs=False):
if pos is None:
pos = nx.spring_layout(G, seed=3430)
if save_frames:
output_dir = "frames"
os.makedirs(output_dir, exist_ok=True)
# Generate initial batch of paths
population = [random_valid_path(G, start, goal) for _ in range(pop_size)]
best_paths = []
for gen in range(generations):
# Evaluate paths
fitness_scores = [evaluate_fitness(p, G, goal) for p in population]
# Select the best individuals
sorted_pop = [p for _, p in sorted(zip(fitness_scores, population), key=lambda x: x[0])]
population = sorted_pop[:pop_size // 2] # keep top 50%
# Generate new individuals through crossover and mutation
new_population = population.copy()
while len(new_population) < pop_size:
parents = random.sample(population, 2)
child = crossover(parents[0], parents[1], start, goal)
child = mutate(child, G, mutation_rate)
new_population.append(child)
population = new_population
# Track and log the best individual of this generation
best_individual = min(population, key=lambda p: evaluate_fitness(p, G, goal))
best_paths.append(best_individual)
best_score = evaluate_fitness(best_individual, G, goal)
if Logs:
print(f"Generation {gen+1}, Best path length: {best_score:.2f}")
# Save a frame for GIF creation
if save_frames and pos is not None:
frame_path = os.path.join(output_dir, f"gen_{gen:03d}.png")
plot_path(G, pos, best_individual, start, goal, generation=gen, save_path=frame_path)
# Final best path
final_scores = [evaluate_fitness(p, G, goal) for p in population]
best_idx = min(range(len(final_scores)), key=lambda i: final_scores[i])
return population[best_idx], final_scores[best_idx], pos
Function to generate GIF for visualization
def create_gif_from_frames(frame_dir, output_gif, fps=5):
frames = sorted(
[os.path.join(frame_dir, f) for f in os.listdir(frame_dir) if f.endswith('.png')]
)
images = [imageio.imread(frame) for frame in frames]
imageio.mimsave(output_gif, images, fps=fps)
print(f"GIF saved to {output_gif}")
Run Simulation#
Play around with different paramters, such as # of generation, mutation rate, and even go back up to use a different road network. You’ll notice that increasing generations leads to better paths, but at the cost of increased computational cost. This is more noticable with larger networks.
best_path, best_length, pos = genetic_algorithm(G, A, B, generations=30, save_frames=True,
pos=pos, mutation_rate=0.3)
print(f"Best path: {best_path} with distance {best_length:.2f}")
create_gif_from_frames("frames", "evolution.gif", fps=3)
Best path: [1, 3, 60, 42, 5, 48, 39, 19] with distance 1.29
C:\Users\Osvaldo A\AppData\Local\Temp\ipykernel_124\3377199376.py:6: DeprecationWarning: Starting with ImageIO v3 the behavior of this function will switch to that of iio.v3.imread. To keep the current behavior (and make this warning disappear) use `import imageio.v2 as imageio` or call `imageio.v2.imread` directly.
images = [imageio.imread(frame) for frame in frames]
GIF saved to evolution.gif
Animate Results#
display(Image(filename="evolution.gif"))
<IPython.core.display.Image object>