fix: genome GA converges via BFS fitness + two-rate prefix-aware mutation

- Replace Manhattan distance with BFS distances (actual maze path length)
- Switch from NN to direct sequence genome (what YouTube Shorts actually use)
- Mutation-only GA (crossover breaks positional maze paths)
- Two-rate mutation: low rate before best-step (preserve prefix), high after (explore tail)
- Auto-seed selection finds maze with short BFS path
- Default maze 15x15, cell_size=36, max_steps=150
- Typically converges in 5-15 generations
This commit is contained in:
2026-06-13 15:48:22 +02:00
parent 20d9e99d5f
commit 45b37f9357
5 changed files with 210 additions and 99 deletions

View File

@@ -1,56 +1,87 @@
import numpy as np
from agent import Agent, NeuralNet
from agent import Agent, Genome
from config import MazeConfig
_AgentInfo = tuple # (genes, best_step)
def tournament_select(weights: np.ndarray, fitnesses: np.ndarray, n_parents: int, k: int = 5) -> np.ndarray:
def tournament_select(genes_list: list[np.ndarray], fitnesses: np.ndarray,
n_parents: int, k: int = 5) -> list[np.ndarray]:
n = len(fitnesses)
selected = np.empty((n_parents, weights.shape[1]), dtype=np.float32)
for i in range(n_parents):
selected = []
for _ in range(n_parents):
indices = np.random.choice(n, size=k, replace=False)
winner = indices[np.argmax(fitnesses[indices])]
selected[i] = weights[winner]
selected.append(genes_list[winner].copy())
return selected
def uniform_crossover(p1: np.ndarray, p2: np.ndarray) -> np.ndarray:
mask = np.random.rand(len(p1)) < 0.5
child = np.where(mask, p1, p2)
return child.astype(np.float32)
def single_point_crossover(g1: np.ndarray, g2: np.ndarray) -> np.ndarray:
point = np.random.randint(1, len(g1))
return np.concatenate([g1[:point], g2[point:]])
def mutate(weights: np.ndarray, std: float = 0.05, rate: float = 0.20) -> np.ndarray:
mask = np.random.rand(len(weights)) < rate
noise = np.random.randn(len(weights)).astype(np.float32) * std
return (weights + mask * noise).astype(np.float32)
def mutate_seq(genes: np.ndarray, rate: float) -> np.ndarray:
"""Replace each gene with a random action with probability rate."""
mask = np.random.rand(len(genes)) < rate
noise = np.random.randint(0, 4, size=len(genes), dtype=np.uint8)
result = genes.copy()
result[mask] = noise[mask]
return result
def next_generation(agents: list[Agent], cfg: MazeConfig) -> list[NeuralNet]:
def mutate_two_rate(genes: np.ndarray, pivot: int,
low_rate: float = 0.005, high_rate: float = 0.30) -> np.ndarray:
"""Prefix-aware mutation: low rate before pivot (preserve good path),
high rate after pivot (aggressively explore the stuck region)."""
result = genes.copy()
n = len(genes)
pivot = min(pivot, n)
# Prefix: preserve
mask_pre = np.random.rand(pivot) < low_rate
result[:pivot][mask_pre] = np.random.randint(0, 4, mask_pre.sum(), dtype=np.uint8)
# Tail: explore
tail = n - pivot
if tail > 0:
mask_tail = np.random.rand(tail) < high_rate
result[pivot:][mask_tail] = np.random.randint(0, 4, mask_tail.sum(), dtype=np.uint8)
return result
def next_generation(agents: list[Agent], cfg: MazeConfig) -> list[Genome]:
fitnesses = np.array([a.fitness for a in agents], dtype=np.float32)
all_weights = np.stack([a.net.weights for a in agents], axis=0)
genes_list = [a.genome.genes for a in agents]
n_elite = 10
elite_indices = np.argsort(fitnesses)[::-1][:n_elite]
elite_weights = all_weights[elite_indices]
# Elites: top n_elite survive unchanged
n_elite = cfg.n_elite
elite_idx = np.argsort(fitnesses)[::-1][:n_elite]
new_genomes: list[Genome] = [Genome(genes_list[i].copy()) for i in elite_idx]
n_pool = max(1, int(len(agents) * cfg.elite_frac))
pool_indices = np.argsort(fitnesses)[::-1][:n_pool]
pool_weights = all_weights[pool_indices]
pool_fitnesses = fitnesses[pool_indices]
# Parents pool: top elite_frac
n_pool = max(2, int(len(agents) * cfg.elite_frac))
pool_idx = np.argsort(fitnesses)[::-1][:n_pool]
pool = [genes_list[i] for i in pool_idx]
pool_fits = fitnesses[pool_idx]
# Build pool with (genes, best_step) pairs
pool_agents = [agents[i] for i in pool_idx]
pool_genes = [a.genome.genes for a in pool_agents]
pool_pivots = [a._best_step for a in pool_agents]
n_offspring = cfg.population - n_elite
parents = tournament_select(pool_weights, pool_fitnesses, n_parents=n_offspring * 2)
parent_indices = [
pool_idx[np.argmax(pool_fits[np.random.choice(len(pool_fits), 5, replace=False)])]
for _ in range(n_offspring)
]
new_nets: list[NeuralNet] = []
for idx in parent_indices:
parent_agent = agents[idx]
child = mutate_two_rate(
parent_agent.genome.genes,
pivot=parent_agent._best_step,
low_rate=0.003,
high_rate=0.30,
)
new_genomes.append(Genome(child))
for w in elite_weights:
new_nets.append(NeuralNet(w.copy()))
for i in range(n_offspring):
p1 = parents[i * 2]
p2 = parents[i * 2 + 1]
child = uniform_crossover(p1, p2)
child = mutate(child, std=cfg.mutation_std, rate=cfg.mutation_rate)
new_nets.append(NeuralNet(child))
return new_nets
return new_genomes