443 lines
17 KiB
Python
443 lines
17 KiB
Python
""" CS2109S Problem Set 2: Informed Search"""
|
||
|
||
import copy
|
||
import heapq
|
||
import math
|
||
import random
|
||
import time
|
||
from typing import List, Tuple
|
||
|
||
import cube
|
||
import utils
|
||
|
||
""" ADD HELPER FUNCTION HERE """
|
||
|
||
"""
|
||
We provide implementations for the Node and PriorityQueue classes in utils.py, but you can implement your own if you wish
|
||
"""
|
||
from utils import Node
|
||
from utils import PriorityQueue
|
||
|
||
|
||
#TODO Task 1.1: Implement your heuristic function, which takes in an instance of the Cube and
|
||
# the State class and returns the estimated cost of reaching the goal state from the state given.
|
||
def heuristic_func(problem: cube.Cube, curr_state: cube.State) -> float:
|
||
r"""
|
||
Computes the heuristic value of a state
|
||
|
||
Args:
|
||
problem (cube.Cube): the problem to compute
|
||
state (cube.State): the state to be evaluated
|
||
|
||
Returns:
|
||
h_n (float): the heuristic value
|
||
"""
|
||
h_n = 0.0
|
||
goals = problem.goal
|
||
""" YOUR CODE HERE """
|
||
for idx, val in enumerate(curr_state.layout):
|
||
if val != goals.layout[idx]:
|
||
h_n += 1
|
||
""" END YOUR CODE HERE """
|
||
|
||
h_n /= max(curr_state.shape)
|
||
|
||
return h_n
|
||
|
||
# Test
|
||
def wrap_test(func):
|
||
def inner(*args, **kwargs):
|
||
try:
|
||
return func(*args, **kwargs)
|
||
except Exception as e:
|
||
return f'FAILED, error: {type(e).__name__}, reason: {str(e)}'
|
||
return inner
|
||
|
||
# Test case for Task 1.1
|
||
@wrap_test
|
||
def test_heuristic(case):
|
||
|
||
input_dict = case['input_dict']
|
||
answer = case['answer']
|
||
problem = cube.Cube(input_dict = input_dict)
|
||
|
||
assert heuristic_func(problem, problem.goal) == 0, "Heuristic is not 0 at the goal state"
|
||
assert heuristic_func(problem, problem.initial) <= answer['cost'], "Heuristic is not admissible"
|
||
|
||
return "PASSED"
|
||
|
||
if __name__ == '__main__':
|
||
|
||
cube1 = {'input_dict': {"initial": {'shape': [3, 3], 'layout': ['N', 'U',
|
||
'S', 'N','U', 'S', 'N', 'U', 'S']}, 'goal': {'shape': [3, 3], 'layout':
|
||
['N', 'U', 'S', 'N', 'U', 'S', 'N', 'U', 'S']}}, 'answer': {'solution':
|
||
[], 'cost': 0}}
|
||
|
||
cube2 = {'input_dict': {"initial": {'shape': [3, 3], 'layout': ['S', 'O',
|
||
'C', 'S', 'O', 'C', 'S', 'O', 'C']}, 'goal': {'shape': [3, 3],
|
||
'layout': ['S', 'S', 'S', 'O', 'O', 'O', 'C', 'C', 'C']}}, 'answer':
|
||
{'solution': [[2, 'right'], [1, 'left'], [1, 'down'],
|
||
[2, 'up']], 'cost': 4}}
|
||
|
||
cube3 = {'input_dict': {"initial": {'shape': [3, 3], 'layout': ['N', 'U',
|
||
'S', 'N', 'U', 'S', 'N', 'U', 'S']}, 'goal': {'shape': [3, 3], 'layout':
|
||
['S', 'U', 'N', 'N', 'S', 'U', 'U', 'N', 'S']}}, 'answer': {'solution':
|
||
[[0, 'left'], [1, 'right'], [0, 'up'], [1, 'down']], 'cost': 4}}
|
||
|
||
cube4 = {'input_dict':{"initial": {'shape': [3, 4], 'layout': [1, 1, 9, 0,
|
||
2, 2, 0, 2, 9, 0, 1, 9]}, 'goal': {'shape': [3, 4], 'layout': [ 1, 0,
|
||
9, 2, 2, 1, 0, 9, 2, 1, 0, 9]}}, 'answer': {'solution': [[1, 'down'],
|
||
[3, 'up'], [2, 'left']], 'cost': 3}}
|
||
|
||
print('Task 1.1:')
|
||
print('cube1: ' + test_heuristic(cube1))
|
||
print('cube2: ' + test_heuristic(cube2))
|
||
print('cube3: ' + test_heuristic(cube3))
|
||
print('cube4: ' + test_heuristic(cube4))
|
||
print('\n')
|
||
|
||
|
||
#TODO Task 1.2: Implement A* search which takes in an instance of the Cube
|
||
# class and returns a list of actions [(2,'left'), ...] from the provided action set.
|
||
def astar_search(problem: cube.Cube):
|
||
r"""
|
||
A* Search finds the solution to reach the goal from the initial.
|
||
If no solution is found, return False.
|
||
|
||
Args:
|
||
problem (cube.Cube): Cube instance
|
||
|
||
Returns:
|
||
solution (List[Action]): the action sequence
|
||
"""
|
||
fail = True
|
||
solution = []
|
||
|
||
frontier = PriorityQueue()
|
||
visited = set()
|
||
|
||
|
||
""" YOUR CODE HERE """
|
||
initial_state = Node(None, (), problem.initial, 0, heuristic_func(problem, problem.initial))
|
||
frontier.push(initial_state.get_fn(), initial_state)
|
||
|
||
while frontier:
|
||
curr_node: Node = frontier.pop()
|
||
if problem.goal_test(curr_node.state):
|
||
fail = False
|
||
solution = list(curr_node.act)
|
||
break
|
||
if curr_node in visited:
|
||
continue
|
||
visited.add(curr_node)
|
||
for action in problem.actions(curr_node.state):
|
||
next_state = problem.result(curr_node.state, action)
|
||
next_node = Node(curr_node, curr_node.act + (action,), next_state, problem.path_cost(curr_node.g_n, curr_node.state, action, next_state), heuristic_func(problem, next_state))
|
||
frontier.push(next_node.get_fn(), next_node)
|
||
|
||
""" END YOUR CODE HERE """
|
||
|
||
if fail:
|
||
return False
|
||
return solution
|
||
|
||
@wrap_test
|
||
def test_astar(case):
|
||
|
||
input_dict = case['input_dict']
|
||
answer = case['answer']
|
||
problem = cube.Cube(input_dict = input_dict)
|
||
|
||
start = time.time()
|
||
solution = astar_search(problem)
|
||
print(f"Time lapsed: {time.time() - start}")
|
||
|
||
if solution is False:
|
||
assert answer['solution'] is False, "Solution is not False"
|
||
else:
|
||
correctness, cost = problem.verify_solution(solution, _print=False)
|
||
assert correctness, f"Fail to reach goal state with solution {solution}"
|
||
assert cost <= answer['cost'], f"Cost is not optimal."
|
||
return "PASSED"
|
||
|
||
|
||
if __name__ == '__main__':
|
||
|
||
cube1 = {'input_dict': {"initial": {'shape': [3, 3], 'layout': ['N', 'U',
|
||
'S', 'N','U', 'S', 'N', 'U', 'S']}, 'goal': {'shape': [3, 3], 'layout':
|
||
['N', 'U', 'S', 'N', 'U', 'S', 'N', 'U', 'S']}}, 'answer': {'solution':
|
||
[], 'cost': 0}}
|
||
|
||
cube2 = {'input_dict': {"initial": {'shape': [3, 3], 'layout': ['S', 'O',
|
||
'C', 'S', 'O', 'C', 'S', 'O', 'C']}, 'goal': {'shape': [3, 3],
|
||
'layout': ['S', 'S', 'S', 'O', 'O', 'O', 'C', 'C', 'C']}}, 'answer':
|
||
{'solution': [[2, 'right'], [1, 'left'], [1, 'down'],
|
||
[2, 'up']], 'cost': 4}}
|
||
|
||
cube3 = {'input_dict': {"initial": {'shape': [3, 3], 'layout': ['N', 'U',
|
||
'S', 'N', 'U', 'S', 'N', 'U', 'S']}, 'goal': {'shape': [3, 3], 'layout':
|
||
['S', 'U', 'N', 'N', 'S', 'U', 'U', 'N', 'S']}}, 'answer': {'solution':
|
||
[[0, 'left'], [1, 'right'], [0, 'up'], [1, 'down']], 'cost': 4}}
|
||
|
||
cube4 = {'input_dict':{"initial": {'shape': [3, 4], 'layout': [1, 1, 9, 0,
|
||
2, 2, 0, 2, 9, 0, 1, 9]}, 'goal': {'shape': [3, 4], 'layout': [ 1, 0,
|
||
9, 2, 2, 1, 0, 9, 2, 1, 0, 9]}}, 'answer': {'solution': [[1, 'down'],
|
||
[3, 'up'], [2, 'left']], 'cost': 3}}
|
||
|
||
print('Task 1.2:')
|
||
print('cube1: ' + test_astar(cube1))
|
||
print('cube2: ' + test_astar(cube2))
|
||
print('cube3: ' + test_astar(cube3))
|
||
print('cube4: ' + test_astar(cube4))
|
||
print('\n')
|
||
|
||
|
||
|
||
#TODO Task 1.3: Explain why the heuristic you designed for Task 1.1 is {consistent}
|
||
# and {admissible}.
|
||
#A heuristic is admissible if for every node, h(n) <= h*(n), where h*(n) is the true cost (in this case, the number of operations, left / right / up / down rotations on a column).
|
||
|
||
# In this case, my heuristic calculates the mismatches in position against the goal divided by the max(row, col).
|
||
# For a 3x3 grid, assuming the worst case initial state, where everything is not in its correct position, where there is 9 mismatches. Then h(initial) = 9/3 = 3. And at this state, it would take a minimum of 3 moves to get to its original state. Thus, h(initial) <= h*(initial). At goal state, h(goal) = 0/3 = 0 <= h*(goal).
|
||
|
||
|
||
|
||
|
||
#TODO Task 2.1: Propose a state representation for this problem if we want to formulate it
|
||
# as a local search problem.
|
||
# State representation: List[int] The current route as a list of cities in the order of travel
|
||
|
||
|
||
#TODO Task 2.2: What are the initial and goal states under your proposed representation?
|
||
# Initial state: (List[int]) The current route as a list of cities in the order of travel
|
||
# Goal state: (List[int]) The shortest route, represented by a list of cities in the order to be traversed.
|
||
|
||
|
||
#TODO Task 2.3: Implement a reasonable transition function to generate new routes by applying
|
||
# minor "tweaks" to the current route. It should return a list of new routes to be used in
|
||
# the next iteration in the hill-climbing algorithm.
|
||
def transition(route: List[int]):
|
||
r"""
|
||
Generates new routes to be used in the next iteration in the hill-climbing algorithm.
|
||
|
||
Args:
|
||
route (List[int]): The current route as a list of cities in the order of travel
|
||
|
||
Returns:
|
||
new_routes (List[List[int]]): New routes to be considered
|
||
"""
|
||
new_routes = []
|
||
|
||
""" YOUR CODE HERE """
|
||
import copy
|
||
for i in range(len(route)):
|
||
for j in range(i+1, len(route)):
|
||
new_route = copy.deepcopy(route)
|
||
new_route[i], new_route[j] = new_route[j], new_route[i]
|
||
new_routes.append(new_route)
|
||
""" END YOUR CODE HERE """
|
||
|
||
return new_routes
|
||
|
||
# Test
|
||
@wrap_test
|
||
def test_transition(route: List[int]):
|
||
counter = 0
|
||
for new_route in transition(route):
|
||
counter += 1
|
||
assert sorted(new_route) == list(range(len(route))), "Invalid route"
|
||
print(f"Number of new routes: {counter}, with route length: {len(route)}")
|
||
|
||
return "PASSED"
|
||
|
||
if __name__ == '__main__':
|
||
print('Task 2.3:')
|
||
print(test_transition([1, 3, 2, 0]))
|
||
print(test_transition([7, 8, 6, 3, 5, 4, 9, 2, 0, 1]))
|
||
print('\n')
|
||
|
||
|
||
#TODO Task 2.4: Implement an evaluation function `evaluation_func(cities, distances, route)` that
|
||
# would be helpful in deciding on the "goodness" of a route, i.e. an optimal route should
|
||
# return a higher evaluation score than a suboptimal one.
|
||
def evaluation_func(cities: int, distances: List[Tuple[int]], route: List[int]) -> float:
|
||
r"""
|
||
Computes the evaluation score of a route
|
||
|
||
Args:
|
||
cities (int): The number of cities to be visited
|
||
|
||
distances (List[Tuple[int]]): The list of distances between every two cities
|
||
Each distance is represented as a tuple in the form of (c1, c2, d), where
|
||
c1 and c2 are the two cities and d is the distance between them.
|
||
The length of the list should be equal to cities * (cities - 1)/2.
|
||
|
||
route (List[int]): The current route as a list of cities in the order of travel
|
||
|
||
Returns:
|
||
h_n (float): the evaluation score
|
||
"""
|
||
h_n = 0.0
|
||
|
||
|
||
""" YOUR CODE HERE """
|
||
# distance from the first city to the last city
|
||
dist = {}
|
||
for d in distances:
|
||
dist[(d[0], d[1])] = d[2]
|
||
dist[(d[1], d[0])] = d[2]
|
||
for i in range(len(route)):
|
||
c1, c2 = route[i-1], route[i]
|
||
h_n += dist[(c1, c2)]
|
||
# last city to the first city
|
||
h_n = 1/h_n
|
||
""" END YOUR CODE HERE """
|
||
return h_n
|
||
|
||
if __name__ == '__main__':
|
||
cities = 4
|
||
distances = [(1, 0, 10), (0, 3, 22), (2, 1, 8), (2, 3, 30), (1, 3, 25), (0, 2, 15)]
|
||
|
||
route_1 = evaluation_func(cities, distances, [0, 1, 2, 3])
|
||
route_2 = evaluation_func(cities, distances, [2, 1, 3, 0])
|
||
route_3 = evaluation_func(cities, distances, [1, 3, 2, 0])
|
||
print(f"route_1: {route_1}, route_2: {route_2}, route_3: {route_3}")
|
||
|
||
print('Task 2.4:')
|
||
print(route_1 == route_2) # True
|
||
print(route_1 > route_3) # True
|
||
print('\n')
|
||
|
||
|
||
#TODO Task 2.5: Explain why your evaluation function is suitable for this problem.
|
||
# The evaluation function calculates the inverse of total distance travelled. Since we're trying to minimise the
|
||
# distance, we're trying to maximise the inverse of the distance. Thus, the evaluation function is suitable.
|
||
|
||
|
||
#TODO Task 2.6: Implement hill-climbing which takes in the number of cities and the list of
|
||
# distances, and returns the shortest route as a list of cities.
|
||
def hill_climbing(cities: int, distances: List[Tuple[int]]):
|
||
r"""
|
||
Hill climbing finds the solution to reach the goal from the initial.
|
||
|
||
Args:
|
||
cities (int): The number of cities to be visited
|
||
|
||
distances (List[Tuple[int]]): The list of distances between every two cities
|
||
Each distance is represented as a tuple in the form of (c1, c2, d), where
|
||
c1 and c2 are the two cities and d is the distance between them.
|
||
The length of the list should be equal to cities * (cities - 1)/2.
|
||
|
||
Returns:
|
||
route (List[int]): The shortest route, represented by a list of cities
|
||
in the order to be traversed.
|
||
"""
|
||
route = []
|
||
|
||
""" YOUR CODE HERE """
|
||
# I'm going to make an assumption that the graph is connected so that I don't have to find a path between every pair of cities.
|
||
# https://chat.openai.com/share/588631bb-6261-48cc-8490-e29401febad9 to explain hill climbing and hwo to randomize shuffle
|
||
initial_state = list(range(cities))
|
||
random.shuffle(initial_state)
|
||
curr_h_n = evaluation_func(cities, distances, initial_state)
|
||
while True:
|
||
states = transition(initial_state)
|
||
eval_states = [(evaluation_func(cities, distances, state), state) for state in states]
|
||
max_h_n, max_state = max(eval_states)
|
||
if curr_h_n >= max_h_n:
|
||
return initial_state
|
||
initial_state = max_state
|
||
curr_h_n = max_h_n
|
||
""" END YOUR CODE HERE """
|
||
|
||
return route
|
||
|
||
# Test
|
||
@wrap_test
|
||
def test_hill_climbing(cities: int, distances: List[Tuple[int]]):
|
||
start = time.time()
|
||
route = hill_climbing(cities, distances)
|
||
print(f"Time lapsed: {(time.time() - start)*1000}")
|
||
|
||
assert sorted(route) == list(range(cities)), "Invalid route"
|
||
|
||
return "PASSED"
|
||
|
||
if __name__ == '__main__':
|
||
|
||
cities_1 = 4
|
||
distances_1 = [(1, 0, 10), (0, 3, 22), (2, 1, 8), (2, 3, 30), (1, 3, 25), (0, 2, 15)]
|
||
|
||
cities_2 = 10
|
||
distances_2 = [(2, 7, 60), (1, 6, 20), (5, 4, 70), (9, 8, 90), (3, 7, 54), (2, 5, 61), (4, 1, 106), (0, 6, 51), (3, 1, 45), (0, 5, 86), (9, 2, 73), (8, 4, 14), (0, 1, 51), (9, 7, 22), (3, 2, 22), (8, 1, 120), (5, 7, 92), (5, 6, 60), (6, 2, 10), (8, 3, 78), (9, 6, 82), (0, 2, 41), (2, 8, 99), (7, 8, 71), (0, 9, 32), (4, 0, 73), (0, 3, 42), (9, 1, 80), (4, 2, 85), (5, 9, 113), (3, 6, 28), (5, 8, 81), (3, 9, 72), (9, 4, 81), (5, 3, 45), (7, 4, 60), (6, 8, 106), (0, 8, 85), (4, 6, 92), (7, 6, 70), (7, 0, 22), (7, 1, 73), (4, 3, 64), (5, 1, 80), (2, 1, 22)]
|
||
print('Task 2.6:')
|
||
print('cities_1: ' + test_hill_climbing(cities_1, distances_1))
|
||
print('cities_2: ' + test_hill_climbing(cities_2, distances_2))
|
||
print('\n')
|
||
|
||
|
||
#TODO Task 2.7: Implement hill_climbing_with_random_restarts by repeating hill climbing
|
||
# at different random locations.
|
||
def hill_climbing_with_random_restarts(cities: int, distances: List[Tuple[int]], repeats: int = 10):
|
||
r"""
|
||
Hill climbing with random restarts finds the solution to reach the goal from the initial.
|
||
|
||
Args:
|
||
cities (int): The number of cities to be visited
|
||
|
||
distances (List[Tuple[int]]): The list of distances between every two cities
|
||
Each distance is represented as a tuple in the form of (c1, c2, d), where
|
||
c1 and c2 are the two cities and d is the distance between them.
|
||
The length of the list should be equal to cities * (cities - 1)/2.
|
||
|
||
repeats (int): The number of times hill climbing to be repeated. The default
|
||
value is 10.
|
||
|
||
Returns:
|
||
route (List[int]): The shortest route, represented by a list of cities
|
||
in the order to be traversed.
|
||
"""
|
||
|
||
route = []
|
||
|
||
""" YOUR CODE HERE """
|
||
results = [hill_climbing(cities, distances) for _ in range(repeats)]
|
||
results = sorted(results, key=lambda x: evaluation_func(cities, distances, x))
|
||
route = results[-1]
|
||
""" END YOUR CODE HERE """
|
||
|
||
return route
|
||
|
||
# Test
|
||
@wrap_test
|
||
def test_random_restarts(cities: int, distances: List[Tuple[int]], repeats: int = 10):
|
||
start = time.time()
|
||
route = hill_climbing_with_random_restarts(cities, distances, repeats)
|
||
print(f"Time lapsed: {time.time() - start}")
|
||
|
||
assert sorted(route) == list(range(cities)), "Invalid route"
|
||
|
||
return "PASSED"
|
||
|
||
if __name__ == '__main__':
|
||
|
||
cities_1 = 4
|
||
distances_1 = [(1, 0, 10), (0, 3, 22), (2, 1, 8), (2, 3, 30), (1, 3, 25), (0, 2, 15)]
|
||
|
||
cities_2 = 10
|
||
distances_2 = [(2, 7, 60), (1, 6, 20), (5, 4, 70), (9, 8, 90), (3, 7, 54), (2, 5, 61),
|
||
(4, 1, 106), (0, 6, 51), (3, 1, 45), (0, 5, 86), (9, 2, 73), (8, 4, 14), (0, 1, 51),
|
||
(9, 7, 22), (3, 2, 22), (8, 1, 120), (5, 7, 92), (5, 6, 60), (6, 2, 10), (8, 3, 78),
|
||
(9, 6, 82), (0, 2, 41), (2, 8, 99), (7, 8, 71), (0, 9, 32), (4, 0, 73), (0, 3, 42),
|
||
(9, 1, 80), (4, 2, 85), (5, 9, 113), (3, 6, 28), (5, 8, 81), (3, 9, 72), (9, 4, 81),
|
||
(5, 3, 45), (7, 4, 60), (6, 8, 106), (0, 8, 85), (4, 6, 92), (7, 6, 70), (7, 0, 22),
|
||
(7, 1, 73), (4, 3, 64), (5, 1, 80), (2, 1, 22)]
|
||
|
||
print('Task 2.7:')
|
||
print('cities_1: ' + test_random_restarts(cities_1, distances_1))
|
||
print('cities_2: ' + test_random_restarts(cities_2, distances_2, 20))
|
||
|
||
|
||
#TODO Task 2.8: Compared to previous search algorithms you have seen (uninformed search, A*),
|
||
# why do you think local search is more suitable for this problem?
|