Skip to content

Commit dad6fc4

Browse files
committed
Add grid search visualization (#1155)
Issue #1155 asked for a visualization of how a search algorithm expands and finds the path (à la redblobgames), on a 2-D grid. Adds: - search.py: GridProblem — shortest-path finding on a grid with obstacles (4- or 8-connected, unit step cost, Manhattan h), a clean reusable Problem. - notebook_utils.py: grid_search_steps(problem, strategy) records the cells in the order they are expanded (strategies: bfs/ucs/greedy/astar) and returns the path; plot_grid_search() shades the expanded cells by order and draws the path, obstacles and start/goal (matplotlib, works headless). - search.ipynb: a 'GRID SEARCH VISUALIZATION' section comparing BFS vs greedy vs A* on the same grid (with the rendered figure) — A* expands far fewer cells than the uninformed search while still returning an optimal path. Tests: GridProblem (optimal path around a wall, unreachable goal) and the grid visualization (A* expands <= BFS; plot renders). 34 passed in test_search.
1 parent f7b1000 commit dad6fc4

5 files changed

Lines changed: 220 additions & 2 deletions

File tree

aima/notebook_utils.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import heapq
12
import os
23
import time
34
from collections import defaultdict
@@ -16,7 +17,7 @@
1617
from aima.games import TicTacToe, alpha_beta_player, random_player, Fig52Extended
1718
from aima.learning import DataSet
1819
from aima.logic import parse_definite_clause, standardize_variables, unify_mm, subst
19-
from aima.search import GraphProblem, romania_map
20+
from aima.search import GraphProblem, romania_map, Node
2021

2122
# repo root (the directory containing the `aima` package), so cwd-relative
2223
# data/image paths resolve regardless of where a notebook is launched from
@@ -1236,3 +1237,75 @@ def plot_model_boundary(dataset, attr1, attr2, model=None):
12361237
plt.xlim(xx.min(), xx.max())
12371238
plt.ylim(yy.min(), yy.max())
12381239
plt.show()
1240+
1241+
1242+
# ______________________________________________________________________________
1243+
# Visualizing search on a 2-D grid
1244+
1245+
1246+
def grid_search_steps(problem, strategy='astar'):
1247+
"""Run a search on a :class:`~aima.search.GridProblem`, recording how it explores.
1248+
1249+
Returns ``(explored, path)``: ``explored`` is the list of cells in the order
1250+
they are expanded (popped from the frontier), and ``path`` is the list of cells
1251+
from start to goal (``None`` if the goal is unreachable). ``strategy`` selects
1252+
the evaluation function -- ``'astar'`` (g + h), ``'ucs'`` (g), ``'bfs'`` (depth)
1253+
or ``'greedy'`` (h) -- which is what makes the different exploration patterns.
1254+
"""
1255+
evaluators = {
1256+
'astar': lambda n: n.path_cost + problem.h(n),
1257+
'ucs': lambda n: n.path_cost,
1258+
'bfs': lambda n: n.depth,
1259+
'greedy': lambda n: problem.h(n),
1260+
}
1261+
f = evaluators[strategy]
1262+
start = Node(problem.initial)
1263+
frontier = [(f(start), 0, start)] # (priority, tie-breaker, node)
1264+
best_cost = {problem.initial: start.path_cost}
1265+
explored, expanded = [], set()
1266+
counter = 1
1267+
while frontier:
1268+
_, _, node = heapq.heappop(frontier)
1269+
if node.state in expanded:
1270+
continue
1271+
expanded.add(node.state)
1272+
explored.append(node.state)
1273+
if problem.goal_test(node.state):
1274+
return explored, [n.state for n in node.path()]
1275+
for child in node.expand(problem):
1276+
if child.state not in best_cost or child.path_cost < best_cost[child.state]:
1277+
best_cost[child.state] = child.path_cost
1278+
heapq.heappush(frontier, (f(child), counter, child))
1279+
counter += 1
1280+
return explored, None
1281+
1282+
1283+
def plot_grid_search(problem, explored, path=None, ax=None, title=None):
1284+
"""Visualise a grid search: obstacles, the cells expanded (shaded by *when* they
1285+
were expanded), the solution path, and the start/goal. ``explored`` and ``path``
1286+
are as returned by :func:`grid_search_steps`. Returns the matplotlib Axes.
1287+
"""
1288+
width, height = problem.width, problem.height
1289+
shade = np.full((height, width), np.nan)
1290+
for order, (x, y) in enumerate(explored):
1291+
shade[y, x] = order
1292+
1293+
if ax is None:
1294+
_, ax = plt.subplots(figsize=(width * 0.45 + 1, height * 0.45 + 1))
1295+
ax.imshow(shade, origin='lower', cmap='YlGnBu',
1296+
extent=(-0.5, width - 0.5, -0.5, height - 0.5))
1297+
for (x, y) in problem.obstacles:
1298+
ax.add_patch(plt.Rectangle((x - 0.5, y - 0.5), 1, 1, color='0.2'))
1299+
if path:
1300+
ax.plot([c[0] for c in path], [c[1] for c in path],
1301+
color='orange', linewidth=2, marker='.', zorder=2)
1302+
(sx, sy), (gx, gy) = problem.initial, problem.goal
1303+
ax.scatter([sx], [sy], c='lime', s=130, marker='o', edgecolors='k', zorder=3)
1304+
ax.scatter([gx], [gy], c='red', s=160, marker='*', edgecolors='k', zorder=3)
1305+
ax.set_xticks(range(width))
1306+
ax.set_yticks(range(height))
1307+
ax.grid(True, color='0.85', linewidth=0.5)
1308+
ax.set_xlim(-0.5, width - 0.5)
1309+
ax.set_ylim(-0.5, height - 0.5)
1310+
ax.set_title(title or '{} cells expanded'.format(len(explored)))
1311+
return ax

aima/search.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,43 @@ def value(self, state):
11031103
return self.grid[x][y]
11041104

11051105

1106+
class GridProblem(Problem):
1107+
"""Shortest-path finding on a 2-D grid.
1108+
1109+
States are ``(x, y)`` cells. The agent steps between in-bounds, obstacle-free
1110+
cells -- 4-connected via ``directions4`` by default, or pass ``directions8``
1111+
for 8-connected movement. Every step costs 1; ``h`` is the Manhattan distance
1112+
to the goal (admissible for the 4-connected, unit-cost case).
1113+
"""
1114+
1115+
def __init__(self, initial, goal, width, height, obstacles=(), directions=directions4):
1116+
super().__init__(initial, goal)
1117+
self.width = width
1118+
self.height = height
1119+
self.obstacles = set(map(tuple, obstacles))
1120+
self.directions = directions
1121+
1122+
def passable(self, cell):
1123+
"""True if ``cell`` is on the grid and not an obstacle."""
1124+
x, y = cell
1125+
return 0 <= x < self.width and 0 <= y < self.height and tuple(cell) not in self.obstacles
1126+
1127+
def actions(self, state):
1128+
return [a for a, d in self.directions.items() if self.passable(vector_add(state, d))]
1129+
1130+
def result(self, state, action):
1131+
return vector_add(state, self.directions[action])
1132+
1133+
def path_cost(self, c, state1, action, state2):
1134+
return c + 1
1135+
1136+
def h(self, node):
1137+
"""Manhattan distance from ``node`` (or a state) to the goal."""
1138+
x, y = node.state if isinstance(node, Node) else node
1139+
gx, gy = self.goal
1140+
return abs(x - gx) + abs(y - gy)
1141+
1142+
11061143
class OnlineDFSAgent:
11071144
"""
11081145
[Figure 4.21]

notebooks/search.ipynb

Lines changed: 53 additions & 1 deletion
Large diffs are not rendered by default.

notebooks/search.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,33 @@ def color_city_and_update_map(node, color):
991991
assert puzzle.check_solvability((2, 4, 3, 1, 5, 6, 7, 8, 0))
992992
recursive_best_first_search(puzzle).solution()
993993

994+
# %% [markdown]
995+
# ## GRID SEARCH VISUALIZATION
996+
#
997+
# The visualizations above color the nodes of a *graph* (the Romania road map) as the search expands. On a 2-D **grid** the same idea is even more intuitive: we can watch the frontier spread out across the cells and see how an informed search is pulled towards the goal.
998+
#
999+
# `GridProblem` (in `search.py`) is shortest-path finding on a grid with obstacles, and `grid_search_steps` / `plot_grid_search` (in `notebook_utils.py`) run a strategy and draw the cells in the order they were expanded, shaded light-to-dark, with the solution path in orange and the start (green) and goal (red star).
1000+
#
1001+
# Below, the same grid (with a wall that has a gap near the top) is solved by an **uninformed** breadth-first search, a **greedy** best-first search, and **A\***:
1002+
1003+
# %%
1004+
# %matplotlib inline
1005+
from aima.notebook_utils import grid_search_steps, plot_grid_search
1006+
import matplotlib.pyplot as plt
1007+
1008+
walls = [(4, y) for y in range(8)] # vertical wall x=4, with a gap at y=8,9
1009+
grid = GridProblem((0, 0), (8, 2), 12, 10, obstacles=walls)
1010+
1011+
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
1012+
for ax, strategy in zip(axes, ['bfs', 'greedy', 'astar']):
1013+
explored, path = grid_search_steps(grid, strategy)
1014+
plot_grid_search(grid, explored, path, ax=ax,
1015+
title='{}: {} cells expanded'.format(strategy.upper(), len(explored)))
1016+
plt.show()
1017+
1018+
# %% [markdown]
1019+
# Breadth-first search fans out in all directions and expands many cells; greedy best-first heads straight for the goal (expanding the fewest, but it can be misled by obstacles and is not optimal in general); **A\*** balances the two -- it is pulled toward the goal yet still returns an optimal path, expanding far fewer cells than the uninformed search.
1020+
9941021
# %% [markdown]
9951022
# ## A* HEURISTICS
9961023
#

tests/test_search.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,5 +483,34 @@ def test_plan_route():
483483
assert problem.result(WumpusPosition(1, 1, 'RIGHT'), 'Forward').get_location() == (1, 1)
484484

485485

486+
def test_grid_problem():
487+
# 5x5 grid with a wall blocking the direct route (gap at y=4)
488+
walls = [(2, y) for y in range(4)]
489+
problem = GridProblem((0, 0), (4, 0), 5, 5, obstacles=walls)
490+
astar = astar_search(problem)
491+
bfs = breadth_first_graph_search(problem)
492+
assert astar is not None
493+
assert astar.path_cost == bfs.path_cost # both optimal
494+
assert all(problem.passable(node.state) for node in astar.path()) # avoids walls
495+
# a goal walled off on every side is unreachable
496+
boxed = GridProblem((0, 0), (4, 4), 5, 5, obstacles=[(3, 4), (4, 3)])
497+
assert astar_search(boxed) is None
498+
499+
500+
def test_grid_search_visualization():
501+
import matplotlib
502+
matplotlib.use('Agg')
503+
from aima.notebook_utils import grid_search_steps, plot_grid_search
504+
walls = [(3, y) for y in range(7)]
505+
problem = GridProblem((0, 0), (6, 0), 10, 10, obstacles=walls)
506+
expl_bfs, path_bfs = grid_search_steps(problem, 'bfs')
507+
expl_astar, path_astar = grid_search_steps(problem, 'astar')
508+
assert path_bfs[0] == path_astar[0] == (0, 0)
509+
assert path_bfs[-1] == path_astar[-1] == (6, 0)
510+
assert len(path_astar) == len(path_bfs) # same optimal path length
511+
assert len(expl_astar) <= len(expl_bfs) # informed search is more focused
512+
assert plot_grid_search(problem, expl_astar, path_astar) is not None
513+
514+
486515
if __name__ == '__main__':
487516
pytest.main()

0 commit comments

Comments
 (0)