├── SearchProblemsAI ├── __init__.py ├── search_problems │ ├── __init__.py │ └── FindCandy.py ├── utils.py ├── SearchProblem.py └── SearchAlgorithms.py ├── tests └── test_true.py ├── assets └── search_pb.png ├── example ├── context.py ├── main_sensorless.py ├── main_nonDeterministic.py └── main.py ├── requirements.txt ├── setup.py ├── LICENSE ├── .github └── workflows │ └── python-app.yml ├── .gitignore └── README.md /SearchProblemsAI/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /SearchProblemsAI/search_problems/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_true.py: -------------------------------------------------------------------------------- 1 | def test_true(): 2 | assert True -------------------------------------------------------------------------------- /assets/search_pb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tboulet/AI-Agents-for-Search-Problems/HEAD/assets/search_pb.png -------------------------------------------------------------------------------- /example/context.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 4 | 5 | import SearchProblemsAI -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | blinker==1.6.2 2 | click==8.1.3 3 | colorama==0.4.6 4 | exceptiongroup==1.1.1 5 | Flask==2.3.2 6 | importlib-metadata==6.7.0 7 | iniconfig==2.0.0 8 | itsdangerous==2.1.2 9 | Jinja2==3.1.2 10 | MarkupSafe==2.1.3 11 | packaging==23.1 12 | pluggy==1.2.0 13 | pytest==7.4.0 14 | tomli==2.0.1 15 | Werkzeug==2.3.6 16 | zipp==3.15.0 17 | -------------------------------------------------------------------------------- /SearchProblemsAI/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | 4 | def manhattan_distance(a, b): 5 | """ 6 | Manhattan distance between two points. 7 | """ 8 | return abs(a[0] - b[0]) + abs(a[1] - b[1]) 9 | 10 | 11 | def counted(f : Callable) -> Callable: 12 | def wrapped(*args, **kwargs): 13 | wrapped.calls += 1 14 | return f(*args, **kwargs) 15 | 16 | wrapped.calls = 0 17 | return wrapped -------------------------------------------------------------------------------- /example/main_sensorless.py: -------------------------------------------------------------------------------- 1 | import context 2 | from SearchProblemsAI.search_problems.FindCandy import SensorlessFindCandyProblem 3 | from SearchProblemsAI.SearchAlgorithms import DFS, BFS, UCS, A_star 4 | 5 | #Define problem 6 | print(SensorlessFindCandyProblem) 7 | problem = SensorlessFindCandyProblem(side_lenght=4, wall_ratio=0.1) 8 | print("Start state:") 9 | print(problem.get_start_state()) 10 | 11 | #Define algorithm solving it, solve it 12 | print("Solving...") 13 | list_of_actions = UCS().solve(problem) 14 | 15 | #Test the solution 16 | print("\nTesting solution :", list_of_actions) 17 | problem.apply_solution(list_of_actions) -------------------------------------------------------------------------------- /example/main_nonDeterministic.py: -------------------------------------------------------------------------------- 1 | import context 2 | from SearchProblemsAI.search_problems.FindCandy import NonDeterministicFindCandyProblem 3 | from SearchProblemsAI.SearchAlgorithms import NonDeterministicSearchProblemAlgorithm 4 | 5 | #Define problem 6 | problem = NonDeterministicFindCandyProblem(side_lenght=4, wall_ratio=0.1) 7 | print("Start state:") 8 | print(problem.get_start_state()) 9 | 10 | #Define algorithm solving it, solve it 11 | print("Solving...") 12 | algo = NonDeterministicSearchProblemAlgorithm() 13 | plan = algo.solve(problem = problem) 14 | 15 | #Test the solution 16 | print("\nTesting solution :") 17 | print(plan) 18 | problem.apply_solution(plan) -------------------------------------------------------------------------------- /example/main.py: -------------------------------------------------------------------------------- 1 | import context 2 | from SearchProblemsAI.search_problems.FindCandy import FindCandyProblem 3 | from SearchProblemsAI.SearchAlgorithms import NonDeterministicSearchProblemAlgorithm 4 | from SearchProblemsAI.SearchAlgorithms import DFS, BFS, UCS, A_star 5 | from SearchProblemsAI.utils import manhattan_distance 6 | 7 | #Define problem 8 | problem = FindCandyProblem(side_lenght=10) 9 | print("Start state:") 10 | print(problem.get_start_state()) 11 | 12 | #Define algorithm solving it, solve it 13 | print("Solving...") 14 | def h(state): 15 | return manhattan_distance(state.pos, problem.goal_pos) 16 | list_of_actions = A_star(heuristic = h).solve(problem) 17 | 18 | #Test the solution 19 | print("\nTesting solution :", list_of_actions) 20 | problem.apply_solution(list_of_actions) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_namespace_packages 2 | 3 | with open("requirements.txt", "r") as f: 4 | requirements = [package.replace("\n", "") for package in f.readlines()] 5 | 6 | setup( 7 | name="SearchProblemsAI", 8 | url="https://github.com/tboulet/AI-Agents-for-Search-Problems", 9 | author="Timothé Boulet", 10 | author_email="timothe.boulet0@gmail.com", 11 | 12 | packages=find_namespace_packages(), 13 | # Needed for dependencies 14 | install_requires=requirements[1:], 15 | dependency_links=requirements[:1], 16 | # package_data={"configs": "*.yaml"}, 17 | version="1.0.1", 18 | license="MIT", 19 | description="SearchProblemsAI is a library of AI agents for Search Problems.", 20 | long_description=open('README.md').read(), 21 | ) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Timothé Boulet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python application 5 | 6 | on: [push] 7 | 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python 3.9 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: "3.9" 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install flake8 pytest 27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 28 | - name: Lint with flake8 29 | run: | 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 34 | - name: Test with pytest 35 | run: | 36 | pytest 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test.py 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI-Agents-for-Search-Problems 2 | A search problem is defined as a graph of node (states) and weighted edges (action leading from one state to another and costing a cost) where the goal is to find a certain goal state while minimizing the cost if possible. A good example is the pathfinding problem where your agent want to find the shortest path to a certain location. 3 | These problems can be partially solved using easy to implement graph algorithms adapted to the model-free nature of those problems (we don't know every states but rather discover them during the exploration of the state space). 4 | 5 | ![Alt text](assets/search_pb.png) 6 | 7 | ## Package SearchProblemsAI 8 | An implementation in python of some algorithms for search problems such as A*, that can be applied to any problem object that follow the SearchProblem interface. 9 | 10 | The main feature is to define relatively quickly your concrete search problem as a subclass of the interface SearchProblem in order to solve it using already implemented algorithms. Some of those algorithms including BFS, DFS, Uniform Cost Search (Dijkstra ) and A* are already implemented. 11 | 12 | ## Installation : 13 | Run this command to install the package SearchProblemAI: 14 | 15 | pip install SearchProblemsAI 16 | 17 | ## Using the package 18 | An example of how to use those algorithms once you have defined your SearchProblem can be found in example.py or here : 19 | 20 | ```python 21 | from SearchProblemsAI.search_problems.FindCandy import FindCandyProblem 22 | from SearchProblemsAI.SearchAlgorithms import DFS, BFS, IDDFS, UCS, A_star 23 | from SearchProblemsAI.utils import manhattan_distance 24 | 25 | #Define problem 26 | problem = FindCandyProblem() 27 | print("Start state:") 28 | print(problem.get_start_state()) 29 | 30 | #Define algorithm solving it, solve it 31 | def h(state): 32 | return manhattan_distance(state.pos, problem.goal_pos) 33 | algo = A_star(heuristic = h) 34 | list_of_actions = algo.solve(problem) 35 | 36 | #Test the solution 37 | print("\nTesting solution :", list_of_actions) 38 | problem.apply_solution(list_of_actions) 39 | ``` 40 | 41 | The search problems have to be deterministic : one action in one state always leads to exactly one state. 42 | 43 | 44 | 45 | ## Define your search problem 46 | 47 | The main interest of this package is to be able to quicly define your search problem and then use already implemented algorithms on it. Here is how to define your search problems. 48 | 49 | ```python 50 | from SearchProblemsAI.SearchProblem import State, SearchProblem 51 | 52 | class YourState(State): 53 | pass 54 | 55 | class YourSearchProblem(SearchProblem): 56 | pass 57 | ``` 58 | 59 | You need to first define your state class that must inherit the State class. A state represent a state of the problem with every information you are giving to the agent. Please implements the following methods, as well as the __str__ method eventually : 60 | 61 | __hash__() : a state should be hashable 62 | __eq__(state : State) : two state should be comparable 63 | 64 | Then you must define your search problem class. Every search problem class must inherit the class SearchProblem and implements the following methods : 65 | 66 | get_start_state() -> State : return the initial state 67 | is_goal_state(state : State) -> bool : return whether the state is a goal state 68 | get_actions(state : State) -> List[Action] : return the list of actions available in a given state 69 | get_transition(state : State, action : object) -> Tuple[State, float] : return a tuple (next_state, cost_of_action) that represents the transition 70 | 71 | A path finding problem can be found in ./search_problem/FindCandy.py as an example. 72 | 73 | 74 | ## Define your search algorithm 75 | Some algorithms are already implemented. 76 | ```python 77 | from SearchAlgorithms import DFS, BFS, IDDFS, UCS, A_star 78 | ``` 79 | 80 | But you can also define other search algorithms by inheriting the SearchAlgorithm class and implements the abstract required method : solve. Example can be found in ./searchAlgorithms.py 81 | 82 | solve(problem : SearchProblem) -> Union[List[object], None] : return the sequence of actions leading to a goal state, or None if no solution found. 83 | 84 | 85 | 86 | ## Solve your search problem 87 | Once it is done, you can solve your problem by using method .solve() on an instance of your search problem class. 88 | ````python 89 | problem = YourSearchProblem(*args) 90 | list_of_actions = DFS().solve(problem) 91 | problem.apply_solution(list_of_actions) 92 | ```` 93 | 94 | ## Improvements and limitations 95 | Many other search algorithms are yet to be implemented. 96 | 97 | A wrapper transforming a searchProblem object in a sensorless problem that can be interpreted as a more complex search problem is implemented but I'm not sure it is working. A sensorless problem is a search problem where you don't know the state you are in, and you need a sequence of action that always lead to a goal state no matter where you start. 98 | 99 | This package is limited to deterministic search problem, ie a given action in a given state always lead to the same next state. Algorithms for non deterministic search problems exist, returning a plan (a policy giving what to do in any state) rather than a sequence of actions, but are not yet implemented successfully. 100 | 101 | A benchmark of all the algorithms existing and their performance in time and space complexity with regards to different problem parameters such as branching factor or solution's depth could be interesting. 102 | -------------------------------------------------------------------------------- /SearchProblemsAI/search_problems/FindCandy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for the FindCandy problem. The goal is for an agent to navigate inside a 10*10 grid, in order to reach a location with a candy. 3 | 10% of the map are walls, and the rest is empty. The map size and wall percentage are parameters. The initial location of the agent and of the goal are random, as well as the walls. 4 | 5 | A non deterministic version is also implemented, where an action to a certain direction will lead to 1 or 2 steps (chosen randomly). 6 | A sensorless version is also implemented, where the goal is now to find a sequence of actions that would lead every possible initial state to a goal state (and not only 1 starting state). 7 | """ 8 | import random 9 | from SearchProblemsAI.SearchProblem import SearchProblem, State, NonDeterministicSearchProblem, SensorlessSearchProblem, SensorlessSearchProblem_v2 10 | 11 | class FindCandyState(State): 12 | def __init__(self, map, pos, goal_pos): 13 | """ 14 | The state object need to contain every information the agent can access from its perspective. 15 | """ 16 | self.map = map 17 | self.pos = pos 18 | self.goal_pos = goal_pos 19 | 20 | def __str__(self): 21 | res = "\n" 22 | for j, line in enumerate(self.map): 23 | to_print = "" 24 | for (i, is_free) in enumerate(line): 25 | if self.pos == (i, j): 26 | to_print += "|A|" 27 | elif self.goal_pos == (i, j): 28 | to_print += "|G|" 29 | elif is_free: 30 | to_print += "| |" 31 | else: 32 | to_print += "|#|" 33 | res += to_print + "\n" 34 | return res 35 | 36 | def __hash__(self): 37 | return hash(self.pos) 38 | 39 | def __eq__(self, other): 40 | """Inside a problem, the state is defined as unique by the position of the agent only.""" 41 | return self.pos == other.pos 42 | 43 | class FindCandyProblem(SearchProblem): 44 | """The FindCandyProblem class.""" 45 | def __init__(self, side_lenght = 10, wall_ratio = 0.3): 46 | self.side_lenght = side_lenght 47 | super().__init__() 48 | self.map = [[random.random() > wall_ratio for _ in range(side_lenght)] for _ in range(side_lenght)] 49 | pos = random.randint(0, side_lenght - 1), random.randint(0, side_lenght - 1) 50 | xg, yg = random.randint(0, side_lenght - 1), random.randint(0, side_lenght - 1) 51 | while (xg, yg) == pos or not self.map[yg][xg]: 52 | xg, yg = random.randint(0, side_lenght - 1), random.randint(0, side_lenght - 1) 53 | self.goal_pos = xg, yg 54 | self.start_state = FindCandyState(self.map, pos, self.goal_pos) 55 | 56 | def get_start_state(self) -> FindCandyState: 57 | return self.start_state 58 | 59 | def is_goal_state(self, state) -> bool: 60 | return state.pos == self.goal_pos 61 | 62 | def get_actions(self, state) -> list[object]: 63 | actions = [] 64 | x, y = state.pos 65 | if x > 0 and self.map[y][x-1]: 66 | actions.append('left') 67 | if x < self.side_lenght - 1 and self.map[y][x+1]: 68 | actions.append('right') 69 | if y > 0 and self.map[y-1][x]: 70 | actions.append('up') 71 | if y < self.side_lenght - 1 and self.map[y+1][x]: 72 | actions.append('down') 73 | return actions 74 | 75 | def get_transition(self, state, action) -> tuple[FindCandyState, float]: 76 | x, y = state.pos 77 | if action == 'left': 78 | x -= 1 79 | elif action == 'right': 80 | x += 1 81 | elif action == 'up': 82 | y -= 1 83 | elif action == 'down': 84 | y += 1 85 | child_state = FindCandyState(self.map, (x, y), self.goal_pos) 86 | return child_state, 1 87 | 88 | 89 | 90 | 91 | class NonDeterministicFindCandyProblem(FindCandyProblem, NonDeterministicSearchProblem): 92 | """A Non deterministic version of the FindCandyProblem class. Moving to a direction will lead to randomly 1 or 2 steps.""" 93 | 94 | def __init__(self, side_lenght = 10, wall_ratio = 0.4): 95 | FindCandyProblem.__init__(self, side_lenght, wall_ratio) 96 | 97 | def get_transition(self, state, action) -> list[FindCandyState]: 98 | x, y = state.pos 99 | childs = list() 100 | if action == 'left': 101 | x -= 1 102 | if x > 0 and self.map[y][x-1]: 103 | child_state = FindCandyState(self.map, (x-1, y), self.goal_pos) 104 | childs.append(child_state) 105 | elif action == 'right': 106 | x += 1 107 | if x < self.side_lenght - 1 and self.map[y][x+1]: 108 | child_state = FindCandyState(self.map, (x+1, y), self.goal_pos) 109 | childs.append(child_state) 110 | elif action == 'up': 111 | y -= 1 112 | if y > 0 and self.map[y-1][x]: 113 | child_state = FindCandyState(self.map, (x, y-1), self.goal_pos) 114 | childs.append(child_state) 115 | elif action == 'down': 116 | y += 1 117 | if y < self.side_lenght - 1 and self.map[y+1][x]: 118 | child_state = FindCandyState(self.map, (x, y+1), self.goal_pos) 119 | childs.append(child_state) 120 | child_state = FindCandyState(self.map, (x, y), self.goal_pos) 121 | return childs + [child_state] 122 | 123 | 124 | 125 | class SensorlessFindCandyProblem(SensorlessSearchProblem_v2): 126 | """The sensorless version of the FindCandy problem. 127 | Goal is to find a sequence of actions that would lead every possible initial state to a goal state (and not only 1 starting state).""" 128 | def __init__(self, side_lenght = 10, wall_ratio = 0.3) -> None: 129 | physical_problem = FindCandyProblem(side_lenght = side_lenght, wall_ratio = wall_ratio) 130 | initial_belief_state = set() 131 | for y in range(physical_problem.side_lenght): 132 | for x in range(physical_problem.side_lenght): 133 | if physical_problem.map[y][x]: 134 | initial_state = FindCandyState(physical_problem.map, (x, y), physical_problem.goal_pos) 135 | initial_belief_state.add(initial_state) 136 | super().__init__(physical_problem, initial_belief_state) -------------------------------------------------------------------------------- /SearchProblemsAI/SearchProblem.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import random 3 | from typing import Union 4 | 5 | #Definition of objects : 6 | # Node, which is a node in the search tree (or OrderedNode) 7 | # State, which is a state in the search space 8 | # Belief State, which is a set of state the agent believe it could be in 9 | # Percept, which is a percept of a state 10 | 11 | class Action: pass 12 | class Plan: pass 13 | 14 | class Node: 15 | """The Node class for the SEARCH problem. A node is simply defined as a state, a parent node, and an action taken to reach this state.""" 16 | def __init__(self, state : "State", parent : "Node", action : Action) -> None: 17 | self.state = state 18 | self.parent = parent 19 | self.action = action 20 | 21 | class OrderedNode(Node): 22 | """Some algorithms requiring to keep the cost in memory and to be able to compare node. 23 | Such node are 'lower' if their cost is 'lower'.""" 24 | def __init__(self, state: "State", parent: "OrderedNode", action: Action, cost : float) -> None: 25 | super().__init__(state, parent, action) 26 | self.cost = cost 27 | def __lt__(self, other : "OrderedNode") -> bool: 28 | return self.cost < other.cost 29 | 30 | class State(ABC): 31 | """The state of a problem. Every state should be hashable and comparable. 32 | """ 33 | 34 | @abstractmethod 35 | def __hash__(self) -> int: 36 | pass 37 | 38 | @abstractmethod 39 | def __eq__(self, __o: "State") -> bool: 40 | pass 41 | 42 | class BeliefState(State): 43 | """A belief state, composed of a set of possible states from the point of view of the agent.""" 44 | 45 | def __init__(self, states : set[State]) -> None: 46 | super().__init__() 47 | self.states = states 48 | 49 | def __hash__(self) -> int: 50 | return hash(tuple(self.states)) 51 | 52 | def __eq__(self, other : State) -> bool: 53 | return self.states == other.states 54 | 55 | def __str__(self) -> str: 56 | # a = "" 57 | # for s in self.states: 58 | # a += '\n\n' + str(s) 59 | # return a 60 | for state in self.states: 61 | return str(state) 62 | 63 | 64 | class Percept(State): 65 | """A percept is associated to one or several states, it is one of the percept of a state received for the agent.""" 66 | 67 | @abstractmethod 68 | def __init__(self, state : State) -> None: 69 | """Initialize the percept with the state it is associated to. 70 | """ 71 | 72 | 73 | #Definition of SearchProblem in its most basic form as well as some of its variants. 74 | class SearchProblem(ABC): 75 | """The class for defining a search problem. 76 | """ 77 | 78 | @abstractmethod 79 | def get_start_state(self) -> State: 80 | """Returns the start state for the search problem. 81 | """ 82 | 83 | @abstractmethod 84 | def is_goal_state(self, state) -> bool: 85 | """Returns True if and only if the state is a valid goal state. 86 | state: Search state 87 | """ 88 | 89 | @abstractmethod 90 | def get_actions(self, state) -> list[Action]: 91 | """Returns a list of actions that can be executed in the given state. 92 | state: Search state 93 | """ 94 | 95 | @abstractmethod 96 | def get_transition(self, state, action) -> tuple[State, float]: 97 | """This return a tuple composed of the next state and the cost for reaching this state from its parent. 98 | state: Search state 99 | action: Action to be executed 100 | """ 101 | 102 | def apply_solution(self, list_of_actions : list[Action]) -> None: 103 | if list_of_actions is None: 104 | print("No path found.") 105 | return 106 | total_cost = 0 107 | state = self.get_start_state() 108 | for action in list_of_actions: 109 | if not action in self.get_actions(state): 110 | raise Exception("The action " + str(action) + " is not available in the state " + str(state)) 111 | if self.is_goal_state(state) and list_of_actions != []: 112 | raise Exception("The goal state has been reached, but they were other actions to take for reaching a goal according to the input.\nTotal cost:", total_cost) 113 | state, cost = self.get_transition(state, action) 114 | total_cost += cost 115 | if self.is_goal_state(state): 116 | print("The goal state has been reached, and the total cost is:", total_cost) 117 | else: 118 | raise Exception("The goal state has not been reached, but the input list of actions is empty.") 119 | 120 | 121 | 122 | 123 | 124 | class NonDeterministicSearchProblem(SearchProblem): 125 | """The class for defining a NON DETERMINISTIC search problem. 126 | What changes is that the get_transition method returns a list of states and not a tuple (state, cost). This list represent the possible next states given such action was taken. 127 | """ 128 | @abstractmethod 129 | def get_transition(self, state, action) -> list[State]: 130 | """This return a list of states than can be reached by using action in a state. 131 | state: Search state 132 | action: Action to be executed 133 | """ 134 | 135 | def apply_solution(self, plan : tuple[Action, Plan]): # plan = (action, plans) plans = {state : [action, plans]} 136 | actions_taken = [] 137 | if plan is None: 138 | print("No path found.") 139 | return 140 | state = self.get_start_state() 141 | while (self.is_goal_state(state)) or plan != (): 142 | if self.is_goal_state(state): 143 | print("A goal state has been reached following this plan.") 144 | print("Action taken:", actions_taken) 145 | return 146 | 147 | action, plans = plan 148 | if action not in self.get_actions(state): 149 | raise Exception("The action " + str(action) + " is not available in the state " + str(state)) 150 | 151 | states = self.get_transition(state, action) 152 | state = random.choice(states) 153 | if state not in plans: 154 | raise Exception("The state is not in the plan.") 155 | plan = plans[state] 156 | actions_taken.append(action) 157 | raise Exception("Plan has been followed, but the goal state has not been reached.") 158 | 159 | 160 | 161 | 162 | 163 | class SensorlessSearchProblem(SearchProblem): 164 | """Class for transforming a search problem into a sensorless search problem. Requires to specify the set of possible states at the start.""" 165 | 166 | def __init__(self, problem : SearchProblem, initial_belief_state_set : set[State]) -> None: 167 | super().__init__() 168 | self.physical_problem = problem 169 | self.inital_belief_state = BeliefState(initial_belief_state_set) 170 | 171 | def get_start_state(self) -> BeliefState: 172 | return self.inital_belief_state 173 | 174 | def is_goal_state(self, belief_state : BeliefState) -> bool: 175 | for state in belief_state.states: 176 | if not self.physical_problem.is_goal_state(state): 177 | return False 178 | return True 179 | 180 | def get_actions(self, belief_state : BeliefState) -> list[Action]: 181 | return list(set().union(*[self.physical_problem.get_actions(state) for state in belief_state.states])) 182 | 183 | def get_transition(self, belief_state : BeliefState, action : Action) -> tuple[BeliefState, float]: 184 | new_states = set() 185 | pessimistic_cost = 0 186 | for state in belief_state.states: 187 | if action in self.physical_problem.get_actions(state): 188 | new_state, cost = self.physical_problem.get_transition(state, action) 189 | new_states.add(new_state) 190 | pessimistic_cost = max(pessimistic_cost, cost) 191 | else: 192 | new_states.add(state) 193 | new_belief_state = BeliefState(new_states) 194 | return new_belief_state, pessimistic_cost 195 | 196 | class SensorlessSearchProblem_v2(SensorlessSearchProblem): 197 | """Improve the definition a a sensorless problem for solving. 198 | This class consider that a belief state is a goal belief state if all of its states are state that were goal state previously. 199 | The v1 class only consider a belief state as a goal belief state if all of its states are goal states. 200 | """ 201 | def __init__(self, problem: SearchProblem, initial_belief_state_set: set[State]) -> None: 202 | super().__init__(problem, initial_belief_state_set) 203 | 204 | def get_start_state(self) -> BeliefState: 205 | belief_state = super().get_start_state() 206 | belief_state.was_goal_state = {state : self.physical_problem.is_goal_state(state) for state in belief_state.states} 207 | return belief_state 208 | 209 | def get_transition(self, belief_state : BeliefState, action : Action) -> tuple[BeliefState, float]: 210 | new_states = set() 211 | new_was_goal_state = dict() 212 | pessimistic_cost = 0 213 | for state in belief_state.states: 214 | if action in self.physical_problem.get_actions(state): 215 | new_state, cost = self.physical_problem.get_transition(state, action) 216 | new_states.add(new_state) 217 | pessimistic_cost = max(pessimistic_cost, cost) 218 | new_was_goal_state[new_state] = belief_state.was_goal_state[state] or self.physical_problem.is_goal_state(new_state) 219 | else: 220 | new_states.add(state) 221 | new_was_goal_state[state] = belief_state.was_goal_state[state] 222 | new_belief_state = BeliefState(new_states) 223 | new_belief_state.was_goal_state = new_was_goal_state 224 | return new_belief_state, pessimistic_cost 225 | 226 | def is_goal_state(self, belief_state : BeliefState) -> bool: 227 | for state in belief_state.states: 228 | if not belief_state.was_goal_state[state]: 229 | return False 230 | return True 231 | 232 | 233 | 234 | 235 | class PartiallyObservableSearchProblem(NonDeterministicSearchProblem): #WIP 236 | 237 | def __init__(self, physical_problem : SearchProblem) -> None: 238 | self.physical_problem = physical_problem 239 | super().__init__() 240 | 241 | def predict(self, belief_state : BeliefState, action : Action) -> BeliefState: 242 | """Return the belief state the agent is in after taking an action.""" 243 | if isinstance(self.physical_problem, NonDeterministicSearchProblem): 244 | states = set().union(*[self.physical_problem.get_transitions(state, action) for state in belief_state.states]) 245 | elif isinstance(self.physical_problem, SearchProblem): 246 | states = {self.physical_problem.get_transition(state, action) for state in belief_state.states} 247 | else : raise Exception("The problem is not a deterministic or non deterministic problem.") 248 | return BeliefState(states = states) 249 | 250 | def possible_percepts(self, belief_state : BeliefState) -> set[Percept]: 251 | """Return the set of possible percepts the agent can perceive.""" 252 | return set().union(*[self.physical_problem.get_actions(state) for state in belief_state.states]) 253 | 254 | -------------------------------------------------------------------------------- /SearchProblemsAI/SearchAlgorithms.py: -------------------------------------------------------------------------------- 1 | """Module implementing the SearchAlgorithm base class from which all search algorithms can be built. 2 | Several algorithms are implemented in this module such as DFS, BFS, A_star. 3 | 4 | Properties: 5 | Completness : whether a solution is found (if one exists) 6 | Optimality : whether an optimal solution is found (if a solution exists) 7 | Space complexity : in terms of states kept in memory. b is the branching factor, m the depth of a solution, M is the maximum depth. 8 | Time complexity : in terms of number of basic operations. 9 | """ 10 | from abc import ABC, abstractmethod 11 | from typing import Callable, Union 12 | from random import random 13 | import heapq 14 | 15 | from SearchProblemsAI.SearchProblem import Action, Plan, Node, OrderedNode, State, SearchProblem, NonDeterministicSearchProblem 16 | from SearchProblemsAI.utils import * 17 | 18 | class SearchAlgorithm(ABC): 19 | """The mother class for every SEARCH algorithm. The method solve() is the minimal representation of how a SEARCH algorithm works. 20 | Every SEARCH algorithm can be built from this class by implementing methods called in solve(). 21 | """ 22 | 23 | @abstractmethod 24 | def solve(self, problem : SearchProblem, verbose : int = 1) -> Union[list[object], None]: 25 | """Solve the problem using a search algorithm. 26 | Return a list of actions that lead to the goal state starting from the start state. 27 | """ 28 | self.init_solver(problem) 29 | while self.should_keep_searching(): 30 | node = self.extract_best_node_to_explore() 31 | if problem.is_goal_state(node.state): 32 | return self.reconstruct_path(node) 33 | else: 34 | actions = problem.get_actions(node.state) 35 | for action in actions: 36 | child_state, cost = problem.get_transition(node.state, action) 37 | self.deal_with_child_state(child_state, node, action, cost) 38 | 39 | if verbose >= 1: print("No path found") 40 | return None 41 | 42 | #Permanent methods 43 | def reconstruct_path(self, node : Node) -> list[object]: 44 | """Given a node, return a list of actions that lead to the node. 45 | """ 46 | if node.parent == None: 47 | return [] 48 | return self.reconstruct_path(node.parent) + [node.action] 49 | 50 | 51 | 52 | class DFS_treeSearch(SearchAlgorithm): 53 | """Depth First Search algorithm for trees. 54 | Complete, suboptimal, space complexity: O(bm), time complexity: O(b^m) 55 | """ 56 | def __init__(self): 57 | super().__init__() 58 | 59 | def solve(self, problem : SearchProblem, verbose : int = 1) -> Union[list[object], None]: 60 | """Solve the problem using a search algorithm. 61 | Return a list of actions that lead to the goal state starting from the start state. 62 | """ 63 | 64 | initial_node = Node(problem.get_start_state(), None, None) 65 | self.frontier = [initial_node] 66 | 67 | while len(self.frontier) > 0: 68 | node = self.frontier.pop() 69 | if problem.is_goal_state(node.state): 70 | return self.reconstruct_path(node) 71 | else: 72 | actions = problem.get_actions(node.state) 73 | for action in actions: 74 | child_state, cost = problem.get_transition(node.state, action) 75 | child_node = Node(state = child_state, parent = node, action = action) 76 | self.frontier.append(child_node) 77 | 78 | if verbose >= 1: print("No path found") 79 | return None 80 | 81 | 82 | class DFS(SearchAlgorithm): 83 | """Depth First Search algorithm. 84 | Complete, suboptimal, space complexity: O(bm), time complexity: O(b^M)""" 85 | def __init__(self): 86 | super().__init__() 87 | 88 | def solve(self, problem : SearchProblem, verbose : int = 1) -> Union[list[object], None]: 89 | """Solve the problem using a search algorithm. 90 | Return a list of actions that lead to the goal state starting from the start state. 91 | """ 92 | initial_node = Node(problem.get_start_state(), None, None) 93 | self.frontier = [initial_node] 94 | self.explored = {initial_node.state : initial_node} 95 | while len(self.frontier) > 0: 96 | node = self.frontier.pop() 97 | if problem.is_goal_state(node.state): 98 | return self.reconstruct_path(node) 99 | else: 100 | actions = problem.get_actions(node.state) 101 | for action in actions: 102 | child_state, cost = problem.get_transition(node.state, action) 103 | if not child_state in self.explored: 104 | child_node = Node(state = child_state, parent = node, action = action) 105 | self.frontier.append(child_node) 106 | self.explored[child_state] = child_node 107 | 108 | if verbose >= 1: print("No path found") 109 | return None 110 | 111 | 112 | 113 | class BFS(SearchAlgorithm): 114 | """Breadth First Search algorithm. 115 | Complete, suboptimal, space complexity: O(b^m), time complexity: O(b^m)""" 116 | def __init__(self): 117 | super().__init__() 118 | 119 | def solve(self, problem : SearchProblem, verbose : int = 1) -> Union[list[object], None]: 120 | """Solve the problem using a search algorithm. 121 | Return a list of actions that lead to the goal state starting from the start state. 122 | """ 123 | initial_node = Node(problem.get_start_state(), None, None) 124 | self.frontier = [initial_node] 125 | self.explored = {initial_node.state : initial_node} 126 | while len(self.frontier) > 0: 127 | node = self.frontier.pop(0) 128 | if problem.is_goal_state(node.state): 129 | return self.reconstruct_path(node) 130 | else: 131 | actions = problem.get_actions(node.state) 132 | for action in actions: 133 | child_state, cost = problem.get_transition(node.state, action) 134 | if not child_state in self.explored: 135 | child_node = Node(state = child_state, parent = node, action = action) 136 | self.frontier.append(child_node) 137 | self.explored[child_state] = child_node 138 | 139 | if verbose >= 1: print("No path found") 140 | return None 141 | 142 | 143 | class DepthLimitedDFS(SearchAlgorithm): 144 | """Depth First Search algorithm with a limited depth. This implementation consider nodes as ordered wrt their depth, 145 | so the cost attribute is here define as the depth of the node in the search. 146 | Not complete, suboptimal, space complexity: O(min(bm, b*depth_limit)), time complexity: O(b^min(M, depth_limit))""" 147 | def __init__(self, depth_limit : int): 148 | self.depth_limit = depth_limit 149 | super().__init__() 150 | 151 | def solve(self, problem : SearchProblem, verbose : int = 1) -> Union[list[object], None]: 152 | """Solve the problem using a search algorithm. 153 | Return a list of actions that lead to the goal state starting from the start state. 154 | """ 155 | initial_node = OrderedNode(problem.get_start_state(), None, None, cost = 0) 156 | self.frontier = [initial_node] 157 | self.explored = {initial_node.state : initial_node} 158 | while len(self.frontier) > 0: 159 | node = self.frontier.pop() 160 | if problem.is_goal_state(node.state): 161 | return self.reconstruct_path(node) 162 | else: 163 | actions = problem.get_actions(node.state) 164 | for action in actions: 165 | child_state, cost = problem.get_transition(node.state, action) 166 | if node.cost >= self.depth_limit: #If the node is too deep, we don't explore it 167 | return 168 | if not child_state in self.explored: 169 | child_node = OrderedNode(state = child_state, parent = node, action = action, cost = node.cost + 1) 170 | self.frontier.append(child_node) 171 | self.explored[child_state] = child_node 172 | 173 | if verbose >= 1: print("No path found") 174 | return None 175 | 176 | 177 | 178 | 179 | class IDDFS(): 180 | """Iterative Deepening Depth First Search algorithm. 181 | Complete, optimal if transitions cost are constant, space complexity: O(bm), time complexity: O(b^m)""" 182 | def __init__(self): 183 | self.n_node_explored = 0 184 | super().__init__() 185 | 186 | def solve(self, problem : SearchProblem): 187 | n_node_explored_last_DLS = None 188 | depth = 0 189 | while True: 190 | #Perform depth limited DFS for this depth 191 | algo = DepthLimitedDFS(depth) 192 | list_of_actions = algo.solve(problem, verbose=0) 193 | if list_of_actions != None: 194 | return list_of_actions 195 | else: 196 | depth += 1 197 | 198 | #If the DLS has explored the same number of nodes as the last DLS, we have reached the limit depth = M (max depth of the problem) without finding a solution : no solution 199 | n_node_explored = len(algo.explored) 200 | if n_node_explored == n_node_explored_last_DLS: 201 | return 202 | n_node_explored_last_DLS = n_node_explored 203 | 204 | 205 | 206 | class UCS(SearchAlgorithm): 207 | """Uniform Cost Search algorithm. 208 | Complete, optimal, space complexity: O(b^m), time complexity: O(b^m)""" 209 | def __init__(self): 210 | super().__init__() 211 | 212 | def solve(self, problem : SearchProblem, verbose : int = 1) -> Union[list[object], None]: 213 | """Solve the problem using a search algorithm. 214 | Return a list of actions that lead to the goal state starting from the start state. 215 | """ 216 | initial_node = OrderedNode(problem.get_start_state(), None, None, 0) 217 | self.frontier = [initial_node] 218 | self.explored = {initial_node.state : initial_node} 219 | while len(self.frontier) > 0: 220 | node = heapq.heappop(self.frontier) 221 | if problem.is_goal_state(node.state): 222 | return self.reconstruct_path(node) 223 | else: 224 | actions = problem.get_actions(node.state) 225 | for action in actions: 226 | child_state, cost = problem.get_transition(node.state, action) 227 | if (not child_state in self.explored) or (self.explored[child_state].cost > self.explored[node.state].cost + cost): 228 | new_cost = self.explored[node.state].cost + cost 229 | child_node = OrderedNode(state = child_state, parent = node, action = action, cost = new_cost) 230 | heapq.heappush(self.frontier, child_node) 231 | self.explored[child_state] = child_node 232 | 233 | if verbose >= 1: print("No path found") 234 | return None 235 | 236 | 237 | 238 | class A_star(SearchAlgorithm): 239 | """A* algorithm. 240 | Complete, optimal if admissible heuristic""" 241 | def __init__(self, heuristic : Callable[[State], float] = lambda state : 0) -> None: 242 | super().__init__() 243 | self.heuristic = heuristic 244 | 245 | def solve(self, problem : SearchProblem, verbose : int = 1) -> Union[list[object], None]: 246 | """Solve the problem using a search algorithm. 247 | Return a list of actions that lead to the goal state starting from the start state. 248 | """ 249 | initial_state = problem.get_start_state() 250 | initial_node = OrderedNode(initial_state, None, None, self.heuristic(initial_state)) 251 | self.frontier = [initial_node] 252 | self.explored = {initial_node.state : initial_node} 253 | while len(self.frontier) > 0: 254 | node = heapq.heappop(self.frontier) 255 | if problem.is_goal_state(node.state): 256 | return self.reconstruct_path(node) 257 | else: 258 | actions = problem.get_actions(node.state) 259 | for action in actions: 260 | child_state, cost = problem.get_transition(node.state, action) 261 | if child_state in self.explored: 262 | new_cost = self.explored[node.state].cost + cost + self.heuristic(child_state) 263 | if self.explored[child_state].cost > new_cost: 264 | child_node = OrderedNode(state = child_state, parent = node, action = action, cost = new_cost) 265 | heapq.heappush(self.frontier, child_node) 266 | self.explored[child_state] = child_node 267 | 268 | else: 269 | new_cost = self.explored[node.state].cost + cost + self.heuristic(child_state) 270 | child_node = OrderedNode(state = child_state, parent = node, action = action, cost = new_cost) 271 | heapq.heappush(self.frontier, child_node) 272 | self.explored[child_state] = child_node 273 | 274 | if verbose >= 1: print("No path found") 275 | return None 276 | 277 | 278 | 279 | 280 | 281 | 282 | class NonDeterministicSearchProblemAlgorithm: 283 | """A solving algorithms that deals with Search Problem that are non deterministic, 284 | ie that returns a list of states instead of a tuple (state, cost) for the method .get_transition() 285 | 286 | Returns a conditional plan that leads the agent to a goal state in any situations he might fell in. 287 | The form of this plan is a tuple plan = (action, {state1 : plan1, state2 : plan2, ...}). 288 | """ 289 | def solve(self, problem : NonDeterministicSearchProblem): 290 | return self.or_search(problem.get_start_state(), problem, set()) 291 | 292 | def or_search(self, state : State, problem : NonDeterministicSearchProblem, path : set): 293 | """Return a plan = {"action" : action, "plans" : {state1 : plan1, ...}) that give the agent the action to take as well as the next plans he will have to consider. 294 | """ 295 | if problem.is_goal_state(state): 296 | return () 297 | if state in path: 298 | return 299 | for action in problem.get_actions(state): 300 | plan_statesToPlans = self.and_search(problem.get_transition(state, action), problem, path | {state}) 301 | if plan_statesToPlans is not None: 302 | return (action, plan_statesToPlans) 303 | 304 | def and_search(self, states : list[State], problem : NonDeterministicSearchProblem, path : set): 305 | """Return a dictionnary {state1 : plan1, ...} that describes what the agent should do in any situation the nature will lead to. 306 | """ 307 | plan_statesToPlans = {} 308 | for state in states: 309 | plan_actionAndPlan = self.or_search(state, problem, path) 310 | if plan_actionAndPlan is None: 311 | return None 312 | plan_statesToPlans[state] = plan_actionAndPlan 313 | return plan_statesToPlans 314 | 315 | 316 | class NonDeterministicSearchProblemAlgorithm_v2: #WIP 317 | """A solving algorithms that deals with Search Problem that are non deterministic, 318 | ie that returns a list of states instead of a tuple (state, cost) for the method .get_transition() 319 | 320 | Returns a conditional plan that leads the agent to a goal state in any situations he might fell in. 321 | The form of this plan is a tuple plan = (action, {state1 : plan1, state2 : plan2, ...}). 322 | """ 323 | def solve(self, problem : NonDeterministicSearchProblem): 324 | return self.or_search(problem.get_start_state(), problem, set()) 325 | 326 | def or_search(self, state : State, problem : NonDeterministicSearchProblem, path : set): 327 | """Return a plan = {"action" : action, "plans" : {state1 : plan1, ...}) that give the agent the action to take as well as the next plans he will have to consider. 328 | """ 329 | if problem.is_goal_state(state): 330 | return () 331 | if state in path: 332 | return 333 | for action in problem.get_actions(state): 334 | plan_statesToPlans = self.and_search(problem.get_transition(state, action), problem, path | {state}) 335 | if plan_statesToPlans is not None: 336 | return (action, plan_statesToPlans) 337 | 338 | def and_search(self, states : list[State], problem : NonDeterministicSearchProblem, path : set): 339 | """Return a dictionnary {state1 : plan1, ...} that describes what the agent should do in any situation the nature will lead to. 340 | """ 341 | plan_statesToPlans = {} 342 | for state in states: 343 | plan_actionAndPlan = self.or_search(state, problem, path) 344 | if plan_actionAndPlan is None: 345 | return None 346 | plan_statesToPlans[state] = plan_actionAndPlan 347 | return plan_statesToPlans --------------------------------------------------------------------------------