303 lines
9.4 KiB
Python
303 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Evolution Lab: Evolving simple programs through genetic algorithms.
|
|
|
|
This experiment evolves mathematical expressions to fit target behaviors.
|
|
It's a simple form of genetic programming - letting programs breed and mutate.
|
|
|
|
The goal: See what emerges from random variation and selection.
|
|
"""
|
|
|
|
import random
|
|
import math
|
|
from dataclasses import dataclass
|
|
from typing import List, Callable, Optional
|
|
import copy
|
|
|
|
|
|
# The primitives our evolved programs can use
|
|
OPERATIONS = ['+', '-', '*', '/', 'sin', 'cos', 'abs', 'max', 'min']
|
|
CONSTANTS = [0, 1, 2, 0.5, math.pi, math.e]
|
|
|
|
|
|
@dataclass
|
|
class Node:
|
|
"""A node in the expression tree."""
|
|
op: str # Operation or 'const' or 'x'
|
|
value: Optional[float] = None # For constants
|
|
left: Optional['Node'] = None
|
|
right: Optional['Node'] = None
|
|
|
|
def evaluate(self, x: float) -> float:
|
|
"""Evaluate this subtree with the given x value."""
|
|
try:
|
|
if self.op == 'x':
|
|
return x
|
|
elif self.op == 'const':
|
|
return self.value
|
|
elif self.op == '+':
|
|
return self.left.evaluate(x) + self.right.evaluate(x)
|
|
elif self.op == '-':
|
|
return self.left.evaluate(x) - self.right.evaluate(x)
|
|
elif self.op == '*':
|
|
return self.left.evaluate(x) * self.right.evaluate(x)
|
|
elif self.op == '/':
|
|
r = self.right.evaluate(x)
|
|
return self.left.evaluate(x) / r if abs(r) > 1e-10 else 0
|
|
elif self.op == 'sin':
|
|
return math.sin(self.left.evaluate(x))
|
|
elif self.op == 'cos':
|
|
return math.cos(self.left.evaluate(x))
|
|
elif self.op == 'abs':
|
|
return abs(self.left.evaluate(x))
|
|
elif self.op == 'max':
|
|
return max(self.left.evaluate(x), self.right.evaluate(x))
|
|
elif self.op == 'min':
|
|
return min(self.left.evaluate(x), self.right.evaluate(x))
|
|
else:
|
|
return 0
|
|
except (ValueError, OverflowError, ZeroDivisionError):
|
|
return 0
|
|
|
|
def to_string(self) -> str:
|
|
"""Convert to readable string."""
|
|
if self.op == 'x':
|
|
return 'x'
|
|
elif self.op == 'const':
|
|
if self.value == math.pi:
|
|
return 'pi'
|
|
elif self.value == math.e:
|
|
return 'e'
|
|
else:
|
|
return f'{self.value:.2f}'
|
|
elif self.op in ['sin', 'cos', 'abs']:
|
|
return f'{self.op}({self.left.to_string()})'
|
|
elif self.op in ['max', 'min']:
|
|
return f'{self.op}({self.left.to_string()}, {self.right.to_string()})'
|
|
else:
|
|
return f'({self.left.to_string()} {self.op} {self.right.to_string()})'
|
|
|
|
def depth(self) -> int:
|
|
"""Get tree depth."""
|
|
if self.left is None and self.right is None:
|
|
return 1
|
|
left_d = self.left.depth() if self.left else 0
|
|
right_d = self.right.depth() if self.right else 0
|
|
return 1 + max(left_d, right_d)
|
|
|
|
def size(self) -> int:
|
|
"""Get number of nodes."""
|
|
count = 1
|
|
if self.left:
|
|
count += self.left.size()
|
|
if self.right:
|
|
count += self.right.size()
|
|
return count
|
|
|
|
|
|
def random_tree(max_depth: int = 4) -> Node:
|
|
"""Generate a random expression tree."""
|
|
if max_depth <= 1 or random.random() < 0.3:
|
|
# Leaf node
|
|
if random.random() < 0.5:
|
|
return Node('x')
|
|
else:
|
|
return Node('const', value=random.choice(CONSTANTS))
|
|
else:
|
|
op = random.choice(OPERATIONS)
|
|
if op in ['sin', 'cos', 'abs']:
|
|
return Node(op, left=random_tree(max_depth - 1))
|
|
else:
|
|
return Node(op,
|
|
left=random_tree(max_depth - 1),
|
|
right=random_tree(max_depth - 1))
|
|
|
|
|
|
def crossover(parent1: Node, parent2: Node) -> Node:
|
|
"""Combine two trees via crossover."""
|
|
child = copy.deepcopy(parent1)
|
|
|
|
# Find random subtree in child to replace
|
|
def get_all_nodes(node, path=[]):
|
|
result = [(node, path)]
|
|
if node.left:
|
|
result.extend(get_all_nodes(node.left, path + ['left']))
|
|
if node.right:
|
|
result.extend(get_all_nodes(node.right, path + ['right']))
|
|
return result
|
|
|
|
child_nodes = get_all_nodes(child)
|
|
parent2_nodes = get_all_nodes(parent2)
|
|
|
|
if len(child_nodes) > 1 and parent2_nodes:
|
|
# Pick a node to replace (not root)
|
|
_, replace_path = random.choice(child_nodes[1:])
|
|
# Pick a subtree from parent2
|
|
donor, _ = random.choice(parent2_nodes)
|
|
|
|
# Navigate to replacement point and replace
|
|
current = child
|
|
for step in replace_path[:-1]:
|
|
current = getattr(current, step)
|
|
setattr(current, replace_path[-1], copy.deepcopy(donor))
|
|
|
|
return child
|
|
|
|
|
|
def mutate(node: Node, rate: float = 0.1) -> Node:
|
|
"""Randomly mutate parts of the tree."""
|
|
node = copy.deepcopy(node)
|
|
|
|
def mutate_recursive(n: Node):
|
|
if random.random() < rate:
|
|
# Replace this subtree with a new random one
|
|
new = random_tree(2)
|
|
n.op = new.op
|
|
n.value = new.value
|
|
n.left = new.left
|
|
n.right = new.right
|
|
else:
|
|
if n.left:
|
|
mutate_recursive(n.left)
|
|
if n.right:
|
|
mutate_recursive(n.right)
|
|
|
|
mutate_recursive(node)
|
|
return node
|
|
|
|
|
|
class Population:
|
|
"""A population of evolving programs."""
|
|
|
|
def __init__(self, size: int = 50, target_func: Callable = None):
|
|
self.size = size
|
|
self.individuals = [random_tree() for _ in range(size)]
|
|
self.target_func = target_func or (lambda x: x * x)
|
|
self.generation = 0
|
|
self.best_fitness_history = []
|
|
|
|
def fitness(self, individual: Node) -> float:
|
|
"""Evaluate how well an individual matches the target."""
|
|
error = 0
|
|
test_points = [x / 10.0 for x in range(-50, 51)]
|
|
|
|
for x in test_points:
|
|
try:
|
|
predicted = individual.evaluate(x)
|
|
expected = self.target_func(x)
|
|
error += (predicted - expected) ** 2
|
|
except:
|
|
error += 1000
|
|
|
|
# Penalize complexity slightly
|
|
complexity_penalty = individual.size() * 0.01
|
|
|
|
return 1.0 / (1.0 + error + complexity_penalty)
|
|
|
|
def evolve(self, generations: int = 100, verbose: bool = True):
|
|
"""Run evolution for specified generations."""
|
|
for gen in range(generations):
|
|
# Evaluate fitness
|
|
scored = [(self.fitness(ind), ind) for ind in self.individuals]
|
|
scored.sort(key=lambda x: -x[0]) # Best first
|
|
|
|
best_fitness = scored[0][0]
|
|
self.best_fitness_history.append(best_fitness)
|
|
|
|
if verbose and gen % 10 == 0:
|
|
print(f"Gen {gen:4d}: Best fitness = {best_fitness:.6f}")
|
|
print(f" Best expr: {scored[0][1].to_string()}")
|
|
|
|
# Selection (tournament)
|
|
def tournament(k=3):
|
|
contestants = random.sample(scored, k)
|
|
return max(contestants, key=lambda x: x[0])[1]
|
|
|
|
# Create new population
|
|
new_pop = []
|
|
|
|
# Elitism: keep best 2
|
|
new_pop.append(copy.deepcopy(scored[0][1]))
|
|
new_pop.append(copy.deepcopy(scored[1][1]))
|
|
|
|
while len(new_pop) < self.size:
|
|
if random.random() < 0.8:
|
|
# Crossover
|
|
p1 = tournament()
|
|
p2 = tournament()
|
|
child = crossover(p1, p2)
|
|
else:
|
|
# Mutation only
|
|
child = mutate(tournament(), rate=0.2)
|
|
|
|
# Always apply some mutation
|
|
child = mutate(child, rate=0.05)
|
|
|
|
# Limit tree depth
|
|
if child.depth() <= 8:
|
|
new_pop.append(child)
|
|
|
|
self.individuals = new_pop
|
|
self.generation += 1
|
|
|
|
return scored[0][1] # Return best individual
|
|
|
|
|
|
def run_experiment(name: str, target_func: Callable, description: str):
|
|
"""Run an evolution experiment."""
|
|
print("=" * 60)
|
|
print(f"EXPERIMENT: {name}")
|
|
print(f"Target: {description}")
|
|
print("=" * 60)
|
|
|
|
pop = Population(size=100, target_func=target_func)
|
|
best = pop.evolve(generations=100, verbose=True)
|
|
|
|
print()
|
|
print("FINAL RESULT:")
|
|
print(f" Expression: {best.to_string()}")
|
|
print(f" Fitness: {pop.fitness(best):.6f}")
|
|
|
|
# Test on some values
|
|
print("\n Sample outputs:")
|
|
for x in [-2, -1, 0, 1, 2]:
|
|
expected = target_func(x)
|
|
predicted = best.evaluate(x)
|
|
print(f" f({x:2d}) = {predicted:8.4f} (expected: {expected:8.4f})")
|
|
|
|
return best, pop
|
|
|
|
|
|
def main():
|
|
print("EVOLUTION LAB: Evolving Mathematical Expressions")
|
|
print()
|
|
|
|
# Experiment 1: Evolve x^2
|
|
run_experiment(
|
|
"Square Function",
|
|
lambda x: x * x,
|
|
"f(x) = x^2"
|
|
)
|
|
|
|
print("\n" + "=" * 60 + "\n")
|
|
|
|
# Experiment 2: Evolve something more complex
|
|
run_experiment(
|
|
"Sine Wave",
|
|
lambda x: math.sin(x),
|
|
"f(x) = sin(x)"
|
|
)
|
|
|
|
print("\n" + "=" * 60 + "\n")
|
|
|
|
# Experiment 3: A weird target - let's see what evolves
|
|
run_experiment(
|
|
"Mystery Function",
|
|
lambda x: abs(x) - x*x/10 + math.sin(x*2),
|
|
"f(x) = |x| - x^2/10 + sin(2x)"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|