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:
115
agent.py
115
agent.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user