# Learn to Solve Mazes # Book: Cellular Automata From First Principles # Chapter URL: https://programmer.ie/books/cellular-automata-from-first-principles/46-chapter/ # Prose fingerprint (sha256, normalized): ae451f34eadc774aa13eed978a0cdd4e62c3cf1455a959e6583b5f11d35d3a3d # Snapshot generated at build time by Hugo. This is the complete rendered prose # of the chapter; it is not a transcript of the source file. A pathfinding NCA becomes interesting only when the training problem itself is disciplined. If every maze has the same size, wall density and corridor style, the model may learn the dataset more than the algorithm. So this chapter builds the training system around procedural variation. Generate mazes as data Start with binary occupancy: 0 = open 1 = wall One simple generator begins with random walls. It does not reject disconnected examples; the BFS teacher below marks unreachable cells with a mask instead. import numpy as np def random_grid(size=32, wall_probability=0.28, seed=None): rng = np.random.default_rng(seed) grid = (rng.random((size, size)) < wall_probability).astype(np.uint8) grid[0, :] = 1 grid[-1, :] = 1 grid[:, 0] = 1 grid[:, -1] = 1 return grid This is not necessarily a beautiful human-style maze. That is useful. We want many local obstacle arrangements, not one generator’s aesthetic signature. Sample start and goal positions def sample_open_cell(grid, rng): open_cells = np.argwhere(grid == 0) return tuple(open_cells[rng.integers(len(open_cells))]) Then choose start and goal far enough apart that the problem requires propagation. def sample_problem(grid, rng, minimum_manhattan=12): for _ in range(1000): start = sample_open_cell(grid, rng) goal = sample_open_cell(grid, rng) distance = abs(start[0] - goal[0]) + abs(start[1] - goal[1]) if distance >= minimum_manhattan: return start, goal raise RuntimeError("could not sample distant start/goal") Use BFS as the teacher We do not need human labels. A classical algorithm can produce exact supervision. from collections import deque def bfs_distance(grid, goal): distance = np.full(grid.shape, np.inf, dtype=np.float32) queue = deque([goal]) distance[goal] = 0.0 while queue: y, x = queue.popleft() for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]: ny, nx = y + dy, x + dx if not (0 <= ny < grid.shape[0] and 0 <= nx < grid.shape[1]): continue if grid[ny, nx] == 1: continue if np.isfinite(distance[ny, nx]): continue distance[ny, nx] = distance[y, x] + 1 queue.append((ny, nx)) return distance (Verified: detour distances route around walls exactly; goal cell reads 0.0.) This gives us a perfect target distance field for every reachable cell. Normalize without destroying semantics Raw distances vary with maze size. Normalize reachable values: def normalize_distance(distance): reachable = np.isfinite(distance) out = np.ones_like(distance, dtype=np.float32) if reachable.any(): maximum = distance[reachable].max() if maximum > 0: out[reachable] = distance[reachable] / maximum else: out[reachable] = 0.0 return out, reachable.astype(np.float32) (Verified: reachable cells scale to max 1.0; unreachable cells stay 1.0 but flagged by the mask.) Keep the reachable mask separately. Do not turn unreachable cells into ordinary high-distance targets and pretend the distinction disappeared. Train on batches of different mazes Reuse the previous chapter’s tools — clamp_inputs for input preservation and the frozen rollout for clamped execution — rather than inlining them again: def training_step(model, batch, optimizer): optimizer.zero_grad() losses = [] for state, target, mask, steps in batch: frozen = state.clone() final = rollout_frozen(model, state, steps, frozen) prediction = final[:, 3:4] error = (prediction - target).pow(2) loss = (error * mask).sum() / mask.sum().clamp_min(1) losses.append(loss) loss = torch.stack(losses).mean() loss.backward() optimizer.step() return float(loss) Sampling a different rollout length per problem discourages dependence on one exact stopping time. The training loop as a pipeline — data, teacher, execution, and update in their fixed roles: flowchart LR G[generate maze] --> B[BFS teacher distances] B --> N[normalize + reachable mask] N --> R[rollout_frozen: clamped execution] R --> L[masked MSE loss] L --> U[optimizer step] U --> G Element Role Leakage risk if mishandled input channels (walls/start/goal) problem statement, clamped frozen model rewriting the maze instead of solving it BFS distance target exact supervision memorizing distances, not procedures reachable mask separates solvable from void unreachable cells trained as far-away rollout length sampling prevents timing tricks fixed-horizon brittleness pixel-loss + path metrics train signal vs real objective low MSE with no valid paths Curriculum can help A useful curriculum is: stage 1: sparse obstacles stage 2: moderate obstacle density stage 3: longer routes stage 4: dead ends and bottlenecks stage 5: mixed generators This does not mean the model must always be trained this way. It means we can control task difficulty instead of treating failed optimization as mysterious. Measure more than pixel loss A low distance-field MSE is not the actual application objective. Evaluate: reachable classification accuracy valid-path rate shortest-path rate mean excess path length failure rate steps required before solution stabilizes For example: def excess_length(found, optimal): if found is None: return np.inf return len(found) - optimal Then report solved and unsolved cases separately. Watch computation unfold Save the predicted distance field at intermediate steps: snapshots = [] for step in range(64): state = model(state) if step in {0, 1, 2, 4, 8, 16, 32, 63}: snapshots.append(state[:, 3:4].detach().cpu()) Plotted as a sequence, these snapshots are one of the most informative views of the computation. You should see information spreading through corridors rather than the final answer appearing all at once. A maze tests distributed state Dead ends reveal why hidden state matters. A cell may need to distinguish: unvisited frontier just arrived visited earlier reachable but not useful for final route Those concepts do not all have to be explicit output channels. The model can encode them in hidden state. That makes maze solving a better probe of NCA computation than simple image growth. But solving familiar mazes is still not enough Suppose training uses only 32×32 grids with 28% random walls. A good validation score there tells us almost nothing about algorithmic generalization. The next chapter tests generalization by deliberately changing the problem: larger grids longer paths higher wall density new maze generators more dead ends narrower bottlenecks If performance survives, we have stronger evidence that the NCA learned a reusable iterative computation. Research Earle, S., Yildiz, O., Togelius, J. & Hegde, C. — Pathfinding Neural Cellular Automata (2023). The training-and-evaluation discipline formalized here: procedural maze variation during training, adversarially evolved mazes as the hard distribution, and out-of-distribution testing as the actual claim. The next chapter’s harder-maze evaluation follows this paper’s protocol. https://arxiv.org/abs/2301.06820 Mordvintsev, A., Randazzo, E., Niklasson, E. & Levin, M. — Growing Neural Cellular Automata (Distill, 2020). The substrate under the maze task: shared local rule, hidden working state, pool training. Unchanged from morphogenesis — only the objective moved. https://doi.org/10.23915/distill.00023