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

115
agent.py
View File

@@ -1,78 +1,99 @@
import numpy as np
from maze import get_walls, is_passable, NORTH, SOUTH, EAST, WEST
from maze import get_walls, is_passable, bfs_distances
from config import MazeConfig
class NeuralNet:
WEIGHT_COUNT = 8*12 + 12 + 12*4 + 4 # = 160
def __init__(self, weights=None):
if weights is None:
weights = np.random.randn(self.WEIGHT_COUNT) * 0.5
self.weights = weights.astype(np.float32)
class Genome:
"""Direct action-sequence genome: each gene is a move (0=N 1=S 2=E 3=W).
The GA evolves the sequence directly — no NN weights to get in the way.
This is the approach used in "AI learns maze" viral videos.
"""
N_ACTIONS = 4
def forward(self, x: np.ndarray) -> int:
# x shape: (8,) float32
# Slices: W1=weights[0:96].reshape(8,12), b1=weights[96:108]
# W2=weights[108:156].reshape(12,4), b2=weights[156:160]
# Forward: h = ReLU(x @ W1 + b1), logits = h @ W2 + b2
# Return argmax(logits) — deterministic, no sampling
W1 = self.weights[0:96].reshape(8, 12)
b1 = self.weights[96:108]
W2 = self.weights[108:156].reshape(12, 4)
b2 = self.weights[156:160]
h = np.maximum(0.0, x @ W1 + b1)
logits = h @ W2 + b2
return int(np.argmax(logits))
def __init__(self, genes: np.ndarray | None = None, cfg: MazeConfig | None = None):
steps = cfg.max_steps if cfg is not None else 300
if genes is None:
genes = np.random.randint(0, self.N_ACTIONS, size=steps, dtype=np.uint8)
self.genes = genes
@property
def weights(self) -> np.ndarray:
"""Alias so genetic.py can treat Genome like NeuralNet."""
return self.genes.astype(np.float32)
@classmethod
def from_weights(cls, w: np.ndarray) -> "Genome":
return cls(genes=np.clip(np.round(w), 0, 3).astype(np.uint8))
class Agent:
def __init__(self, net: NeuralNet, start_row: int, start_col: int):
self.net = net
def __init__(self, genome: Genome, start_row: int, start_col: int):
self.net = genome # kept as .net so visualizer/main.py don't need changes
self.genome = genome
self.row = start_row
self.col = start_col
self.alive = True
self.reached_goal = False
self.steps = 0
self.fitness = 0.0
self.trail: list[tuple[int,int]] = [] # pixel coords
self.dist_traveled_toward = 0.0
self.trail: list[tuple[int, int]] = []
self._visited: set[tuple[int, int]] = {(start_row, start_col)}
self._visit_counts: dict[tuple[int, int], int] = {(start_row, start_col): 1}
self.wall_hits = 0
self._min_bfs: int = 10000 # closest BFS approach to goal this episode
self._best_step: int = 0 # genome index when min_bfs was achieved
self._bfs: np.ndarray | None = None # set externally before stepping
def _pixel_center(self, cfg) -> tuple[int, int]:
def _pixel_center(self, cfg: MazeConfig) -> tuple[int, int]:
return (self.col * cfg.cell_size + cfg.cell_size // 2,
self.row * cfg.cell_size + cfg.cell_size // 2)
def get_inputs(self, grid, goal_row, goal_col, cfg) -> np.ndarray:
wN, wS, wE, wW = get_walls(grid, self.row, self.col)
dx = (goal_col - self.col) / cfg.maze_cols
dy = (goal_row - self.row) / cfg.maze_rows
px = self.col / cfg.maze_cols
py = self.row / cfg.maze_rows
return np.array([wN, wS, wE, wW, dx, dy, px, py], dtype=np.float32)
def step(self, grid, goal_row, goal_col, cfg):
if self.reached_goal:
def step(self, grid, goal_row: int, goal_col: int, cfg: MazeConfig):
if self.reached_goal or self.steps >= len(self.genome.genes):
return
x = self.get_inputs(grid, goal_row, goal_col, cfg)
action = self.net.forward(x)
action = int(self.genome.genes[self.steps])
dr = [-1, 1, 0, 0][action]
dc = [0, 0, 1, -1][action]
prev_dist = abs(goal_row - self.row) + abs(goal_col - self.col)
dc = [0, 0, 1, -1][action]
if is_passable(grid, self.row, self.col, action):
self.row += dr
self.col += dc
new_dist = abs(goal_row - self.row) + abs(goal_col - self.col)
self.dist_traveled_toward += max(0.0, prev_dist - new_dist)
else:
self.wall_hits += 1
self.steps += 1
cell = (self.row, self.col)
self._visited.add(cell)
self._visit_counts[cell] = self._visit_counts.get(cell, 0) + 1
self.trail.append(self._pixel_center(cfg))
if self._bfs is not None:
bfs_d = int(self._bfs[self.row, self.col])
if bfs_d >= 0 and bfs_d < self._min_bfs:
self._min_bfs = bfs_d
self._best_step = self.steps # record when we achieved best approach
if self.row == goal_row and self.col == goal_col:
self.reached_goal = True
def compute_fitness(self, goal_row, goal_col, cfg):
dist = abs(goal_row - self.row) + abs(goal_col - self.col)
def compute_fitness(self, goal_row: int, goal_col: int, cfg: MazeConfig) -> float:
# BFS distance from start (0,0) to goal = max_bfs
# best BFS approach: the closest the agent ever got (through actual maze corridors)
max_bfs = int(self._bfs[0, 0]) if self._bfs is not None else (goal_row + goal_col)
best_bfs = self._min_bfs if self._min_bfs < 10000 else max_bfs
unique_cells = len(self._visited)
total_visits = sum(self._visit_counts.values())
revisit_penalty = total_visits - unique_cells
self.fitness = (
self.dist_traveled_toward
- dist * 0.5
(max_bfs - best_bfs) * 12.0 # real maze proximity (primary)
+ unique_cells * 1.0 # exploration
- revisit_penalty * 0.1 # penalize looping
- self.wall_hits * 0.05
+ (cfg.goal_bonus if self.reached_goal else 0.0)
- self.steps * cfg.step_penalty
+ ((cfg.max_steps - self.steps) * 5.0 if self.reached_goal else 0.0)
)
return self.fitness
# Keep NeuralNet as alias so any external code importing it still works
NeuralNet = Genome