24 KiB
Solving Problems by Searching
Whenever we need to solve a problem, our agent will need to be able to foresee
outcomes in order to find a sequence of actions to get to the goal.
Here our environment will always be:
- Episodic
- Single-Agent
- Fully Observable
- Deterministic
- Static
- Discrete
- Known
And our agents may be:
- Informed: when they know how far they are from the objective
- Uninformed: when they don't know how far they are from the objective
Problem-Solving agent
This is an agent which has atomic representations of states.
Problem-Solving Phases
- Formulate
Goal - Formulate problem with adequate abstaction
- Search a solution before taking any action
- Excute plan
With these 4 phases the agent will either come to a solution or
that there are *none.
Once it gets a solution, our agent will be able to blindly execute its
action plan, as it will be fixed and thus, the agent won't need to perceive
anything else.
Note
This is also called Open Loop in Control Theory
Caution
The 3rd and 4th step can be done only in this
environmentas it isfully-observable,deterministicandknown, so theenvironmentcan be predicted at eachstepof the searching simulation.
Planning Agent
This is an agent which has factored or structures representation of states.
Search Problem
A search problem is the union of the followings:
-
State Space Set of possible
states.It can be represented as a
graphwhere eachstateis anodeand eachactionis anedge, leading from astateto another -
Initial State The initial
statetheagentis in -
Goal State(s) The
statewhere theagentwill have reached its goal. There can be multiplegoal-states -
Available Actions All the
actionsavailable to theagent:def get_actions(state: State) : set[Action] -
Transition Model A
functionwhich returns thenext-stateafter taking anactionin thecurrent-state:def move_to_next_state(state: State, action: Action): State -
Action Cost Function A
functionwhich denotes the cost of taking thatactionto reach anew-statefromcurrent-state:def action_cost( current_state: State, action: Action, new_state: State ) : float
A sequence of actions to go from a state to another is called path.
A path leading to the goal is called a solution.
The shortest path to the goal is called the optimal-solution, or
in other words, this is the path with the lowest cost.
Obviously we always need a level of abstraction to get our agent
perform at its best. For example, we don't need to express any detail
about the physics of the real world to go from point-A to point-B.
Searching Algorithms
Most algorithms used to solve Searching Problems rely
on a tree based representation, where the root-node is the initial-state
and each child-node is the next-available-state from a node.
By the data-structure being a search-tree, each node has a unique
path back to the root as each node has a reference to the parent-node.
For each action we generate a node and each generated-node, wheter
further explored or not, become part of the frontier or fringe.
Tip
Before going on how to implement
searchalgorithms, let's say that we'll use thesedata-structuresforfrontiers:
priority-queuewhen we need to evaluate forlowest-costsfirstFIFOwhen we want to explore thetreehorizontally firstLIFOwhen we want to explore thetreevertically firstThen we need to take care of reduntant-paths in some ways:
- Remember all previous
statesand only care for bestpathsto thesestates, best whenproblemfits into memory.- Ignore the problem when it is rare or impossible to repeat them, like in an assembly line in factories.
- Check for repeated
statesalong theparent-chainup to therootor firstn-links. This allows us to save up on memoryIf we check for
redundant-pathswe have agraph-search, otherwise atree-like-search
Measuring Performance
We have 4 parameters:
-
Completeness
Is the
algorithmguaranteed to find thesolution, if any, and report for no solution?This is easy for
finitestate-spaceswhile we need a systematic algorithm forinfiniteones, though it would be difficult reporting for no solution as it is impossible to explore the wholespace. -
Cost Optimality
Can it find the
optimal-solution? -
Time Complexity
O(n) timeperformance -
Space Complexity
O(n) spaceperformance, explicit one (if thegraphis explicit) or by mean of:depthofactionsfor anoptimal-solutionmax-number-of-actionsin anypathbranching-factorfor a node
Uninformed Algorithms
These algorithms know nothing about the space
Breadth-First Search
def expand(
problem: Problem,
node: Node
) : Node
"""
Gets all children from a node and packet them
in a node
"""
# Initialize variables
state = node.state
for action in problem.actions:
new_state = problem.result(state, action)
cost = node.path_cost + problem.action_cost(state, action, new_state)
# See https://docs.python.org/3/reference/expressions.html#yield-expressions
yield Node(
state = new_node,
parent = node,
action = action,
path_cost = cost
)
def breadth_first_search(
problem: Problem
) : Node | null
"""
Graph-Search
Gets all nodes with lower cost
to expand first.
"""
# Initialize variables
root = problem.initial_state
# Check if root is goal
if problem.is_goal(root.state):
return node
# This will change according to the algorithms
frontier = FIFO_Queue()
reached_nodes = set[State]
reached_nodes.add(root.state)
# Repeat until all states have been expanded
while len(frontier) != 0:
node = frontier.pop()
# Get all reachable states
for child in expand(problem, node):
state = child.state
# If state is goal, return the node
# Early Goal Checking
if problem.is_goal(state):
return child
# Check if state is new and add it
if state is not in reached_nodes:
reached_nodes.append(child)
frontier.push(child)
continue
# We get here if we have no
# more nodes to expand from
return null
In this algorithm we use the depth of nodes as the cost to
reach such nodes.
In comparison to the Best-First Search, we have these differences:
-
FIFO Queueinstead of aPriority Queue: Since we expand on breadth, aFIFOguarantees us that all nodes are in order as thenodesgenerated at the samedepthare generated before that those atdepth + 1. -
early-goal testinstead of alate-goal test: We can immediately see if thestateis thegoal-stateas it would have the minimumcostalready -
The
reached_statesis now asetinstead of adict: Sincedepth + 1has a highercostthandepth, this means that we alread reached the minimumcostfor thatstateafter the first time we reached it.
However the space-complexity and time-complexity are
high with O(b^d) space, where b is the
max-branching-factor and d is the search-depth1
This algorithm is:
optimalcomplete(as long eachactionhas the samecost)
Caution
All of these considerations are valid as long as each
edgehas auniform-cost
Dijkstra'Algorithm | AKA Uniform-Cost Search2
This algorithm is basically Best-First Search but with path_cost()
as the cost_function.
It works by expanding all nodes that have the lowest path-cost and
evaluating them for the goal after poppoing them out of the queue, otherwise
it would pick up one of the non-optimal solutions.
Its performance depends on C^{*}, the optimal-solution and \epsilon > 0, the lower
bound over the cost of each action. The worst-case would be
O(b^{1 + \frac{C^*}{\epsilon}}) for bot time and space-complexity
In the worst-case the complexity is O(b^{d + 1}) when all actions cost \epsilon
This algorithm is:
optimalcomplete
Tip
Notice that at worst, we will have to expand
\frac{C^*}{\epsilon}if each action costed at most\epsilon, sinceC^*is theoptimal-cost, plus the last-expansion before realizing it got theoptimal-solution
Depth-First Search
def expand(
problem: Problem,
node: Node
) : Node
"""
Gets all children from a node and packet them
in a node
"""
# Initialize variables
state = node.state
for action in problem.actions:
new_state = problem.result(state, action)
cost = node.path_cost + problem.action_cost(state, action, new_state)
# See https://docs.python.org/3/reference/expressions.html#yield-expressions
yield Node(
state = new_node,
parent = node,
action = action,
path_cost = cost
)
def depth_first_search(
problem: Problem
) : Node | null
"""
Graph-Search
Gets all nodes with lower cost
to expand first.
"""
# Initialize variables
root = problem.initial_state
# Check if root is goal
if problem.is_goal(root.state):
return node
# This will change according to the algorithms
frontier = LIFO_Queue()
# Repeat until all states have been expanded
while len(frontier) != 0:
node = frontier.pop()
# Get all reachable states
for child in expand(problem, node):
state = child.state
# If state is goal, return the node
# Early Goal Checking
if problem.is_goal(state):
return child
# We don't care if we reached that state
# or not before
frontier.push(child)
# We get here if we have no
# more nodes to expand from
return null
This is basically a Best-First Search but with the cost_function
being the negative of depth. However we can use a LIFO Queue, instead of a
cost_function, and delete the reached_space dict.
This algorithm is:
non-optimalas it returns the firstsolution, not the bestincompleteas it isnon-systematic, but it iscompleteforacyclic graphsandtreesO(b^{m})withmbeing themax-depthof thespaceO(b\, m)forspace-complexitywithmbeing themax-depthof thespace
One evolution of this algorithm, is the backtracking search
Tip
While it is
non-optimalandnot-completeand having a hugetime-complexity, thespace-complexitymakes it appealing as we have much more time than space available.
Caution
This algorithm needs a way to handle
cycles
Depth-Limited
def expand(
problem: Problem,
node: Node
) : Node
"""
Gets all children from a node and packet them
in a node
"""
# Initialize variables
state = node.state
for action in problem.actions:
new_state = problem.result(state, action)
cost = node.path_cost + problem.action_cost(state, action, new_state)
# See https://docs.python.org/3/reference/expressions.html#yield-expressions
yield Node(
state = new_node,
parent = node,
action = action,
path_cost = cost
)
def depth_limited_search(
problem: Problem,
max_depth: int
) : Node | null
"""
Graph-Search
Gets all nodes with lower cost
to expand first.
"""
# Initialize variables
root = problem.initial_state
# Check if root is goal
if problem.is_goal(root.state):
return node
# This will change according to the algorithms
frontier = LIFO_Queue()
# Repeat until all states have been expanded
while len(frontier) != 0:
node = frontier.pop()
# Do not expand, over max_depth
#
# We throw an error to differentiate
# from when there's no solution
if abs(node.path_cost) >= max_depth:
throw MaxDepthError("We got over max_depth")
# Get all reachable states
for child in expand(problem, node):
state = child.state
# If state is goal, return the node
# Early Goal Checking
if problem.is_goal(state):
return child
# We don't care if we reached that state
# or not before
frontier.push(child)
# We get here if we have no
# more nodes to expand from
return null
This is a flavour of Depth-First Search.
One addition of this algorithm is the parameter of max_depth. After we go after
max_depth, we don't expand anymore.
This algorithm is:
non-optimal(see Depth-First Search)incomplete(see Depth-First Search) and it now depends on themax_depthas wellO(b^{max\_depth})time-complexityO(b\, max\_depth)space-complexity
Caution
This algorithm needs a way to handle
cyclesas its parent
Tip
Depending on the domain of the
problem, we can estimate a goodmax_depth, for example, graphs have a number calleddiameterthat tells us the max number ofactionsto reach anynodein thegraph
Iterative Deepening Search
def iterative_deepening_search(
problem: Porblem
) : Node | null
done = False
max_depth = 0
while not done:
try:
solution = depth_limited_search(
problem,
max_depth
)
done = True
# We only catch for this exception
except MaxDepthError as e:
pass
max_depth += 1
return solution
This is a flavour of depth-limited search. whenever it reaches the max_depth
the search is restarted until one is found.
This algorithm is:
non-optimal(see Depth-First Search)incomplete(see Depth-First Search) and it now depends on themax_depthas wellO(b^{m})time-complexityO(b\, m)space-complexity
Tip
This is the preferred method for
uninformed-searchwhen we have no idea of themax_depthand thespaceis larger than memory
Bidirectional Search
# Watch other implementations
def expand( problem: Problem, node: Node ) : Node
pass
# It is not defined
def join_nodes( node_1: Node, node_2 : Node) : Node
def best_first_search(
problem_1: Problem, # Initial Problem
cost_function_1: Callable[[node: Node] float],
problem_2: Problem, # Reverse Problem
cost_function_2: Callable[[node: Node] float]
) : Node | null
"""
Graph-Search
Gets all nodes with lower cost
to expand first.
"""
# Initialize variables
root_1 = problem_1.initial_state
root_2 = problem_2.initial_state
# This will change according to the algorithms
frontier_1 = Priority_Queue(order_by = cost_function_1)
frontier_2 = Priority_Queue(order_by = cost_function_2)
reached_nodes_1 = dict[State, Node]
reached_nodes_1[root_1.state] = root_1
reached_nodes_2 = dict[State, Node]
reached_nodes_2[root_2.state] = root_2
# Keep track of best solution
solution = null
# Repeat until all states have been expanded
while len(frontier_1) != 0 and len(frontier_2) != 0:
# Expand frontier with lowest cost
if cost_function_1(frontier_1[0]) < cost_function_2(frontier_2[0]):
node_1 = frontier_1.pop()
# Get all reachable states for 1
for child in expand(problem_1, node_1):
state = child.state
# Check if state is new or has
# lower cost and add to frontier
if (
state is not in reached_nodes_1
or
child.path_cost < reached_nodes_1[state].path_cost
):
# Add node to frontier
reached_nodes_1[state] = child
frontier.push(child)
# Check if state has previously been
# reached by the other frontier
if state is in reached_nodes_2:
tmp_solution = join_solutions(
reached_nodes_1[state],
reached_nodes_2[state]
)
# Check if this solution is better
if tmp_solution.path_cost < solution.path_cost:
solution = tmp_solution
else:
node_2 = frontier21.pop()
# Get all reachable states for 2
for child in expand(problem_2, node_2):
state = child.state
# Check if state is new or has
# lower cost and add to frontier
if (
state is not in reached_nodes_2
or
child.path_cost < reached_nodes_2[state].path_cost
):
# Add node to frontier
reached_nodes_2[state] = child
frontier.push(child)
# Check if state has previously been
# reached by the other frontier
if state is in reached_nodes_1:
tmp_solution = join_solutions(
reached_nodes_1[state],
reached_nodes_2[state]
)
# Check if this solution is better
if tmp_solution.path_cost < solution.path_cost:
solution = tmp_solution
# We get here if we have no
# more nodes to expand from
return solution
This method expands from both the starting-state and goal-state like
a Best-First Search, making us save up on memory and time.
On the other hand, the implementation algorithm is harder to implement
This algorithm is:
optimal(see Best-First Search)complete(see Best-First Search)O(b^{1 + \frac{C^*}{2\epsilon}})time-complexity(see Uniform-Cost Search)O(b^{1 + \frac{C^*}{2\epsilon}})space-complexity(see Uniform-Cost Search)
Tip
If the
cost_functionis thepath_cost, it isbi-directionaland we can do the following consideration:
C^*is theoptimal-pathand nonodewithpath\_cost > \frac{C^*}{2}will be expandedThis is an important speedup, however, without having a
bi-directionalcost_function, then we need to check for the bestsolutionseveral times.
Informed Algorithms | AKA Heuristic Search
These algorithms know something about the closeness of nodes
Best-First Search
def expand(
problem: Problem,
node: Node
) : Node
"""
Gets all children from a node and packet them
in a node
"""
# Initialize variables
state = node.state
for action in problem.actions:
new_state = problem.result(state, action)
cost = node.path_cost + problem.action_cost(state, action, new_state)
# See https://docs.python.org/3/reference/expressions.html#yield-expressions
yield Node(
state = new_node,
parent = node,
action = action,
path_cost = cost
)
def best_first_search(
problem: Problem,
cost_function: Callable[[node: Node] float]
) : Node | null
"""
Graph-Search
Gets all nodes with lower cost
to expand first.
"""
# Initialize variables
root = problem.initial_state
# This will change according to the algorithms
frontier = Priority_Queue(order_by = cost_function)
reached_nodes = dict[State, Node]
reached_nodes[root.state] = root
# Repeat until all states have been expanded
while len(frontier) != 0:
node = frontier.pop()
# If state is goal, return the node
if problem.is_goal(node.state):
return node
# Get all reachable states
for child in expand(problem, node):
state = child.state
# Check if state is new and add it
if state is not in reached_nodes:
reached_nodes[state] = child
frontier.push(child)
continue
# Here we do know that the state has been reached before
# Check if state has a lower cost and add it
if child.path_cost < reached_nodes[state].path_cost:
reached_nodes[state] = child
frontier.push(child)
continue
# We get here if we have no
# more nodes to expand from
return null
In a best_first_search we start from the root and then we
expand and add these states as nodes at frontier if they are
either new or at lower path_cost.
Whenever we get from a state to a node, we keep track of:
stateparent-nodeactionused to go fromparent-nodeto herecostthat is cumulative fromparent-nodeand theactionused
If there's no node available in the frontier, and we didn't find
the goal-state, then the solution is null.