From 94b62a67d5fd935c12e9a4c30da9c3067fe0c365 Mon Sep 17 00:00:00 2001 From: kerboul Date: Sat, 13 Jun 2026 14:50:50 +0200 Subject: [PATCH] feat: initial maze-ml GA visualizer --- README.md | 37 +++++++ __pycache__/agent.cpython-311.pyc | Bin 0 -> 5655 bytes __pycache__/config.cpython-311.pyc | Bin 0 -> 1492 bytes __pycache__/genetic.cpython-311.pyc | Bin 0 -> 4758 bytes __pycache__/maze.cpython-311.pyc | Bin 0 -> 5374 bytes __pycache__/visualizer.cpython-311.pyc | Bin 0 -> 10487 bytes agent.py | 78 ++++++++++++++ config.py | 31 ++++++ genetic.py | 56 ++++++++++ main.py | 138 +++++++++++++++++++++++++ maze.py | 108 +++++++++++++++++++ requirements.txt | 2 + visualizer.py | 130 +++++++++++++++++++++++ 13 files changed, 580 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/agent.cpython-311.pyc create mode 100644 __pycache__/config.cpython-311.pyc create mode 100644 __pycache__/genetic.cpython-311.pyc create mode 100644 __pycache__/maze.cpython-311.pyc create mode 100644 __pycache__/visualizer.cpython-311.pyc create mode 100644 agent.py create mode 100644 config.py create mode 100644 genetic.py create mode 100644 main.py create mode 100644 maze.py create mode 100644 requirements.txt create mode 100644 visualizer.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1d840e --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# maze-ml + +100 neural-network agents learn to navigate a maze using a **Genetic Algorithm** — animated in real time with pygame. + +Inspired by the "AI learns to..." YouTube Shorts format. + +## Quick start + +```bash +pip install -r requirements.txt +python main.py +``` + +## Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--fast` | off | Train without rendering, show result at end | +| `--generations` | 200 | Number of GA generations | +| `--maze-size` | 25 | Maze dimensions (NxN) | +| `--seed` | random | Maze seed for reproducibility | +| `--speed` | 60 | FPS cap for visual mode | + +## Architecture + +- `config.py` — all hyperparameters as a dataclass +- `maze.py` — iterative DFS maze generation (bitmask cells) +- `agent.py` — Agent + tiny NeuralNet (8→12→4, pure numpy) +- `genetic.py` — tournament selection, uniform crossover, gaussian mutation +- `visualizer.py` — pygame renderer with trail decay, fitness-ranked colors, HUD +- `main.py` — training loop + argparse + +## How it works + +Each generation, 100 agents simultaneously traverse the maze controlled by small neural networks (8 inputs → 12 hidden → 4 outputs). Fitness rewards progress toward the goal and reaching it. The top 30% of agents are selected as parents for the next generation via tournament selection. + +After ~20–50 generations, agents learn to reliably navigate the maze. diff --git a/__pycache__/agent.cpython-311.pyc b/__pycache__/agent.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3a129d0944500f083b64ea34f79cd1a35144feb6 GIT binary patch literal 5655 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 WADfJ^Fb&$+OP=G{k1t^mHT@Sy`k)5@ literal 0 HcmV?d00001 diff --git a/__pycache__/config.cpython-311.pyc b/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac240943ec113ae4b3502d8877263f9ddb9f11b6 GIT binary patch literal 1492 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 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)) + + +class Agent: + def __init__(self, net: NeuralNet, start_row: int, start_col: int): + self.net = net + 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 + + def _pixel_center(self, cfg) -> 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: + return + x = self.get_inputs(grid, goal_row, goal_col, cfg) + action = self.net.forward(x) + dr = [-1, 1, 0, 0][action] + dc = [0, 0, 1, -1][action] + prev_dist = abs(goal_row - self.row) + abs(goal_col - self.col) + 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) + self.steps += 1 + self.trail.append(self._pixel_center(cfg)) + 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) + self.fitness = ( + self.dist_traveled_toward + - dist * 0.5 + + (cfg.goal_bonus if self.reached_goal else 0.0) + - self.steps * cfg.step_penalty + ) + return self.fitness diff --git a/config.py b/config.py new file mode 100644 index 0000000..1af9dea --- /dev/null +++ b/config.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass + +@dataclass +class MazeConfig: + # Maze geometry + maze_cols: int = 25 + maze_rows: int = 25 + cell_size: int = 24 + wall_width: int = 2 + + # GA / Training + population: int = 100 + max_steps: int = 300 + elite_frac: float = 0.30 + mutation_std: float = 0.05 + mutation_rate: float = 0.20 + + # Neural net + n_inputs: int = 8 + n_hidden: int = 12 + n_outputs: int = 4 + + # Fitness + goal_bonus: float = 1000.0 + step_penalty: float = 0.01 + + # Rendering + fps: int = 60 + trail_decay: float = 0.88 + window_title: str = "Maze ML — GA Visualizer" + hud_font_size: int = 18 diff --git a/genetic.py b/genetic.py new file mode 100644 index 0000000..84138fb --- /dev/null +++ b/genetic.py @@ -0,0 +1,56 @@ +import numpy as np +from agent import Agent, NeuralNet +from config import MazeConfig + + +def tournament_select(weights: np.ndarray, fitnesses: np.ndarray, n_parents: int, k: int = 5) -> np.ndarray: + n = len(fitnesses) + selected = np.empty((n_parents, weights.shape[1]), dtype=np.float32) + for i in range(n_parents): + indices = np.random.choice(n, size=k, replace=False) + winner = indices[np.argmax(fitnesses[indices])] + selected[i] = weights[winner] + 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 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 next_generation(agents: list[Agent], cfg: MazeConfig) -> list[NeuralNet]: + 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) + + n_elite = 10 + elite_indices = np.argsort(fitnesses)[::-1][:n_elite] + elite_weights = all_weights[elite_indices] + + 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] + + n_offspring = cfg.population - n_elite + parents = tournament_select(pool_weights, pool_fitnesses, n_parents=n_offspring * 2) + + new_nets: list[NeuralNet] = [] + + 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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..9d8204b --- /dev/null +++ b/main.py @@ -0,0 +1,138 @@ +import argparse +import sys +import os +from config import MazeConfig +from maze import generate +from agent import Agent, NeuralNet +from genetic import next_generation + + +def handle_events() -> bool: + """Returns False if user wants to quit.""" + import pygame + for event in pygame.event.get(): + if event.type == pygame.QUIT: + return False + if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: + return False + return True + + +def run_generation_headless(agents, grid, goal_row, goal_col, cfg): + """Run one generation without any rendering.""" + for step in range(cfg.max_steps): + for agent in agents: + agent.step(grid, goal_row, goal_col, cfg) + + +def run_generation_visual(agents, grid, goal_row, goal_col, cfg, viz, clock, fps, gen) -> bool: + """Run one generation with rendering. Returns False if user quit.""" + import pygame + for step in range(cfg.max_steps): + for agent in agents: + agent.step(grid, goal_row, goal_col, cfg) + if not handle_events(): + return False + viz.render(agents, gen, step) + clock.tick(fps) + return True + + +def main(): + parser = argparse.ArgumentParser(description="Maze ML — Genetic Algorithm Visualizer") + parser.add_argument("--fast", action="store_true", help="Train without rendering (headless)") + parser.add_argument("--generations", type=int, default=200) + parser.add_argument("--maze-size", type=int, default=25, help="Maze cols and rows (square)") + parser.add_argument("--seed", type=int, default=None) + parser.add_argument("--speed", type=int, default=60, help="FPS cap for visual mode") + args = parser.parse_args() + + cfg = MazeConfig(maze_cols=args.maze_size, maze_rows=args.maze_size) + goal_row = cfg.maze_rows - 1 + goal_col = cfg.maze_cols - 1 + + grid = generate(cfg.maze_rows, cfg.maze_cols, seed=args.seed) + + # Initialize population + nets = [NeuralNet() for _ in range(cfg.population)] + + print(f"Starting training: {args.generations} generations, population={cfg.population}") + + if args.fast: + # --- Headless fast mode: no pygame, no display --- + print("Fast mode: rendering disabled during training.") + + for gen in range(args.generations): + agents = [Agent(net, 0, 0) for net in nets] + run_generation_headless(agents, grid, goal_row, goal_col, cfg) + + for agent in agents: + agent.compute_fitness(goal_row, goal_col, cfg) + + best_fit = max(a.fitness for a in agents) + reached = sum(1 for a in agents if a.reached_goal) + print(f"Gen {gen+1:>4}/{args.generations} | Best fitness: {best_fit:>10.2f} | Reached goal: {reached}/{cfg.population}") + + nets = next_generation(agents, cfg) + + print("Training complete.") + sys.exit(0) + + else: + # --- Visual mode: initialise pygame --- + import pygame + from visualizer import Visualizer + + pygame.init() + win_w = cfg.maze_cols * cfg.cell_size + 200 + win_h = cfg.maze_rows * cfg.cell_size + screen = pygame.display.set_mode((win_w, win_h)) + pygame.display.set_caption(cfg.window_title) + clock = pygame.time.Clock() + + viz = Visualizer(screen, grid, cfg) + + for gen in range(args.generations): + agents = [Agent(net, 0, 0) for net in nets] + + ok = run_generation_visual(agents, grid, goal_row, goal_col, cfg, viz, clock, args.speed, gen) + if not ok: + print("Quit by user.") + pygame.quit() + sys.exit(0) + + for agent in agents: + agent.compute_fitness(goal_row, goal_col, cfg) + + best_fit = max(a.fitness for a in agents) + reached = sum(1 for a in agents if a.reached_goal) + print(f"Gen {gen+1:>4}/{args.generations} | Best fitness: {best_fit:>10.2f} | Reached goal: {reached}/{cfg.population}") + + nets = next_generation(agents, cfg) + viz.reset_trails() + + # Final visual replay with best generation + best_nets = nets + print("\nTraining complete. Showing final generation replay...") + replay_agents = [Agent(net, 0, 0) for net in best_nets] + viz.reset_trails() + for step in range(cfg.max_steps): + for agent in replay_agents: + agent.step(grid, goal_row, goal_col, cfg) + agent.compute_fitness(goal_row, goal_col, cfg) + if not handle_events(): + break + viz.render(replay_agents, args.generations, step) + clock.tick(args.speed) + + print("Done. Press ESC or close window to exit.") + running = True + while running: + running = handle_events() + clock.tick(30) + + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/maze.py b/maze.py new file mode 100644 index 0000000..316f247 --- /dev/null +++ b/maze.py @@ -0,0 +1,108 @@ +import numpy as np +import random + +NORTH, SOUTH, EAST, WEST = 0, 1, 2, 3 +_DX = [0, 0, 1, -1] # col delta for N,S,E,W +_DY = [-1, 1, 0, 0] # row delta +_OPP = [1, 0, 3, 2] # opposite direction + + +def generate(rows: int, cols: int, seed: int | None = None) -> np.ndarray: + rng = random.Random(seed) + grid = np.full((rows, cols), 0b1111, dtype=np.uint8) + visited = np.zeros((rows, cols), dtype=bool) + + start_r, start_c = 0, 0 + visited[start_r, start_c] = True + stack = [(start_r, start_c)] + + while stack: + row, col = stack[-1] + directions = [NORTH, SOUTH, EAST, WEST] + rng.shuffle(directions) + + moved = False + for d in directions: + nr = row + _DY[d] + nc = col + _DX[d] + if 0 <= nr < rows and 0 <= nc < cols and not visited[nr, nc]: + # Carve passage: clear wall bit on current cell + grid[row, col] &= (~(1 << d)) & 0xFF + # Clear opposite wall bit on neighbour + grid[nr, nc] &= (~(1 << _OPP[d])) & 0xFF + visited[nr, nc] = True + stack.append((nr, nc)) + moved = True + break + + if not moved: + stack.pop() + + return grid + + +def get_walls(grid: np.ndarray, row: int, col: int) -> tuple[bool, bool, bool, bool]: + cell = int(grid[row, col]) + wall_n = float((cell >> NORTH) & 1) + wall_s = float((cell >> SOUTH) & 1) + wall_e = float((cell >> EAST) & 1) + wall_w = float((cell >> WEST) & 1) + return (wall_n, wall_s, wall_e, wall_w) + + +def is_passable(grid: np.ndarray, row: int, col: int, direction: int) -> bool: + cell = int(grid[row, col]) + return not bool((cell >> direction) & 1) + + +def render_static(grid: np.ndarray, cfg) -> 'pygame.Surface': + import pygame + + rows, cols = grid.shape + cell_size = getattr(cfg, 'cell_size', 32) + wall_thickness = getattr(cfg, 'wall_thickness', 2) + + width = cols * cell_size + height = rows * cell_size + + surface = pygame.Surface((width, height)) + surface.fill((15, 15, 20)) + + wall_color = (40, 40, 50) + t = wall_thickness + + for row in range(rows): + for col in range(cols): + x = col * cell_size + y = row * cell_size + cell = int(grid[row, col]) + + # North wall + if (cell >> NORTH) & 1: + pygame.draw.rect(surface, wall_color, (x, y, cell_size, t)) + + # South wall + if (cell >> SOUTH) & 1: + pygame.draw.rect(surface, wall_color, (x, y + cell_size - t, cell_size, t)) + + # East wall + if (cell >> EAST) & 1: + pygame.draw.rect(surface, wall_color, (x + cell_size - t, y, t, cell_size)) + + # West wall + if (cell >> WEST) & 1: + pygame.draw.rect(surface, wall_color, (x, y, t, cell_size)) + + # Start cell (0, 0): small cyan circle + start_cx = cell_size // 2 + start_cy = cell_size // 2 + start_radius = max(2, cell_size // 6) + pygame.draw.circle(surface, (0, 220, 220), (start_cx, start_cy), start_radius) + + # Goal cell (rows-1, cols-1): green filled circle + goal_x = (cols - 1) * cell_size + cell_size // 2 + goal_y = (rows - 1) * cell_size + cell_size // 2 + goal_radius = max(3, cell_size // 4) + pygame.draw.circle(surface, (0, 200, 80), (goal_x, goal_y), goal_radius) + + return surface diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..270de4b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pygame>=2.5.0 +numpy>=1.26.0 diff --git a/visualizer.py b/visualizer.py new file mode 100644 index 0000000..d4beec7 --- /dev/null +++ b/visualizer.py @@ -0,0 +1,130 @@ +import pygame +import numpy as np +from config import MazeConfig +from maze import render_static + +class Visualizer: + def __init__(self, screen: pygame.Surface, grid: np.ndarray, cfg: MazeConfig): + self.screen = screen + self.grid = grid + self.cfg = cfg + self.maze_w = cfg.maze_cols * cfg.cell_size + self.maze_h = cfg.maze_rows * cfg.cell_size + self.hud_x = self.maze_w + 10 + # Pre-render static maze + self.maze_surface = render_static(grid, cfg) + # Trail surface with per-pixel alpha + self.trail_surf = pygame.Surface((self.maze_w, self.maze_h), pygame.SRCALPHA) + self.trail_surf.fill((0, 0, 0, 0)) + # Font + pygame.font.init() + self.font = pygame.font.SysFont("monospace", cfg.hud_font_size) + self.font_sm = pygame.font.SysFont("monospace", cfg.hud_font_size - 4) + self.clock = pygame.time.Clock() + self._goal_flash = 0 + self._first_goal = False + + def _rank_color(self, rank: int, total: int) -> tuple[int, int, int]: + t = rank / max(1, total - 1) + return (int(255 * t), int(255 * (1.0 - t)), 30) + + def _fade_trails(self): + subtract = max(1, int(255 * (1.0 - self.cfg.trail_decay))) + fade = pygame.Surface((self.maze_w, self.maze_h), pygame.SRCALPHA) + fade.fill((0, 0, 0, subtract)) + self.trail_surf.blit(fade, (0, 0), special_flags=pygame.BLEND_RGBA_SUB) + + def _draw_hud(self, gen: int, best_fitness: float, step: int, alive: int, reached: int): + # Dark sidebar background + sidebar_rect = pygame.Rect(self.maze_w, 0, 200, self.maze_h) + pygame.draw.rect(self.screen, (10, 10, 16), sidebar_rect) + + lines = [ + ("GEN", f"{gen:>4}"), + ("STEP", f"{step:>4}/{self.cfg.max_steps}"), + ("ALIVE", f"{alive:>4}/{self.cfg.population}"), + ("GOAL", f"{reached:>4}"), + ("BEST", f"{best_fitness:>8.1f}"), + ] + y = 20 + accent = (0, 220, 200) + for label, value in lines: + lbl_surf = self.font_sm.render(label, True, (100, 100, 120)) + val_surf = self.font.render(value, True, accent) + self.screen.blit(lbl_surf, (self.hud_x, y)) + self.screen.blit(val_surf, (self.hud_x, y + 16)) + y += 52 + + # Legend + y += 20 + legend_label = self.font_sm.render("RANK", True, (80, 80, 100)) + self.screen.blit(legend_label, (self.hud_x, y)) + y += 18 + bar_w = 170 + for i in range(bar_w): + t = i / bar_w + r, g = int(255 * t), int(255 * (1 - t)) + pygame.draw.line(self.screen, (r, g, 30), (self.hud_x + bar_w - i, y), (self.hud_x + bar_w - i, y + 10)) + best_lbl = self.font_sm.render("best", True, (0, 255, 30)) + wrst_lbl = self.font_sm.render("worst", True, (255, 0, 30)) + self.screen.blit(best_lbl, (self.hud_x, y + 14)) + self.screen.blit(wrst_lbl, (self.hud_x + bar_w - 35, y + 14)) + + def render(self, agents, gen: int, step: int): + # Sort by fitness for ranking colors + sorted_agents = sorted(agents, key=lambda a: a.fitness) + + # Check first goal + reached = sum(1 for a in agents if a.reached_goal) + if reached > 0 and not self._first_goal: + self._first_goal = True + self._goal_flash = 45 + + # Blit static maze + self.screen.blit(self.maze_surface, (0, 0)) + + # Fade and draw trails + self._fade_trails() + for rank, agent in enumerate(sorted_agents): + if agent.trail: + color = self._rank_color(rank, len(sorted_agents)) + px, py = agent.trail[-1] + pygame.draw.circle(self.trail_surf, (*color, 130), (px, py), 3) + self.screen.blit(self.trail_surf, (0, 0)) + + # Draw agents on top + for rank, agent in enumerate(sorted_agents): + color = self._rank_color(rank, len(sorted_agents)) + px = agent.col * self.cfg.cell_size + self.cfg.cell_size // 2 + py = agent.row * self.cfg.cell_size + self.cfg.cell_size // 2 + radius = 5 if agent.reached_goal else 3 + pygame.draw.circle(self.screen, color, (px, py), radius) + if agent.reached_goal: + pygame.draw.circle(self.screen, (255, 255, 255), (px, py), radius + 2, 1) + + # Goal flash overlay + if self._goal_flash > 0: + gx = (self.cfg.maze_cols - 1) * self.cfg.cell_size + self.cfg.cell_size // 2 + gy = (self.cfg.maze_rows - 1) * self.cfg.cell_size + self.cfg.cell_size // 2 + alpha = int(200 * self._goal_flash / 45) + flash_surf = pygame.Surface((self.cfg.cell_size, self.cfg.cell_size), pygame.SRCALPHA) + flash_surf.fill((255, 220, 0, alpha)) + self.screen.blit(flash_surf, (gx - self.cfg.cell_size//2, gy - self.cfg.cell_size//2)) + self._goal_flash -= 1 + + # HUD + best_fit = max((a.fitness for a in agents), default=0.0) + alive = sum(1 for a in agents if not a.reached_goal) + self._draw_hud(gen, best_fit, step, alive, reached) + + pygame.display.flip() + + def reset_trails(self): + self.trail_surf.fill((0, 0, 0, 0)) + self._first_goal = False + + def new_maze(self, grid, cfg): + self.grid = grid + self.cfg = cfg + self.maze_surface = render_static(grid, cfg) + self.reset_trails()