diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..14e02016a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,53 @@ +name: docs + +on: + push: + branches: [master] + +# allow the workflow to publish to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive # aima-data is needed: learning.py builds DataSets at import time + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install documentation dependencies + run: pip install -r docs/requirements.txt + - name: Build the Sphinx documentation + # -W --keep-going: fail the build if any page can't be imported/rendered, + # so an incomplete API reference can never be published silently again + run: sphinx-build -b html -W --keep-going docs docs/_build/html + - name: Build the JupyterLite browser companion (issue #1072) + # Published alongside the API docs at /lite/. Kept non-fatal so a Pyodide + # toolchain hiccup can never block the reference-docs deploy. + continue-on-error: true + run: | + pip install -r lite/requirements-lite.txt + bash lite/build.sh "$PWD/docs/_build/html/lite" + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/notebooks-to-py.yml b/.github/workflows/notebooks-to-py.yml new file mode 100644 index 000000000..7b0ebd4fe --- /dev/null +++ b/.github/workflows/notebooks-to-py.yml @@ -0,0 +1,59 @@ +name: Notebooks → .py + +# Per Peter Norvig's suggestion (PR #1340): the .ipynb notebooks are the source +# of truth; this action regenerates a readable, diffable .py mirror of each one +# (via jupytext, py:percent) every time a notebook is edited on master, and +# commits the result. It only writes notebooks/**/*.py — it never touches the +# aima/ package, which is maintained by hand. + +on: + push: + branches: [master] + paths: + - 'notebooks/**/*.ipynb' + workflow_dispatch: + +permissions: + contents: write + +# only one regeneration at a time; newer pushes supersede in-flight ones +concurrency: + group: notebooks-to-py + cancel-in-progress: true + +jobs: + jupytext: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install jupytext + run: pip install jupytext + + - name: Generate .py mirrors from notebooks + run: | + # one-way export: each notebooks/**/X.ipynb -> notebooks/**/X.py (py:percent) + find notebooks -name '*.ipynb' -not -path '*/.ipynb_checkpoints/*' \ + -exec jupytext --to py:percent {} + + # drop any .py whose source notebook no longer exists + find notebooks -name '*.py' | while read -r py; do + [ -f "${py%.py}.ipynb" ] || { echo "removing orphan $py"; rm -f "$py"; } + done + + - name: Commit regenerated .py + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A notebooks + if git diff --cached --quiet; then + echo "notebook .py mirrors already up to date" + else + # the commit only changes *.py, which doesn't match this workflow's + # .ipynb path filter, so it won't retrigger; [skip ci] is belt-and-braces + git commit -m "Auto-generate notebook .py mirrors from edited .ipynb [skip ci]" + git push + fi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..c2a1c686c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,40 @@ +name: tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + env: + MPLBACKEND: Agg + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov flake8 + + - name: Lint with flake8 + run: flake8 . + continue-on-error: true + + - name: Run tests + run: pytest --cov=./ diff --git a/.gitignore b/.gitignore index 58e83214e..f649448cd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ __pycache__/ # Distribution / packaging .Python env/ +.venv/ +venv/ build/ develop-eggs/ dist/ @@ -76,3 +78,8 @@ target/ # for macOS .DS_Store ._.DS_Store + +# JupyterLite browser companion build artifacts (see lite/README.md) +lite/_output/ +lite/.jupyterlite.doit.db +lite/aima-*.whl diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e465e8e4c..000000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: python - -python: - - 3.5 - - 3.6 - - 3.7 - - 3.8 - -before_install: - - git submodule update --remote - -install: - - pip install --upgrade -r requirements.txt - -script: - - py.test --cov=./ - - python -m doctest -v *.py - -after_success: - - flake8 --max-line-length 100 --ignore=E121,E123,E126,E221,E222,E225,E226,E242,E701,E702,E704,E731,W503 . - -notifications: - email: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f92643700..d4987ab51 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,10 @@ In more detail: - Check for common problems in [porting to Python 3](http://python3porting.com/problems.html), such as: `print` is now a function; `range` and `map` and other functions no longer produce `list`; objects of different types can no longer be compared with `<`; strings are now Unicode; it would be nice to move `%` string formatting to `.format`; there is a new `next` function for generators; integer division now returns a float; we can now use set literals. - Replace old Lisp-based idioms with proper Python idioms. For example, we have many functions that were taken directly from Common Lisp, such as the `every` function: `every(callable, items)` returns true if every element of `items` is callable. This is good Lisp style, but good Python style would be to use `all` and a generator expression: `all(callable(f) for f in items)`. Eventually, fix all calls to these legacy Lisp functions and then remove the functions. +## Editions: target the 4th edition + +We are **not** maintaining two parallel editions. As [Peter Norvig stated](https://github.com/aimacode/aima-python/issues/1188#issuecomment-641669882), all new work should move toward the 4th edition. The old 3e/4e module pairs have now been **merged into a single version per topic** (no `*4e.py` files remain), and every module lives in the importable `aima/` package. Add new 4e content directly to the relevant module (e.g. `aima/search.py`); don't reintroduce edition-suffixed files. See the *Edition policy* section in the README for details. + ## New and Improved Algorithms - Implement functions that were in the third edition of the book but were not yet implemented in the code. Check the [list of pseudocode algorithms (pdf)](https://github.com/aimacode/pseudocode/blob/master/aima3e-algorithms.pdf) to see what's missing. @@ -34,14 +38,14 @@ We hope to have an `algorithm-name.md` file for each algorithm, eventually; it w ## Jupyter Notebooks -In this project we use Jupyter/IPython Notebooks to showcase the algorithms in the book. They serve as short tutorials on what the algorithms do, how they are implemented and how one can use them. To install Jupyter, you can follow the instructions [here](https://jupyter.org/install.html). These are some ways you can contribute to the notebooks: +In this project we use Jupyter/IPython Notebooks to showcase the algorithms in the book. They serve as short tutorials on what the algorithms do, how they are implemented and how one can use them. To install Jupyter, you can follow the instructions [here](https://jupyter.org/install.html). Each notebook has an auto-generated `.py` mirror next to it (produced by the `notebooks-to-py.yml` GitHub Action via jupytext): the `.ipynb` is the source of truth — edit the notebook, never the generated `.py`. These are some ways you can contribute to the notebooks: - Proofread the notebooks for grammar mistakes, typos, or general errors. -- Move visualization and unrelated to the algorithm code from notebooks to `notebook.py` (a file used to store code for the notebooks, like visualization and other miscellaneous stuff). Make sure the notebooks still work and have their outputs showing! -- Replace the `%psource` magic notebook command with the function `psource` from `notebook.py` where needed. Examples where this is useful are a) when we want to show code for algorithm implementation and b) when we have consecutive cells with the magic keyword (in this case, if the code is large, it's best to leave the output hidden). -- Add the function `pseudocode(algorithm_name)` in algorithm sections. The function prints the pseudocode of the algorithm. You can see some example usage in [`knowledge.ipynb`](https://github.com/aimacode/aima-python/blob/master/knowledge.ipynb). +- Move visualization and unrelated to the algorithm code from notebooks to `notebook_utils.py` (a file used to store code for the notebooks, like visualization and other miscellaneous stuff). Make sure the notebooks still work and have their outputs showing! +- Replace the `%psource` magic notebook command with the function `psource` from `notebook_utils.py` where needed. Examples where this is useful are a) when we want to show code for algorithm implementation and b) when we have consecutive cells with the magic keyword (in this case, if the code is large, it's best to leave the output hidden). +- Add the function `pseudocode(algorithm_name)` in algorithm sections. The function prints the pseudocode of the algorithm. You can see some example usage in [`knowledge_current_best.ipynb`](https://github.com/aimacode/aima-python/blob/master/notebooks/knowledge_current_best.ipynb). - Edit existing sections for algorithms to add more information and/or examples. -- Add visualizations for algorithms. The visualization code should go in `notebook.py` to keep things clean. +- Add visualizations for algorithms. The visualization code should go in `notebook_utils.py` to keep things clean. - Add new sections for algorithms not yet covered. The general format we use in the notebooks is the following: First start with an overview of the algorithm, printing the pseudocode and explaining how it works. Then, add some implementation details, including showing the code (using `psource`). Finally, add examples for the implementations, showing how the algorithms work. Don't fret with adding complex, real-world examples; the project is meant for educational purposes. You can of course choose another format if something better suits an algorithm. Apart from the notebooks explaining how the algorithms work, we also have notebooks showcasing some indicative applications of the algorithms. These notebooks are in the `*_apps.ipynb` format. We aim to have an `apps` notebook for each module, so if you don't see one for the module you would like to contribute to, feel free to create it from scratch! In these notebooks we are looking for applications showing what the algorithms can do. The general format of these sections is this: Add a description of the problem you are trying to solve, then explain how you are going to solve it and finally provide your solution with examples. Note that any code you write should not require any external libraries apart from the ones already provided (like `matplotlib`). diff --git a/README.md b/README.md index 17f1d6085..625eed374 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,38 @@ -# `aima-python` [![Build Status](https://travis-ci.org/aimacode/aima-python.svg?branch=master)](https://travis-ci.org/aimacode/aima-python) [![Binder](http://mybinder.org/badge.svg)](http://mybinder.org/repo/aimacode/aima-python) +# `aima-python` [![tests](https://github.com/aimacode/aima-python/actions/workflows/tests.yml/badge.svg)](https://github.com/aimacode/aima-python/actions/workflows/tests.yml) [![docs](https://github.com/aimacode/aima-python/actions/workflows/docs.yml/badge.svg)](https://aimacode.github.io/aima-python/) [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/aimacode/aima-python/master) [![lite-badge](https://jupyterlite.rtfd.io/en/latest/_static/badge.svg)](https://aimacode.github.io/aima-python/lite/) Python code for the book *[Artificial Intelligence: A Modern Approach](http://aima.cs.berkeley.edu).* You can use this in conjunction with a course on AI, or for study on your own. We're looking for [solid contributors](https://github.com/aimacode/aima-python/blob/master/CONTRIBUTING.md) to help. # Updates for 4th Edition -The 4th edition of the book as out now in 2020, and thus we are updating the code. All code here will reflect the 4th edition. Changes include: +The 4th edition of the book is out now in 2020, and thus we are updating the code. All code here will reflect the 4th edition. Changes include: -- Move from Python 3.5 to 3.7. +- Move from Python 3.5 to 3.7, and on to modern Python (the code now runs on Python 3.9 and up). - More emphasis on Jupyter (Ipython) notebooks. - More projects using external packages (tensorflow, etc.). +**Edition policy (3rd vs 4th).** As [Peter Norvig stated](https://github.com/aimacode/aima-python/issues/1188#issuecomment-641669882), we are *not* maintaining two parallel editions — all new work should move toward the 4th edition. Concretely: + +- The **4th edition is canonical**: new algorithms, fixes and pseudocode references should follow the 4e numbering and content. +- The 3e/4e split is **done**: there is now a **single version per module** (no `*4e.py` files remain). The old 3e/4e pairs have been merged — the canonical implementation was kept and any genuinely-new 4e algorithms were folded in — and the 4e-only modules (`deep_learning.py`, `game_theory.py`, `making_simple_decisions.py`, `perception.py`) were de-suffixed. Add new 4e content directly to the relevant module. + # Structure of the Project -When complete, this project will have Python implementations for all the pseudocode algorithms in the book, as well as tests and examples of use. For each major topic, such as `search`, we provide the following files: +When complete, this project will have Python implementations for all the pseudocode algorithms in the book, as well as tests and examples of use. The code is organised into these top-level folders: -- `search.ipynb` and `search.py`: Implementations of all the pseudocode algorithms, and necessary support functions/classes/data. The `.py` file is generated automatically from the `.ipynb` file; the idea is that it is easier to read the documentation in the `.ipynb` file. -- `search_XX.ipynb`: Notebooks that show how to use the code, broken out into various topics (the `XX`). -- `tests/test_search.py`: A lightweight test suite, using `assert` statements, designed for use with [`py.test`](http://pytest.org/latest/), but also usable on their own. +- **`aima/`** — the importable Python package: one module per major topic (e.g. `aima/search.py`) with the implementations of the pseudocode algorithms and their support functions/classes/data. +- **`notebooks/`** — the Jupyter notebooks that explain and demonstrate the code (e.g. `notebooks/search.ipynb`), plus the per-chapter `notebooks/chapterNN/` demos. Each notebook starts with a `%run bootstrap.ipynb` cell that puts the repo root on `sys.path`, so `from aima import ...` works wherever the notebook is launched. A GitHub Action ([`notebooks-to-py.yml`](.github/workflows/notebooks-to-py.yml)) keeps a readable, diffable `.py` mirror of every notebook beside it (generated with [jupytext](https://jupytext.readthedocs.io)); the `.ipynb` is the source of truth, so edit the notebook, not the generated `.py`. +- **`tests/`** — a lightweight test suite (e.g. `tests/test_search.py`), using `assert` statements, designed for use with [`py.test`](http://pytest.org/latest/) but also usable on their own. +- **`lite/`** — a [JupyterLite](https://jupyterlite.readthedocs.io) proof-of-concept that runs a few notebooks entirely in the browser via [Pyodide](https://pyodide.org), no install required (try it [here](https://aimacode.github.io/aima-python/lite/); see [`lite/README.md`](lite/README.md) and [issue #1072](https://github.com/aimacode/aima-python/issues/1072)). -# Python 3.7 and up +# Python 3.9 and up -The code for the 3rd edition was in Python 3.5; the current 4th edition code is in Python 3.7. It should also run in later versions, but does not run in Python 2. You can [install Python](https://www.python.org/downloads) or use a browser-based Python interpreter such as [repl.it](https://repl.it/languages/python3). -You can run the code in an IDE, or from the command line with `python -i filename.py` where the `-i` option puts you in an interactive loop where you can run Python functions. All notebooks are available in a [binder environment](http://mybinder.org/repo/aimacode/aima-python). Alternatively, visit [jupyter.org](http://jupyter.org/) for instructions on setting up your own Jupyter notebook environment. +The code for the 3rd edition was in Python 3.5; the 4th edition code targets Python 3.7 and runs on Python 3.9 and up, but does not run in Python 2. Continuous integration runs the full test suite (including the deep-learning modules) on Python 3.9, 3.10, 3.11 and 3.12; note that some optional dependencies (`tensorflow`, `keras`, `opencv-python`) do not yet ship wheels for the very latest releases (3.13+), so one of those versions is recommended for running everything. You can [install Python](https://www.python.org/downloads) or use a browser-based Python interpreter such as [repl.it](https://repl.it/languages/python3). +The algorithms live in the `aima` package, so you import them as `from aima.search import astar_search` (or `from aima import search`). You can install the package in editable mode with `pip install -e .` and then run the code in an IDE, or from the command line with `python -i -m aima.search` where the `-i` option puts you in an interactive loop where you can run Python functions. All notebooks are available in a [binder environment](https://mybinder.org/v2/gh/aimacode/aima-python/master). Alternatively, visit [jupyter.org](http://jupyter.org/) for instructions on setting up your own Jupyter notebook environment. Features from Python 3.6 and 3.7 that we will be using for this version of the code: - [f-strings](https://docs.python.org/3.6/whatsnew/3.6.html#whatsnew36-pep498): all string formatting should be done with `f'var = {var}'`, not with `'var = {}'.format(var)` nor `'var = %s' % var`. @@ -51,6 +57,10 @@ cd aima-python pip install -r requirements.txt ``` +A couple of notebooks also need the [Graphviz](https://graphviz.org/download/) system +binary (`dot`) for rendering — the `graphviz` PyPI package is only a wrapper. Install it +with your OS package manager if needed (e.g. `apt install graphviz`, `brew install graphviz`). + You also need to fetch the datasets from the [`aima-data`](https://github.com/aimacode/aima-data) repository: ``` @@ -71,7 +81,7 @@ And you are good to go! # Index of Algorithms -Here is a table of algorithms, the figure, name of the algorithm in the book and in the repository, and the file where they are implemented in the repository. This chart was made for the third edition of the book and is being updated for the upcoming fourth edition. Empty implementations are a good place for contributors to look for an issue. The [aima-pseudocode](https://github.com/aimacode/aima-pseudocode) project describes all the algorithms from the book. An asterisk next to the file name denotes the algorithm is not fully implemented. Another great place for contributors to start is by adding tests and writing on the notebooks. You can see which algorithms have tests and notebook sections below. If the algorithm you want to work on is covered, don't worry! You can still add more tests and provide some examples of use in the notebook! +Here is a table of algorithms, the figure, name of the algorithm in the book and in the repository, and the file where they are implemented in the repository. This chart was originally made for the third edition of the book; per the edition policy above, the project has converged on the fourth edition (4th-edition content, a single module per topic, all under the `aima/` package). Empty implementations are a good place for contributors to look for an issue. The [aima-pseudocode](https://github.com/aimacode/aima-pseudocode) project describes all the algorithms from the book. An asterisk next to the file name denotes the algorithm is not fully implemented. Another great place for contributors to start is by adding tests and writing on the notebooks. You can see which algorithms have tests and notebook sections below. If the algorithm you want to work on is covered, don't worry! You can still add more tests and provide some examples of use in the notebook! | **Figure** | **Name (in 3rd edition)** | **Name (in repository)** | **File** | **Tests** | **Notebook** |:-------|:----------------------------------|:------------------------------|:--------------------------------|:-----|:---------| @@ -105,7 +115,9 @@ Here is a table of algorithms, the figure, name of the algorithm in the book and | 4.21 | Online-DFS-Agent | `online_dfs_agent` | [`search.py`][search] | Done | Included | | 4.24 | LRTA\*-Agent | `LRTAStarAgent` | [`search.py`][search] | Done | Included | | 5.3 | Minimax-Decision | `minimax_decision` | [`games.py`][games] | Done | Included | -| 5.7 | Alpha-Beta-Search | `alphabeta_search` | [`games.py`][games] | Done | Included | +| 5.7 | Alpha-Beta-Search | `alpha_beta_search` | [`games.py`][games] | Done | Included | +| 5.11 | Expectiminimax | `expect_minmax` | [`games.py`][games] | Done | Included | +| 5.11 | Monte-Carlo-Tree-Search | `monte_carlo_tree_search` | [`games.py`][games] | Done | Included | | 6 | CSP | `CSP` | [`csp.py`][csp] | Done | Included | | 6.3 | AC-3 | `AC3` | [`csp.py`][csp] | Done | Included | | 6.5 | Backtracking-Search | `backtracking_search` | [`csp.py`][csp] | Done | Included | @@ -120,7 +132,7 @@ Here is a table of algorithms, the figure, name of the algorithm in the book and | 7.15 | PL-FC-Entails? | `pl_fc_entails` | [`logic.py`][logic] | Done | Included | | 7.17 | DPLL-Satisfiable? | `dpll_satisfiable` | [`logic.py`][logic] | Done | Included | | 7.18 | WalkSAT | `WalkSAT` | [`logic.py`][logic] | Done | Included | -| 7.20 | Hybrid-Wumpus-Agent | `HybridWumpusAgent` | | | | +| 7.20 | Hybrid-Wumpus-Agent | `HybridWumpusAgent` | [`logic.py`][logic] | Done | Included | | 7.22 | SATPlan | `SAT_plan` | [`logic.py`][logic] | Done | Included | | 9 | Subst | `subst` | [`logic.py`][logic] | Done | Included | | 9.1 | Unify | `unify` | [`logic.py`][logic] | Done | Included | @@ -147,22 +159,38 @@ Here is a table of algorithms, the figure, name of the algorithm in the book and | 15.4 | Forward-Backward | `forward_backward` | [`probability.py`][probability] | Done | Included | | 15.6 | Fixed-Lag-Smoothing | `fixed_lag_smoothing` | [`probability.py`][probability] | Done | Included | | 15.17 | Particle-Filtering | `particle_filtering` | [`probability.py`][probability] | Done | Included | +| 15.4 | Kalman-Filter | `KalmanFilter` | [`probability.py`][probability] | Done | Included | +| 15.5 | Dynamic-Bayesian-Network | `DynamicBayesNet` | [`probability.py`][probability] | Done | Included | | 16.9 | Information-Gathering-Agent | `InformationGatheringAgent` | [`probability.py`][probability] | Done | Included | | 17.4 | Value-Iteration | `value_iteration` | [`mdp.py`][mdp] | Done | Included | | 17.7 | Policy-Iteration | `policy_iteration` | [`mdp.py`][mdp] | Done | Included | | 17.9 | POMDP-Value-Iteration | `pomdp_value_iteration` | [`mdp.py`][mdp] | Done | Included | +| 17.4 | Dynamic-Decision-Network | `pomdp_lookahead` | [`mdp.py`](aima/mdp.py) | Done | Included | +| 18.2 | Iterated-Dominance | `iterated_dominance` | [`game_theory.py`](aima/game_theory.py) | Done | Included | +| 18.2 | Pure-Nash-Equilibria | `pure_nash_equilibria` | [`game_theory.py`](aima/game_theory.py) | Done | Included | +| 18.2 | Zero-Sum-Game (LP) | `solve_zero_sum_game` | [`game_theory.py`](aima/game_theory.py) | Done | Included | +| 18.3 | Shapley-Value | `shapley_value` | [`game_theory.py`](aima/game_theory.py) | Done | Included | +| 18.3 | Core (cooperative game) | `is_in_core` | [`game_theory.py`](aima/game_theory.py) | Done | Included | +| 18.4 | Voting (plurality/Borda/Condorcet)| `plurality_winner` etc. | [`game_theory.py`](aima/game_theory.py) | Done | Included | +| 18.4 | Vickrey-Auction | `vickrey_auction` | [`game_theory.py`](aima/game_theory.py) | Done | Included | +| 18.4.1 | Contract-Net-Protocol | `contract_net` | [`game_theory.py`](aima/game_theory.py) | Done | Included | +| 18.4.4 | Alternating-Offers-Bargaining | `alternating_offers_bargaining` | [`game_theory.py`](aima/game_theory.py) | Done | Included | | 18.5 | Decision-Tree-Learning | `DecisionTreeLearner` | [`learning.py`][learning] | Done | Included | -| 18.8 | Cross-Validation | `cross_validation` | [`learning.py`][learning]\* | | | -| 18.11 | Decision-List-Learning | `DecisionListLearner` | [`learning.py`][learning]\* | | | +| 18.8 | Cross-Validation | `cross_validation` | [`learning.py`][learning] | Done | Included | +| 18.11 | Decision-List-Learning | `DecisionListLearner` | [`learning.py`][learning] | Done | Included | | 18.24 | Back-Prop-Learning | `BackPropagationLearner` | [`learning.py`][learning] | Done | Included | | 18.34 | AdaBoost | `AdaBoost` | [`learning.py`][learning] | Done | Included | -| 19.2 | Current-Best-Learning | `current_best_learning` | [`knowledge.py`](knowledge.py) | Done | Included | -| 19.3 | Version-Space-Learning | `version_space_learning` | [`knowledge.py`](knowledge.py) | Done | Included | -| 19.8 | Minimal-Consistent-Det | `minimal_consistent_det` | [`knowledge.py`](knowledge.py) | Done | Included | -| 19.12 | FOIL | `FOIL_container` | [`knowledge.py`](knowledge.py) | Done | Included | -| 21.2 | Passive-ADP-Agent | `PassiveADPAgent` | [`rl.py`][rl] | Done | Included | -| 21.4 | Passive-TD-Agent | `PassiveTDAgent` | [`rl.py`][rl] | Done | Included | -| 21.8 | Q-Learning-Agent | `QLearningAgent` | [`rl.py`][rl] | Done | Included | +| 20.X | EM (Mixture of Gaussians) | `gaussian_mixture_em` | [`learning.py`][learning] | Done | Included | +| 20.3.2 | EM (Bayes net hidden variable) | `naive_bayes_em` | [`learning.py`][learning] | Done | Included | +| 20.3 | Baum-Welch (HMM learning) | `baum_welch` | [`probability.py`][probability] | Done | Included | +| 19.2 | Current-Best-Learning | `current_best_learning` | [`knowledge.py`](aima/knowledge.py) | Done | Included | +| 19.3 | Version-Space-Learning | `version_space_learning` | [`knowledge.py`](aima/knowledge.py) | Done | Included | +| 19.8 | Minimal-Consistent-Det | `minimal_consistent_det` | [`knowledge.py`](aima/knowledge.py) | Done | Included | +| 19.12 | FOIL | `FOILContainer` | [`knowledge.py`](aima/knowledge.py) | Done | Included | +| 21.2 | Passive-ADP-Agent | `PassiveADPAgent` | [`reinforcement_learning.py`][rl] | Done | Included | +| 21.4 | Passive-TD-Agent | `PassiveTDAgent` | [`reinforcement_learning.py`][rl] | Done | Included | +| 21.8 | Q-Learning-Agent | `QLearningAgent` | [`reinforcement_learning.py`][rl] | Done | Included | +| 21.8 | SARSA-Agent | `SARSALearningAgent` | [`reinforcement_learning.py`][rl] | Done | Included | | 22.1 | HITS | `HITS` | [`nlp.py`][nlp] | Done | Included | | 23 | Chart-Parse | `Chart` | [`nlp.py`][nlp] | Done | Included | | 23.5 | CYK-Parse | `CYK_parse` | [`nlp.py`][nlp] | Done | Included | @@ -190,18 +218,18 @@ Here is a table of the implemented data structures, the figure, name of the impl Many thanks for contributions over the years. I got bug reports, corrected code, and other support from Darius Bacon, Phil Ruggera, Peng Shao, Amit Patil, Ted Nienstedt, Jim Martin, Ben Catanzariti, and others. Now that the project is on GitHub, you can see the [contributors](https://github.com/aimacode/aima-python/graphs/contributors) who are doing a great job of actively improving the project. Many thanks to all contributors, especially [@darius](https://github.com/darius), [@SnShine](https://github.com/SnShine), [@reachtarunhere](https://github.com/reachtarunhere), [@antmarakis](https://github.com/antmarakis), [@Chipe1](https://github.com/Chipe1), [@ad71](https://github.com/ad71) and [@MariannaSpyrakou](https://github.com/MariannaSpyrakou). -[agents]:../master/agents.py -[csp]:../master/csp.py -[games]:../master/games.py +[agents]:../master/aima/agents.py +[csp]:../master/aima/csp.py +[games]:../master/aima/games.py [grid]:../master/grid.py -[knowledge]:../master/knowledge.py -[learning]:../master/learning.py -[logic]:../master/logic.py -[mdp]:../master/mdp.py -[nlp]:../master/nlp.py -[planning]:../master/planning.py -[probability]:../master/probability.py -[rl]:../master/rl.py -[search]:../master/search.py -[utils]:../master/utils.py -[text]:../master/text.py +[knowledge]:../master/aima/knowledge.py +[learning]:../master/aima/learning.py +[logic]:../master/aima/logic.py +[mdp]:../master/aima/mdp.py +[nlp]:../master/aima/nlp.py +[planning]:../master/aima/planning.py +[probability]:../master/aima/probability.py +[rl]:../master/aima/reinforcement_learning.py +[search]:../master/aima/search.py +[utils]:../master/aima/utils.py +[text]:../master/aima/text.py diff --git a/agents4e.py b/agents4e.py deleted file mode 100644 index 75369a69a..000000000 --- a/agents4e.py +++ /dev/null @@ -1,1089 +0,0 @@ -""" -Implement Agents and Environments. (Chapters 1-2) - -The class hierarchies are as follows: - -Thing ## A physical object that can exist in an environment - Agent - Wumpus - Dirt - Wall - ... - -Environment ## An environment holds objects, runs simulations - XYEnvironment - VacuumEnvironment - WumpusEnvironment - -An agent program is a callable instance, taking percepts and choosing actions - SimpleReflexAgentProgram - ... - -EnvGUI ## A window with a graphical representation of the Environment - -EnvToolbar ## contains buttons for controlling EnvGUI - -EnvCanvas ## Canvas to display the environment of an EnvGUI -""" - -# TODO -# Implement grabbing correctly. -# When an object is grabbed, does it still have a location? -# What if it is released? -# What if the grabbed or the grabber is deleted? -# What if the grabber moves? -# Speed control in GUI does not have any effect -- fix it. - -from utils4e import distance_squared, turn_heading -from statistics import mean -from ipythonblocks import BlockGrid -from IPython.display import HTML, display, clear_output -from time import sleep - -import random -import copy -import collections -import numbers - - -# ______________________________________________________________________________ - - -class Thing: - """This represents any physical object that can appear in an Environment. - You subclass Thing to get the things you want. Each thing can have a - .__name__ slot (used for output only).""" - - def __repr__(self): - return '<{}>'.format(getattr(self, '__name__', self.__class__.__name__)) - - def is_alive(self): - """Things that are 'alive' should return true.""" - return hasattr(self, 'alive') and self.alive - - def show_state(self): - """Display the agent's internal state. Subclasses should override.""" - print("I don't know how to show_state.") - - def display(self, canvas, x, y, width, height): - """Display an image of this Thing on the canvas.""" - # Do we need this? - pass - - -class Agent(Thing): - """An Agent is a subclass of Thing with one required slot, - .program, which should hold a function that takes one argument, the - percept, and returns an action. (What counts as a percept or action - will depend on the specific environment in which the agent exists.) - Note that 'program' is a slot, not a method. If it were a method, - then the program could 'cheat' and look at aspects of the agent. - It's not supposed to do that: the program can only look at the - percepts. An agent program that needs a model of the world (and of - the agent itself) will have to build and maintain its own model. - There is an optional slot, .performance, which is a number giving - the performance measure of the agent in its environment.""" - - def __init__(self, program=None): - self.alive = True - self.bump = False - self.holding = [] - self.performance = 0 - if program is None or not isinstance(program, collections.abc.Callable): - print("Can't find a valid program for {}, falling back to default.".format(self.__class__.__name__)) - - def program(percept): - return eval(input('Percept={}; action? '.format(percept))) - - self.program = program - - def can_grab(self, thing): - """Return True if this agent can grab this thing. - Override for appropriate subclasses of Agent and Thing.""" - return False - - -def TraceAgent(agent): - """Wrap the agent's program to print its input and output. This will let - you see what the agent is doing in the environment.""" - old_program = agent.program - - def new_program(percept): - action = old_program(percept) - print('{} perceives {} and does {}'.format(agent, percept, action)) - return action - - agent.program = new_program - return agent - - -# ______________________________________________________________________________ - - -def TableDrivenAgentProgram(table): - """ - [Figure 2.7] - This agent selects an action based on the percept sequence. - It is practical only for tiny domains. - To customize it, provide as table a dictionary of all - {percept_sequence:action} pairs. - """ - percepts = [] - - def program(percept): - percepts.append(percept) - action = table.get(tuple(percepts)) - return action - - return program - - -def RandomAgentProgram(actions): - """An agent that chooses an action at random, ignoring all percepts. - >>> list = ['Right', 'Left', 'Suck', 'NoOp'] - >>> program = RandomAgentProgram(list) - >>> agent = Agent(program) - >>> environment = TrivialVacuumEnvironment() - >>> environment.add_thing(agent) - >>> environment.run() - >>> environment.status == {(1, 0): 'Clean' , (0, 0): 'Clean'} - True - """ - return lambda percept: random.choice(actions) - - -# ______________________________________________________________________________ - - -def SimpleReflexAgentProgram(rules, interpret_input): - """ - [Figure 2.10] - This agent takes action based solely on the percept. - """ - - def program(percept): - state = interpret_input(percept) - rule = rule_match(state, rules) - action = rule.action - return action - - return program - - -def ModelBasedReflexAgentProgram(rules, update_state, transition_model, sensor_model): - """ - [Figure 2.12] - This agent takes action based on the percept and state. - """ - - def program(percept): - program.state = update_state(program.state, program.action, percept, transition_model, sensor_model) - rule = rule_match(program.state, rules) - action = rule.action - return action - - program.state = program.action = None - return program - - -def rule_match(state, rules): - """Find the first rule that matches state.""" - for rule in rules: - if rule.matches(state): - return rule - - -# ______________________________________________________________________________ - - -loc_A, loc_B = (0, 0), (1, 0) # The two locations for the Vacuum world - - -def RandomVacuumAgent(): - """Randomly choose one of the actions from the vacuum environment. - >>> agent = RandomVacuumAgent() - >>> environment = TrivialVacuumEnvironment() - >>> environment.add_thing(agent) - >>> environment.run() - >>> environment.status == {(1,0):'Clean' , (0,0) : 'Clean'} - True - """ - return Agent(RandomAgentProgram(['Right', 'Left', 'Suck', 'NoOp'])) - - -def TableDrivenVacuumAgent(): - """Tabular approach towards vacuum world as mentioned in [Figure 2.3] - >>> agent = TableDrivenVacuumAgent() - >>> environment = TrivialVacuumEnvironment() - >>> environment.add_thing(agent) - >>> environment.run() - >>> environment.status == {(1,0):'Clean' , (0,0) : 'Clean'} - True - """ - table = {((loc_A, 'Clean'),): 'Right', - ((loc_A, 'Dirty'),): 'Suck', - ((loc_B, 'Clean'),): 'Left', - ((loc_B, 'Dirty'),): 'Suck', - ((loc_A, 'Dirty'), (loc_A, 'Clean')): 'Right', - ((loc_A, 'Clean'), (loc_B, 'Dirty')): 'Suck', - ((loc_B, 'Clean'), (loc_A, 'Dirty')): 'Suck', - ((loc_B, 'Dirty'), (loc_B, 'Clean')): 'Left', - ((loc_A, 'Dirty'), (loc_A, 'Clean'), (loc_B, 'Dirty')): 'Suck', - ((loc_B, 'Dirty'), (loc_B, 'Clean'), (loc_A, 'Dirty')): 'Suck'} - return Agent(TableDrivenAgentProgram(table)) - - -def ReflexVacuumAgent(): - """ - [Figure 2.8] - A reflex agent for the two-state vacuum environment. - >>> agent = ReflexVacuumAgent() - >>> environment = TrivialVacuumEnvironment() - >>> environment.add_thing(agent) - >>> environment.run() - >>> environment.status == {(1,0):'Clean' , (0,0) : 'Clean'} - True - """ - - def program(percept): - location, status = percept - if status == 'Dirty': - return 'Suck' - elif location == loc_A: - return 'Right' - elif location == loc_B: - return 'Left' - - return Agent(program) - - -def ModelBasedVacuumAgent(): - """An agent that keeps track of what locations are clean or dirty. - >>> agent = ModelBasedVacuumAgent() - >>> environment = TrivialVacuumEnvironment() - >>> environment.add_thing(agent) - >>> environment.run() - >>> environment.status == {(1,0):'Clean' , (0,0) : 'Clean'} - True - """ - model = {loc_A: None, loc_B: None} - - def program(percept): - """Same as ReflexVacuumAgent, except if everything is clean, do NoOp.""" - location, status = percept - model[location] = status # Update the model here - if model[loc_A] == model[loc_B] == 'Clean': - return 'NoOp' - elif status == 'Dirty': - return 'Suck' - elif location == loc_A: - return 'Right' - elif location == loc_B: - return 'Left' - - return Agent(program) - - -# ______________________________________________________________________________ - - -class Environment: - """Abstract class representing an Environment. 'Real' Environment classes - inherit from this. Your Environment will typically need to implement: - percept: Define the percept that an agent sees. - execute_action: Define the effects of executing an action. - Also update the agent.performance slot. - The environment keeps a list of .things and .agents (which is a subset - of .things). Each agent has a .performance slot, initialized to 0. - Each thing has a .location slot, even though some environments may not - need this.""" - - def __init__(self): - self.things = [] - self.agents = [] - - def thing_classes(self): - return [] # List of classes that can go into environment - - def percept(self, agent): - """Return the percept that the agent sees at this point. (Implement this.)""" - raise NotImplementedError - - def execute_action(self, agent, action): - """Change the world to reflect this action. (Implement this.)""" - raise NotImplementedError - - def default_location(self, thing): - """Default location to place a new thing with unspecified location.""" - return None - - def exogenous_change(self): - """If there is spontaneous change in the world, override this.""" - pass - - def is_done(self): - """By default, we're done when we can't find a live agent.""" - return not any(agent.is_alive() for agent in self.agents) - - def step(self): - """Run the environment for one time step. If the - actions and exogenous changes are independent, this method will - do. If there are interactions between them, you'll need to - override this method.""" - if not self.is_done(): - actions = [] - for agent in self.agents: - if agent.alive: - actions.append(agent.program(self.percept(agent))) - else: - actions.append("") - for (agent, action) in zip(self.agents, actions): - self.execute_action(agent, action) - self.exogenous_change() - - def run(self, steps=1000): - """Run the Environment for given number of time steps.""" - for step in range(steps): - if self.is_done(): - return - self.step() - - def list_things_at(self, location, tclass=Thing): - """Return all things exactly at a given location.""" - if isinstance(location, numbers.Number): - return [thing for thing in self.things - if thing.location == location and isinstance(thing, tclass)] - return [thing for thing in self.things - if all(x == y for x, y in zip(thing.location, location)) and isinstance(thing, tclass)] - - def some_things_at(self, location, tclass=Thing): - """Return true if at least one of the things at location - is an instance of class tclass (or a subclass).""" - return self.list_things_at(location, tclass) != [] - - def add_thing(self, thing, location=None): - """Add a thing to the environment, setting its location. For - convenience, if thing is an agent program we make a new agent - for it. (Shouldn't need to override this.)""" - if not isinstance(thing, Thing): - thing = Agent(thing) - if thing in self.things: - print("Can't add the same thing twice") - else: - thing.location = location if location is not None else self.default_location(thing) - self.things.append(thing) - if isinstance(thing, Agent): - thing.performance = 0 - self.agents.append(thing) - - def delete_thing(self, thing): - """Remove a thing from the environment.""" - try: - self.things.remove(thing) - except ValueError as e: - print(e) - print(" in Environment delete_thing") - print(" Thing to be removed: {} at {}".format(thing, thing.location)) - print(" from list: {}".format([(thing, thing.location) for thing in self.things])) - if thing in self.agents: - self.agents.remove(thing) - - -class Direction: - """A direction class for agents that want to move in a 2D plane - Usage: - d = Direction("down") - To change directions: - d = d + "right" or d = d + Direction.R #Both do the same thing - Note that the argument to __add__ must be a string and not a Direction object. - Also, it (the argument) can only be right or left.""" - - R = "right" - L = "left" - U = "up" - D = "down" - - def __init__(self, direction): - self.direction = direction - - def __add__(self, heading): - """ - >>> d = Direction('right') - >>> l1 = d.__add__(Direction.L) - >>> l2 = d.__add__(Direction.R) - >>> l1.direction - 'up' - >>> l2.direction - 'down' - >>> d = Direction('down') - >>> l1 = d.__add__('right') - >>> l2 = d.__add__('left') - >>> l1.direction == Direction.L - True - >>> l2.direction == Direction.R - True - """ - if self.direction == self.R: - return { - self.R: Direction(self.D), - self.L: Direction(self.U), - }.get(heading, None) - elif self.direction == self.L: - return { - self.R: Direction(self.U), - self.L: Direction(self.D), - }.get(heading, None) - elif self.direction == self.U: - return { - self.R: Direction(self.R), - self.L: Direction(self.L), - }.get(heading, None) - elif self.direction == self.D: - return { - self.R: Direction(self.L), - self.L: Direction(self.R), - }.get(heading, None) - - def move_forward(self, from_location): - """ - >>> d = Direction('up') - >>> l1 = d.move_forward((0, 0)) - >>> l1 - (0, -1) - >>> d = Direction(Direction.R) - >>> l1 = d.move_forward((0, 0)) - >>> l1 - (1, 0) - """ - # get the iterable class to return - iclass = from_location.__class__ - x, y = from_location - if self.direction == self.R: - return iclass((x + 1, y)) - elif self.direction == self.L: - return iclass((x - 1, y)) - elif self.direction == self.U: - return iclass((x, y - 1)) - elif self.direction == self.D: - return iclass((x, y + 1)) - - -class XYEnvironment(Environment): - """This class is for environments on a 2D plane, with locations - labelled by (x, y) points, either discrete or continuous. - - Agents perceive things within a radius. Each agent in the - environment has a .location slot which should be a location such - as (0, 1), and a .holding slot, which should be a list of things - that are held.""" - - def __init__(self, width=10, height=10): - super().__init__() - - self.width = width - self.height = height - self.observers = [] - # Sets iteration start and end (no walls). - self.x_start, self.y_start = (0, 0) - self.x_end, self.y_end = (self.width, self.height) - - perceptible_distance = 1 - - def things_near(self, location, radius=None): - """Return all things within radius of location.""" - if radius is None: - radius = self.perceptible_distance - radius2 = radius * radius - return [(thing, radius2 - distance_squared(location, thing.location)) - for thing in self.things if distance_squared( - location, thing.location) <= radius2] - - def percept(self, agent): - """By default, agent perceives things within a default radius.""" - return self.things_near(agent.location) - - def execute_action(self, agent, action): - agent.bump = False - if action == 'TurnRight': - agent.direction += Direction.R - elif action == 'TurnLeft': - agent.direction += Direction.L - elif action == 'Forward': - agent.bump = self.move_to(agent, agent.direction.move_forward(agent.location)) - # elif action == 'Grab': - # things = [thing for thing in self.list_things_at(agent.location) - # if agent.can_grab(thing)] - # if things: - # agent.holding.append(things[0]) - elif action == 'Release': - if agent.holding: - agent.holding.pop() - - def default_location(self, thing): - location = self.random_location_inbounds() - while self.some_things_at(location, Obstacle): - # we will find a random location with no obstacles - location = self.random_location_inbounds() - return location - - def move_to(self, thing, destination): - """Move a thing to a new location. Returns True on success or False if there is an Obstacle. - If thing is holding anything, they move with him.""" - thing.bump = self.some_things_at(destination, Obstacle) - if not thing.bump: - thing.location = destination - for o in self.observers: - o.thing_moved(thing) - for t in thing.holding: - self.delete_thing(t) - self.add_thing(t, destination) - t.location = destination - return thing.bump - - def add_thing(self, thing, location=None, exclude_duplicate_class_items=False): - """Add things to the world. If (exclude_duplicate_class_items) then the item won't be - added if the location has at least one item of the same class.""" - if location is None: - super().add_thing(thing) - elif self.is_inbounds(location): - if (exclude_duplicate_class_items and - any(isinstance(t, thing.__class__) for t in self.list_things_at(location))): - return - super().add_thing(thing, location) - - def is_inbounds(self, location): - """Checks to make sure that the location is inbounds (within walls if we have walls)""" - x, y = location - return not (x < self.x_start or x > self.x_end or y < self.y_start or y > self.y_end) - - def random_location_inbounds(self, exclude=None): - """Returns a random location that is inbounds (within walls if we have walls)""" - location = (random.randint(self.x_start, self.x_end), - random.randint(self.y_start, self.y_end)) - if exclude is not None: - while location == exclude: - location = (random.randint(self.x_start, self.x_end), - random.randint(self.y_start, self.y_end)) - return location - - def delete_thing(self, thing): - """Deletes thing, and everything it is holding (if thing is an agent)""" - if isinstance(thing, Agent): - for obj in thing.holding: - super().delete_thing(obj) - for obs in self.observers: - obs.thing_deleted(obj) - - super().delete_thing(thing) - for obs in self.observers: - obs.thing_deleted(thing) - - def add_walls(self): - """Put walls around the entire perimeter of the grid.""" - for x in range(self.width): - self.add_thing(Wall(), (x, 0)) - self.add_thing(Wall(), (x, self.height - 1)) - for y in range(1, self.height - 1): - self.add_thing(Wall(), (0, y)) - self.add_thing(Wall(), (self.width - 1, y)) - - # Updates iteration start and end (with walls). - self.x_start, self.y_start = (1, 1) - self.x_end, self.y_end = (self.width - 1, self.height - 1) - - def add_observer(self, observer): - """Adds an observer to the list of observers. - An observer is typically an EnvGUI. - - Each observer is notified of changes in move_to and add_thing, - by calling the observer's methods thing_moved(thing) - and thing_added(thing, loc).""" - self.observers.append(observer) - - def turn_heading(self, heading, inc): - """Return the heading to the left (inc=+1) or right (inc=-1) of heading.""" - return turn_heading(heading, inc) - - -class Obstacle(Thing): - """Something that can cause a bump, preventing an agent from - moving into the same square it's in.""" - pass - - -class Wall(Obstacle): - pass - - -# ______________________________________________________________________________ - - -class GraphicEnvironment(XYEnvironment): - def __init__(self, width=10, height=10, boundary=True, color={}, display=False): - """Define all the usual XYEnvironment characteristics, - but initialise a BlockGrid for GUI too.""" - super().__init__(width, height) - self.grid = BlockGrid(width, height, fill=(200, 200, 200)) - if display: - self.grid.show() - self.visible = True - else: - self.visible = False - self.bounded = boundary - self.colors = color - - def get_world(self): - """Returns all the items in the world in a format - understandable by the ipythonblocks BlockGrid.""" - result = [] - x_start, y_start = (0, 0) - x_end, y_end = self.width, self.height - for x in range(x_start, x_end): - row = [] - for y in range(y_start, y_end): - row.append(self.list_things_at((x, y))) - result.append(row) - return result - - """ - def run(self, steps=1000, delay=1): - "" "Run the Environment for given number of time steps, - but update the GUI too." "" - for step in range(steps): - sleep(delay) - if self.visible: - self.reveal() - if self.is_done(): - if self.visible: - self.reveal() - return - self.step() - if self.visible: - self.reveal() - """ - - def run(self, steps=1000, delay=1): - """Run the Environment for given number of time steps, - but update the GUI too.""" - for step in range(steps): - self.update(delay) - if self.is_done(): - break - self.step() - self.update(delay) - - def update(self, delay=1): - sleep(delay) - self.reveal() - - def reveal(self): - """Display the BlockGrid for this world - the last thing to be added - at a location defines the location color.""" - self.draw_world() - # wait for the world to update and - # apply changes to the same grid instead - # of making a new one. - clear_output(1) - self.grid.show() - self.visible = True - - def draw_world(self): - self.grid[:] = (200, 200, 200) - world = self.get_world() - for x in range(0, len(world)): - for y in range(0, len(world[x])): - if len(world[x][y]): - self.grid[y, x] = self.colors[world[x][y][-1].__class__.__name__] - - def conceal(self): - """Hide the BlockGrid for this world""" - self.visible = False - display(HTML('')) - - -# ______________________________________________________________________________ -# Continuous environment - -class ContinuousWorld(Environment): - """Model for Continuous World""" - - def __init__(self, width=10, height=10): - super().__init__() - self.width = width - self.height = height - - def add_obstacle(self, coordinates): - self.things.append(PolygonObstacle(coordinates)) - - -class PolygonObstacle(Obstacle): - - def __init__(self, coordinates): - """Coordinates is a list of tuples.""" - super().__init__() - self.coordinates = coordinates - - -# ______________________________________________________________________________ -# Vacuum environment - - -class Dirt(Thing): - pass - - -class VacuumEnvironment(XYEnvironment): - """The environment of [Ex. 2.12]. Agent perceives dirty or clean, - and bump (into obstacle) or not; 2D discrete world of unknown size; - performance measure is 100 for each dirt cleaned, and -1 for - each turn taken.""" - - def __init__(self, width=10, height=10): - super().__init__(width, height) - self.add_walls() - - def thing_classes(self): - return [Wall, Dirt, ReflexVacuumAgent, RandomVacuumAgent, - TableDrivenVacuumAgent, ModelBasedVacuumAgent] - - def percept(self, agent): - """The percept is a tuple of ('Dirty' or 'Clean', 'Bump' or 'None'). - Unlike the TrivialVacuumEnvironment, location is NOT perceived.""" - status = ('Dirty' if self.some_things_at( - agent.location, Dirt) else 'Clean') - bump = ('Bump' if agent.bump else 'None') - return status, bump - - def execute_action(self, agent, action): - agent.bump = False - if action == 'Suck': - dirt_list = self.list_things_at(agent.location, Dirt) - if dirt_list != []: - dirt = dirt_list[0] - agent.performance += 100 - self.delete_thing(dirt) - else: - super().execute_action(agent, action) - - if action != 'NoOp': - agent.performance -= 1 - - -class TrivialVacuumEnvironment(Environment): - """This environment has two locations, A and B. Each can be Dirty - or Clean. The agent perceives its location and the location's - status. This serves as an example of how to implement a simple - Environment.""" - - def __init__(self): - super().__init__() - self.status = {loc_A: random.choice(['Clean', 'Dirty']), - loc_B: random.choice(['Clean', 'Dirty'])} - - def thing_classes(self): - return [Wall, Dirt, ReflexVacuumAgent, RandomVacuumAgent, TableDrivenVacuumAgent, ModelBasedVacuumAgent] - - def percept(self, agent): - """Returns the agent's location, and the location status (Dirty/Clean).""" - return agent.location, self.status[agent.location] - - def execute_action(self, agent, action): - """Change agent's location and/or location's status; track performance. - Score 10 for each dirt cleaned; -1 for each move.""" - if action == 'Right': - agent.location = loc_B - agent.performance -= 1 - elif action == 'Left': - agent.location = loc_A - agent.performance -= 1 - elif action == 'Suck': - if self.status[agent.location] == 'Dirty': - agent.performance += 10 - self.status[agent.location] = 'Clean' - - def default_location(self, thing): - """Agents start in either location at random.""" - return random.choice([loc_A, loc_B]) - - -# ______________________________________________________________________________ -# The Wumpus World - - -class Gold(Thing): - - def __eq__(self, rhs): - """All Gold are equal""" - return rhs.__class__ == Gold - - pass - - -class Bump(Thing): - pass - - -class Glitter(Thing): - pass - - -class Pit(Thing): - pass - - -class Breeze(Thing): - pass - - -class Arrow(Thing): - pass - - -class Scream(Thing): - pass - - -class Wumpus(Agent): - screamed = False - pass - - -class Stench(Thing): - pass - - -class Explorer(Agent): - holding = [] - has_arrow = True - killed_by = "" - direction = Direction("right") - - def can_grab(self, thing): - """Explorer can only grab gold""" - return thing.__class__ == Gold - - -class WumpusEnvironment(XYEnvironment): - pit_probability = 0.2 # Probability to spawn a pit in a location. (From Chapter 7.2) - - # Room should be 4x4 grid of rooms. The extra 2 for walls - - def __init__(self, agent_program, width=6, height=6): - super().__init__(width, height) - self.init_world(agent_program) - - def init_world(self, program): - """Spawn items in the world based on probabilities from the book""" - - "WALLS" - self.add_walls() - - "PITS" - for x in range(self.x_start, self.x_end): - for y in range(self.y_start, self.y_end): - if random.random() < self.pit_probability: - self.add_thing(Pit(), (x, y), True) - self.add_thing(Breeze(), (x - 1, y), True) - self.add_thing(Breeze(), (x, y - 1), True) - self.add_thing(Breeze(), (x + 1, y), True) - self.add_thing(Breeze(), (x, y + 1), True) - - "WUMPUS" - w_x, w_y = self.random_location_inbounds(exclude=(1, 1)) - self.add_thing(Wumpus(lambda x: ""), (w_x, w_y), True) - self.add_thing(Stench(), (w_x - 1, w_y), True) - self.add_thing(Stench(), (w_x + 1, w_y), True) - self.add_thing(Stench(), (w_x, w_y - 1), True) - self.add_thing(Stench(), (w_x, w_y + 1), True) - - "GOLD" - self.add_thing(Gold(), self.random_location_inbounds(exclude=(1, 1)), True) - - "AGENT" - self.add_thing(Explorer(program), (1, 1), True) - - def get_world(self, show_walls=True): - """Return the items in the world""" - result = [] - x_start, y_start = (0, 0) if show_walls else (1, 1) - - if show_walls: - x_end, y_end = self.width, self.height - else: - x_end, y_end = self.width - 1, self.height - 1 - - for x in range(x_start, x_end): - row = [] - for y in range(y_start, y_end): - row.append(self.list_things_at((x, y))) - result.append(row) - return result - - def percepts_from(self, agent, location, tclass=Thing): - """Return percepts from a given location, - and replaces some items with percepts from chapter 7.""" - thing_percepts = { - Gold: Glitter(), - Wall: Bump(), - Wumpus: Stench(), - Pit: Breeze()} - - """Agents don't need to get their percepts""" - thing_percepts[agent.__class__] = None - - """Gold only glitters in its cell""" - if location != agent.location: - thing_percepts[Gold] = None - - result = [thing_percepts.get(thing.__class__, thing) for thing in self.things - if thing.location == location and isinstance(thing, tclass)] - return result if len(result) else [None] - - def percept(self, agent): - """Return things in adjacent (not diagonal) cells of the agent. - Result format: [Left, Right, Up, Down, Center / Current location]""" - x, y = agent.location - result = [] - result.append(self.percepts_from(agent, (x - 1, y))) - result.append(self.percepts_from(agent, (x + 1, y))) - result.append(self.percepts_from(agent, (x, y - 1))) - result.append(self.percepts_from(agent, (x, y + 1))) - result.append(self.percepts_from(agent, (x, y))) - - """The wumpus gives out a loud scream once it's killed.""" - wumpus = [thing for thing in self.things if isinstance(thing, Wumpus)] - if len(wumpus) and not wumpus[0].alive and not wumpus[0].screamed: - result[-1].append(Scream()) - wumpus[0].screamed = True - - return result - - def execute_action(self, agent, action): - """Modify the state of the environment based on the agent's actions. - Performance score taken directly out of the book.""" - - if isinstance(agent, Explorer) and self.in_danger(agent): - return - - agent.bump = False - if action == 'TurnRight': - agent.direction += Direction.R - agent.performance -= 1 - elif action == 'TurnLeft': - agent.direction += Direction.L - agent.performance -= 1 - elif action == 'Forward': - agent.bump = self.move_to(agent, agent.direction.move_forward(agent.location)) - agent.performance -= 1 - elif action == 'Grab': - things = [thing for thing in self.list_things_at(agent.location) - if agent.can_grab(thing)] - if len(things): - print("Grabbing", things[0].__class__.__name__) - if len(things): - agent.holding.append(things[0]) - agent.performance -= 1 - elif action == 'Climb': - if agent.location == (1, 1): # Agent can only climb out of (1,1) - agent.performance += 1000 if Gold() in agent.holding else 0 - self.delete_thing(agent) - elif action == 'Shoot': - """The arrow travels straight down the path the agent is facing""" - if agent.has_arrow: - arrow_travel = agent.direction.move_forward(agent.location) - while self.is_inbounds(arrow_travel): - wumpus = [thing for thing in self.list_things_at(arrow_travel) - if isinstance(thing, Wumpus)] - if len(wumpus): - wumpus[0].alive = False - break - arrow_travel = agent.direction.move_forward(agent.location) - agent.has_arrow = False - - def in_danger(self, agent): - """Check if Explorer is in danger (Pit or Wumpus), if he is, kill him""" - for thing in self.list_things_at(agent.location): - if isinstance(thing, Pit) or (isinstance(thing, Wumpus) and thing.alive): - agent.alive = False - agent.performance -= 1000 - agent.killed_by = thing.__class__.__name__ - return True - return False - - def is_done(self): - """The game is over when the Explorer is killed - or if he climbs out of the cave only at (1,1).""" - explorer = [agent for agent in self.agents if isinstance(agent, Explorer)] - if len(explorer): - if explorer[0].alive: - return False - else: - print("Death by {} [-1000].".format(explorer[0].killed_by)) - else: - print("Explorer climbed out {}." - .format("with Gold [+1000]!" if Gold() not in self.things else "without Gold [+0]")) - return True - - # TODO: Arrow needs to be implemented - - -# ______________________________________________________________________________ - - -def compare_agents(EnvFactory, AgentFactories, n=10, steps=1000): - """See how well each of several agents do in n instances of an environment. - Pass in a factory (constructor) for environments, and several for agents. - Create n instances of the environment, and run each agent in copies of - each one for steps. Return a list of (agent, average-score) tuples. - >>> environment = TrivialVacuumEnvironment - >>> agents = [ModelBasedVacuumAgent, ReflexVacuumAgent] - >>> result = compare_agents(environment, agents) - >>> performance_ModelBasedVacuumAgent = result[0][1] - >>> performance_ReflexVacuumAgent = result[1][1] - >>> performance_ReflexVacuumAgent <= performance_ModelBasedVacuumAgent - True - """ - envs = [EnvFactory() for i in range(n)] - return [(A, test_agent(A, steps, copy.deepcopy(envs))) - for A in AgentFactories] - - -def test_agent(AgentFactory, steps, envs): - """Return the mean score of running an agent in each of the envs, for steps - >>> def constant_prog(percept): - ... return percept - ... - >>> agent = Agent(constant_prog) - >>> result = agent.program(5) - >>> result == 5 - True - """ - - def score(env): - agent = AgentFactory() - env.add_thing(agent) - env.run(steps) - return agent.performance - - return mean(map(score, envs)) - - -# _________________________________________________________________________ - - -__doc__ += """ ->>> a = ReflexVacuumAgent() ->>> a.program((loc_A, 'Clean')) -'Right' ->>> a.program((loc_B, 'Clean')) -'Left' ->>> a.program((loc_A, 'Dirty')) -'Suck' ->>> a.program((loc_A, 'Dirty')) -'Suck' - ->>> e = TrivialVacuumEnvironment() ->>> e.add_thing(ModelBasedVacuumAgent()) ->>> e.run(5) - -""" diff --git a/aima-data b/aima-data index f6cbea61a..f4bbabd19 160000 --- a/aima-data +++ b/aima-data @@ -1 +1 @@ -Subproject commit f6cbea61ad0c21c6b7be826d17af5a8d3a7c2c86 +Subproject commit f4bbabd1962c5022fa8c803abe9aa7edce1ea2a9 diff --git a/aima/__init__.py b/aima/__init__.py new file mode 100644 index 000000000..e44e4bf59 --- /dev/null +++ b/aima/__init__.py @@ -0,0 +1 @@ +"""aima: Python implementations of the algorithms from *Artificial Intelligence: A Modern Approach*.""" diff --git a/agents.py b/aima/agents.py similarity index 89% rename from agents.py rename to aima/agents.py index d29b0c382..d159466cc 100644 --- a/agents.py +++ b/aima/agents.py @@ -1,37 +1,37 @@ """ Implement Agents and Environments. (Chapters 1-2) -The class hierarchies are as follows: +The class hierarchies are as follows:: -Thing ## A physical object that can exist in an environment - Agent - Wumpus - Dirt - Wall - ... + Thing ## A physical object that can exist in an environment + Agent + Wumpus + Dirt + Wall + ... + + Environment ## An environment holds objects, runs simulations + XYEnvironment + VacuumEnvironment + WumpusEnvironment -Environment ## An environment holds objects, runs simulations - XYEnvironment - VacuumEnvironment - WumpusEnvironment +An agent program is a callable instance, taking percepts and choosing actions:: -An agent program is a callable instance, taking percepts and choosing actions SimpleReflexAgentProgram ... -EnvGUI ## A window with a graphical representation of the Environment - -EnvToolbar ## contains buttons for controlling EnvGUI +The GUI helpers are:: -EnvCanvas ## Canvas to display the environment of an EnvGUI + EnvGUI ## A window with a graphical representation of the Environment + EnvToolbar ## contains buttons for controlling EnvGUI + EnvCanvas ## Canvas to display the environment of an EnvGUI """ # TODO # Speed control in GUI does not have any effect -- fix it. -from utils import distance_squared, turn_heading +from aima.utils import distance_squared, turn_heading from statistics import mean -from ipythonblocks import BlockGrid from IPython.display import HTML, display, clear_output from time import sleep @@ -284,10 +284,12 @@ def program(percept): class Environment: """Abstract class representing an Environment. 'Real' Environment classes - inherit from this. Your Environment will typically need to implement: - percept: Define the percept that an agent sees. - execute_action: Define the effects of executing an action. - Also update the agent.performance slot. + inherit from this. Your Environment will typically need to implement:: + + percept: Define the percept that an agent sees. + execute_action: Define the effects of executing an action; + also update the agent.performance slot. + The environment keeps a list of .things and .agents (which is a subset of .things). Each agent has a .performance slot, initialized to 0. Each thing has a .location slot, even though some environments may not @@ -298,6 +300,7 @@ def __init__(self): self.agents = [] def thing_classes(self): + """Return the list of Thing subclasses that may appear in this environment.""" return [] # List of classes that can go into environment def percept(self, agent): @@ -385,13 +388,16 @@ def delete_thing(self, thing): class Direction: - """A direction class for agents that want to move in a 2D plane - Usage: - d = Direction("down") - To change directions: - d = d + "right" or d = d + Direction.R #Both do the same thing - Note that the argument to __add__ must be a string and not a Direction object. - Also, it (the argument) can only be right or left.""" + """A direction class for agents that want to move in a 2D plane. + + Usage:: + + d = Direction("down") + # to change directions: + d = d + "right" or d = d + Direction.R # both do the same thing + + Note that the argument to __add__ must be a string and not a Direction + object, and it can only be 'right' or 'left'.""" R = "right" L = "left" @@ -480,7 +486,7 @@ def __init__(self, width=10, height=10): self.observers = [] # Sets iteration start and end (no walls). self.x_start, self.y_start = (0, 0) - self.x_end, self.y_end = (self.width, self.height) + self.x_end, self.y_end = (self.width - 1, self.height - 1) perceptible_distance = 1 @@ -498,6 +504,10 @@ def percept(self, agent): return self.things_near(agent.location) def execute_action(self, agent, action): + """Apply a motion or manipulation action for the agent. Supports turning + ('TurnRight'/'TurnLeft'), moving one step ('Forward', setting agent.bump on + a collision), grabbing a grabbable thing at the agent's location ('Grab'), + and dropping the last held thing ('Release').""" agent.bump = False if action == 'TurnRight': agent.direction += Direction.R @@ -518,6 +528,7 @@ def execute_action(self, agent, action): self.add_thing(dropped, location=agent.location) def default_location(self, thing): + """Return a random inbounds location that contains no Obstacle.""" location = self.random_location_inbounds() while self.some_things_at(location, Obstacle): # we will find a random location with no obstacles @@ -607,6 +618,7 @@ class Obstacle(Thing): class Wall(Obstacle): + """An impassable Obstacle forming the boundary or interior walls of a grid.""" pass @@ -614,10 +626,21 @@ class Wall(Obstacle): class GraphicEnvironment(XYEnvironment): + """An XYEnvironment that visualises itself in a Jupyter notebook using an + ipythonblocks BlockGrid, colouring each cell according to the class of the + last thing placed there.""" + def __init__(self, width=10, height=10, boundary=True, color={}, display=False): """Define all the usual XYEnvironment characteristics, but initialise a BlockGrid for GUI too.""" super().__init__(width, height) + # imported lazily so the rest of aima.agents (and modules that import it, + # e.g. logic/csp) can be used without the optional ipythonblocks GUI dep + try: + from ipythonblocks import BlockGrid + except ImportError as e: + raise ImportError("GraphicEnvironment needs the optional 'ipythonblocks' " + "package; install it with `pip install ipythonblocks`.") from e self.grid = BlockGrid(width, height, fill=(200, 200, 200)) if display: self.grid.show() @@ -668,6 +691,7 @@ def run(self, steps=1000, delay=1): self.update(delay) def update(self, delay=1): + """Pause for delay seconds, then redraw the world in the GUI.""" sleep(delay) self.reveal() @@ -683,6 +707,8 @@ def reveal(self): self.visible = True def draw_world(self): + """Repaint the BlockGrid, colouring each occupied cell by the class of the + last thing at that location using the configured color mapping.""" self.grid[:] = (200, 200, 200) world = self.get_world() for x in range(0, len(world)): @@ -708,10 +734,13 @@ def __init__(self, width=10, height=10): self.height = height def add_obstacle(self, coordinates): + """Add a PolygonObstacle defined by the given list of vertex coordinates.""" self.things.append(PolygonObstacle(coordinates)) class PolygonObstacle(Obstacle): + """An Obstacle in a ContinuousWorld whose shape is a polygon described by a + list of vertex coordinates.""" def __init__(self, coordinates): """Coordinates is a list of tuples.""" @@ -724,6 +753,7 @@ def __init__(self, coordinates): class Dirt(Thing): + """A piece of dirt that a vacuum agent can clean up.""" pass @@ -738,6 +768,7 @@ def __init__(self, width=10, height=10): self.add_walls() def thing_classes(self): + """Return the Thing/Agent classes that may populate the vacuum world.""" return [Wall, Dirt, ReflexVacuumAgent, RandomVacuumAgent, TableDrivenVacuumAgent, ModelBasedVacuumAgent] @@ -750,6 +781,9 @@ def percept(self, agent): return status, bump def execute_action(self, agent, action): + """Carry out the agent's action. 'Suck' removes dirt at the agent's location + and adds 100 to its performance; movement actions are delegated to the + XYEnvironment, and every action other than 'NoOp' costs 1 performance point.""" agent.bump = False if action == 'Suck': dirt_list = self.list_things_at(agent.location, Dirt) @@ -776,6 +810,7 @@ def __init__(self): loc_B: random.choice(['Clean', 'Dirty'])} def thing_classes(self): + """Return the Thing/Agent classes that may populate this vacuum world.""" return [Wall, Dirt, ReflexVacuumAgent, RandomVacuumAgent, TableDrivenVacuumAgent, ModelBasedVacuumAgent] def percept(self, agent): @@ -806,6 +841,7 @@ def default_location(self, thing): class Gold(Thing): + """The gold the explorer is trying to grab in the Wumpus World.""" def __eq__(self, rhs): """All Gold are equal""" @@ -815,39 +851,49 @@ def __eq__(self, rhs): class Bump(Thing): + """Percept signalling that the explorer walked into a wall.""" pass class Glitter(Thing): + """Percept indicating that gold is in the explorer's current room.""" pass class Pit(Thing): + """A pit that kills the explorer if entered.""" pass class Breeze(Thing): + """Percept felt in rooms adjacent to a Pit.""" pass class Arrow(Thing): + """The single arrow the explorer can shoot to try to kill the wumpus.""" pass class Scream(Thing): + """Percept heard throughout the world when the wumpus is killed.""" pass class Wumpus(Agent): + """The wumpus: a monster that kills the explorer sharing its room.""" screamed = False pass class Stench(Thing): + """Percept smelled in rooms adjacent to the Wumpus.""" pass class Explorer(Agent): + """The agent that explores the Wumpus World, seeking gold while avoiding + pits and the wumpus.""" holding = [] has_arrow = True killed_by = "" @@ -859,6 +905,10 @@ def can_grab(self, thing): class WumpusEnvironment(XYEnvironment): + """A grid implementation of the Wumpus World from Chapter 7: a cave of rooms + surrounded by walls, holding pits, the wumpus and gold, in which an Explorer + agent perceives stench, breeze, glitter, bump and scream.""" + pit_probability = 0.2 # Probability to spawn a pit in a location. (From Chapter 7.2) # Room should be 4x4 grid of rooms. The extra 2 for walls diff --git a/csp.py b/aima/csp.py similarity index 91% rename from csp.py rename to aima/csp.py index 46ae07dd5..a4caec5b0 100644 --- a/csp.py +++ b/aima/csp.py @@ -10,13 +10,14 @@ from sortedcontainers import SortedSet -import search -from utils import argmin_random_tie, count, first, extend +from aima import search +from aima.utils import argmin_random_tie, count, first, extend, flatten class CSP(search.Problem): """This class describes finite-domain Constraint Satisfaction Problems. - A CSP is specified by the following inputs: + A CSP is specified by the following inputs:: + variables A list of variables; each is atomic (e.g. int or string). domains A dict of {var:[possible_value, ...]} entries. neighbors A dict of {var:[var,...]} that for each variable lists @@ -35,18 +36,23 @@ class CSP(search.Problem): However, the class also supports data structures and methods that help you solve CSPs by calling a search function on the CSP. Methods and slots are as follows, where the argument 'a' represents an assignment, which is a - dict of {var:val} entries: + dict of {var:val} entries:: + assign(var, val, a) Assign a[var] = val; do other bookkeeping unassign(var, a) Do del a[var], plus other bookkeeping nconflicts(var, val, a) Return the number of other variables that conflict with var=val curr_domains[var] Slot: remaining consistent values for var Used by constraint propagation routines. - The following methods are used only by graph_search and tree_search: + + The following methods are used only by graph_search and tree_search:: + actions(state) Return a list of actions result(state, action) Return a successor of state goal_test(state) Return true if all constraints satisfied - The following are just for debugging purposes: + + The following are just for debugging purposes:: + nassigns Slot: tracks the number of assignments made display(a) Print a human-readable representation """ @@ -58,9 +64,20 @@ def __init__(self, variables, domains, neighbors, constraints): self.variables = variables self.domains = domains self.neighbors = neighbors - self.constraints = constraints + # store the constraint relation privately and expose it through the + # constraints() method below, which counts every consistency check + self.constraint_relation = constraints self.curr_domains = None self.nassigns = 0 + self.nchecks = 0 + + def constraints(self, A, a, B, b): + """Return True if neighbors A, B satisfy the constraint when A=a, B=b. + Every call is tallied in self.nchecks, so callers can measure the number + of consistency checks performed (e.g. to reproduce the benchmarks in + Figure 6.1), just as self.nassigns tallies the assignments made.""" + self.nchecks += 1 + return self.constraint_relation(A, a, B, b) def assign(self, var, val, assignment): """Add {var: val} to assignment; Discard the old value if any.""" @@ -83,6 +100,14 @@ def conflict(var2): return count(conflict(v) for v in self.neighbors[var]) + def count_lost_values(self, var, val, assignment): + """Return how many values would be ruled out in the domains of the + unassigned neighbours of var if var were assigned val (the count used + by the least-constraining-value heuristic).""" + return count(not self.constraints(var, val, neighbor, dval) + for neighbor in self.neighbors[var] if neighbor not in assignment + for dval in self.domains[neighbor]) + def display(self, assignment): """Show a human-readable representation of the CSP.""" # Subclasses can print in a prettier way, or display with a GUI @@ -162,10 +187,13 @@ def conflicted_vars(self, current): def no_arc_heuristic(csp, queue): + """Return the arc queue unchanged (no ordering heuristic for AC3).""" return queue def dom_j_up(csp, queue): + """Order the arc queue so arcs whose second variable has the smallest current + domain are popped first (a SortedSet keyed by the negated domain size).""" return SortedSet(queue, key=lambda t: neg(len(csp.curr_domains[t[1]]))) @@ -185,7 +213,7 @@ def AC3(csp, queue=None, removals=None, arc_heuristic=dom_j_up): for Xk in csp.neighbors[Xi]: if Xk != Xj: queue.add((Xk, Xi)) - return True, checks # CSP is satisfiable + return True, checks # CSP is arc-consistent def revise(csp, Xi, Xj, removals, checks=0): @@ -211,6 +239,10 @@ def revise(csp, Xi, Xj, removals, checks=0): # of AC3 with double-support domain-heuristic def AC3b(csp, queue=None, removals=None, arc_heuristic=dom_j_up): + """An improved version of AC3 that uses double-support checks to share work + between an arc (Xi, Xj) and its reverse (Xj, Xi). Returns a tuple + (is_consistent, checks): False if a domain is wiped out, True if the CSP is + made arc-consistent, where checks counts the consistency checks performed.""" if queue is None: queue = {(Xi, Xk) for Xi in csp.variables for Xk in csp.neighbors[Xi]} csp.support_pruning() @@ -257,10 +289,14 @@ def AC3b(csp, queue=None, removals=None, arc_heuristic=dom_j_up): for Xk in csp.neighbors[Xj]: if Xk != Xi: queue.add((Xk, Xj)) - return True, checks # CSP is satisfiable + return True, checks # CSP is arc-consistent def partition(csp, Xi, Xj, checks=0): + """Helper for AC3b: split the domains of Xi and Xj using double-support checks. + Returns (Si_p, Sj_p, Sj_u, checks) where Si_p is the set of Xi values supported + by Xj, Sj_p the set of Xj values already known to be supported by Xi, Sj_u the + remaining (as yet unconfirmed) Xj values, and checks the updated check count.""" Si_p = set() Sj_p = set() Sj_u = set(csp.curr_domains[Xj]) @@ -295,6 +331,10 @@ def partition(csp, Xi, Xj, checks=0): # Constraint Propagation with AC4 def AC4(csp, queue=None, removals=None, arc_heuristic=dom_j_up): + """Enforce arc consistency using AC4, which keeps per-value support counters so + that pruning a value only triggers re-checks of the values it supported. Returns + a tuple (is_consistent, checks): False if a domain is wiped out, True if the CSP + is made arc-consistent, where checks counts the consistency checks performed.""" if queue is None: queue = {(Xi, Xk) for Xi in csp.variables for Xk in csp.neighbors[Xi]} csp.support_pruning() @@ -334,7 +374,7 @@ def AC4(csp, queue=None, removals=None, arc_heuristic=dom_j_up): if revised: if not csp.curr_domains[Xi]: return False, checks # CSP is inconsistent - return True, checks # CSP is satisfiable + return True, checks # CSP is arc-consistent # ______________________________________________________________________________ @@ -355,6 +395,9 @@ def mrv(assignment, csp): def num_legal_values(csp, var, assignment): + """Return how many values are still legal for var (used by the mrv heuristic): + the size of its current domain if pruning is active, otherwise the number of + domain values that conflict with no other variable in the assignment.""" if csp.curr_domains: return len(csp.curr_domains[var]) else: @@ -371,13 +414,14 @@ def unordered_domain_values(var, assignment, csp): def lcv(var, assignment, csp): """Least-constraining-values heuristic.""" - return sorted(csp.choices(var), key=lambda val: csp.nconflicts(var, val, assignment)) + return sorted(csp.choices(var), key=lambda val: csp.count_lost_values(var, val, assignment)) # Inference def no_inference(csp, var, value, assignment, removals): + """The default inference step for backtracking: do nothing and always succeed.""" return True @@ -459,7 +503,9 @@ def min_conflicts_value(csp, var, current): def tree_csp_solver(csp): - """[Figure 6.11]""" + """[Figure 6.11] Solve a tree-structured CSP in linear time: order the variables + so each has at most one parent, make the tree directionally arc-consistent from + the leaves up, then assign each variable consistently from the root down.""" assignment = {} root = csp.variables[0] X, parent = topological_sort(csp, root) @@ -632,22 +678,25 @@ def queen_constraint(A, a, B, b): class NQueensCSP(CSP): - """ + r""" Make a CSP for the nQueens problem for search with min_conflicts. Suitable for large n, it uses only data structures of size O(n). Think of placing queens one per column, from left to right. That means position (x, y) represents (var, val) in the CSP. - The main structures are three arrays to count queens that could conflict: + The main structures are three arrays to count queens that could conflict:: + rows[i] Number of queens in the ith row (i.e. val == i) downs[i] Number of queens in the \ diagonal such that their (x, y) coordinates sum to i ups[i] Number of queens in the / diagonal such that their (x, y) coordinates have x-y+n-1 = i + We increment/decrement these counts each time a queen is placed/moved from a row/diagonal. So moving is O(1), as is nconflicts. But choosing a variable, and a best value for the variable, are each O(n). If you want, you can keep track of conflicted variables, then variable selection will also be O(1). + >>> len(backtracking_search(NQueensCSP(8))) 8 """ @@ -719,10 +768,6 @@ def display(self, assignment): # Sudoku -def flatten(seqs): - return sum(seqs, []) - - easy1 = '..3.2.6..9..3.5..1..18.64....81.29..7.......8..67.82....26.95..8..2.3..9..5.1.3..' harder1 = '4173698.5.3..........7......2.....6.....8.4......1.......6.3.7.5..2.....1.4......' @@ -797,6 +842,8 @@ def __init__(self, grid): CSP.__init__(self, None, domains, self.neighbors, different_values_constraint) def display(self, assignment): + """Print the Sudoku grid for the given assignment as a 9x9 board.""" + def show_box(box): return [' '.join(map(show_cell, row)) for row in box] def show_cell(cell): return str(assignment.get(cell, '.')) @@ -818,7 +865,7 @@ def Zebra(): Colors = 'Red Yellow Blue Green Ivory'.split() Pets = 'Dog Fox Snails Horse Zebra'.split() Drinks = 'OJ Tea Coffee Milk Water'.split() - Countries = 'Englishman Spaniard Norwegian Ukranian Japanese'.split() + Countries = 'Englishman Spaniard Norwegian Ukrainian Japanese'.split() Smokes = 'Kools Chesterfields Winston LuckyStrike Parliaments'.split() variables = Colors + Pets + Drinks + Countries + Smokes domains = {} @@ -829,7 +876,7 @@ def Zebra(): neighbors = parse_neighbors("""Englishman: Red; Spaniard: Dog; Kools: Yellow; Chesterfields: Fox; Norwegian: Blue; Winston: Snails; LuckyStrike: OJ; - Ukranian: Tea; Japanese: Parliaments; Kools: Horse; + Ukrainian: Tea; Japanese: Parliaments; Kools: Horse; Coffee: Green; Green: Ivory""") for type in [Colors, Pets, Drinks, Countries, Smokes]: for A in type: @@ -857,7 +904,7 @@ def zebra_constraint(A, a, B, b, recurse=0): return same if A == 'LuckyStrike' and B == 'OJ': return same - if A == 'Ukranian' and B == 'Tea': + if A == 'Ukrainian' and B == 'Tea': return same if A == 'Japanese' and B == 'Parliaments': return same @@ -881,6 +928,9 @@ def zebra_constraint(A, a, B, b, recurse=0): def solve_zebra(algorithm=min_conflicts, **args): + """Solve the Zebra Puzzle with the given CSP algorithm, print which person is in + each house, and return a tuple (zebra_owner_house, water_drinker_house, nassigns, + full_assignment).""" z = Zebra() ans = algorithm(z, **args) for h in range(1, 6): @@ -928,8 +978,9 @@ def display(self, assignment=None): def consistent(self, assignment): """assignment is a variable:value dictionary + returns True if all of the constraints that can be evaluated - evaluate to True given assignment. + evaluate to True given assignment. """ return all(con.holds(assignment) for con in self.constraints @@ -1019,10 +1070,13 @@ def nev(x): def no_heuristic(to_do): + """Return the to_do arc set unchanged (no ordering heuristic for GAC).""" return to_do def sat_up(to_do): + """Order the to_do arc set so that (variable, constraint) pairs whose constraint + has the smallest scope are processed first (a SortedSet keyed by 1 / scope size).""" return SortedSet(to_do, key=lambda t: 1 / len([var for var in t[1].scope])) @@ -1165,6 +1219,9 @@ def goal_test(self, node): return all(len(node[var]) == 1 for var in node) def actions(self, state): + """Return the successor states obtained by splitting some still-multi-valued + variable's domain in two and enforcing GAC on each half; only arc-consistent + results are kept.""" var = first(x for x in state if len(state[x]) > 1) neighs = [] if var: @@ -1178,6 +1235,7 @@ def actions(self, state): return neighs def result(self, state, action): + """Return the successor state, which is just the chosen reduced domains.""" return action @@ -1188,7 +1246,7 @@ def ac_solver(csp, arc_heuristic=sat_up): def ac_search_solver(csp, arc_heuristic=sat_up): """Arc consistency (search interface)""" - from search import depth_first_tree_search + from aima.search import depth_first_tree_search solution = None try: solution = depth_first_tree_search(ACSearchSolver(csp, arc_heuristic=arc_heuristic)).state @@ -1225,6 +1283,9 @@ def ac_search_solver(csp, arc_heuristic=sat_up): class Crossword(NaryCSP): + """An n-ary CSP for a crossword puzzle. The puzzle is a grid where '_' marks a + writable cell and '*' a blocked cell; each maximal run of two or more cells (across + or down) becomes a variable sequence constrained to spell one of the given words.""" def __init__(self, puzzle, words): domains = {} @@ -1258,6 +1319,7 @@ def __init__(self, puzzle, words): self.puzzle = puzzle def display(self, assignment=None): + """Print the crossword grid, filling in letters from assignment where known.""" for i, line in enumerate(self.puzzle): puzzle = "" for j, element in enumerate(line): @@ -1325,6 +1387,9 @@ def display(self, assignment=None): class Kakuro(NaryCSP): + """An n-ary CSP for a Kakuro puzzle. Each '_' cell is a variable with domain 1..9; + every horizontal or vertical run of cells must contain distinct digits that sum to + the clue given in the adjacent '[down, right]' header cell.""" def __init__(self, puzzle): variables = [] @@ -1380,6 +1445,8 @@ def __init__(self, puzzle): self.puzzle = puzzle def display(self, assignment=None): + """Print the Kakuro grid, showing clue cells and filling in digits from + assignment where known.""" for i, line in enumerate(self.puzzle): puzzle = "" for j, element in enumerate(line): diff --git a/deep_learning4e.py b/aima/deep_learning.py similarity index 79% rename from deep_learning4e.py rename to aima/deep_learning.py index 9f5b0a8f7..7c69da9e7 100644 --- a/deep_learning4e.py +++ b/aima/deep_learning.py @@ -8,8 +8,40 @@ from keras.layers import Embedding, SimpleRNN, Dense from keras.preprocessing import sequence -from utils4e import (conv1D, gaussian_kernel, element_wise_product, vector_add, random_weights, - scalar_vector_product, map_vector, mean_squared_error_loss) +from aima.utils import conv1D, gaussian_kernel, random_weights, map_vector, mean_squared_error_loss + + +# The neural-network code operates on nested weight matrices, so it needs the +# recursive variants of these vector helpers (the canonical aima.utils versions +# are the flat ones used by the grid/search code, which return tuples/np arrays). +def element_wise_product(x, y): + """Element-wise product of x and y, recursing into nested iterables.""" + if hasattr(x, '__iter__') and hasattr(y, '__iter__'): + assert len(x) == len(y) + return [element_wise_product(_x, _y) for _x, _y in zip(x, y)] + elif hasattr(x, '__iter__') == hasattr(y, '__iter__'): + return x * y + else: + raise Exception('Inputs must be in the same size!') + + +def vector_add(a, b): + """Component-wise (recursive) addition of two vectors.""" + if not (a and b): + return a or b + if hasattr(a, '__iter__') and hasattr(b, '__iter__'): + assert len(a) == len(b) + return list(map(vector_add, a, b)) + else: + try: + return a + b + except TypeError: + raise Exception('Inputs must be in the same size!') + + +def scalar_vector_product(x, y): + """Product of a scalar x and a (possibly nested) vector y, recursively.""" + return [scalar_vector_product(x, _y) for _y in y] if hasattr(y, '__iter__') else x * y class Node: @@ -39,92 +71,123 @@ def forward(self, inputs): class Activation: + """Abstract base class for neural-network activation functions. + + Subclasses implement ``function`` and its ``derivative``; calling an + instance applies the activation to its input. + """ def function(self, x): - return NotImplementedError + """Apply the activation function to input ``x``.""" + raise NotImplementedError def derivative(self, x): - return NotImplementedError + """Return the derivative of the activation function at ``x``.""" + raise NotImplementedError def __call__(self, x): return self.function(x) class Sigmoid(Activation): + """Logistic sigmoid activation, ``1 / (1 + e**-x)``.""" def function(self, x): + """Return the logistic sigmoid of ``x``.""" return 1 / (1 + np.exp(-x)) def derivative(self, value): + """Return the sigmoid derivative given the layer output ``value``.""" return value * (1 - value) class ReLU(Activation): + """Rectified Linear Unit activation, ``max(0, x)``.""" def function(self, x): + """Return ``max(0, x)``.""" return max(0, x) def derivative(self, value): + """Return the ReLU derivative (1 if ``value`` > 0 else 0).""" return 1 if value > 0 else 0 class ELU(Activation): + """Exponential Linear Unit activation, with scale ``alpha`` for non-positive inputs.""" def __init__(self, alpha=0.01): self.alpha = alpha def function(self, x): + """Return ``x`` if positive else ``alpha * (e**x - 1)``.""" return x if x > 0 else self.alpha * (np.exp(x) - 1) def derivative(self, value): + """Return the ELU derivative given the layer output ``value``.""" return 1 if value > 0 else self.alpha * np.exp(value) class LeakyReLU(Activation): + """Leaky ReLU activation, with small slope ``alpha`` for negative inputs.""" def __init__(self, alpha=0.01): self.alpha = alpha def function(self, x): + """Return ``max(x, alpha * x)``.""" return max(x, self.alpha * x) def derivative(self, value): + """Return the Leaky ReLU derivative (1 if ``value`` > 0 else ``alpha``).""" return 1 if value > 0 else self.alpha class Tanh(Activation): + """Hyperbolic tangent activation.""" def function(self, x): + """Return ``tanh(x)``.""" return np.tanh(x) def derivative(self, value): + """Return the tanh derivative given the layer output ``value`` (``1 - value**2``).""" return 1 - (value ** 2) class SoftMax(Activation): + """Softmax activation that normalises a vector into a probability distribution.""" def function(self, x): + """Return the softmax of vector ``x`` (normalised exponentials).""" return np.exp(x) / np.sum(np.exp(x)) def derivative(self, x): + """Return a placeholder unit gradient for each element of ``x``.""" return np.ones_like(x) class SoftPlus(Activation): + """SoftPlus activation, ``log(1 + e**x)`` (a smooth approximation of ReLU).""" def function(self, x): + """Return ``log(1 + e**x)`` for ``x``.""" return np.log(1. + np.exp(x)) def derivative(self, x): + """Return the SoftPlus derivative at ``x`` (the logistic sigmoid).""" return 1. / (1. + np.exp(-x)) class Linear(Activation): + """Identity (linear) activation that returns its input unchanged.""" def function(self, x): + """Return ``x`` unchanged.""" return x def derivative(self, x): + """Return an all-ones gradient matching the shape of ``x``.""" return np.ones_like(x) @@ -149,6 +212,7 @@ def __init__(self, size=3): super().__init__(size) def forward(self, inputs, activation=SoftMax): + """Apply ``activation`` (softmax by default) to ``inputs`` and store it in each node.""" assert len(self.nodes) == len(inputs) res = activation().function(inputs) for node, val in zip(self.nodes, res): @@ -174,6 +238,7 @@ def __init__(self, in_size=3, out_size=3, activation=Sigmoid): node.weights = random_weights(-0.5, 0.5, in_size) def forward(self, inputs): + """Apply the activation to each unit's weighted sum of ``inputs`` and return the outputs.""" self.inputs = inputs res = [] # get the output value of each unit @@ -197,6 +262,7 @@ def __init__(self, size=3, kernel_size=3): node.weights = gaussian_kernel(kernel_size) def forward(self, features): + """Convolve each input channel in ``features`` with its node kernel and return the outputs.""" # each node in layer takes a channel in the features assert len(self.nodes) == len(features) res = [] @@ -220,6 +286,7 @@ def __init__(self, size=3, kernel_size=3): self.inputs = None def forward(self, features): + """Apply 1D max pooling over each channel in ``features`` and return the pooled outputs.""" assert len(self.nodes) == len(features) res = [] self.inputs = features @@ -245,6 +312,7 @@ def __init__(self, size, eps=0.001): self.inputs = None def forward(self, inputs): + """Normalise ``inputs`` by their mean and std, then scale and shift by the layer weights.""" # mean value of inputs mu = sum(inputs) / len(inputs) # standard error of inputs @@ -459,11 +527,13 @@ def __init__(self, dataset, hidden_layer_sizes, l_rate=0.01, epochs=1000, batch_ self.raw_net = raw_net def fit(self, X, y): + """Train the network with the configured optimizer and loss, returning ``self``.""" self.learned_net = self.optimizer(self.dataset, self.raw_net, loss=self.loss, epochs=self.epochs, l_rate=self.l_rate, batch_size=self.batch_size, verbose=self.verbose) return self def predict(self, example): + """Forward-pass ``example`` through the trained net and return the index of the max output.""" n_layers = len(self.learned_net) layer_input = example @@ -500,11 +570,13 @@ def __init__(self, dataset, l_rate=0.01, epochs=1000, batch_size=10, optimizer=s self.raw_net = [InputLayer(input_size), DenseLayer(input_size, output_size)] def fit(self, X, y): + """Train the perceptron with the configured optimizer and loss, returning ``self``.""" self.learned_net = self.optimizer(self.dataset, self.raw_net, loss=self.loss, epochs=self.epochs, l_rate=self.l_rate, batch_size=self.batch_size, verbose=self.verbose) return self def predict(self, example): + """Forward-pass ``example`` and return the index of the maximum output unit.""" layer_out = self.learned_net[1].forward(np.array(example).reshape((-1, 1))) return layer_out.index(max(layer_out)) @@ -526,9 +598,11 @@ def keras_dataset_loader(dataset, max_length=500): def SimpleRNNLearner(train_data, val_data, epochs=2, verbose=False): """ RNN example for text sentimental analysis. - :param train_data: a tuple of (training data, targets) - Training data: ndarray taking training examples, while each example is coded by embedding - Targets: ndarray taking targets of each example. Each target is mapped to an integer + + :param train_data: + a tuple of (training data, targets) + Training data: ndarray taking training examples, while each example is coded by embedding + Targets: ndarray taking targets of each example. Each target is mapped to an integer :param val_data: a tuple of (validation data, targets) :param epochs: number of epochs :param verbose: verbosity mode @@ -575,7 +649,7 @@ def AutoencoderLearner(inputs, encoding_size, epochs=200, verbose=False): model.add(Dense(input_size, activation='relu', kernel_initializer='random_uniform', bias_initializer='ones')) # update model with sgd - sgd = optimizers.SGD(lr=0.01) + sgd = optimizers.SGD(learning_rate=0.01) model.compile(loss='mean_squared_error', optimizer=sgd, metrics=['accuracy']) # train the model diff --git a/aima/game_theory.py b/aima/game_theory.py new file mode 100644 index 000000000..b25442f4f --- /dev/null +++ b/aima/game_theory.py @@ -0,0 +1,257 @@ +"""Multiagent decision making: game theory and social choice (Chapter 18).""" + +from collections import Counter, defaultdict +from itertools import combinations, permutations +from math import factorial + +import numpy as np +from scipy.optimize import linprog + + +def dominates(payoff, s, t, strongly=True): + """ + [Section 18.2] + True if, for the player whose payoff matrix is 'payoff' (rows = that player's + own strategies, columns = the opponents' joint strategies), strategy s + dominates strategy t. Strong domination requires s to be strictly better than + t against every opponent strategy; weak domination requires s to be no worse + everywhere and strictly better in at least one column. + """ + payoff = np.asarray(payoff, dtype=float) + if strongly: + return bool(np.all(payoff[s] > payoff[t])) + return bool(np.all(payoff[s] >= payoff[t]) and np.any(payoff[s] > payoff[t])) + + +def dominant_strategy(payoff, strongly=True): + """ + [Section 18.2] + Return the strategy (row index) that dominates all of the player's other + strategies, or None if the player has no such dominant strategy. To analyze + the column player of a game, pass the transpose of their payoff matrix so + that their strategies become the rows. + """ + payoff = np.asarray(payoff, dtype=float) + for s in range(payoff.shape[0]): + if all(dominates(payoff, s, t, strongly) for t in range(payoff.shape[0]) if t != s): + return s + return None + + +def iterated_dominance(payoff1, payoff2, strongly=True): + """ + [Section 18.2] + Iterated elimination of dominated strategies: since a rational player never + plays a dominated strategy, we can repeatedly delete, for either player, any + strategy that is dominated among the strategies still in play, until none + remains. 'payoff1'/'payoff2' are the row and column player's payoff matrices. + Returns the surviving (rows, cols) strategy indices of the original game. + """ + A, B = np.asarray(payoff1, dtype=float), np.asarray(payoff2, dtype=float) + rows, cols = list(range(A.shape[0])), list(range(A.shape[1])) + + def find_dominated(payoff, own, opponent): + """A strategy in 'own' that is dominated against the surviving 'opponent' strategies.""" + for s in own: + for t in own: + if s != t: + better, worse = payoff[t][opponent], payoff[s][opponent] + if (np.all(better > worse) if strongly else + np.all(better >= worse) and np.any(better > worse)): + return s + return None + + eliminated = True + while eliminated: + # the row player's payoff is A[row][col]; the column player's is B.T[col][row] + row = find_dominated(A, rows, cols) + if row is not None: + rows.remove(row) + col = find_dominated(B.T, cols, rows) + if col is not None: + cols.remove(col) + eliminated = row is not None or col is not None + + return rows, cols + + +def pure_nash_equilibria(payoff1, payoff2): + """ + [Section 18.2] + All pure-strategy Nash equilibria of a two-player game, given the payoff + matrices payoff1[i][j] and payoff2[i][j] for the row and column player when + the row player plays i and the column player plays j. A profile (i, j) is a + Nash equilibrium when each player is playing a best response to the other, so + neither can gain by deviating unilaterally. Returns a list of (i, j) profiles + (possibly empty, e.g. for matching pennies). + """ + payoff1, payoff2 = np.asarray(payoff1, dtype=float), np.asarray(payoff2, dtype=float) + m, n = payoff1.shape + return [(i, j) for i in range(m) for j in range(n) + # row player best-responds within column j and column player within row i + if payoff1[i, j] >= payoff1[:, j].max() and payoff2[i, j] >= payoff2[i, :].max()] + + +def solve_zero_sum_game(payoff): + """ + [Section 18.2] + Solve a two-player zero-sum game by linear programming. 'payoff' is the payoff + to the row (maximizing) player; the column (minimizing) player receives its + negation. By von Neumann's minimax theorem the game has a value v and optimal + mixed strategies x, y such that x maximizes the row player's guaranteed payoff + (for every column j, sum_i x_i payoff[i][j] >= v) and y minimizes the column + player's guaranteed loss. Returns (value, row_strategy, col_strategy). + """ + payoff = np.asarray(payoff, dtype=float) + m, n = payoff.shape + + # row player: maximize v s.t. for every column j, sum_i x_i payoff[i][j] >= v; + # variables are [x_1, ..., x_m, v], and linprog minimizes, so we minimize -v + res = linprog(c=np.append(np.zeros(m), -1), + A_ub=np.column_stack([-payoff.T, np.ones(n)]), b_ub=np.zeros(n), + A_eq=np.append(np.ones(m), 0).reshape(1, -1), b_eq=[1], + bounds=[(0, None)] * m + [(None, None)]) + value, row_strategy = res.x[m], res.x[:m] + + # column player: minimize w s.t. for every row i, sum_j payoff[i][j] y_j <= w + res = linprog(c=np.append(np.zeros(n), 1), + A_ub=np.column_stack([payoff, -np.ones(m)]), b_ub=np.zeros(m), + A_eq=np.append(np.ones(n), 0).reshape(1, -1), b_eq=[1], + bounds=[(0, None)] * n + [(None, None)]) + col_strategy = res.x[:n] + + return value, row_strategy, col_strategy + + +# ______________________________________________________________________________ +# 18.3 Cooperative Game Theory + + +def shapley_value(players, characteristic_function): + """ + [Section 18.3] + The Shapley value of a cooperative game (players, v): a fair division of the + grand coalition's value v(N) that pays each player the average, over all n! + orderings of the players, of the marginal contribution + mc_i(C) = v(C and {i}) - v(C) they make to the players preceding them. The + 'characteristic_function' is a callable mapping a frozenset of players to its + value. Returns a dict mapping each player to their Shapley value. + """ + players = list(players) + phi = {i: 0.0 for i in players} + for order in permutations(players): + preceding = set() + for i in order: + phi[i] += (characteristic_function(frozenset(preceding | {i})) + - characteristic_function(frozenset(preceding))) + preceding.add(i) + return {i: phi[i] / factorial(len(players)) for i in players} + + +def is_in_core(players, characteristic_function, payoff): + """ + [Section 18.3] + True if 'payoff' (a dict mapping each player to their share) lies in the core + of the cooperative game (players, v): it must distribute exactly the grand + coalition's value (sum of shares = v(N)) and be immune to defection, i.e. for + every coalition C the players in C receive at least v(C) (otherwise C would be + better off on its own). An empty core means the grand coalition cannot form. + """ + players, v = list(players), characteristic_function + # efficiency: the grand coalition's value is fully distributed + if not np.isclose(sum(payoff[i] for i in players), v(frozenset(players))): + return False + # no coalition can object: x(C) >= v(C) for every coalition C + return all(sum(payoff[i] for i in coalition) >= v(frozenset(coalition)) - 1e-9 + for size in range(1, len(players)) for coalition in combinations(players, size)) + + +# ______________________________________________________________________________ +# 18.4 Making Collective Decisions + + +def plurality_winner(preferences): + """ + [Section 18.4] + Winner under plurality voting: the candidate ranked first by the most voters. + 'preferences' is a list of ballots, each a list of candidates ordered from + most to least preferred. + """ + first_choices = Counter(ballot[0] for ballot in preferences) + return max(first_choices, key=first_choices.get) + + +def borda_winner(preferences): + """ + [Section 18.4] + Winner under the Borda count: with k candidates each voter awards k points to + their top choice, k-1 to the next, down to 1 for the last; the candidate with + the highest total score wins. + """ + scores = defaultdict(int) + for ballot in preferences: + for rank, candidate in enumerate(ballot): + scores[candidate] += len(ballot) - rank + return max(scores, key=scores.get) + + +def condorcet_winner(preferences): + """ + [Section 18.4] + The Condorcet winner: the candidate that beats every other candidate in a + pairwise majority comparison. Returns None when no such candidate exists + (Condorcet's paradox), in which case majority preference is cyclic. + """ + candidates = list(preferences[0]) + + def beats(a, b): # a majority of voters rank a above b + votes = sum(ballot.index(a) < ballot.index(b) for ballot in preferences) + return votes > len(preferences) / 2 + + return next((a for a in candidates if all(a == b or beats(a, b) for b in candidates)), None) + + +def vickrey_auction(bids): + """ + [Section 18.4] + Sealed-bid second-price (Vickrey) auction: the highest bidder wins but pays + only the second-highest bid. 'bids' maps each bidder to their bid. Returns the + (winner, price) pair. Because the winner does not pay their own bid, bidding + one's true value is a dominant strategy (the mechanism is truth-revealing). + """ + ranked = sorted(bids.values(), reverse=True) + winner = max(bids, key=bids.get) + price = ranked[1] if len(ranked) > 1 else ranked[0] + return winner, price + + +def contract_net(tasks, agents, bid, select=min): + """ + [Section 18.4.1] + The contract net protocol for task allocation. For each task the manager + broadcasts a task announcement; every agent submits a bid through the callable + bid(agent, task), which returns a numeric bid or None when the agent cannot or + will not perform the task. The manager then awards the task to the agent with + the best bid ('select' = min for costs, max for values). Returns a dict mapping + each task to an (agent, bid) award, or to None if nobody bid. + """ + allocation = {} + for task in tasks: + bids = {agent: bid(agent, task) for agent in agents} + bids = {agent: b for agent, b in bids.items() if b is not None} + allocation[task] = (select(bids, key=bids.get), select(bids.values())) if bids else None + return allocation + + +def alternating_offers_bargaining(discount_a, discount_b): + """ + [Section 18.4.4] + Rubinstein's alternating-offers bargaining over how to split a pie of size 1 + between two impatient agents with discount factors discount_a and discount_b + in [0, 1). In the unique subgame-perfect equilibrium the agent who makes the + first offer (A) keeps a share (1 - discount_b) / (1 - discount_a * discount_b) + and B receives the rest; the more patient an agent is (larger discount factor) + the larger the share it secures. Returns the (share_a, share_b) pair. + """ + share_a = (1 - discount_b) / (1 - discount_a * discount_b) + return share_a, 1 - share_a diff --git a/games4e.py b/aima/games.py similarity index 89% rename from games4e.py rename to aima/games.py index aba5b0eb3..533ebc277 100644 --- a/games4e.py +++ b/aima/games.py @@ -7,7 +7,7 @@ import numpy as np -from utils4e import vector_add, MCT_Node, ucb +from aima.utils import vector_add, MCT_Node, ucb GameState = namedtuple('GameState', 'to_move, utility, board, moves') StochasticGameState = namedtuple('StochasticGameState', 'to_move, utility, board, moves, chance') @@ -50,8 +50,8 @@ def expect_minmax(state, game): """ [Figure 5.11] Return the best move for a player after dice are thrown. The game tree - includes chance nodes along with min and max nodes. - """ + includes chance nodes along with min and max nodes. + """ player = game.to_move(state) def max_value(state): @@ -82,7 +82,7 @@ def chance_node(state, action): sum_chances += util * game.probability(chance) return sum_chances / num_chances - # Body of expect_min_max: + # Body of expect_minmax: return max(game.actions(state), key=lambda a: chance_node(state, a), default=None) @@ -176,6 +176,9 @@ def min_value(state, alpha, beta, depth): def monte_carlo_tree_search(state, game, N=1000): + """Choose a move by running N iterations of Monte Carlo tree search from the + given state, repeatedly selecting a leaf via UCB, expanding it, simulating a + random playout, and backing the result up; return the most-visited child move.""" def select(n): """select a leaf node in the tree""" if n.children: @@ -234,11 +237,15 @@ def query_player(game, state): print("") move = None if game.actions(state): - move_string = input('Your move? ') - try: - move = eval(move_string) - except NameError: - move = move_string + while True: + move_string = input('Your move? ') + try: + move = eval(move_string) + except NameError: + move = move_string + if move in game.actions(state): + break + print('illegal move, try again') else: print('no legal moves: passing turn to next player') return move @@ -250,14 +257,22 @@ def random_player(game, state): def alpha_beta_player(game, state): + """A player that picks a move using full alpha-beta search.""" return alpha_beta_search(state, game) -def expect_min_max_player(game, state): +def minmax_player(game,state): + """A player that picks a move using full minimax search.""" + return minmax_decision(state,game) + + +def expect_minmax_player(game, state): + """A player for stochastic games that picks a move using expectiminimax search.""" return expect_minmax(state, game) def mcts_player(game, state): + """A player that picks a move using Monte Carlo tree search.""" return monte_carlo_tree_search(state, game) @@ -356,21 +371,26 @@ class Fig52Game(Game): initial = 'A' def actions(self, state): + """Return the moves available from the given node of the game tree.""" return list(self.succs.get(state, {}).keys()) def result(self, state, move): + """Return the successor node reached by taking the given move.""" return self.succs[state][move] def utility(self, state, player): + """Return the leaf value, negated for the MIN player.""" if player == 'MAX': return self.utils[state] else: return -self.utils[state] def terminal_test(self, state): + """Return True for leaf nodes, i.e. anything other than A, B, C, D.""" return state not in ('A', 'B', 'C', 'D') def to_move(self, state): + """Return 'MIN' for the second-ply nodes B, C, D, otherwise 'MAX'.""" return 'MIN' if state in 'BCD' else 'MAX' @@ -381,21 +401,26 @@ class Fig52Extended(Game): utils = dict() def actions(self, state): + """Return the moves, sorted, available from the given node.""" return sorted(list(self.succs.get(state, {}).keys())) def result(self, state, move): + """Return the successor node reached by taking the given move.""" return self.succs[state][move] def utility(self, state, player): + """Return the leaf value, negated for the MIN player.""" if player == 'MAX': return self.utils[state] else: return -self.utils[state] def terminal_test(self, state): + """Return True once the state falls outside the 0-12 node range.""" return state not in range(13) def to_move(self, state): + """Return 'MIN' for the second-ply nodes 1, 2, 3, otherwise 'MAX'.""" return 'MIN' if state in {1, 2, 3} else 'MAX' @@ -418,6 +443,8 @@ def actions(self, state): return state.moves def result(self, state, move): + """Place the current player's mark at move and return the new state; + an illegal move leaves the state unchanged.""" if move not in state.moves: return state # Illegal move has no effect board = state.board.copy() @@ -437,6 +464,7 @@ def terminal_test(self, state): return state.utility != 0 or len(state.moves) == 0 def display(self, state): + """Print the board as a grid, with empty squares shown as dots.""" board = state.board for x in range(1, self.h + 1): for y in range(1, self.v + 1): @@ -478,8 +506,15 @@ def __init__(self, h=7, v=6, k=4): TicTacToe.__init__(self, h, v, k) def actions(self, state): + """Return legal moves: bottom-row squares or squares directly above an occupied one.""" return [(x, y) for (x, y) in state.moves - if y == 1 or (x, y - 1) in state.board] + if x == self.h or (x + 1 , y ) in state.board] + +class Gomoku(TicTacToe): + """Also known as Five in a row.""" + + def __init__(self, h=15, v=16, k=5): + TicTacToe.__init__(self, h, v, k) class Backgammon(StochasticGame): @@ -516,6 +551,8 @@ def actions(self, state): return legal_moves def result(self, state, move): + """Apply the player's checker move(s) for the current dice roll and return + the resulting state, with the turn passed to the opponent.""" board = copy.deepcopy(state.board) player = state.to_move self.move_checker(board, move[0], state.chance[0], player) @@ -576,8 +613,8 @@ def checkers_at_home(self, board, player): def is_legal_move(self, board, start, steps, player): """Move is a tuple which contains starting points of checkers to be - moved during a player's turn. An on-board move is legal if both the destinations - are open. A bear-off move is the one where a checker is moved off-board. + moved during a player's turn. An on-board move is legal if both the destinations + are open. A bear-off move is the one where a checker is moved off-board. It is legal only after a player has moved all his checkers to his home.""" dest1, dest2 = vector_add(start, steps) dest_range = range(0, 24) diff --git a/ipyviews.py b/aima/ipyviews.py similarity index 76% rename from ipyviews.py rename to aima/ipyviews.py index b304af7bb..3976cc71a 100644 --- a/ipyviews.py +++ b/aima/ipyviews.py @@ -1,11 +1,16 @@ from IPython.display import HTML, display, clear_output from collections import defaultdict -from agents import PolygonObstacle +from aima.agents import PolygonObstacle +import os import time import json import copy import __main__ +# repo root (the directory containing the `aima` package), so the bundled js/ +# files load regardless of the current working directory +_AIMA_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + # ______________________________________________________________________________ # Continuous environment @@ -21,7 +26,7 @@ ''' # noqa -with open('js/continuousworld.js', 'r') as js_file: +with open(os.path.join(_AIMA_ROOT, 'js/continuousworld.js'), 'r') as js_file: _JS_CONTINUOUS_WORLD = js_file.read() @@ -35,6 +40,8 @@ def __init__(self, world, fill="#AAA"): self.height = world.height def object_name(self): + """Return the variable name this view is bound to in the main namespace, + matched by its creation timestamp, so the JavaScript canvas can address it.""" globals_in_main = {x: getattr(__main__, x) for x in dir(__main__)} for x in globals_in_main: if isinstance(globals_in_main[x], type(self)): @@ -49,9 +56,11 @@ def handle_add_obstacle(self, vertices): self.show() def handle_remove_obstacle(self): - return NotImplementedError + """Hook for removing an obstacle from the world; not yet implemented.""" + raise NotImplementedError def get_polygon_obstacles_coordinates(self): + """Return the vertex coordinates of every polygon obstacle in the world.""" obstacle_coordiantes = [] for thing in self.world.things: if isinstance(thing, PolygonObstacle): @@ -59,6 +68,8 @@ def get_polygon_obstacles_coordinates(self): return obstacle_coordiantes def show(self): + """Render the continuous world (its dimensions and obstacles) as an HTML + canvas and display it in the notebook output.""" clear_output() total_html = _CONTINUOUS_WORLD_HTML.format(self.width, self.height, self.object_name(), str(self.get_polygon_obstacles_coordinates()), @@ -82,14 +93,14 @@ def show(self): ''' -with open('js/gridworld.js', 'r') as js_file: +with open(os.path.join(_AIMA_ROOT, 'js/gridworld.js'), 'r') as js_file: _JS_GRID_WORLD = js_file.read() class GridWorldView: """ View for grid world. Uses XYEnviornment in agents.py as model. world: an instance of XYEnviornment. - block_size: size of individual blocks in pixes. + block_size: size of individual blocks in pixels. default_fill: color of blocks. A hex value or name should be passed. """ @@ -101,6 +112,8 @@ def __init__(self, world, block_size=30, default_fill="white"): self.block_size = block_size def object_name(self): + """Return the variable name this view is bound to in the main namespace, + matched by its creation timestamp, so the JavaScript canvas can address it.""" globals_in_main = {x: getattr(__main__, x) for x in dir(__main__)} for x in globals_in_main: if isinstance(globals_in_main[x], type(self)): @@ -108,7 +121,7 @@ def object_name(self): return x def set_label(self, coordinates, label): - """ Add lables to a particular block of grid. + """ Add labels to a particular block of grid. coordinates: a tuple of (row, column). rows and columns are 0 indexed. """ @@ -127,11 +140,14 @@ def set_representation(self, thing, repr_type, source): self.representation[thing_class_name] = {"type": repr_type, "source": source} def handle_click(self, coordinates): - """ This method needs to be overidden. Make sure to include a + """ This method needs to be overridden. Make sure to include a self.show() call at the end. """ self.show() def map_to_render(self): + """Build the grid's render model and return it as a JSON string: a 2D array of + cells annotated with each thing's class name and any per-location tooltip + label.""" default_representation = {"val": "default", "tooltip": ""} world_map = [[copy.deepcopy(default_representation) for _ in range(self.world.width)] for _ in range(self.world.height)] @@ -150,6 +166,8 @@ def map_to_render(self): return json.dumps(world_map) def show(self): + """Render the grid world (its cells, sizes, and representations) as an HTML + canvas and display it in the notebook output.""" clear_output() total_html = _GRID_WORLD_HTML.format( self.object_name(), self.map_to_render(), diff --git a/knowledge.py b/aima/knowledge.py similarity index 92% rename from knowledge.py rename to aima/knowledge.py index 8c27c3eb8..66d9adc0c 100644 --- a/knowledge.py +++ b/aima/knowledge.py @@ -7,9 +7,9 @@ import numpy as np -from logic import (FolKB, constant_symbols, predicate_symbols, standardize_variables, +from aima.logic import (FolKB, constant_symbols, predicate_symbols, standardize_variables, variables, is_definite_clause, subst, expr, Expr) -from utils import power_set +from aima.utils import power_set def current_best_learning(examples, h, examples_so_far=None): @@ -139,6 +139,8 @@ def version_space_learning(examples): def version_space_update(V, e): + """Return the version space ``V`` restricted to the hypotheses that remain + consistent with example ``e``.""" return [h for h in V if is_consistent(e, h)] @@ -253,6 +255,8 @@ def __init__(self, clauses=None): super().__init__(clauses) def tell(self, sentence): + """Add a definite clause to the knowledge base, recording its constant and + predicate symbols; raise an exception if it is not a definite clause.""" if is_definite_clause(sentence): self.clauses.append(sentence) self.const_syms.update(constant_symbols(sentence)) @@ -320,16 +324,17 @@ def choose_literal(self, literals, examples): def gain(self, l, examples): """ Find the utility of each literal when added to the body of the clause. - Utility function is: + Utility function is:: + gain(R, l) = T * (log_2 (post_pos / (post_pos + post_neg)) - log_2 (pre_pos / (pre_pos + pre_neg))) - where: - - pre_pos = number of possitive bindings of rule R (=current set of rules) + where:: + + pre_pos = number of positive bindings of rule R (=current set of rules) pre_neg = number of negative bindings of rule R - post_pos = number of possitive bindings of rule R' (= R U {l} ) + post_pos = number of positive bindings of rule R' (= R U {l} ) post_neg = number of negative bindings of rule R' - T = number of possitive bindings of rule R that are still covered + T = number of positive bindings of rule R that are still covered after adding literal l """ @@ -411,12 +416,16 @@ def guess_value(e, h): def is_consistent(e, h): + """Return True iff hypothesis ``h`` predicts the same value as example ``e``'s + actual GOAL label.""" return e['GOAL'] == guess_value(e, h) def false_positive(e, h): + """Return True iff ``h`` predicts True for ``e`` while its actual GOAL is False.""" return guess_value(e, h) and not e['GOAL'] def false_negative(e, h): + """Return True iff ``h`` predicts False for ``e`` while its actual GOAL is True.""" return e['GOAL'] and not guess_value(e, h) diff --git a/learning.py b/aima/learning.py similarity index 74% rename from learning.py rename to aima/learning.py index 71b6b15e7..a4efc8922 100644 --- a/learning.py +++ b/aima/learning.py @@ -1,37 +1,36 @@ """Learning from examples (Chapters 18)""" import copy +import itertools from collections import defaultdict from statistics import stdev -from qpsolvers import solve_qp - -from probabilistic_learning import NaiveBayesLearner -from utils import * +from aima.probabilistic_learning import NaiveBayesLearner +from aima.utils import * class DataSet: """ - A data set for a machine learning problem. It has the following fields: - - d.examples A list of examples. Each one is a list of attribute values. - d.attrs A list of integers to index into an example, so example[attr] - gives a value. Normally the same as range(len(d.examples[0])). - d.attr_names Optional list of mnemonic names for corresponding attrs. - d.target The attribute that a learning algorithm will try to predict. - By default the final attribute. - d.inputs The list of attrs without the target. - d.values A list of lists: each sublist is the set of possible - values for the corresponding attribute. If initially None, - it is computed from the known examples by self.set_problem. - If not None, an erroneous value raises ValueError. - d.distance A function from a pair of examples to a non-negative number. - Should be symmetric, etc. Defaults to mean_boolean_error - since that can handle any field types. - d.name Name of the data set (for output display only). - d.source URL or other source where the data came from. - d.exclude A list of attribute indexes to exclude from d.inputs. Elements - of this list can either be integers (attrs) or attr_names. + A data set for a machine learning problem. It has the following fields:: + + d.examples A list of examples. Each one is a list of attribute values. + d.attrs A list of integers to index into an example, so example[attr] + gives a value. Normally the same as range(len(d.examples[0])). + d.attr_names Optional list of mnemonic names for corresponding attrs. + d.target The attribute that a learning algorithm will try to predict. + By default the final attribute. + d.inputs The list of attrs without the target. + d.values A list of lists: each sublist is the set of possible + values for the corresponding attribute. If initially None, + it is computed from the known examples by self.set_problem. + If not None, an erroneous value raises ValueError. + d.distance A function from a pair of examples to a non-negative number. + Should be symmetric, etc. Defaults to mean_boolean_error + since that can handle any field types. + d.name Name of the data set (for output display only). + d.source URL or other source where the data came from. + d.exclude A list of attribute indexes to exclude from d.inputs. Elements + of this list can either be integers (attrs) or attr_names. Normally, you call the constructor and you're done; then you just access fields like d.examples and d.target and d.inputs. @@ -124,11 +123,12 @@ def attr_num(self, attr): return attr def update_values(self): + """Recompute ``self.values`` (the list of distinct values per attribute) from the examples.""" self.values = list(map(unique, zip(*self.examples))) def sanitize(self, example): """Return a copy of example, with non-input attributes replaced by None.""" - return [attr_i if i in self.inputs else None for i, attr_i in enumerate(example)] + return [attr_i if i in self.inputs else None for i, attr_i in enumerate(example)][:-1] def classes_to_numbers(self, classes=None): """Converts class names to numbers.""" @@ -156,11 +156,12 @@ def split_values_by_classes(self): def find_means_and_deviations(self): """ - Finds the means and standard deviations of self.dataset. - means : a dictionary for each class/target. Holds a list of the means - of the features for the class. - deviations: a dictionary for each class/target. Holds a list of the sample - standard deviations of the features for the class. + Finds the means and standard deviations of self.dataset:: + + means : a dictionary for each class/target. Holds a list of the means + of the features for the class. + deviations: a dictionary for each class/target. Holds a list of the sample + standard deviations of the features for the class. """ target_names = self.values[self.target] feature_numbers = len(self.inputs) @@ -205,7 +206,10 @@ def err_ratio(predict, dataset, examples=None): """ Return the proportion of the examples that are NOT correctly predicted. verbose - 0: No output; 1: Output wrong; 2 (or greater): Output correct + Accepts either a callable predictor or a learner object with a .predict method. """ + if hasattr(predict, 'predict'): + predict = predict.predict examples = examples or dataset.examples if len(examples) == 0: return 0.0 @@ -222,7 +226,10 @@ def grade_learner(predict, tests): """ Grades the given learner based on how many tests it passes. tests is a list with each element in the form: (values, output). + Accepts either a callable predictor or a learner object with a .predict method. """ + if hasattr(predict, 'predict'): + predict = predict.predict return mean(int(predict(X) == y) for X, y in tests) @@ -300,7 +307,9 @@ def cross_validation(learner, dataset, size=None, k=10, trials=1): for fold in range(k): train_data, val_data = train_test_split(dataset, fold * (n // k), (fold + 1) * (n // k)) dataset.examples = train_data - h = learner(dataset, size) + # pass `size` only to learners that take it (model selection); the + # plain learners used by e.g. compare() have a (dataset)-only signature + h = learner(dataset, size) if size is not None else learner(dataset) fold_errT += err_ratio(h, dataset, train_data) fold_errV += err_ratio(h, dataset, val_data) # reverting back to original once test is completed @@ -314,6 +323,8 @@ def leave_one_out(learner, dataset, size=None): def learning_curve(learner, dataset, trials=10, sizes=None): + """Return a list of (training-set size, mean accuracy) pairs, obtained by + repeatedly cross-validating the learner on training sets of each given size.""" if sizes is None: sizes = list(range(2, len(dataset.examples) - trials, 2)) @@ -365,6 +376,7 @@ def add(self, val, subtree): self.branches[val] = subtree def display(self, indent=0): + """Print this subtree, showing the tested attribute and each branch, indented by ``indent``.""" name = self.attr_name print('Test', name) for (val, subtree) in self.branches.items(): @@ -385,6 +397,7 @@ def __call__(self, example): return self.result def display(self): + """Print the result stored at this leaf.""" print('RESULT =', self.result) def __repr__(self): @@ -392,7 +405,9 @@ def __repr__(self): def DecisionTreeLearner(dataset): - """[Figure 18.5]""" + """[Figure 18.5] Learn a decision tree by recursively splitting the examples on + the attribute with the highest information gain, until each branch is pure (or + no attributes remain, falling back to the plurality class).""" target, values = dataset.target, dataset.values @@ -454,38 +469,57 @@ def information_content(values): return sum(-p * np.log2(p) for p in probabilities) -def DecisionListLearner(dataset): +def DecisionListLearner(dataset, max_test_size=None): """ [Figure 18.11] - A decision list implemented as a list of (test, value) pairs. + A decision list is a list of (test, outcome) pairs, where a test is a + conjunction of (attribute, value) literals. An example is classified by the + outcome of the first test it satisfies. Learning repeatedly finds a test that + selects a non-empty subset of the remaining examples that all share a single + outcome, appends (test, outcome), and removes those examples (Figure 18.11). + Works on datasets with discrete attribute values. """ + attrs = dataset.inputs + target = dataset.target + # the largest conjunction tried; the default lets a test pin down a single + # example, so a consistent list exists whenever the data is not contradictory + max_size = max_test_size or len(attrs) - def decision_list_learning(examples): - if not examples: - return [(True, False)] - t, o, examples_t = find_examples(examples) - if not t: - raise Exception - return [(t, o)] + decision_list_learning(examples - examples_t) + def passes(example, test): + """Does the example satisfy every literal of the (conjunctive) test?""" + return all(example[attr] == val for attr, val in test) def find_examples(examples): - """ - Find a set of examples that all have the same outcome under - some test. Return a tuple of the test, outcome, and examples. - """ - raise NotImplementedError + """Find the smallest test selecting a non-empty subset of examples that + all share one outcome; return (test, outcome, matched_examples), or + (None, None, None) if no such test exists up to max_size literals.""" + literals = sorted({(attr, e[attr]) for e in examples for attr in attrs}, key=str) + for size in range(1, max_size + 1): + for test in itertools.combinations(literals, size): + # a test may constrain each attribute at most once + if len({attr for attr, _ in test}) != size: + continue + matched = [e for e in examples if passes(e, test)] + if matched and len({e[target] for e in matched}) == 1: + return test, matched[0][target], matched + return None, None, None - def passes(example, test): - """Does the example pass the test?""" - raise NotImplementedError + def decision_list_learning(examples): + if not examples: + return [((), None)] # catch-all: the empty test matches any example + test, outcome, matched = find_examples(examples) + if test is None: + raise ValueError('DecisionListLearner: examples are not separable ' + '(contradictory examples sharing identical attributes)') + return [(test, outcome)] + decision_list_learning([e for e in examples if e not in matched]) def predict(example): - """Predict the outcome for the first passing test.""" + """Return the outcome of the first test the example satisfies.""" for test, outcome in predict.decision_list: if passes(example, test): return outcome - predict.decision_list = decision_list_learning(set(dataset.examples)) + predict.decision_list = decision_list_learning(list(dataset.examples)) return predict @@ -495,8 +529,12 @@ def NearestNeighborLearner(dataset, k=1): def predict(example): """Find the k closest items, and have them vote for the best.""" - best = heapq.nsmallest(k, ((dataset.distance(e, example), e) for e in dataset.examples)) - return mode(e[dataset.target] for (d, e) in best) + # the enumerate() index is a tiebreaker so that equal-distance examples + # are never compared directly (examples may be numpy arrays, e.g. MNIST + # images, which are not orderable -> "truth value is ambiguous") + best = heapq.nsmallest(k, ((dataset.distance(e, example), i, e) + for i, e in enumerate(dataset.examples))) + return mode(e[dataset.target] for (d, i, e) in best) return predict @@ -511,8 +549,8 @@ def LinearLearner(dataset, learning_rate=0.01, epochs=100): examples = dataset.examples num_examples = len(examples) - # X transpose - X_col = [dataset.values[i] for i in idx_i] # vertical columns of X + # X transpose: the actual value of each input feature across the examples + X_col = [[example[i] for example in examples] for i in idx_i] # vertical columns of X # add dummy ones = [1 for _ in range(len(examples))] @@ -526,7 +564,7 @@ def LinearLearner(dataset, learning_rate=0.01, epochs=100): err = [] # pass over all examples for example in examples: - x = [1] + example + x = [1] + [example[i] for i in idx_i] y = np.dot(w, x) t = example[idx_t] err.append(t - y) @@ -536,7 +574,7 @@ def LinearLearner(dataset, learning_rate=0.01, epochs=100): w[i] = w[i] + learning_rate * (np.dot(err, X_col[i]) / num_examples) def predict(example): - x = [1] + example + x = [1] + [example[i] for i in idx_i] return np.dot(w, x) return predict @@ -552,8 +590,8 @@ def LogisticLinearLeaner(dataset, learning_rate=0.01, epochs=100): examples = dataset.examples num_examples = len(examples) - # X transpose - X_col = [dataset.values[i] for i in idx_i] # vertical columns of X + # X transpose: the actual value of each input feature across the examples + X_col = [[example[i] for example in examples] for i in idx_i] # vertical columns of X # add dummy ones = [1 for _ in range(len(examples))] @@ -568,7 +606,7 @@ def LogisticLinearLeaner(dataset, learning_rate=0.01, epochs=100): h = [] # pass over all examples for example in examples: - x = [1] + example + x = [1] + [example[i] for i in idx_i] y = sigmoid(np.dot(w, x)) h.append(sigmoid_derivative(y)) t = example[idx_t] @@ -580,7 +618,7 @@ def LogisticLinearLeaner(dataset, learning_rate=0.01, epochs=100): w[i] = w[i] + learning_rate * (np.dot(buffer, X_col[i]) / num_examples) def predict(example): - x = [1] + example + x = [1] + [example[i] for i in idx_i] return sigmoid(np.dot(w, x)) return predict @@ -785,6 +823,12 @@ def network(input_units, hidden_layer_sizes, output_units, activation=sigmoid): def init_examples(examples, idx_i, idx_t, o_units): + """Split examples into input and target dicts keyed by example index. + + Inputs are read from the attribute positions in ``idx_i`` and targets from + position ``idx_t``. When ``o_units`` > 1 each target is one-hot encoded over + ``o_units`` units, otherwise it is wrapped in a single-element list. Returns + the pair (inputs, targets).""" inputs, targets = {}, {} for i, e in enumerate(examples): @@ -804,10 +848,13 @@ def init_examples(examples, idx_i, idx_t, o_units): def find_max_node(nodes): + """Return the index of the node with the greatest ``value`` attribute.""" return nodes.index(max(nodes, key=lambda node: node.value)) class SVC: + """Support Vector Classifier trained in dual form by solving a quadratic + programming problem; supports arbitrary kernels and a soft-margin penalty ``C``.""" def __init__(self, kernel=linear_kernel, C=1.0, verbose=False): self.kernel = kernel @@ -854,6 +901,10 @@ def solve_qp(self, X, y): ub = np.ones(m) * self.C # upper bounds A = y.astype(np.float64) # equality matrix b = np.zeros(1) # equality vector + # imported lazily: SVM training is the only thing that needs the optional + # qpsolvers/cvxopt dependency, so the rest of the module (trees, kNN, naive + # Bayes, neural nets, ...) stays importable without it + from qpsolvers import solve_qp self.alphas = solve_qp(P, q, A=A, b=b, lb=lb, ub=ub, solver='cvxopt', sym_proj=True, verbose=self.verbose) @@ -873,6 +924,8 @@ def predict(self, X): class SVR: + """Support Vector Regressor trained in dual form by solving a quadratic + programming problem, using an epsilon-insensitive loss and penalty ``C``.""" def __init__(self, kernel=linear_kernel, C=1.0, epsilon=0.1, verbose=False): self.kernel = kernel @@ -926,18 +979,22 @@ def solve_qp(self, X, y): ub = np.ones(2 * m) * self.C # upper bounds A = np.hstack((np.ones(m), -np.ones(m))) # equality matrix b = np.zeros(1) # equality vector + from qpsolvers import solve_qp # lazy: see SVC.solve_qp above alphas = solve_qp(P, q, A=A, b=b, lb=lb, ub=ub, solver='cvxopt', sym_proj=True, verbose=self.verbose) self.alphas_p = alphas[:m] self.alphas_n = alphas[m:] def predict(self, X): + """Predict the regression target value(s) for the samples ``X``.""" if self.kernel != linear_kernel: return np.dot(self.alphas_p - self.alphas_n, self.kernel(self.sv, X)) + self.b return np.dot(X, self.w) + self.b class MultiClassLearner: + """Wrap a binary classifier ``clf`` to handle multiple classes, using either + the one-vs-rest ('ovr') or one-vs-one ('ovo') decision function.""" def __init__(self, clf, decision_function='ovr'): self.clf = clf @@ -1017,7 +1074,9 @@ def predict(example): def ada_boost(dataset, L, K): - """[Figure 18.34]""" + """[Figure 18.34] AdaBoost: train ``K`` hypotheses with the weighted learner + ``L``, increasing the weight of misclassified examples after each round, and + return their weighted-majority ensemble.""" examples, target = dataset.examples, dataset.target n = len(examples) @@ -1123,25 +1182,32 @@ def weighted_replicate(seq, weights, n): # metrics def accuracy_score(y_pred, y_true): + """Return the fraction of predictions in ``y_pred`` that match ``y_true``.""" assert y_pred.shape == y_true.shape - return np.mean(np.equal(y_pred, y_true)) + return np.mean(y_pred == y_true) def r2_score(y_pred, y_true): + """Return the R^2 (coefficient of determination) of ``y_pred`` against ``y_true``.""" assert y_pred.shape == y_true.shape return 1. - (np.sum(np.square(y_pred - y_true)) / # sum of square of residuals np.sum(np.square(y_true - np.mean(y_true)))) # total sum of squares # datasets +# Loaded from the aima-data directory at import time. Guard against it being +# absent (e.g. in a browser/Pyodide environment) so the rest of the module still +# imports; these names are None when the data directory is unavailable. +try: + orings = DataSet(name='orings', target='Distressed', attr_names='Rings Distressed Temp Pressure Flightnum') -orings = DataSet(name='orings', target='Distressed', attr_names='Rings Distressed Temp Pressure Flightnum') - -zoo = DataSet(name='zoo', target='type', exclude=['name'], - attr_names='name hair feathers eggs milk airborne aquatic predator toothed backbone ' - 'breathes venomous fins legs tail domestic catsize type') + zoo = DataSet(name='zoo', target='type', exclude=['name'], + attr_names='name hair feathers eggs milk airborne aquatic predator toothed backbone ' + 'breathes venomous fins legs tail domestic catsize type') -iris = DataSet(name='iris', target='class', attr_names='sepal-len sepal-width petal-len petal-width class') + iris = DataSet(name='iris', target='class', attr_names='sepal-len sepal-width petal-len petal-width class') +except FileNotFoundError: + orings = zoo = iris = None def RestaurantDataSet(examples=None): @@ -1153,10 +1219,15 @@ def RestaurantDataSet(examples=None): attr_names='Alternate Bar Fri/Sat Hungry Patrons Price Raining Reservation Type WaitEstimate Wait') -restaurant = RestaurantDataSet() +try: + restaurant = RestaurantDataSet() +except FileNotFoundError: + restaurant = None def T(attr_name, branches): + """Build a DecisionFork testing the restaurant attribute ``attr_name``, wrapping each + non-fork child in a DecisionLeaf; a shorthand for writing decision trees by hand.""" branches = {value: (child if isinstance(child, DecisionFork) else DecisionLeaf(child)) for value, child in branches.items()} return DecisionFork(restaurant.attr_num(attr_name), attr_name, print, branches) @@ -1167,7 +1238,7 @@ def T(attr_name, branches): A decision tree for deciding whether to wait for a table at a hotel. """ -waiting_decision_tree = T('Patrons', +waiting_decision_tree = None if restaurant is None else T('Patrons', {'None': 'No', 'Some': 'Yes', 'Full': T('WaitEstimate', {'>60': 'No', '0-10': 'Yes', @@ -1237,6 +1308,119 @@ def ContinuousXor(n): return DataSet(name='continuous xor', examples=examples) +def gaussian_mixture_em(dataset, k, epsilon=1e-4, max_iterations=100): + """ + [Section 20.3] + Unsupervised clustering with the Expectation-Maximization (EM) algorithm, + fitting a mixture of k Gaussians to 'dataset' (a sequence of points). Each + iteration performs two steps:: + + E-step: compute the responsibilities p_ij = P(C=i | x_j), the posterior + probability that point x_j was generated by component i, which by + Bayes' rule is proportional to P(x_j | C=i) * P(C=i). + M-step: re-estimate the weight, mean and covariance of each component as + the responsibility-weighted statistics of the whole data set. + + EM is guaranteed to increase the data log likelihood at each iteration; it is + iterated until that improvement falls below 'epsilon' or 'max_iterations' is + reached. Returns a dict with the fitted 'weights', 'means', 'covariances' and + the final 'responsibilities'. + """ + X = np.asarray(dataset, dtype=float) + n, d = X.shape + + def multivariate_gaussian(points, mean, cov): + """Density of N(mean, cov) evaluated at each row of 'points'.""" + diff = points - mean + return (np.exp(-0.5 * np.sum(diff @ np.linalg.inv(cov) * diff, axis=1)) / + np.sqrt((2 * np.pi) ** d * np.linalg.det(cov))) + + # initialize: uniform weights, means at k distinct random data points and + # covariances at the sample covariance of the whole data set + weights = np.full(k, 1 / k) + means = X[np.random.choice(n, k, replace=False)] + covariances = np.array([np.cov(X, rowvar=False) for _ in range(k)]) + + log_likelihood = -np.inf + responsibilities = np.zeros((n, k)) + for _ in range(max_iterations): + # E-step: p_ij = alpha * P(x_j | C=i) * P(C=i) + for i in range(k): + responsibilities[:, i] = weights[i] * multivariate_gaussian(X, means[i], covariances[i]) + point_likelihoods = responsibilities.sum(axis=1) + responsibilities /= point_likelihoods[:, np.newaxis] + + # M-step: refit each component to the responsibility-weighted data + counts = responsibilities.sum(axis=0) + weights = counts / n + means = (responsibilities.T @ X) / counts[:, np.newaxis] + for i in range(k): + diff = X - means[i] + # regularize the covariance to avoid the degenerate zero-variance maximum + covariances[i] = (responsibilities[:, i] * diff.T) @ diff / counts[i] + 1e-6 * np.eye(d) + + # stop once the data log likelihood stops improving appreciably + new_log_likelihood = np.sum(np.log(point_likelihoods)) + if abs(new_log_likelihood - log_likelihood) < epsilon: + break + log_likelihood = new_log_likelihood + + return {'weights': weights, 'means': means, 'covariances': covariances, + 'responsibilities': responsibilities} + + +def naive_bayes_em(dataset, k, epsilon=1e-4, max_iterations=100): + """ + [Section 20.3] + Learn the parameters of a Bayes net with a hidden variable via EM: a naive + Bayes model with a hidden k-valued class (the 'bags of candy' example of + Section 20.3.2). 'dataset' is a sequence of binary feature vectors; given the + unobserved class, the features are independent Bernoulli variables. Each + iteration performs two steps:: + + E-step: responsibilities r_ji = P(class=i | x_j), which by Bayes' rule and + conditional independence is proportional to + P(class=i) * prod_f P(x_jf | class=i). + M-step: re-estimate the class priors and every conditional probability + P(feature_f = 1 | class=i) as the responsibility-weighted counts. + + Returns a dict with the learned class 'weights', the 'probabilities' matrix + (k x d, entry [i][f] = P(feature f = 1 | class i)) and the final + 'responsibilities'. + """ + X = np.asarray(dataset, dtype=float) + n, d = X.shape + + # initialize: uniform priors and random conditionals (a symmetric init is a + # fixed point of EM, so the components must start out distinct) + weights = np.full(k, 1 / k) + theta = np.random.uniform(0.25, 0.75, size=(k, d)) + + log_likelihood = -np.inf + responsibilities = np.zeros((n, k)) + for _ in range(max_iterations): + # E-step: r_ji = alpha * P(class=i) * prod_f theta_if^x_jf (1-theta_if)^(1-x_jf) + for i in range(k): + responsibilities[:, i] = weights[i] * np.prod(theta[i] ** X * (1 - theta[i]) ** (1 - X), axis=1) + point_likelihoods = responsibilities.sum(axis=1) + responsibilities /= point_likelihoods[:, np.newaxis] + + # M-step: priors and conditional probabilities from the expected counts + counts = responsibilities.sum(axis=0) + weights = counts / n + theta = (responsibilities.T @ X) / counts[:, np.newaxis] + # keep the probabilities inside (0, 1) to avoid 0/0 in the next E-step + theta = np.clip(theta, 1e-9, 1 - 1e-9) + + # stop once the data log likelihood stops improving appreciably + new_log_likelihood = np.sum(np.log(point_likelihoods)) + if abs(new_log_likelihood - log_likelihood) < epsilon: + break + log_likelihood = new_log_likelihood + + return {'weights': weights, 'probabilities': theta, 'responsibilities': responsibilities} + + def compare(algorithms=None, datasets=None, k=10, trials=1): """ Compare various learners on various datasets using cross-validation. diff --git a/logic.py b/aima/logic.py similarity index 85% rename from logic.py rename to aima/logic.py index 1624d55a5..700e247bc 100644 --- a/logic.py +++ b/aima/logic.py @@ -11,8 +11,8 @@ Be careful: some functions take an Expr as argument, and some take a KB. -Logical expressions can be created with Expr or expr, imported from utils, TODO -or with expr, which adds the capability to write a string that uses +Logical expressions can be created with Expr, imported from utils, or with expr, +which adds the capability to write a string that uses the connectives ==>, <==, <=>, or <=/=>. But be careful: these have the operator precedence of commas; you may need to add parens to make precedence work. See logic.ipynb for examples. @@ -39,10 +39,10 @@ import networkx as nx -from agents import Agent, Glitter, Bump, Stench, Breeze, Scream -from csp import parse_neighbors, UniversalDict -from search import astar_search, PlanRoute -from utils import remove_all, unique, first, probability, isnumber, issequence, Expr, expr, subexpressions, extend +from aima.agents import Agent, Glitter, Bump, Stench, Breeze, Scream +from aima.csp import parse_neighbors, UniversalDict +from aima.search import astar_search, PlanRoute +from aima.utils import remove_all, unique, first, probability, isnumber, issequence, Expr, expr, subexpressions, extend class KB: @@ -159,6 +159,20 @@ def is_prop_symbol(s): return is_symbol(s) and s[0].isupper() +def gensym(prefix='s_'): + """Return a fresh logic symbol with a unique name, like Lisp's ``gensym`` [#230]. + + Successive calls return distinct symbols ``s_0``, ``s_1``, ``s_2``, ... -- handy + for building an array or dict of symbols, or for standardizing apart. Pass a + ``prefix`` to control the name stem. + """ + gensym.counter += 1 + return Expr(prefix + str(gensym.counter)) + + +gensym.counter = -1 + + def variables(s): """Return a set of the variables in expression s. >>> variables(expr('F(x, x) & G(x, y) & H(y, z) & R(A, z, 2)')) == {x, y, z} @@ -346,7 +360,7 @@ def to_cnf(s): def eliminate_implications(s): - """Change implications into equivalent form with only &, |, and ~ as logical operators.""" + r"""Change implications into equivalent form with only &, \|, and ~ as logical operators.""" s = expr(s) if not s.args or is_symbol(s.op): return s # Atoms are unchanged. @@ -419,9 +433,10 @@ def distribute_and_over_or(s): def associate(op, args): - """Given an associative op, return an expression with the same - meaning as Expr(op, *args), but flattened -- that is, with nested + r"""Given an associative op, return an expression with the same + meaning as Expr(op, \*args), but flattened -- that is, with nested instances of the same op promoted to the top level. + >>> associate('&', [(A&B),(B|C),(B&C)]) (A & B & (B | C) & B & C) >>> associate('|', [A|(B|(C|(A&B)))]) @@ -440,8 +455,9 @@ def associate(op, args): def dissociate(op, args): - """Given an associative op, return a flattened list result such - that Expr(op, *result) means the same as Expr(op, *args). + r"""Given an associative op, return a flattened list result such + that Expr(op, \*result) means the same as Expr(op, \*args). + >>> dissociate('&', [A & B]) [A, B] """ @@ -533,6 +549,7 @@ def ask_generator(self, query): yield {} def retract(self, sentence): + """Remove the given definite clause from this KB.""" self.clauses.remove(sentence) def clauses_with_premise(self, p): @@ -602,10 +619,12 @@ def pl_fc_entails(kb, q): def no_branching_heuristic(symbols, clauses): + """Default DPLL branching rule: pick the first remaining symbol and try True.""" return first(symbols), True def min_clauses(clauses): + """Return the clauses of minimum size (treating units as size 2 for MOMS-style heuristics).""" min_len = min(map(lambda c: len(c.args), clauses), default=2) return filter(lambda c: len(c.args) == (min_len if min_len > 1 else 2), clauses) @@ -677,9 +696,9 @@ def dlcs(symbols, clauses): def jw(symbols, clauses): - """ + r""" Jeroslow-Wang heuristic - For each literal compute J(l) = \sum{l in clause c} 2^{-|c|} + For each literal compute J(l) = \sum{l in clause c} 2^{-\|c\|} Return the literal maximizing J """ scores = Counter() @@ -713,6 +732,7 @@ def dpll_satisfiable(s, branching_heuristic=no_branching_heuristic): rather than True when it succeeds; this is more useful. (2) The function find_pure_symbol is passed a list of unknown clauses, rather than a list of all clauses and the model; this is more efficient. + >>> dpll_satisfiable(A |'<=>'| B) == {A: True, B: True} True """ @@ -720,7 +740,10 @@ def dpll_satisfiable(s, branching_heuristic=no_branching_heuristic): def dpll(clauses, symbols, model, branching_heuristic=no_branching_heuristic): - """See if the clauses are true in a partial model.""" + """[Figure 7.17] The Davis-Putnam-Logemann-Loveland procedure: decide whether + ``clauses`` are satisfiable by depth-first assignment of ``symbols``, accelerated + by the pure-symbol and unit-clause heuristics (plus an optional branching + heuristic). Returns the satisfying model, or False.""" unknown_clauses = [] # clauses with an unknown truth value for c in clauses: val = pl_true(c, model) @@ -815,10 +838,12 @@ def inspect_literal(literal): def no_restart(conflicts, restarts, queue_lbd, sum_lbd): + """Default CDCL restart policy: never restart.""" return False def luby(conflicts, restarts, queue_lbd, sum_lbd, unit=512): + """Restart policy based on the Luby sequence (restart after ``unit * luby(restarts)`` conflicts).""" # in the state-of-art tested with unit value 1, 2, 4, 6, 8, 12, 16, 32, 64, 128, 256 and 512 def _luby(i): k = 1 @@ -833,6 +858,7 @@ def _luby(i): def glucose(conflicts, restarts, queue_lbd, sum_lbd, x=100, k=0.7): + """Glucose-style restart policy based on recent vs. global average literal block distance (LBD).""" # in the state-of-art tested with (x, k) as (50, 0.8) and (100, 0.7) # if there were at least x conflicts since the last restart, and then the average LBD of the last # x learnt clauses was at least k times higher than the average LBD of all learnt clauses @@ -880,6 +906,8 @@ def cdcl_satisfiable(s, vsids_decay=0.95, restart_strategy=no_restart): def assign_decision_literal(symbols, model, scores, G, dl): + """Pick the unassigned symbol with the highest VSIDS score, assign it at decision level ``dl``, + and record it as a decision node in the implication graph ``G``.""" P = max(symbols, key=lambda symbol: scores[symbol] + scores[~symbol]) value = True if scores[P] >= scores[~P] else False symbols.remove(P) @@ -888,6 +916,12 @@ def assign_decision_literal(symbols, model, scores, G, dl): def unit_propagation(clauses, symbols, model, G, dl): + """Boolean constraint propagation over the two-watched-literal database. + + Repeatedly assigns forced literals from unit clauses (extending ``model`` and the implication + graph ``G``) until a fixpoint is reached. Returns True if a conflict clause is found, else False. + """ + def check(c): if not model or clauses.get_first_watched(c) == clauses.get_second_watched(c): return True @@ -948,6 +982,12 @@ def conflict_clause(c): def conflict_analysis(G, dl): + """Analyse a conflict in the implication graph ``G`` using the first-UIP scheme. + + Resolves the conflict clause back to the first unique implication point and returns a tuple + ``(backjump_level, learnt_clause, lbd)`` where ``lbd`` is the learnt clause's literal block + distance. + """ conflict_clause = next(G[p]['K']['antecedent'] for p in G.pred['K']) P = next(node for node in G.nodes() - 'K' if G.nodes[node]['dl'] == dl and G.in_degree(node) == 0) first_uip = nx.immediate_dominators(G, P)['K'] @@ -965,6 +1005,7 @@ def conflict_analysis(G, dl): def pl_binary_resolution(ci, cj): + """Resolve two clauses on a complementary pair of literals and return the resolvent.""" for di in disjuncts(ci): for dj in disjuncts(cj): if di == ~dj or ~di == dj: @@ -974,6 +1015,7 @@ def pl_binary_resolution(ci, cj): def backjump(symbols, model, G, dl=0): + """Undo all assignments made above decision level ``dl``, restoring those symbols as unassigned.""" delete = {node for node in G.nodes() if G.nodes[node]['dl'] > dl} G.remove_nodes_from(delete) for node in delete: @@ -982,6 +1024,11 @@ def backjump(symbols, model, G, dl=0): class TwoWLClauseDatabase: + """Clause database using the two-watched-literal lazy data structure for fast unit propagation. + + Maintains, for each clause, two watched literals and, for each literal, the set of clauses in + which it is watched (split into positive and negative occurrences). + """ def __init__(self, clauses): self.__twl = {} @@ -990,17 +1037,21 @@ def __init__(self, clauses): self.add(c, None) def get_clauses(self): + """Return a view of all clauses currently stored in the database.""" return self.__twl.keys() def set_first_watched(self, clause, new_watching): + """Set the first watched literal of ``clause`` (no-op for clauses of length <= 2).""" if len(clause.args) > 2: self.__twl[clause][0] = new_watching def set_second_watched(self, clause, new_watching): + """Set the second watched literal of ``clause`` (no-op for clauses of length <= 2).""" if len(clause.args) > 2: self.__twl[clause][1] = new_watching def get_first_watched(self, clause): + """Return the first watched literal of ``clause``.""" if len(clause.args) == 2: return clause.args[0] if len(clause.args) > 2: @@ -1008,6 +1059,7 @@ def get_first_watched(self, clause): return clause def get_second_watched(self, clause): + """Return the second watched literal of ``clause``.""" if len(clause.args) == 2: return clause.args[-1] if len(clause.args) > 2: @@ -1015,12 +1067,15 @@ def get_second_watched(self, clause): return clause def get_pos_watched(self, l): + """Return the set of clauses in which symbol ``l`` is watched positively.""" return self.__watch_list[l][0] def get_neg_watched(self, l): + """Return the set of clauses in which symbol ``l`` is watched negatively.""" return self.__watch_list[l][1] def add(self, clause, model): + """Add ``clause`` to the database, choosing its two watched literals given ``model``.""" self.__twl[clause] = self.__assign_watching_literals(clause, model) w1, p1 = inspect_literal(self.get_first_watched(clause)) w2, p2 = inspect_literal(self.get_second_watched(clause)) @@ -1029,6 +1084,7 @@ def add(self, clause, model): self.__watch_list[w2][0].add(clause) if p2 else self.__watch_list[w2][1].add(clause) def remove(self, clause): + """Remove ``clause`` from the database and from its watched literals' watch lists.""" w1, p1 = inspect_literal(self.get_first_watched(clause)) w2, p2 = inspect_literal(self.get_second_watched(clause)) del self.__twl[clause] @@ -1037,6 +1093,10 @@ def remove(self, clause): self.__watch_list[w2][0].discard(clause) if p2 else self.__watch_list[w2][1].discard(clause) def update_first_watched(self, clause, model): + """Try to replace the first watched literal of ``clause`` with another non-falsified literal. + + Returns True if a new watch was installed, otherwise None (the clause stays as is). + """ # if a non-zero literal different from the other watched literal is found found, new_watching = self.__find_new_watching_literal(clause, self.get_first_watched(clause), model) if found: # then it will replace the watched literal @@ -1048,6 +1108,10 @@ def update_first_watched(self, clause, model): return True def update_second_watched(self, clause, model): + """Try to replace the second watched literal of ``clause`` with another non-falsified literal. + + Returns True if a new watch was installed, otherwise None (the clause stays as is). + """ # if a non-zero literal different from the other watched literal is found found, new_watching = self.__find_new_watching_literal(clause, self.get_second_watched(clause), model) if found: # then it will replace the watched literal @@ -1168,86 +1232,107 @@ def MapColoringSAT(colors, neighbors): # Expr functions for WumpusKB and HybridWumpusAgent def facing_east(time): + """Proposition: the agent is facing east at the given time step.""" return Expr('FacingEast', time) def facing_west(time): + """Proposition: the agent is facing west at the given time step.""" return Expr('FacingWest', time) def facing_north(time): + """Proposition: the agent is facing north at the given time step.""" return Expr('FacingNorth', time) def facing_south(time): + """Proposition: the agent is facing south at the given time step.""" return Expr('FacingSouth', time) def wumpus(x, y): + """Proposition: there is a wumpus in square (x, y).""" return Expr('W', x, y) def pit(x, y): + """Proposition: there is a pit in square (x, y).""" return Expr('P', x, y) def breeze(x, y): + """Proposition: there is a breeze in square (x, y).""" return Expr('B', x, y) def stench(x, y): + """Proposition: there is a stench in square (x, y).""" return Expr('S', x, y) def wumpus_alive(time): + """Proposition: the wumpus is alive at the given time step.""" return Expr('WumpusAlive', time) def have_arrow(time): + """Proposition: the agent still has its arrow at the given time step.""" return Expr('HaveArrow', time) def percept_stench(time): + """Proposition: the agent perceives a stench at the given time step.""" return Expr('Stench', time) def percept_breeze(time): + """Proposition: the agent perceives a breeze at the given time step.""" return Expr('Breeze', time) def percept_glitter(time): + """Proposition: the agent perceives glitter at the given time step.""" return Expr('Glitter', time) def percept_bump(time): + """Proposition: the agent perceives a bump at the given time step.""" return Expr('Bump', time) def percept_scream(time): + """Proposition: the agent perceives a scream at the given time step.""" return Expr('Scream', time) def move_forward(time): + """Action proposition: the agent moves forward at the given time step.""" return Expr('Forward', time) def shoot(time): + """Action proposition: the agent shoots its arrow at the given time step.""" return Expr('Shoot', time) def turn_left(time): + """Action proposition: the agent turns left at the given time step.""" return Expr('TurnLeft', time) def turn_right(time): + """Action proposition: the agent turns right at the given time step.""" return Expr('TurnRight', time) def ok_to_move(x, y, time): + """Proposition: square (x, y) is safe to move into at the given time step.""" return Expr('OK', x, y, time) def location(x, y, time=None): + """Proposition for the agent being at square (x, y), optionally at a given time step.""" if time is None: return Expr('L', x, y) else: @@ -1257,16 +1342,19 @@ def location(x, y, time=None): # Symbols def implies(lhs, rhs): + """Build the implication ``lhs ==> rhs`` as an Expr.""" return Expr('==>', lhs, rhs) def equiv(lhs, rhs): + """Build the biconditional ``lhs <=> rhs`` as an Expr.""" return Expr('<=>', lhs, rhs) # Helper Function def new_disjunction(sentences): + """Combine a list of sentences into a single disjunction (their logical OR).""" t = sentences[0] for i in range(1, len(sentences)): t |= sentences[i] @@ -1334,6 +1422,10 @@ def __init__(self, dimrow): for j in range(1, dimrow + 1): self.tell(implies(location(i, j, 0), equiv(percept_breeze(0), breeze(i, j)))) self.tell(implies(location(i, j, 0), equiv(percept_stench(0), stench(i, j)))) + # a square is safe to move into at time 0 iff it has no pit, no + # (live) wumpus -- without this the agent cannot even prove [1,1] + # safe on its first move (the rule was only added for time > 0) + self.tell(equiv(ok_to_move(i, j, 0), ~pit(i, j) & ~wumpus(i, j) & wumpus_alive(0))) if i != 1 or j != 1: self.tell(~location(i, j, 0)) @@ -1345,6 +1437,7 @@ def __init__(self, dimrow): self.tell(~facing_west(0)) def make_action_sentence(self, action, time): + """Tell the KB that ``action`` was taken at ``time`` and that no other action was.""" actions = [move_forward(time), shoot(time), turn_left(time), turn_right(time)] for a in actions: @@ -1354,41 +1447,32 @@ def make_action_sentence(self, action, time): self.tell(~a) def make_percept_sentence(self, percept, time): - # Glitter, Bump, Stench, Breeze, Scream - flags = [0, 0, 0, 0, 0] - - # Things perceived - if isinstance(percept, Glitter): - flags[0] = 1 - self.tell(percept_glitter(time)) - elif isinstance(percept, Bump): - flags[1] = 1 - self.tell(percept_bump(time)) - elif isinstance(percept, Stench): - flags[2] = 1 - self.tell(percept_stench(time)) - elif isinstance(percept, Breeze): - flags[3] = 1 - self.tell(percept_breeze(time)) - elif isinstance(percept, Scream): - flags[4] = 1 - self.tell(percept_scream(time)) - - # Things not perceived - for i in range(len(flags)): - if flags[i] == 0: - if i == 0: - self.tell(~percept_glitter(time)) - elif i == 1: - self.tell(~percept_bump(time)) - elif i == 2: - self.tell(~percept_stench(time)) - elif i == 3: - self.tell(~percept_breeze(time)) - elif i == 4: - self.tell(~percept_scream(time)) + """Tell the KB which of the five percepts hold (and which do not) at ``time``. + + A single square can yield several percepts at once (e.g. a Stench *and* a + Breeze), so ``percept`` may be one percept object or a collection of them + (the format produced by ``WumpusEnvironment.percept``); ``None`` means + nothing was perceived. + """ + if percept is None: + percepts = [] + elif isinstance(percept, (list, tuple, set)): + percepts = list(percept) + else: + percepts = [percept] + + # for each of the five percepts, assert whether it holds at this time + for cls, sentence in ((Glitter, percept_glitter), (Bump, percept_bump), + (Stench, percept_stench), (Breeze, percept_breeze), + (Scream, percept_scream)): + if any(isinstance(p, cls) for p in percepts): + self.tell(sentence(time)) + else: + self.tell(~sentence(time)) def add_temporal_sentences(self, time): + """Add the successor-state axioms relating ``time`` to ``time - 1`` (location, orientation, + arrow possession and wumpus status). Does nothing for ``time == 0``.""" if time == 0: return t = time - 1 @@ -1441,8 +1525,9 @@ def add_temporal_sentences(self, time): s = equiv(facing_south(time), a | b | c) self.tell(s) - # Rules about last action - self.tell(equiv(move_forward(t), ~turn_right(t) & ~turn_left(t))) + # Rules about last action: moving forward implies the agent did not turn + # (the converse does not hold -- the agent may instead grab, shoot or climb) + self.tell(implies(move_forward(t), ~turn_right(t) & ~turn_left(t))) # Rule about the arrow self.tell(equiv(have_arrow(time), have_arrow(t) & ~shoot(t))) @@ -1451,29 +1536,39 @@ def add_temporal_sentences(self, time): self.tell(equiv(wumpus_alive(time), wumpus_alive(t) & ~percept_scream(time))) def ask_if_true(self, query): - return pl_resolution(self, query) + """Return True if the KB entails ``query``, decided via a SAT check on ``KB & ~query``.""" + # the KB entails the query iff KB & ~query is unsatisfiable; using a SAT + # solver here instead of pl_resolution keeps inference tractable on the + # large wumpus clause set (full resolution closure does not terminate) + return not dpll_satisfiable(associate('&', self.clauses + conjuncts(to_cnf(~query)))) # ______________________________________________________________________________ class WumpusPosition: + """A pose in the wumpus world: a square (x, y) together with an orientation.""" + def __init__(self, x, y, orientation): self.X = x self.Y = y self.orientation = orientation def get_location(self): + """Return the (x, y) coordinates of this position.""" return self.X, self.Y def set_location(self, x, y): + """Set the (x, y) coordinates of this position.""" self.X = x self.Y = y def get_orientation(self): + """Return the orientation of this position.""" return self.orientation def set_orientation(self, orientation): + """Set the orientation of this position.""" self.orientation = orientation def __eq__(self, other): @@ -1482,6 +1577,9 @@ def __eq__(self, other): else: return False + def __hash__(self): + return hash((self.X, self.Y, self.orientation)) + # ______________________________________________________________________________ @@ -1492,8 +1590,8 @@ class HybridWumpusAgent(Agent): An agent for the wumpus world that does logical inference. """ - def __init__(self, dimentions): - self.dimrow = dimentions + def __init__(self, dimensions): + self.dimrow = dimensions self.kb = WumpusKB(self.dimrow) self.t = 0 self.plan = list() @@ -1501,6 +1599,12 @@ def __init__(self, dimentions): super().__init__(self.execute) def execute(self, percept): + """Update the KB with ``percept``, infer the current state, and return the next action. + + Implements the agent program: it deduces the current pose and safe squares, then either + grabs the gold, explores unvisited safe squares, shoots at a possible wumpus, takes a + calculated risk, or heads home and climbs out. + """ self.kb.make_percept_sentence(percept, self.t) self.kb.add_temporal_sentences(self.t) @@ -1512,14 +1616,15 @@ def execute(self, percept): temp.append(i) temp.append(j) - if self.kb.ask_if_true(facing_north(self.t)): - self.current_position = WumpusPosition(temp[0], temp[1], 'UP') - elif self.kb.ask_if_true(facing_south(self.t)): - self.current_position = WumpusPosition(temp[0], temp[1], 'DOWN') - elif self.kb.ask_if_true(facing_west(self.t)): - self.current_position = WumpusPosition(temp[0], temp[1], 'LEFT') - elif self.kb.ask_if_true(facing_east(self.t)): - self.current_position = WumpusPosition(temp[0], temp[1], 'RIGHT') + if len(temp) >= 2: # a location was entailed; otherwise keep the previous pose + if self.kb.ask_if_true(facing_north(self.t)): + self.current_position = WumpusPosition(temp[0], temp[1], 'UP') + elif self.kb.ask_if_true(facing_south(self.t)): + self.current_position = WumpusPosition(temp[0], temp[1], 'DOWN') + elif self.kb.ask_if_true(facing_west(self.t)): + self.current_position = WumpusPosition(temp[0], temp[1], 'LEFT') + elif self.kb.ask_if_true(facing_east(self.t)): + self.current_position = WumpusPosition(temp[0], temp[1], 'RIGHT') safe_points = list() for i in range(1, self.dimrow + 1): @@ -1536,17 +1641,13 @@ def execute(self, percept): self.plan.append('Climb') if len(self.plan) == 0: + # unvisited = squares the agent has never been located in (for all t' <= t) unvisited = list() for i in range(1, self.dimrow + 1): for j in range(1, self.dimrow + 1): - for k in range(self.t): - if self.kb.ask_if_true(location(i, j, k)): - unvisited.append([i, j]) - unvisited_and_safe = list() - for u in unvisited: - for s in safe_points: - if u not in unvisited_and_safe and s == u: - unvisited_and_safe.append(u) + if not any(self.kb.ask_if_true(location(i, j, k)) for k in range(self.t + 1)): + unvisited.append([i, j]) + unvisited_and_safe = [u for u in unvisited if u in safe_points] temp = self.plan_route(self.current_position, unvisited_and_safe, safe_points) self.plan.extend(temp) @@ -1555,7 +1656,7 @@ def execute(self, percept): possible_wumpus = list() for i in range(1, self.dimrow + 1): for j in range(1, self.dimrow + 1): - if not self.kb.ask_if_true(wumpus(i, j)): + if not self.kb.ask_if_true(~wumpus(i, j)): possible_wumpus.append([i, j]) temp = self.plan_shot(self.current_position, possible_wumpus, safe_points) @@ -1565,7 +1666,7 @@ def execute(self, percept): not_unsafe = list() for i in range(1, self.dimrow + 1): for j in range(1, self.dimrow + 1): - if not self.kb.ask_if_true(ok_to_move(i, j, self.t)): + if not self.kb.ask_if_true(~ok_to_move(i, j, self.t)): not_unsafe.append([i, j]) temp = self.plan_route(self.current_position, not_unsafe, safe_points) self.plan.extend(temp) @@ -1585,10 +1686,14 @@ def execute(self, percept): return action def plan_route(self, current, goals, allowed): + """Return an action sequence from ``current`` to any of ``goals`` through ``allowed`` squares, + found by A* search.""" problem = PlanRoute(current, goals, allowed, self.dimrow) - return astar_search(problem).solution() + node = astar_search(problem) + return node.solution() if node is not None else [] def plan_shot(self, current, goals, allowed): + """Return an action sequence that moves to a square lined up with a possible wumpus and shoots.""" shooting_positions = set() for loc in goals: @@ -1596,19 +1701,19 @@ def plan_shot(self, current, goals, allowed): y = loc[1] for i in range(1, self.dimrow + 1): if i < x: - shooting_positions.add(WumpusPosition(i, y, 'EAST')) + shooting_positions.add(WumpusPosition(i, y, 'RIGHT')) # face east toward the wumpus if i > x: - shooting_positions.add(WumpusPosition(i, y, 'WEST')) + shooting_positions.add(WumpusPosition(i, y, 'LEFT')) # face west if i < y: - shooting_positions.add(WumpusPosition(x, i, 'NORTH')) + shooting_positions.add(WumpusPosition(x, i, 'UP')) # face north if i > y: - shooting_positions.add(WumpusPosition(x, i, 'SOUTH')) + shooting_positions.add(WumpusPosition(x, i, 'DOWN')) # face south # Can't have a shooting position from any of the rooms the Wumpus could reside - orientations = ['EAST', 'WEST', 'NORTH', 'SOUTH'] + orientations = ['UP', 'DOWN', 'LEFT', 'RIGHT'] for loc in goals: for orientation in orientations: - shooting_positions.remove(WumpusPosition(loc[0], loc[1], orientation)) + shooting_positions.discard(WumpusPosition(loc[0], loc[1], orientation)) actions = list() actions.extend(self.plan_route(current, shooting_positions, allowed)) @@ -1743,6 +1848,11 @@ def is_variable(x): def unify_var(var, x, s): + """Unify the variable ``var`` with ``x`` under substitution ``s``. + + Follows existing bindings, applies the occur-check to avoid cyclic bindings, and returns the + extended substitution (or None if unification fails). + """ if var in s: return unify(s[var], x, s) elif x in s: @@ -1766,7 +1876,7 @@ def occur_check(var, x, s): return (occur_check(var, x.op, s) or occur_check(var, x.args, s)) elif isinstance(x, (list, tuple)): - return first(e for e in x if occur_check(var, e, s)) + return any(occur_check(var, e, s) for e in x) else: return False @@ -1937,18 +2047,22 @@ def __init__(self, clauses=None): self.tell(clause) def tell(self, sentence): + """Add a definite clause to the KB, raising an exception if it is not one.""" if is_definite_clause(sentence): self.clauses.append(sentence) else: raise Exception('Not a definite clause: {}'.format(sentence)) def ask_generator(self, query): + """Yield substitutions under which the KB entails ``query`` (via backward chaining).""" return fol_bc_ask(self, query) def retract(self, sentence): + """Remove the given clause from the KB.""" self.clauses.remove(sentence) def fetch_rules_for_goal(self, goal): + """Return the clauses that could be used to prove ``goal`` (here, all clauses).""" return self.clauses @@ -2001,6 +2115,10 @@ def fol_bc_ask(kb, query): def fol_bc_or(kb, goal, theta): + """Backward-chaining OR step: yield substitutions proving ``goal`` via some rule in ``kb``. + + See [Figure 9.6]. + """ for rule in kb.fetch_rules_for_goal(goal): lhs, rhs = parse_definite_clause(standardize_variables(rule)) for theta1 in fol_bc_and(kb, lhs, unify_mm(rhs, goal, theta)): @@ -2008,6 +2126,10 @@ def fol_bc_or(kb, goal, theta): def fol_bc_and(kb, goals, theta): + """Backward-chaining AND step: yield substitutions proving every goal in ``goals``. + + See [Figure 9.6]. + """ if theta is None: pass elif not goals: diff --git a/aima/making_simple_decisions.py b/aima/making_simple_decisions.py new file mode 100644 index 000000000..9a28037fe --- /dev/null +++ b/aima/making_simple_decisions.py @@ -0,0 +1,11 @@ +"""Making Simple Decisions (Chapter 15/16) + +The decision-theoretic algorithms — decision networks and the +information-gathering agent — are implemented in :mod:`aima.probability`, +alongside the Bayesian-network machinery they build on. They are re-exported +here under the chapter's name; :mod:`aima.probability` is their canonical home. +""" + +from aima.probability import DecisionNetwork, InformationGatheringAgent + +__all__ = ['DecisionNetwork', 'InformationGatheringAgent'] diff --git a/mdp.py b/aima/mdp.py similarity index 74% rename from mdp.py rename to aima/mdp.py index 1003e26b5..0884eb80d 100644 --- a/mdp.py +++ b/aima/mdp.py @@ -13,7 +13,7 @@ import numpy as np -from utils import vector_add, orientations, turn_right, turn_left +from aima.utils import vector_add, orientations, turn_right, turn_left class MDP: @@ -42,6 +42,10 @@ def __init__(self, init, actlist, terminals, transitions=None, reward=None, stat # if actlist is a dict, different actions for each state self.actlist = actlist + elif isinstance(actlist, set): + # if actlist is a set, convert it to a list so actions are indexable + self.actlist = list(actlist) + self.terminals = terminals self.transitions = transitions or {} if not self.transitions: @@ -78,6 +82,8 @@ def actions(self, state): return self.actlist def get_states_from_transitions(self, transitions): + """Return the set of all states mentioned in the transition model, both as source + states and as reachable next states (or None if transitions is not a dict).""" if isinstance(transitions, dict): s1 = set(transitions.keys()) s2 = set(tr[1] for actions in transitions.values() @@ -89,6 +95,9 @@ def get_states_from_transitions(self, transitions): return None def check_consistency(self): + """Assert that the MDP is well formed: the states match those in the transitions, + the initial and terminal states are valid, rewards are defined for every state, and + each action's outcome probabilities sum to 1.""" # check that all states in transitions are valid assert set(self.states) == self.get_states_from_transitions(self.transitions) @@ -120,6 +129,8 @@ def __init__(self, init, actlist, terminals, transitions, reward=None, gamma=0.9 MDP.__init__(self, init, actlist, terminals, transitions, reward, gamma=gamma) def T(self, state, action): + """Return a list of (probability, next-state) pairs for taking the action in the + given state; a None action stays in place with probability 0.0.""" if action is None: return [(0.0, state)] else: @@ -156,6 +167,8 @@ def __init__(self, grid, terminals, init=(0, 0), gamma=.9): reward=reward, states=states, gamma=gamma) def calculate_T(self, state, action): + """Return the (probability, next-state) outcomes for the intended action: 0.8 of + moving as intended and 0.1 each of veering to the right or left.""" if action: return [(0.8, self.go(state, action)), (0.1, self.go(state, turn_right(action))), @@ -164,6 +177,8 @@ def calculate_T(self, state, action): return [(0.0, state)] def T(self, state, action): + """Return the list of (probability, next-state) pairs for the action in the given + state; a falsy action stays in place with probability 0.0.""" return self.transitions[state][action] if action else [(0.0, state)] def go(self, state, direction): @@ -180,10 +195,30 @@ def to_grid(self, mapping): for y in range(self.rows)])) def to_arrows(self, policy): + """Render a policy as a grid of arrow characters (``>``, ``^``, ``<``, ``v``, with + ``.`` for a None action) laid out to match the grid.""" chars = {(1, 0): '>', (0, 1): '^', (-1, 0): '<', (0, -1): 'v', None: '.'} return self.to_grid({s: chars[a] for (s, a) in policy.items()}) +def gen_grid(n_rows=3, n_cols=4, terminals=((3, 2), (3, 1)), main_reward=-0.04, + terminal_rewards=(1, -1), block_coords=((1, 1),)): + """Generate a grid (list of lists of rewards) of arbitrary size in the format + accepted by GridMDP, e.g. GridMDP(gen_grid(...), terminals=[...]). + n_rows, n_cols: grid dimensions. + terminals: (x, y) coordinates of the terminal cells. + main_reward: reward for every non-terminal, non-blocked cell. + terminal_rewards: reward for each cell in terminals (paired by position). + block_coords: (x, y) coordinates of obstacles (set to None / unreachable).""" + grid = [[main_reward] * n_cols for _ in range(n_rows)] + for (x, y), reward in zip(terminals, terminal_rewards): + grid[y][x] = reward + for x, y in block_coords: + grid[y][x] = None + grid.reverse() # row 0 at the bottom, matching GridMDP's convention + return grid + + # ______________________________________________________________________________ @@ -200,6 +235,18 @@ def to_arrows(self, policy): # ______________________________________________________________________________ +def q_value(mdp, s, a, U): + """Return the Q-value of taking action ``a`` in state ``s`` given the utility estimates + ``U``, i.e. the expected sum of the immediate reward and the discounted utility of the + successor states. With no action it falls back to the reward of ``s``.""" + if not a: + return mdp.R(s) + res = 0 + for p, s_prime in mdp.T(s, a): + res += p * (mdp.R(s) + mdp.gamma * U[s_prime]) + return res + + def value_iteration(mdp, epsilon=0.001): """Solving an MDP by value iteration. [Figure 17.4]""" @@ -264,9 +311,9 @@ def policy_evaluation(pi, U, mdp, k=20): class POMDP(MDP): - """A Partially Observable Markov Decision Process, defined by - a transition model P(s'|s,a), actions A(s), a reward function R(s), - and a sensor model P(e|s). We also keep track of a gamma value, + r"""A Partially Observable Markov Decision Process, defined by + a transition model P(s'\|s,a), actions A(s), a reward function R(s), + and a sensor model P(e\|s). We also keep track of a gamma value, for use by algorithms. The transition and the sensor models are defined as matrices. We also keep track of the possible states and actions for each state. [Page 659].""" @@ -404,10 +451,10 @@ def multiply(A, B): """Multiply two matrices A and B element-wise""" matrix = [] - for i in range(len(B)): + for i in range(len(A)): row = [] - for j in range(len(B[0])): - row.append(B[i][j] * A[j][i]) + for j in range(len(A[0])): + row.append(A[i][j] * B[i][j]) matrix.append(row) return matrix @@ -456,6 +503,60 @@ def pomdp_value_iteration(pomdp, epsilon=0.1): return U +def update_belief(pomdp, belief, action, observation): + """ + [Equation 17.17] + POMDP filtering: update the belief state (a probability distribution over the + states) after executing 'action' and perceiving 'observation'. The prediction + step propagates the belief through the transition model and the update step + weights it by the sensor model, then renormalizes:: + + b'(s') = alpha * P(o | s') * sum_s P(s' | s, a) b(s) + """ + transition, sensor = pomdp.t_prob[int(action)], pomdp.e_prob[int(action)] + n = len(belief) + # prediction: b_pred(s') = sum_s b(s) P(s' | s, a) + predicted = [sum(belief[s] * transition[s][sp] for s in range(n)) for sp in range(n)] + # update: weight each predicted state by the likelihood of the observation + updated = [sensor[sp][observation] * predicted[sp] for sp in range(n)] + total = sum(updated) + return [b / total for b in updated] if total else predicted + + +def pomdp_lookahead(pomdp, belief, depth): + """ + [Section 17.5 - Dynamic Decision Networks] + Online decision making for a POMDP modeled as a dynamic decision network + (DDN): the network is projected 'depth' steps into the future and solved by + belief-state expectimax search. Decision nodes range over the belief states + and chance nodes branch over the possible observations, with the belief + updated by POMDP filtering ([Equation 17.17]) along each branch. Returns the + action that maximizes the expected discounted utility from 'belief'. + """ + + def utility(belief, depth): + if depth == 0: + return 0 + return max(q_value(belief, action, depth) for action in pomdp.actions) + + def q_value(belief, action, depth): + # expected immediate reward of taking 'action' in the current belief + reward = sum(belief[s] * pomdp.rewards[int(action)][s] for s in range(len(belief))) + # expectation over the possible observations of the next belief's utility + sensor = pomdp.e_prob[int(action)] + predicted = [sum(belief[s] * pomdp.t_prob[int(action)][s][sp] for s in range(len(belief))) + for sp in range(len(belief))] + future = 0 + for observation in range(len(sensor[0])): + # P(observation | belief, action) + p_o = sum(predicted[sp] * sensor[sp][observation] for sp in range(len(belief))) + if p_o > 0: + future += p_o * utility(update_belief(pomdp, belief, action, observation), depth - 1) + return reward + pomdp.gamma * future + + return max(pomdp.actions, key=lambda action: q_value(belief, action, depth)) + + __doc__ += """ >>> pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .01)) diff --git a/nlp.py b/aima/nlp.py similarity index 68% rename from nlp.py rename to aima/nlp.py index 03aabf54b..32243bb3a 100644 --- a/nlp.py +++ b/aima/nlp.py @@ -1,8 +1,11 @@ """Natural Language Processing; Chart Parsing and PageRanking (Chapter 22-23)""" from collections import defaultdict -from utils import weighted_choice +from aima.utils import weighted_choice +from aima.search import Problem import urllib.request +import copy +import heapq import re @@ -31,6 +34,10 @@ def Lexicon(**rules): class Grammar: + """A context-free grammar defined by a set of rewrite rules and a lexicon. + + Rules map non-terminal categories to alternative right-hand sides, while the + lexicon maps categories to the words that belong to them.""" def __init__(self, name, rules, lexicon): """A grammar has a set of rules and a lexicon.""" @@ -116,6 +123,10 @@ def ProbLexicon(**rules): class ProbGrammar: + """A probabilistic context-free grammar. + + Like :class:`Grammar`, but every rule and lexicon entry carries a + probability, so derivations can be sampled and scored by likelihood.""" def __init__(self, name, rules, lexicon): """A grammar has a set of rules and a lexicon. @@ -376,11 +387,145 @@ def CYK_parse(words, grammar): return P +class Tree: + """A simple parse-tree node with a root label and a list of child leaves + (which may themselves be subtrees), as built by CYK parsing.""" + + def __init__(self, root, *args): + self.root = root + self.leaves = [leaf for leaf in args] + + +def subspan(N): + """returns all tuple(i, j, k) covering a span (i, k) with i <= j < k""" + for length in range(2, N + 1): + for i in range(1, N + 2 - length): + k = i + length - 1 + for j in range(i, k): + yield (i, j, k) + + +# ______________________________________________________________________________ +# Text Parsing using Search + + +class TextParsingProblem(Problem): + """A search problem that parses a list of words bottom-up into a goal symbol. + + States are partially-reduced word/category sequences; actions replace words + with their lexical categories and replace spans of categories by the rules + that produce them, so that a solution path corresponds to a valid parse.""" + + def __init__(self, initial, grammar, goal='S'): + """ + :param initial: the initial state of words in a list. + :param grammar: a grammar object + :param goal: the goal state, usually S + """ + super(TextParsingProblem, self).__init__(initial, goal) + self.grammar = grammar + self.combinations = defaultdict(list) # article combinations + # backward lookup of rules + for rule in grammar.rules: + for comb in grammar.rules[rule]: + self.combinations[' '.join(comb)].append(rule) + + def actions(self, state): + """Return the successor states reachable from ``state``: first replace each + word by each of its lexical categories, and once only categories remain + replace spans that match a grammar rule's right-hand side by that rule.""" + actions = [] + categories = self.grammar.categories + # first change each word to the article of its category + for i in range(len(state)): + word = state[i] + if word in categories: + for X in categories[word]: + state[i] = X + actions.append(copy.copy(state)) + state[i] = word + # if all words are replaced by articles, replace combinations of articles by inferring rules. + if not actions: + for start in range(len(state)): + for end in range(start, len(state) + 1): + # try combinations between (start, end) + articles = ' '.join(state[start:end]) + for c in self.combinations[articles]: + actions.append(state[:start] + [c] + state[end:]) + return actions + + def result(self, state, action): + """Return the state resulting from applying ``action`` (the action is itself + the already-computed successor state).""" + return action + + def h(self, state): + """Heuristic estimating remaining cost as the number of symbols still left + to reduce in ``state``.""" + # heuristic function + return len(state) + + +def astar_search_parsing(words, gramma): + """bottom-up parsing using A* search to find whether a list of words is a sentence""" + # init the problem + problem = TextParsingProblem(words, gramma, 'S') + state = problem.initial + # init the searching frontier + frontier = [(len(state) + problem.h(state), state)] + heapq.heapify(frontier) + + while frontier: + # search the frontier node with lowest cost first + cost, state = heapq.heappop(frontier) + actions = problem.actions(state) + for action in actions: + new_state = problem.result(state, action) + # update the new frontier node to the frontier + if new_state == [problem.goal]: + return problem.goal + if new_state != state: + heapq.heappush(frontier, (len(new_state) + problem.h(new_state), new_state)) + return False + + +def beam_search_parsing(words, gramma, b=3): + """bottom-up text parsing using beam search""" + # init problem + problem = TextParsingProblem(words, gramma, 'S') + # init frontier + frontier = [(len(problem.initial), problem.initial)] + heapq.heapify(frontier) + + # explore the current frontier and keep b new states with lowest cost + def explore(frontier): + new_frontier = [] + for cost, state in frontier: + # expand the possible children states of current state + if not problem.goal_test(' '.join(state)): + actions = problem.actions(state) + for action in actions: + new_state = problem.result(state, action) + if [len(new_state), new_state] not in new_frontier and new_state != state: + new_frontier.append([len(new_state), new_state]) + else: + return problem.goal + heapq.heapify(new_frontier) + # only keep b states + return heapq.nsmallest(b, new_frontier) + + while frontier: + frontier = explore(frontier) + if frontier == problem.goal: + return frontier + return False + + # ______________________________________________________________________________ # Page Ranking # First entry in list is the base URL, and then following are relative URL pages -examplePagesSet = ["https://en.wikipedia.org/wiki/", "Aesthetics", "Analytic_philosophy", +example_pages_set = ["https://en.wikipedia.org/wiki/", "Aesthetics", "Analytic_philosophy", "Ancient_Greek", "Aristotle", "Astrology", "Atheism", "Baruch_Spinoza", "Belief", "Betrand Russell", "Confucius", "Consciousness", "Continental Philosophy", "Dialectic", "Eastern_Philosophy", @@ -392,58 +537,58 @@ def CYK_parse(words, grammar): "Truth", "Western_philosophy"] -def loadPageHTML(addressList): +def load_page_html(address_list): """Download HTML page content for every URL address passed as argument""" - contentDict = {} - for addr in addressList: + content_dict = {} + for addr in address_list: with urllib.request.urlopen(addr) as response: raw_html = response.read().decode('utf-8') - # Strip raw html of unnessecary content. Basically everything that isn't link or text - html = stripRawHTML(raw_html) - contentDict[addr] = html - return contentDict + # Strip raw html of unnecessary content. Basically everything that isn't link or text + html = strip_raw_html(raw_html) + content_dict[addr] = html + return content_dict -def initPages(addressList): +def init_pages(address_list): """Create a dictionary of pages from a list of URL addresses""" pages = {} - for addr in addressList: + for addr in address_list: pages[addr] = Page(addr) return pages -def stripRawHTML(raw_html): +def strip_raw_html(raw_html): """Remove the section of the HTML which contains links to stylesheets etc., - and remove all other unnessecary HTML""" + and remove all other unnecessary HTML""" # TODO: Strip more out of the raw html return re.sub(".*?", "", raw_html, flags=re.DOTALL) # remove section -def determineInlinks(page): +def determine_inlinks(page): """Given a set of pages that have their outlinks determined, we can fill out a page's inlinks by looking through all other page's outlinks""" inlinks = [] - for addr, indexPage in pagesIndex.items(): - if page.address == indexPage.address: + for addr, index_page in pages_index.items(): + if page.address == index_page.address: continue - elif page.address in indexPage.outlinks: + elif page.address in index_page.outlinks: inlinks.append(addr) return inlinks -def findOutlinks(page, handleURLs=None): +def find_outlinks(page, handle_urls=None): """Search a page's HTML content for URL links to other pages""" - urls = re.findall(r'href=[\'"]?([^\'" >]+)', pagesContent[page.address]) - if handleURLs: - urls = handleURLs(urls) + urls = re.findall(r'href=[\'"]?([^\'" >]+)', pages_content[page.address]) + if handle_urls: + urls = handle_urls(urls) return urls -def onlyWikipediaURLS(urls): +def only_wikipedia_urls(urls): """Some example HTML page data is from wikipedia. This function converts relative wikipedia links to full wikipedia URLs""" - wikiURLs = [url for url in urls if url.startswith('/wiki/')] - return ["https://en.wikipedia.org" + url for url in wikiURLs] + wiki_urls = [url for url in urls if url.startswith('/wiki/')] + return ["https://en.wikipedia.org" + url for url in wiki_urls] # ______________________________________________________________________________ @@ -458,25 +603,25 @@ def expand_pages(pages): expanded[addr] = page for inlink in page.inlinks: if inlink not in expanded: - expanded[inlink] = pagesIndex[inlink] + expanded[inlink] = pages_index[inlink] for outlink in page.outlinks: if outlink not in expanded: - expanded[outlink] = pagesIndex[outlink] + expanded[outlink] = pages_index[outlink] return expanded def relevant_pages(query): """Relevant pages are pages that contain all of the query words. They are obtained by intersecting the hit lists of the query words.""" - hit_intersection = {addr for addr in pagesIndex} + hit_intersection = {addr for addr in pages_index} query_words = query.split() for query_word in query_words: hit_list = set() - for addr in pagesIndex: - if query_word.lower() in pagesContent[addr].lower(): + for addr in pages_index: + if query_word.lower() in pages_content[addr].lower(): hit_list.add(addr) hit_intersection = hit_intersection.intersection(hit_list) - return {addr: pagesIndex[addr] for addr in hit_intersection} + return {addr: pages_index[addr] for addr in hit_intersection} def normalize(pages): @@ -503,16 +648,18 @@ def __call__(self): return self.detect() def detect(self): - curr_hubs = [page.hub for addr, page in pagesIndex.items()] - curr_auths = [page.authority for addr, page in pagesIndex.items()] + """Return True once the average change in hub and authority values across all + indexed pages falls below the convergence threshold, otherwise False.""" + curr_hubs = [page.hub for addr, page in pages_index.items()] + curr_auths = [page.authority for addr, page in pages_index.items()] if self.hub_history is None: self.hub_history, self.auth_history = [], [] else: - diffsHub = [abs(x - y) for x, y in zip(curr_hubs, self.hub_history[-1])] - diffsAuth = [abs(x - y) for x, y in zip(curr_auths, self.auth_history[-1])] - aveDeltaHub = sum(diffsHub) / float(len(pagesIndex)) - aveDeltaAuth = sum(diffsAuth) / float(len(pagesIndex)) - if aveDeltaHub < 0.01 and aveDeltaAuth < 0.01: # may need tweaking + diffs_hub = [abs(x - y) for x, y in zip(curr_hubs, self.hub_history[-1])] + diffs_auth = [abs(x - y) for x, y in zip(curr_auths, self.auth_history[-1])] + ave_delta_hub = sum(diffs_hub) / float(len(pages_index)) + ave_delta_auth = sum(diffs_auth) / float(len(pages_index)) + if ave_delta_hub < 0.01 and ave_delta_auth < 0.01: # may need tweaking return True if len(self.hub_history) > 2: # prevent list from getting long del self.hub_history[0] @@ -522,32 +669,37 @@ def detect(self): return False -def getInLinks(page): +def get_in_links(page): + """Return the addresses of indexed pages that link into the given page.""" if not page.inlinks: - page.inlinks = determineInlinks(page) - return [addr for addr, p in pagesIndex.items() if addr in page.inlinks] + page.inlinks = determine_inlinks(page) + return [addr for addr, p in pages_index.items() if addr in page.inlinks] -def getOutLinks(page): +def get_out_links(page): + """Return the addresses of indexed pages that the given page links out to.""" if not page.outlinks: - page.outlinks = findOutlinks(page) - return [addr for addr, p in pagesIndex.items() if addr in page.outlinks] + page.outlinks = find_outlinks(page) + return [addr for addr, p in pages_index.items() if addr in page.outlinks] # ______________________________________________________________________________ # HITS Algorithm class Page(object): - def __init__(self, address, inLinks=None, outLinks=None, hub=0, authority=0): + """A web page used by the HITS algorithm, holding its address, inbound and + outbound links, and its current hub and authority scores.""" + + def __init__(self, address, in_links=None, out_links=None, hub=0, authority=0): self.address = address self.hub = hub self.authority = authority - self.inlinks = inLinks - self.outlinks = outLinks + self.inlinks = in_links + self.outlinks = out_links -pagesContent = {} # maps Page relative or absolute URL/location to page's HTML content -pagesIndex = {} +pages_content = {} # maps Page relative or absolute URL/location to page's HTML content +pages_index = {} convergence = ConvergenceDetector() # assign function to variable to mimic pseudocode's syntax @@ -562,8 +714,8 @@ def HITS(query): hub = {p: pages[p].hub for p in pages} for p in pages: # p.authority ← ∑i Inlinki(p).Hub - pages[p].authority = sum(hub[x] for x in getInLinks(pages[p])) + pages[p].authority = sum(hub[x] for x in get_in_links(pages[p])) # p.hub ← ∑i Outlinki(p).Authority - pages[p].hub = sum(authority[x] for x in getOutLinks(pages[p])) + pages[p].hub = sum(authority[x] for x in get_out_links(pages[p])) normalize(pages) return pages diff --git a/notebook4e.py b/aima/notebook_utils.py similarity index 84% rename from notebook4e.py rename to aima/notebook_utils.py index 5b03081c6..7b881d29c 100644 --- a/notebook4e.py +++ b/aima/notebook_utils.py @@ -1,3 +1,5 @@ +import heapq +import os import time from collections import defaultdict from inspect import getsource @@ -12,10 +14,14 @@ from matplotlib import lines from matplotlib.colors import ListedColormap -from games import TicTacToe, alpha_beta_player, random_player, Fig52Extended -from learning import DataSet -from logic import parse_definite_clause, standardize_variables, unify_mm, subst -from search import GraphProblem, romania_map +from aima.games import TicTacToe, alpha_beta_player, random_player, Fig52Extended +from aima.learning import DataSet +from aima.logic import parse_definite_clause, standardize_variables, unify_mm, subst +from aima.search import GraphProblem, romania_map, Node + +# repo root (the directory containing the `aima` package), so cwd-relative +# data/image paths resolve regardless of where a notebook is launched from +_AIMA_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # ______________________________________________________________________________ @@ -50,41 +56,6 @@ def psource(*functions): print(source_code) -def plot_model_boundary(dataset, attr1, attr2, model=None): - # prepare data - examples = np.asarray(dataset.examples) - X = np.asarray([examples[:, attr1], examples[:, attr2]]) - y = examples[:, dataset.target] - h = 0.1 - - # create color maps - cmap_light = ListedColormap(['#FFAAAA', '#AAFFAA', '#00AAFF']) - cmap_bold = ListedColormap(['#FF0000', '#00FF00', '#00AAFF']) - - # calculate min, max and limits - x_min, x_max = X[0].min() - 1, X[0].max() + 1 - y_min, y_max = X[1].min() - 1, X[1].max() + 1 - # mesh the grid - xx, yy = np.meshgrid(np.arange(x_min, x_max, h), - np.arange(y_min, y_max, h)) - Z = [] - for grid in zip(xx.ravel(), yy.ravel()): - # put them back to the example - grid = np.round(grid, decimals=1).tolist() - Z.append(model(grid)) - # Put the result into a color plot - Z = np.asarray(Z) - Z = Z.reshape(xx.shape) - plt.figure() - plt.pcolormesh(xx, yy, Z, cmap=cmap_light) - - # Plot also the training points - plt.scatter(X[0], X[1], c=y, cmap=cmap_bold) - plt.xlim(xx.min(), xx.max()) - plt.ylim(yy.min(), yy.max()) - plt.show() - - # ______________________________________________________________________________ # Iris Visualization @@ -134,6 +105,10 @@ def show_iris(i=0, j=1, k=2): def load_MNIST(path="aima-data/MNIST/Digits", fashion=False): + """Load the MNIST (or Fashion-MNIST when ``fashion`` is True) dataset from its + IDX files under ``path``. Returns ``(train_img, train_lbl, test_img, test_lbl)`` + as numpy arrays, with images flattened to one row per sample. + """ import os, struct import array import numpy as np @@ -141,6 +116,9 @@ def load_MNIST(path="aima-data/MNIST/Digits", fashion=False): if fashion: path = "aima-data/MNIST/Fashion" + if not os.path.isabs(path): + path = os.path.join(_AIMA_ROOT, path) + plt.rcParams.update(plt.rcParamsDefault) plt.rcParams['figure.figsize'] = (10.0, 8.0) plt.rcParams['image.interpolation'] = 'nearest' @@ -189,6 +167,9 @@ def load_MNIST(path="aima-data/MNIST/Digits", fashion=False): def show_MNIST(labels, images, samples=8, fashion=False): + """Display a grid of ``samples`` random example images for each class of the + (Fashion-)MNIST dataset. + """ if not fashion: classes = digit_classes else: @@ -211,6 +192,9 @@ def show_MNIST(labels, images, samples=8, fashion=False): def show_ave_MNIST(labels, images, fashion=False): + """Display the average image of every class in the (Fashion-)MNIST dataset and + print how many images each class contains. + """ if not fashion: item_type = "Digit" classes = digit_classes @@ -316,6 +300,7 @@ def mouse_click(self, x, y): raise NotImplementedError def mouse_move(self, x, y): + """Override this method to handle mouse move at position (x, y)""" raise NotImplementedError def execute(self, exec_str): @@ -408,6 +393,7 @@ def update(self): def display_html(html_string): + """Render the given HTML string in the Jupyter notebook output.""" display(HTML(html_string)) @@ -432,6 +418,9 @@ def __init__(self, varname, player_1='human', player_2='random', self.draw_board() def mouse_click(self, x, y): + """Handle a click at pixel (x, y): make the move for the current human or AI + player, or restart the game when it is over, then redraw the board. + """ player = self.players[self.turn] if self.ttt.terminal_test(self.state): if 0.55 <= x / self.width <= 0.95 and 6 / 7 <= y / self.height <= 6 / 7 + 1 / 8: @@ -455,6 +444,9 @@ def mouse_click(self, x, y): self.draw_board() def draw_board(self): + """Redraw the board: grid lines, the current X/O marks, and the game status + (whose turn it is, the winning line, or the restart button). + """ self.clear() self.stroke(0, 0, 0) offset = 1 / 20 @@ -503,6 +495,7 @@ def draw_board(self): self.update() def draw_x(self, position): + """Draw an 'X' mark in the cell at the given (column, row) board position.""" self.stroke(0, 255, 0) x, y = [i - 1 for i in position] offset = 1 / 15 @@ -510,6 +503,7 @@ def draw_x(self, position): self.line_n(x / 3 + 1 / 3 - offset, (y / 3 + offset) * 6 / 7, x / 3 + offset, (y / 3 + 1 / 3 - offset) * 6 / 7) def draw_o(self, position): + """Draw an 'O' mark in the cell at the given (column, row) board position.""" self.stroke(255, 0, 0) x, y = [i - 1 for i in position] self.arc_n(x / 3 + 1 / 6, (y / 3 + 1 / 6) * 6 / 7, 1 / 9, 0, 360) @@ -541,6 +535,10 @@ def __init__(self, varname, util_list, width=800, height=600, cid=None): self.stack_manager = self.stack_manager_gen() def min_max(self, node): + """Run minimax from the given node, recording the sequence of canvas changes + (visited nodes, explored utilities, thick edges) for step-by-step animation. + Returns the node's minimax value. + """ game = self.game player = game.to_move(node) @@ -579,6 +577,9 @@ def min_value(node): return max_value(node) def stack_manager_gen(self): + """Generator that replays the recorded change list, updating the node stack, + explored set and thick lines, and yielding once per animation step. + """ self.min_max(0) for change in self.change_list: if change[0] == 'a': @@ -593,6 +594,7 @@ def stack_manager_gen(self): self.node_stack.pop() def mouse_click(self, x, y): + """Advance the minimax animation by one step on each click, then redraw the graph.""" try: self.stack_manager.send(None) except StopIteration: @@ -600,6 +602,9 @@ def mouse_click(self, x, y): self.draw_graph() def draw_graph(self): + """Draw the game tree: the nodes (highlighting those on the stack and showing the + utilities of explored nodes) together with the edges connecting them. + """ self.clear() # draw nodes self.stroke(0, 0, 0) @@ -668,6 +673,10 @@ def __init__(self, varname, util_list, width=800, height=600, cid=None): self.stack_manager = self.stack_manager_gen() def alpha_beta_search(self, node): + """Run alpha-beta pruning from the given node, recording the sequence of canvas + changes (visited nodes, alpha/beta bounds, pruned nodes, thick edges) for + step-by-step animation. Returns the node's value. + """ game = self.game player = game.to_move(node) @@ -733,6 +742,10 @@ def min_value(node, alpha, beta): return max_value(node, -np.inf, np.inf) def stack_manager_gen(self): + """Generator that replays the recorded change list, updating the node stack, + alpha/beta bounds, explored and pruned sets and thick lines, yielding once + per animation step. + """ self.alpha_beta_search(0) for change in self.change_list: if change[0] == 'a': @@ -749,6 +762,7 @@ def stack_manager_gen(self): self.node_stack.pop() def mouse_click(self, x, y): + """Advance the alpha-beta animation by one step on each click, then redraw the graph.""" try: self.stack_manager.send(None) except StopIteration: @@ -756,6 +770,9 @@ def mouse_click(self, x, y): self.draw_graph() def draw_graph(self): + """Draw the game tree with nodes (highlighting those on the stack, explored and + pruned) and edges, and display the alpha/beta bounds of nodes on the stack. + """ self.clear() # draw nodes self.stroke(0, 0, 0) @@ -836,6 +853,9 @@ def __init__(self, varname, kb, query, width=800, height=600, cid=None): self.draw_table() def fol_bc_ask(self): + """Run first-order backward chaining for the stored query against the knowledge + base, yielding proof trees together with their substitutions. + """ KB = self.kb query = self.query @@ -859,6 +879,9 @@ def fol_bc_and(KB, goals, theta): return fol_bc_or(KB, query, {}) def make_table(self, graph): + """Lay out the proof tree as a table of node positions and edges (stored on the + instance) ready for rendering on the canvas. + """ table = [] pos = {} links = set() @@ -890,6 +913,7 @@ def dfs(node, depth): self.edges = edges def mouse_click(self, x, y): + """Select the proof-tree node under the click position and redraw, showing its text.""" x, y = x / self.width, y / self.height for node in self.pos: xs, ys = self.pos[node] @@ -900,6 +924,9 @@ def mouse_click(self, x, y): self.draw_table() def draw_table(self): + """Draw the proof-tree nodes and edges, plus a text area showing the content of + the currently selected node. + """ self.clear() self.strokeWidth(3) self.stroke(0, 0, 0) @@ -942,6 +969,9 @@ def draw_table(self): def show_map(graph_data, node_colors=None): + """Draw a networkx graph of the given map data with coloured nodes, edge weight + labels and a legend describing the search-state colours. + """ G = nx.Graph(graph_data['graph_dict']) node_colors = node_colors or graph_data['node_colors'] node_positions = graph_data['node_positions'] @@ -960,7 +990,7 @@ def show_map(graph_data, node_colors=None): # add a white bounding box behind the node labels [label.set_bbox(dict(facecolor='white', edgecolor='none')) for label in node_label_handles.values()] - # add edge lables to the graph + # add edge labels to the graph nx.draw_networkx_edge_labels(G, pos=node_positions, edge_labels=edge_weights, font_size=14) # add a legend @@ -980,7 +1010,7 @@ def show_map(graph_data, node_colors=None): # helper functions for visualisations def final_path_colors(initial_node_colors, problem, solution): - """Return a node_colors dict of the final path provided the problem and solution.""" + "Return a node_colors dict of the final path provided the problem and solution." # get initial node colors final_colors = dict(initial_node_colors) @@ -992,6 +1022,10 @@ def final_path_colors(initial_node_colors, problem, solution): def display_visual(graph_data, user_input, algorithm=None, problem=None): + """Build interactive ipywidgets controls to animate a search algorithm on the map. + When ``user_input`` is True, also let the user choose the algorithm and the + start and goal cities; otherwise animate the supplied ``algorithm``. + """ initial_node_colors = graph_data['node_colors'] if user_input is False: def slider_callback(iteration): @@ -1093,11 +1127,14 @@ def visualize_callback(visualize): # Function to plot NQueensCSP in csp.py and NQueensProblem in search.py def plot_NQueens(solution): + """Draw an N-Queens chessboard with queen images for the given solution, which may + be a dict (from NQueensCSP) or a list (from NQueensProblem). + """ n = len(solution) board = np.array([2 * int((i + j) % 2) for j in range(n) for i in range(n)]).reshape((n, n)) - im = Image.open('images/queen_s.png') + im = Image.open(os.path.join(_AIMA_ROOT, 'images/queen_s.png')) height = im.size[1] - im = np.array(im).astype(np.float) / 255 + im = np.array(im).astype(float) / 255 fig = plt.figure(figsize=(7, 7)) ax = fig.add_subplot(111) ax.set_title('{} Queens'.format(n)) @@ -1120,6 +1157,7 @@ def plot_NQueens(solution): # Function to plot a heatmap, given a grid def heatmap(grid, cmap='binary', interpolation='nearest'): + """Display the given 2D grid as a heatmap.""" fig = plt.figure(figsize=(7, 7)) ax = fig.add_subplot(111) ax.set_title('Heatmap') @@ -1130,6 +1168,7 @@ def heatmap(grid, cmap='binary', interpolation='nearest'): # Generates a gaussian kernel def gaussian_kernel(l=5, sig=1.0): + """Return an ``l`` x ``l`` Gaussian kernel with standard deviation ``sig``.""" ax = np.arange(-l // 2 + 1., l // 2 + 1.) xx, yy = np.meshgrid(ax, ax) kernel = np.exp(-(xx ** 2 + yy ** 2) / (2. * sig ** 2)) @@ -1138,6 +1177,9 @@ def gaussian_kernel(l=5, sig=1.0): # Plots utility function for a POMDP def plot_pomdp_utility(utility): + """Plot the piecewise-linear utility functions of a POMDP's actions and mark the + belief thresholds between the optimal actions. + """ save = utility['0'][0] delete = utility['1'][0] ask_save = utility['2'][0] @@ -1156,3 +1198,114 @@ def plot_pomdp_utility(utility): plt.text((right + left) / 2 - 0.02, 10, 'Ask') plt.text((right + 1) / 2 - 0.07, 10, 'Delete') plt.show() + + +def plot_model_boundary(dataset, attr1, attr2, model=None): + """Plot the decision boundary of a classifier over two attributes of a dataset. + Builds a mesh over the ``attr1``/``attr2`` plane, colours each cell by the + ``model``'s prediction, and overlays the training examples. + """ + # prepare data + examples = np.asarray(dataset.examples) + X = np.asarray([examples[:, attr1], examples[:, attr2]]) + y = examples[:, dataset.target] + h = 0.1 + + # create color maps + cmap_light = ListedColormap(['#FFAAAA', '#AAFFAA', '#00AAFF']) + cmap_bold = ListedColormap(['#FF0000', '#00FF00', '#00AAFF']) + + # calculate min, max and limits + x_min, x_max = X[0].min() - 1, X[0].max() + 1 + y_min, y_max = X[1].min() - 1, X[1].max() + 1 + # mesh the grid + xx, yy = np.meshgrid(np.arange(x_min, x_max, h), + np.arange(y_min, y_max, h)) + Z = [] + for grid in zip(xx.ravel(), yy.ravel()): + # put them back to the example + grid = np.round(grid, decimals=1).tolist() + Z.append(model(grid)) + # Put the result into a color plot + Z = np.asarray(Z) + Z = Z.reshape(xx.shape) + plt.figure() + plt.pcolormesh(xx, yy, Z, cmap=cmap_light) + + # Plot also the training points + plt.scatter(X[0], X[1], c=y, cmap=cmap_bold) + plt.xlim(xx.min(), xx.max()) + plt.ylim(yy.min(), yy.max()) + plt.show() + + +# ______________________________________________________________________________ +# Visualizing search on a 2-D grid + + +def grid_search_steps(problem, strategy='astar'): + """Run a search on a :class:`~aima.search.GridProblem`, recording how it explores. + + Returns ``(explored, path)``: ``explored`` is the list of cells in the order + they are expanded (popped from the frontier), and ``path`` is the list of cells + from start to goal (``None`` if the goal is unreachable). ``strategy`` selects + the evaluation function -- ``'astar'`` (g + h), ``'ucs'`` (g), ``'bfs'`` (depth) + or ``'greedy'`` (h) -- which is what makes the different exploration patterns. + """ + evaluators = { + 'astar': lambda n: n.path_cost + problem.h(n), + 'ucs': lambda n: n.path_cost, + 'bfs': lambda n: n.depth, + 'greedy': lambda n: problem.h(n), + } + f = evaluators[strategy] + start = Node(problem.initial) + frontier = [(f(start), 0, start)] # (priority, tie-breaker, node) + best_cost = {problem.initial: start.path_cost} + explored, expanded = [], set() + counter = 1 + while frontier: + _, _, node = heapq.heappop(frontier) + if node.state in expanded: + continue + expanded.add(node.state) + explored.append(node.state) + if problem.goal_test(node.state): + return explored, [n.state for n in node.path()] + for child in node.expand(problem): + if child.state not in best_cost or child.path_cost < best_cost[child.state]: + best_cost[child.state] = child.path_cost + heapq.heappush(frontier, (f(child), counter, child)) + counter += 1 + return explored, None + + +def plot_grid_search(problem, explored, path=None, ax=None, title=None): + """Visualise a grid search: obstacles, the cells expanded (shaded by *when* they + were expanded), the solution path, and the start/goal. ``explored`` and ``path`` + are as returned by :func:`grid_search_steps`. Returns the matplotlib Axes. + """ + width, height = problem.width, problem.height + shade = np.full((height, width), np.nan) + for order, (x, y) in enumerate(explored): + shade[y, x] = order + + if ax is None: + _, ax = plt.subplots(figsize=(width * 0.45 + 1, height * 0.45 + 1)) + ax.imshow(shade, origin='lower', cmap='YlGnBu', + extent=(-0.5, width - 0.5, -0.5, height - 0.5)) + for (x, y) in problem.obstacles: + ax.add_patch(plt.Rectangle((x - 0.5, y - 0.5), 1, 1, color='0.2')) + if path: + ax.plot([c[0] for c in path], [c[1] for c in path], + color='orange', linewidth=2, marker='.', zorder=2) + (sx, sy), (gx, gy) = problem.initial, problem.goal + ax.scatter([sx], [sy], c='lime', s=130, marker='o', edgecolors='k', zorder=3) + ax.scatter([gx], [gy], c='red', s=160, marker='*', edgecolors='k', zorder=3) + ax.set_xticks(range(width)) + ax.set_yticks(range(height)) + ax.grid(True, color='0.85', linewidth=0.5) + ax.set_xlim(-0.5, width - 0.5) + ax.set_ylim(-0.5, height - 0.5) + ax.set_title(title or '{} cells expanded'.format(len(explored))) + return ax diff --git a/perception4e.py b/aima/perception.py similarity index 87% rename from perception4e.py rename to aima/perception.py index edd556607..69a2a8a18 100644 --- a/perception4e.py +++ b/aima/perception.py @@ -1,5 +1,7 @@ """Perception (Chapter 24)""" +import os + import cv2 import keras import matplotlib.pyplot as plt @@ -9,7 +11,11 @@ from keras.layers import Dense, Activation, Flatten, InputLayer, Conv2D, MaxPooling2D from keras.models import Sequential -from utils4e import gaussian_kernel_2D +from aima.utils import gaussian_kernel_2D + +# repo root (the directory containing the `aima` package), so the bundled sample +# images resolve regardless of the current working directory +_AIMA_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # ____________________________________________________ @@ -58,6 +64,48 @@ def gaussian_derivative_edge_detector(image): return edges +def hog(image, cell_size=8, bins=9, block_size=2): + """Histogram of Oriented Gradients (HOG) feature descriptor [#446]. + + Implements Dalal & Triggs (2005) -- the descriptor referenced by AIMA Chapter + 24 (Perception), for which the book gives no pseudocode. The steps are: + gradient magnitude and unsigned (0-180 deg) orientation; for each + ``cell_size`` x ``cell_size`` cell, a ``bins``-bin orientation histogram weighted + by gradient magnitude; L2 normalization over each ``block_size`` x ``block_size`` + block of cells; the normalized blocks concatenated into one feature vector. + + Source: N. Dalal & B. Triggs, "Histograms of Oriented Gradients for Human + Detection", CVPR 2005. + """ + image = np.asarray(image, dtype=float) + if image.ndim == 3: # collapse colour channels to grayscale + image = image.mean(axis=2) + + gy, gx = np.gradient(image) + magnitude = np.hypot(gx, gy) + orientation = np.rad2deg(np.arctan2(gy, gx)) % 180 # unsigned gradient angle + + cells_y, cells_x = image.shape[0] // cell_size, image.shape[1] // cell_size + bin_width = 180 / bins + histograms = np.zeros((cells_y, cells_x, bins)) + for cy in range(cells_y): + for cx in range(cells_x): + rows = slice(cy * cell_size, (cy + 1) * cell_size) + cols = slice(cx * cell_size, (cx + 1) * cell_size) + cell_bins = (orientation[rows, cols] // bin_width).astype(int) % bins + cell_mag = magnitude[rows, cols] + for k in range(bins): + histograms[cy, cx, k] = cell_mag[cell_bins == k].sum() + + eps = 1e-5 + features = [] + for by in range(cells_y - block_size + 1): + for bx in range(cells_x - block_size + 1): + block = histograms[by:by + block_size, bx:bx + block_size].ravel() + features.append(block / np.sqrt((block ** 2).sum() + eps ** 2)) + return np.concatenate(features) if features else histograms.ravel() + + def laplacian_edge_detector(image): """Extract image edge with laplacian filter""" if not isinstance(image, np.ndarray): @@ -109,9 +157,11 @@ def sum_squared_difference(pic1, pic2): def gen_gray_scale_picture(size, level=3): """ Generate a picture with different gray scale levels + :param size: size of generated picture :param level: the number of level of gray scales in the picture, range (0, 255) are equally divided by number of levels + :return image in numpy ndarray type """ assert level > 0 @@ -364,7 +414,7 @@ def selective_search(image): :return list of bounding boxes, each element is in form of [x_min, y_min, x_max, y_max] """ if not image: - im = cv2.imread("./images/stapler1-test.png") + im = cv2.imread(os.path.join(_AIMA_ROOT, "images/stapler1-test.png")) elif isinstance(image, str): im = cv2.imread(image) else: diff --git a/planning.py b/aima/planning.py similarity index 65% rename from planning.py rename to aima/planning.py index 1e4a19209..4a14549bc 100644 --- a/planning.py +++ b/aima/planning.py @@ -7,11 +7,11 @@ import numpy as np -import search -from csp import sat_up, NaryCSP, Constraint, ac_search_solver, is_constraint -from logic import FolKB, conjuncts, unify_mm, associate, SAT_plan, cdcl_satisfiable -from search import Node -from utils import Expr, expr, first +from aima import search +from aima.csp import sat_up, NaryCSP, Constraint, ac_search_solver, is_constraint +from aima.logic import FolKB, conjuncts, unify_mm, associate, SAT_plan, cdcl_satisfiable +from aima.search import Node +from aima.utils import Expr, expr, first class PlanningProblem: @@ -48,6 +48,13 @@ def convert(self, clauses): return new_clauses def expand_fluents(self, name=None): + """ + Generate all ground fluents obtainable by binding the problem's objects to the + predicates appearing in the initial state, goals and action effects. If a fluent + ``name`` is given, only expansions of that single fluent are produced. When a domain + is defined, candidate ground fluents are filtered through a FolKB so that only those + consistent with the domain are kept. + """ kb = None if self.domain: @@ -527,6 +534,234 @@ def socks_and_shoes(): effect='LeftSockOn')]) +def logistics_problem(initial_state=None, goal_state=None): + """ + LOGISTICS-PROBLEM + + A logistics problem where a robot moves between places, picking up and + putting down containers in order to deliver them to their destinations. + + Example: + >>> from planning import * + >>> lp = logistics_problem(goal_state='In(C2, D3) & In(C3, D3)') + >>> lp.goal_test() + False + >>> lp.act(expr('PutDown(R1, C1, D1)')) + >>> lp.act(expr('PickUp(R1, C2, D1)')) + >>> lp.act(expr('Move(R1, D1, D3)')) + >>> lp.act(expr('PutDown(R1, C2, D3)')) + >>> lp.act(expr('Move(R1, D3, D2)')) + >>> lp.act(expr('PickUp(R1, C3, D2)')) + >>> lp.act(expr('Move(R1, D2, D3)')) + >>> lp.goal_test() + False + >>> lp.act(expr('PutDown(R1, C3, D3)')) + >>> lp.goal_test() + True + >>> + """ + if initial_state is None: + initial_state = 'In(C1, R1) & In(C2, D1) & In(C3, D2) & In(R1, D1) & Holding(R1)' + if goal_state is None: + raise ValueError('Goal must be defined') + + return PlanningProblem(initial=initial_state, + goals=goal_state, + actions=[Action('PickUp(r, c, d)', + precond='In(r, d) & In(c, d) & ~Holding(r)', + effect='Holding(r) & ~In(c, d) & In(c, r)', + domain='Robot(r) & Place(d) & Container(c)'), + Action('PutDown(r, c, d)', + precond='In(r, d) & In(c, r) & Holding(r)', + effect='~Holding(r) & ~In(c, r) & In(c, d)', + domain='Robot(r) & Place(d) & Container(c)'), + Action('Move(r, d_start, d_end)', + precond='In(r, d_start)', + effect='~In(r, d_start) & In(r, d_end)', + domain='Robot(r) & Place(d_start) & Place(d_end)')], + domain='Container(C1) & Container(C2) & Container(C3) & ' + 'Place(D1) & Place(D2) & Place(D3) & Robot(R1)') + + +def blocks_world(initial, goals, blocks): + """ + GENERALIZED-BLOCKS-WORLD-PROBLEM + + A flexible constructor for creating blocks-world planning problems. + Any initial and goal configuration can be specified for a given set of blocks. + + Example: + >>> from planning import * + >>> initial_state = 'On(C, A) & On(A, Table) & On(B, Table) & Clear(C) & Clear(B)' + >>> goal_state = 'On(A, B) & On(B, C)' + >>> sussman_anomaly = blocks_world(initial_state, goal_state, ['A', 'B', 'C']) + >>> sussman_anomaly.goal_test() + False + >>> sussman_anomaly.act(expr('MoveToTable(C, A)')) + >>> sussman_anomaly.act(expr('Move(B, Table, C)')) + >>> sussman_anomaly.act(expr('Move(A, Table, B)')) + >>> sussman_anomaly.goal_test() + True + >>> + """ + # Dynamically generate the domain knowledge based on the list of blocks + domain = ' & '.join('Block({})'.format(b) for b in blocks) + + actions = [Action('Move(b, x, y)', + precond='On(b, x) & Clear(b) & Clear(y)', + effect='On(b, y) & Clear(x) & ~On(b, x) & ~Clear(y)', + domain='Block(b) & Block(y)'), + Action('MoveToTable(b, x)', + precond='On(b, x) & Clear(b)', + effect='On(b, Table) & Clear(x) & ~On(b, x)', + domain='Block(b) & Block(x)')] + + return PlanningProblem(initial=initial, + goals=goals, + actions=actions, + domain=domain) + + +def rush_hour(): + """ + RUSH-HOUR-PROBLEM (non-numeric version) + + A planning problem for the Rush Hour sliding block puzzle. The goal is to + maneuver the RedCar to the exit. This version uses non-numeric symbols for + grid positions (e.g. R1, C1) instead of integers. + + This specific instance uses: + - RedCar (2x1, horizontal) starting at (R3, C1) + - GreenTruck (3x1, vertical) starting at (R1, C4) + - BlueCar (2x1, vertical) starting at (R5, C2) + """ + initial_state = 'At(RedCar, R3, C1) & At(GreenTruck, R1, C4) & At(BlueCar, R5, C2) & ' \ + 'IsHorizontal(RedCar) & IsVertical(GreenTruck) & IsVertical(BlueCar) & ' \ + 'Clear(R1, C1) & Clear(R1, C2) & Clear(R1, C3) & Clear(R1, C5) & Clear(R1, C6) & ' \ + 'Clear(R2, C1) & Clear(R2, C2) & Clear(R2, C3) & Clear(R2, C5) & Clear(R2, C6) & ' \ + 'Clear(R3, C3) & Clear(R3, C4) & Clear(R3, C5) & Clear(R3, C6) & ' \ + 'Clear(R4, C1) & Clear(R4, C2) & Clear(R4, C3) & Clear(R4, C4) & Clear(R4, C5) & Clear(R4, C6) & ' \ + 'Clear(R5, C1) & Clear(R5, C3) & Clear(R5, C4) & Clear(R5, C5) & Clear(R5, C6) & ' \ + 'Clear(R6, C1) & Clear(R6, C3) & Clear(R6, C4) & Clear(R6, C5) & Clear(R6, C6)' + + # Goal state: the RedCar's left-most part is at column C5. + goal_state = 'At(RedCar, R3, C5)' + + domain = 'Vehicle(RedCar) & Vehicle(GreenTruck) & Vehicle(BlueCar) & ' \ + 'Car(RedCar) & Truck(GreenTruck) & Car(BlueCar) & ' \ + 'Row(R1) & Row(R2) & Row(R3) & Row(R4) & Row(R5) & Row(R6) & ' \ + 'Col(C1) & Col(C2) & Col(C3) & Col(C4) & Col(C5) & Col(C6) & ' \ + 'NextTo(R1, R2) & NextTo(R2, R3) & NextTo(R3, R4) & NextTo(R4, R5) & NextTo(R5, R6) & ' \ + 'NextTo(C1, C2) & NextTo(C2, C3) & NextTo(C3, C4) & NextTo(C4, C5) & NextTo(C5, C6)' + + actions = [ + # car actions (length 2) + Action('MoveRightCar(v, r, c1, c2, c3)', + precond='At(v, r, c1) & Car(v) & IsHorizontal(v) & NextTo(c1, c2) & NextTo(c2, c3) & Clear(r, c3)', + effect='At(v, r, c2) & ~At(v, r, c1) & Clear(r, c1) & ~Clear(r, c3)', + domain='Vehicle(v) & Row(r) & Col(c1) & Col(c2) & Col(c3)'), + Action('MoveLeftCar(v, r, c1, c2, c3)', + precond='At(v, r, c2) & Car(v) & IsHorizontal(v) & NextTo(c1, c2) & NextTo(c2, c3) & Clear(r, c1)', + effect='At(v, r, c1) & ~At(v, r, c2) & Clear(r, c3) & ~Clear(r, c1)', + domain='Vehicle(v) & Row(r) & Col(c1) & Col(c2) & Col(c3)'), + Action('MoveDownCar(v, r1, r2, r3, c)', + precond='At(v, r1, c) & Car(v) & IsVertical(v) & NextTo(r1, r2) & NextTo(r2, r3) & Clear(r3, c)', + effect='At(v, r2, c) & ~At(v, r1, c) & Clear(r1, c) & ~Clear(r3, c)', + domain='Vehicle(v) & Row(r1) & Row(r2) & Row(r3) & Col(c)'), + Action('MoveUpCar(v, r1, r2, r3, c)', + precond='At(v, r2, c) & Car(v) & IsVertical(v) & NextTo(r1, r2) & NextTo(r2, r3) & Clear(r1, c)', + effect='At(v, r1, c) & ~At(v, r2, c) & Clear(r3, c) & ~Clear(r1, c)', + domain='Vehicle(v) & Row(r1) & Row(r2) & Row(r3) & Col(c)'), + # truck actions (length 3) + Action('MoveRightTruck(v, r, c1, c2, c3, c4)', + precond='At(v, r, c1) & Truck(v) & IsHorizontal(v) & NextTo(c1, c2) & NextTo(c2, c3) & ' + 'NextTo(c3, c4) & Clear(r, c4)', + effect='At(v, r, c2) & ~At(v, r, c1) & Clear(r, c1) & ~Clear(r, c4)', + domain='Vehicle(v) & Row(r) & Col(c1) & Col(c2) & Col(c3) & Col(c4)'), + Action('MoveLeftTruck(v, r, c1, c2, c3, c4)', + precond='At(v, r, c2) & Truck(v) & IsHorizontal(v) & NextTo(c1, c2) & NextTo(c2, c3) & ' + 'NextTo(c3, c4) & Clear(r, c1)', + effect='At(v, r, c1) & ~At(v, r, c2) & Clear(r, c4) & ~Clear(r, c1)', + domain='Vehicle(v) & Row(r) & Col(c1) & Col(c2) & Col(c3) & Col(c4)'), + Action('MoveDownTruck(v, r1, r2, r3, r4, c)', + precond='At(v, r1, c) & Truck(v) & IsVertical(v) & NextTo(r1, r2) & NextTo(r2, r3) & ' + 'NextTo(r3, r4) & Clear(r4, c)', + effect='At(v, r2, c) & ~At(v, r1, c) & Clear(r1, c) & ~Clear(r4, c)', + domain='Vehicle(v) & Row(r1) & Row(r2) & Row(r3) & Row(r4) & Col(c)'), + Action('MoveUpTruck(v, r1, r2, r3, r4, c)', + precond='At(v, r2, c) & Truck(v) & IsVertical(v) & NextTo(r1, r2) & NextTo(r2, r3) & ' + 'NextTo(r3, r4) & Clear(r1, c)', + effect='At(v, r1, c) & ~At(v, r2, c) & Clear(r4, c) & ~Clear(r1, c)', + domain='Vehicle(v) & Row(r1) & Row(r2) & Row(r3) & Row(r4) & Col(c)')] + + return PlanningProblem(initial=initial_state, + goals=goal_state, + actions=actions, + domain=domain) + + +def rush_hour_optimized(): + """ + RUSH-HOUR-PROBLEM (optimized version) + + This version optimizes the planning problem by creating vehicle-specific + actions. Since each vehicle's orientation is fixed, generic predicates like + IsHorizontal can be removed and actions can be created that only apply to the + correct vehicle on its fixed axis of movement. This drastically reduces the + number of permutations the planner needs to generate and check. + """ + # Initial state is simpler as orientation is now baked into the actions. + initial_state = 'At(RedCar, R3, C1) & At(GreenTruck, R1, C4) & At(BlueCar, R5, C2) & ' \ + 'Clear(R1, C1) & Clear(R1, C2) & Clear(R1, C3) & Clear(R1, C5) & Clear(R1, C6) & ' \ + 'Clear(R2, C1) & Clear(R2, C2) & Clear(R2, C3) & Clear(R2, C5) & Clear(R2, C6) & ' \ + 'Clear(R3, C3) & Clear(R3, C4) & Clear(R3, C5) & Clear(R3, C6) & ' \ + 'Clear(R4, C1) & Clear(R4, C2) & Clear(R4, C3) & Clear(R4, C4) & Clear(R4, C5) & Clear(R4, C6) & ' \ + 'Clear(R5, C1) & Clear(R5, C3) & Clear(R5, C4) & Clear(R5, C5) & Clear(R5, C6) & ' \ + 'Clear(R6, C1) & Clear(R6, C3) & Clear(R6, C4) & Clear(R6, C5) & Clear(R6, C6)' + + goal_state = 'At(RedCar, R3, C5)' + + domain = 'Vehicle(RedCar) & Vehicle(GreenTruck) & Vehicle(BlueCar) & ' \ + 'Row(R1) & Row(R2) & Row(R3) & Row(R4) & Row(R5) & Row(R6) & ' \ + 'Col(C1) & Col(C2) & Col(C3) & Col(C4) & Col(C5) & Col(C6) & ' \ + 'NextTo(R1, R2) & NextTo(R2, R3) & NextTo(R3, R4) & NextTo(R4, R5) & NextTo(R5, R6) & ' \ + 'NextTo(C1, C2) & NextTo(C2, C3) & NextTo(C3, C4) & NextTo(C4, C5) & NextTo(C5, C6)' + + actions = [ + # RedCar is horizontal on row R3, length 2 + Action('MoveRedCarRight(c1, c2, c3)', + precond='At(RedCar, R3, c1) & NextTo(c1, c2) & NextTo(c2, c3) & Clear(R3, c3)', + effect='At(RedCar, R3, c2) & ~At(RedCar, R3, c1) & Clear(R3, c1) & ~Clear(R3, c3)', + domain='Col(c1) & Col(c2) & Col(c3)'), + Action('MoveRedCarLeft(c1, c2, c3)', + precond='At(RedCar, R3, c2) & NextTo(c1, c2) & NextTo(c2, c3) & Clear(R3, c1)', + effect='At(RedCar, R3, c1) & ~At(RedCar, R3, c2) & Clear(R3, c3) & ~Clear(R3, c1)', + domain='Col(c1) & Col(c2) & Col(c3)'), + # GreenTruck is vertical on column C4, length 3 + Action('MoveGreenTruckDown(r1, r2, r3, r4)', + precond='At(GreenTruck, r1, C4) & NextTo(r1, r2) & NextTo(r2, r3) & NextTo(r3, r4) & Clear(r4, C4)', + effect='At(GreenTruck, r2, C4) & ~At(GreenTruck, r1, C4) & Clear(r1, C4) & ~Clear(r4, C4)', + domain='Row(r1) & Row(r2) & Row(r3) & Row(r4)'), + Action('MoveGreenTruckUp(r1, r2, r3, r4)', + precond='At(GreenTruck, r2, C4) & NextTo(r1, r2) & NextTo(r2, r3) & NextTo(r3, r4) & Clear(r1, C4)', + effect='At(GreenTruck, r1, C4) & ~At(GreenTruck, r2, C4) & Clear(r4, C4) & ~Clear(r1, C4)', + domain='Row(r1) & Row(r2) & Row(r3) & Row(r4)'), + # BlueCar is vertical on column C2, length 2 + Action('MoveBlueCarDown(r1, r2, r3)', + precond='At(BlueCar, r1, C2) & NextTo(r1, r2) & NextTo(r2, r3) & Clear(r3, C2)', + effect='At(BlueCar, r2, C2) & ~At(BlueCar, r1, C2) & Clear(r1, C2) & ~Clear(r3, C2)', + domain='Row(r1) & Row(r2) & Row(r3)'), + Action('MoveBlueCarUp(r1, r2, r3)', + precond='At(BlueCar, r2, C2) & NextTo(r1, r2) & NextTo(r2, r3) & Clear(r1, C2)', + effect='At(BlueCar, r1, C2) & ~At(BlueCar, r2, C2) & Clear(r3, C2) & ~Clear(r1, C2)', + domain='Row(r1) & Row(r2) & Row(r3)')] + + return PlanningProblem(initial=initial_state, + goals=goal_state, + actions=actions, + domain=domain) + + def double_tennis_problem(): """ [Figure 11.10] DOUBLE-TENNIS-PROBLEM @@ -572,12 +807,15 @@ def __init__(self, planning_problem): self.expanded_actions = self.planning_problem.expand_actions() def actions(self, state): + """Return the expanded actions whose preconditions all hold in the given state.""" return [action for action in self.expanded_actions if all(pre in conjuncts(state) for pre in action.precond)] def result(self, state, action): + """Return the state resulting from applying the action to the given state.""" return associate('&', action(conjuncts(state), action.args).clauses) def goal_test(self, state): + """Return True if every goal of the planning problem holds in the given state.""" return all(goal in conjuncts(state) for goal in self.planning_problem.goals) def h(self, state): @@ -627,10 +865,12 @@ def negate_clause(clause): for prop in action.precond))] def result(self, subgoal, action): + """Regress the subgoal through the action, i.e. ``g' = (g - effects(a)) + preconds(a)``.""" # g' = (g - effects(a)) + preconds(a) return associate('&', set(set(conjuncts(subgoal)).difference(action.effect)).union(action.precond)) def goal_test(self, subgoal): + """Return True if the subgoal is entailed by the search goal (the problem's initial state).""" return all(goal in conjuncts(self.goal) for goal in conjuncts(subgoal)) def h(self, subgoal): @@ -749,6 +989,11 @@ def expand_transitions(state, actions): associate('&', sorted(planning_problem.goals)), solution_length, SAT_solver=SAT_solver) +def predicate_negate(e): + """Return the logical negation of an Expr predicate, avoiding a double 'Not' prefix.""" + return Expr(e.op[3:], *e.args) if e.op.startswith('Not') else Expr('Not' + e.op, *e.args) + + class Level: """ Contains the state of the planning problem @@ -763,20 +1008,37 @@ def __init__(self, kb): # current state self.current_state = kb.clauses # current action to state link + # Action -> preconditions for that action self.current_action_links = {} # current state to action link + # Precondition -> what is applicable (actions) self.current_state_links = {} - # current action to next state link + # current action to next state link (e.g. Go(Home, HW) --> At(HW) and NotAt(Home)) + # aka forward link in time (dependency) self.next_action_links = {} - # next state to current action link + # next state to current action link (e.g. NotAt(Home): [Go(Home, HW), Go(Home, SM)]) + # aka backwards link in time (dependency) self.next_state_links = {} # mutually exclusive actions - self.mutex = [] + self.action_mutexes = [] + # mutually exclusive states + self.state_mutexes = [] def __call__(self, actions, objects): self.build(actions, objects) self.find_mutex() + def __str__(self): + state_str = ', '.join(str(s) for s in self.current_state) + action_str = ', '.join(str(a) for a in self.current_action_links.keys()) + mutex_str = ', '.join(str(m) for m in self.action_mutexes) + return ('\n' + ' Current State: {{{}}}\n' + ' Actions: {{{}}}\n' + ' Mutex: {{{}}}\n'.format(state_str, action_str, mutex_str)) + + __repr__ = __str__ + def separate(self, e): """Separates an iterable of elements into positive and negative parts""" @@ -792,43 +1054,128 @@ def separate(self, e): def find_mutex(self): """Finds mutually exclusive actions""" - # Inconsistent effects - pos_nsl, neg_nsl = self.separate(self.next_state_links) - - for negeff in neg_nsl: - new_negeff = Expr(negeff.op[3:], *negeff.args) - for poseff in pos_nsl: - if new_negeff == poseff: - for a in self.next_state_links[poseff]: - for b in self.next_state_links[negeff]: - if {a, b} not in self.mutex: - self.mutex.append({a, b}) - - # Interference will be calculated with the last step - pos_csl, neg_csl = self.separate(self.current_state_links) - - # Competing needs - for pos_precond in pos_csl: - for neg_precond in neg_csl: - new_neg_precond = Expr(neg_precond.op[3:], *neg_precond.args) - if new_neg_precond == pos_precond: - for a in self.current_state_links[pos_precond]: - for b in self.current_state_links[neg_precond]: - if {a, b} not in self.mutex: - self.mutex.append({a, b}) - - # Inconsistent support + # clear out effects from state mutex prior computation + self.action_mutexes = [] + + # Competing needs - two actions are mutex if any of their preconditions + # are mutex at the previous state level + for a1, a2 in itertools.combinations(self.current_action_links.keys(), 2): + preconds_a1 = self.current_action_links[a1] + preconds_a2 = self.current_action_links[a2] + + if any({p, q} in self.state_mutexes for p in preconds_a1 for q in preconds_a2): + mutex_pair = {a1, a2} + if mutex_pair not in self.action_mutexes: + self.action_mutexes.append(mutex_pair) + + # Interference and inconsistent effects mutex calculation + for a1, a2 in itertools.combinations(self.next_action_links.keys(), 2): + preconds_a1 = self.current_action_links.get(a1, []) + preconds_a2 = self.current_action_links.get(a2, []) + effects_a1 = self.next_action_links.get(a1, []) + effects_a2 = self.next_action_links.get(a2, []) + + interference = False + # Interference check + for p1 in preconds_a1: + if predicate_negate(p1) in effects_a2: + interference = True + for p2 in preconds_a2: + if predicate_negate(p2) in effects_a1: + interference = True + + # Inconsistent effects check + for e1 in effects_a1: + if predicate_negate(e1) in effects_a2: + interference = True + for e2 in effects_a2: + if predicate_negate(e2) in effects_a1: + interference = True + + if interference: + mutex_pair = {a1, a2} + if mutex_pair not in self.action_mutexes: + self.action_mutexes.append(mutex_pair) + + def populate_prop_mutexes(self): + """Compute the next level's proposition mutexes based on the current action mutexes""" + + # Inconsistent support - two props cannot be true given competing supporting actions state_mutex = [] - for pair in self.mutex: - next_state_0 = self.next_action_links[list(pair)[0]] - if len(pair) == 2: - next_state_1 = self.next_action_links[list(pair)[1]] - else: - next_state_1 = self.next_action_links[list(pair)[0]] - if (len(next_state_0) == 1) and (len(next_state_1) == 1): - state_mutex.append({next_state_0[0], next_state_1[0]}) - - self.mutex = self.mutex + state_mutex + next_state_pairs = itertools.combinations(self.next_state_links.keys(), 2) + for next_state_pair in list(next_state_pairs): + s1, s2 = list(next_state_pair) + acts_to_s1 = self.next_state_links.get(s1, []) + acts_to_s2 = self.next_state_links.get(s2, []) + + # ensure our mutexes only apply to pairs, not single states + if acts_to_s1 == [] or acts_to_s2 == []: + continue + + # if any two actions that lead to these states are not mutex, + # do not add a mutex to these states + if all({a1, a2} in self.action_mutexes or {a2, a1} in self.action_mutexes + for a1 in acts_to_s1 for a2 in acts_to_s2): + mutex_pair = {s1, s2} + if mutex_pair not in state_mutex: + state_mutex.append(mutex_pair) + + # If there are pairs of propositions that are negations of each other, they must be mutex + for s1i in range(len(self.current_state)): + for s2i in range(s1i, len(self.current_state)): + s1, s2 = self.current_state[s1i], self.current_state[s2i] + if (repr(s2)[0:3] == 'Not' and repr(s1) == repr(s2)[3:] or + repr(s1)[0:3] == 'Not' and repr(s1)[3:] == repr(s2)): + mutex_pair = {s1, s2} + if mutex_pair not in state_mutex: + state_mutex.append(mutex_pair) + + return state_mutex + + def prune_invalid_actions(self): + """Remove actions whose own preconditions are mutex (unsupportable)""" + + to_remove = [] + + # Normalize state mutex set for fast membership checks + state_mutex_lookup = set() + for m in self.state_mutexes: + state_mutex_lookup.add(frozenset(m)) + + for action, preconds in list(self.current_action_links.items()): + invalid = False + for p1, p2 in itertools.combinations(preconds, 2): + if frozenset({p1, p2}) in state_mutex_lookup: + invalid = True + break + if invalid: + to_remove.append(action) + + # Remove invalid actions from all mappings + for action in to_remove: + # forward mappings + self.current_action_links.pop(action, None) + self.next_action_links.pop(action, None) + + # reverse mapping: state -> actions (current_state_links) + for precond in list(self.current_state_links.keys()): + actions_for_pre = self.current_state_links.get(precond, []) + if action in actions_for_pre: + actions_for_pre.remove(action) + if not actions_for_pre: + self.current_state_links.pop(precond, None) + else: + self.current_state_links[precond] = actions_for_pre + + # reverse mapping: next_state -> actions (next_state_links) + for effect in list(self.next_state_links.keys()): + actions_for_eff = self.next_state_links.get(effect, []) + if action in actions_for_eff: + actions_for_eff.remove(action) + if not actions_for_eff: + self.next_state_links.pop(effect, None) + else: + self.next_state_links[effect] = actions_for_eff def build(self, actions, objects): """Populates the lists and dictionaries containing the state action dependencies""" @@ -895,19 +1242,32 @@ def __init__(self, planning_problem): def __call__(self): self.expand_graph() + def __str__(self): + levels_str = '\n'.join('Level {}:\n{}'.format(i, level) + for i, level in enumerate(self.levels)) + return '\n Objects: {}\n{}\n'.format(self.objects, levels_str) + + __repr__ = __str__ + def expand_graph(self): """Expands the graph by a level""" last_level = self.levels[-1] + # populate state/actions/mutexes last_level(self.planning_problem.actions, self.objects) - self.levels.append(last_level.perform_actions()) + last_level.prune_invalid_actions() + # create new level + new_level = last_level.perform_actions() + # populate the mutexes for the next state level to come + new_level.state_mutexes = last_level.populate_prop_mutexes() + self.levels.append(new_level) def non_mutex_goals(self, goals, index): """Checks whether the goals are mutually exclusive""" goal_perm = itertools.combinations(goals, 2) for g in goal_perm: - if set(g) in self.levels[index].mutex: + if set(g) in self.levels[index].state_mutexes: return False return True @@ -924,74 +1284,117 @@ def __init__(self, planning_problem): self.no_goods = [] self.solution = [] + def __str__(self): + sol_str = ('No solution found' if not self.solution + else 'Solution with {} steps'.format(len(self.solution))) + return '\n Nogoods: {}\n {}\n'.format(len(self.no_goods), sol_str) + + __repr__ = __str__ + def check_leveloff(self): - """Checks if the graph has levelled off""" + """Checks if the graph has leveled off""" - check = (set(self.graph.levels[-1].current_state) == set(self.graph.levels[-2].current_state)) + if len(self.graph.levels) < 2: + return False - if check: - return True + level = self.graph.levels[-1] + prev_level = self.graph.levels[-2] - def extract_solution(self, goals, index): - """Extracts the solution""" + same_state = set(level.current_state) == set(prev_level.current_state) - level = self.graph.levels[index] - if not self.graph.non_mutex_goals(goals, index): - self.no_goods.append((level, goals)) - return + level_mutex = set(frozenset(m) for m in level.state_mutexes) + prev_mutex = set(frozenset(m) for m in prev_level.state_mutexes) + same_mutex = level_mutex == prev_mutex + + return same_state and same_mutex + + def get_preconditions_for(self, action_set, level): + """Collects all unique preconditions for a given set of actions in a level""" + + all_preconditions = set() + for action in action_set: + preconditions = level.current_action_links.get(action, []) + all_preconditions.update(preconditions) + return all_preconditions + + def find_valid_action_sets(self, goals, level): + """ + Finds sets of actions in the given level that are not mutually exclusive + and that collectively satisfy all the goals. + """ - level = self.graph.levels[index - 1] + valid_sets = [] - # Create all combinations of actions that satisfy the goal - actions = [] - for goal in goals: - actions.append(level.next_state_links[goal]) + actions_for_goal = {g: level.next_state_links.get(g, []) for g in goals} + potential_action_groups = [actions_for_goal[g] for g in goals] - all_actions = list(itertools.product(*actions)) + for action_combination in itertools.product(*potential_action_groups): + action_set = set(action_combination) - # Filter out non-mutex actions - non_mutex_actions = [] - for action_tuple in all_actions: - action_pairs = itertools.combinations(list(set(action_tuple)), 2) - non_mutex_actions.append(list(set(action_tuple))) - for pair in action_pairs: - if set(pair) in level.mutex: - non_mutex_actions.pop(-1) + is_mutex = False + for a1, a2 in itertools.combinations(action_set, 2): + if {a1, a2} in level.action_mutexes: + is_mutex = True break - # Recursion - for action_list in non_mutex_actions: - if [action_list, index] not in self.solution: - self.solution.append([action_list, index]) + if not is_mutex and action_set not in valid_sets: + valid_sets.append(action_set) - new_goals = [] - for act in set(action_list): - if act in level.current_action_links: - new_goals = new_goals + level.current_action_links[act] + return valid_sets - if abs(index) + 1 == len(self.graph.levels): - return - elif (level, new_goals) in self.no_goods: - return - else: - self.extract_solution(new_goals, index - 1) + def extract_solution(self, goals): + """ + Starts the solution extraction process by calling the recursive helper + and returning the final plan. + """ - # Level-Order multiple solutions - solution = [] - for item in self.solution: - if item[1] == -1: - solution.append([]) - solution[-1].append(item[0]) + return self.extract_solution_recursive(set(goals), len(self.graph.levels) - 1) + + def extract_solution_recursive(self, goals, level_index): + """ + Recursively searches for a plan backwards from a given proposition level. + + goals is the set of goal propositions to satisfy and level_index is the + index of the proposition level currently being solved. + """ + + # Base case: we have recursed back to the initial proposition layer (level 0). + # The goals at this point are the preconditions for the very first set of + # actions, so we just check whether they hold in the initial state. + if level_index == 0: + initial_state = set(self.graph.levels[0].current_state) + if goals.issubset(initial_state): + return [] # success, return the empty plan to be built upon else: - solution[-1].append(item[0]) + return None # failure, preconditions are not met - for num, item in enumerate(solution): - item.reverse() - solution[num] = item + # Memoization: check whether we already proved this subproblem unsolvable. + if (level_index, frozenset(goals)) in self.no_goods: + return None - return solution + # Recursive step: to satisfy the goals at level_index we need a set of + # non-mutex actions from the previous level's action layer. + action_level = self.graph.levels[level_index - 1] + valid_action_sets = self.find_valid_action_sets(goals, action_level) + + for action_set in valid_action_sets: + # The new sub-goals are the combined preconditions for this action set. + new_goals = self.get_preconditions_for(action_set, action_level) + + # Recurse to solve for the new goals at the previous proposition layer. + sub_plan = self.extract_solution_recursive(new_goals, level_index - 1) + + if sub_plan is not None: + return sub_plan + [list(action_set)] + + # No solution from this subproblem, so record it as a no-good. + nogood_item = (level_index, frozenset(goals)) + if nogood_item not in self.no_goods: + self.no_goods.append(nogood_item) + return None def goal_test(self, kb): + """Return True if all of the problem's goals can be proven from the knowledge base.""" return all(kb.ask(q) is not False for q in self.graph.planning_problem.goals) def execute(self): @@ -999,17 +1402,20 @@ def execute(self): while True: self.graph.expand_graph() - if (self.goal_test(self.graph.levels[-1].kb) and self.graph.non_mutex_goals( - self.graph.planning_problem.goals, -1)): - solution = self.extract_solution(self.graph.planning_problem.goals, -1) + if (self.goal_test(self.graph.levels[-1].kb) and + self.graph.non_mutex_goals(self.graph.planning_problem.goals, -1)): + solution = self.extract_solution(self.graph.planning_problem.goals) if solution: - return solution + return [solution] - if len(self.graph.levels) >= 2 and self.check_leveloff(): + if self.check_leveloff(): return None class Linearize: + """ + Coordinator that linearizes partially ordered solutions generated by a GraphPlan object. + """ def __init__(self, planning_problem): self.planning_problem = planning_problem @@ -1018,12 +1424,14 @@ def filter(self, solution): """Filter out persistence actions from a solution""" new_solution = [] - for section in solution[0]: + for section in solution: new_section = [] for operation in section: if not (operation.op[0] == 'P' and operation.op[1].isupper()): new_section.append(operation) - new_solution.append(new_section) + # filter may remove all actions if all actions are persistent + if new_section != []: + new_solution.append(new_section) return new_solution def orderlevel(self, level, planning_problem): @@ -1039,22 +1447,43 @@ def orderlevel(self, level, planning_problem): except: count = 0 temp = copy.deepcopy(planning_problem) - break + continue if count == len(permutation): return list(permutation), temp - return None + # identifying a linear ordering for the level failed + return None, planning_problem def execute(self): - """Finds total-order solution for a planning graph""" + """Finds a total-order solution for a planning graph (not necessarily unique)""" + + graph_plan_solution = GraphPlan(self.planning_problem).execute() + + # exit if no plan found + if graph_plan_solution is None: + return None + + ordered_solution = None + for possible_plan in graph_plan_solution: + filtered_solution = self.filter(possible_plan) + + ordered_solution = [] + # planning_problem maintains the current state as we iterate over the + # levels, allowing test application of the actions + planning_problem = self.planning_problem + for level in filtered_solution: + level_solution, planning_problem = self.orderlevel(level, planning_problem) + if not level_solution: + # level failed to apply, this plan does not work + ordered_solution = None + break + + for element in level_solution: + ordered_solution.append(element) - graphPlan_solution = GraphPlan(self.planning_problem).execute() - filtered_solution = self.filter(graphPlan_solution) - ordered_solution = [] - planning_problem = self.planning_problem - for level in filtered_solution: - level_solution, planning_problem = self.orderlevel(level, planning_problem) - for element in level_solution: - ordered_solution.append(element) + if not ordered_solution: + continue + else: + break return ordered_solution @@ -1080,6 +1509,7 @@ class PartialOrderPlanner: A partially ordered plan is defined by a set of actions and a set of constraints of the form A < B, which denotes that action A has to be performed before action B. To summarize the working of a partial order planner, + 1. An open precondition is selected (a sub-goal that we want to achieve). 2. An action that fulfils the open precondition is chosen. 3. Temporal constraints are updated. @@ -1096,6 +1526,11 @@ class PartialOrderPlanner: def __init__(self, planning_problem): self.tries = 1 + # safety bounds for the backtracking search in execute(): the maximum + # number of actions a plan may contain (iterative-deepening target) and + # the maximum number of node expansions per deepening level + self._max_plan_actions = 12 + self._max_expansions = 20000 self.planning_problem = planning_problem self.causal_links = [] self.start = Action('Start', [], self.planning_problem.initial) @@ -1111,39 +1546,33 @@ def __init__(self, planning_problem): self.expanded_actions = planning_problem.expand_actions() def find_open_precondition(self): - """Find open precondition with the least number of possible actions""" - + """ + Find the open precondition with the least number of achieving actions + (a most-constrained-variable heuristic). Returns the triple + (precondition, action_that_needs_it, [achieving_actions]). Iteration is + ordered deterministically so the search does not depend on set/hash + ordering. Returns (None, None, None) when some open precondition has no + achiever at all, which is a dead end for the current partial plan. + """ + possible_actions = list(self.actions) + self.expanded_actions number_of_ways = dict() actions_for_precondition = dict() - for element in self.agenda: - open_precondition = element[0] - possible_actions = list(self.actions) + self.expanded_actions - for action in possible_actions: - for effect in action.effect: - if effect == open_precondition: - if open_precondition in number_of_ways: - number_of_ways[open_precondition] += 1 - actions_for_precondition[open_precondition].append(action) - else: - number_of_ways[open_precondition] = 1 - actions_for_precondition[open_precondition] = [action] - - number = sorted(number_of_ways, key=number_of_ways.__getitem__) - - for k, v in number_of_ways.items(): - if v == 0: + for open_precondition, act in sorted(self.agenda, key=str): + if open_precondition in number_of_ways: + continue + achievers = [action for action in possible_actions + if any(effect == open_precondition for effect in action.effect)] + if not achievers: return None, None, None + number_of_ways[open_precondition] = len(achievers) + actions_for_precondition[open_precondition] = achievers - act1 = None - for element in self.agenda: - if element[0] == number[0]: - act1 = element[1] - break - - if number[0] in self.expanded_actions: - self.expanded_actions.remove(number[0]) + if not number_of_ways: + return None, None, None - return number[0], act1, actions_for_precondition[number[0]] + chosen = min(number_of_ways, key=lambda p: (number_of_ways[p], str(p))) + act1 = next(act for precond, act in sorted(self.agenda, key=str) if precond == chosen) + return chosen, act1, actions_for_precondition[chosen] def find_action_for_precondition(self, oprec): """Find action for a given precondition""" @@ -1315,102 +1744,172 @@ def display_plan(self): for causal_link in self.causal_links: print(causal_link) - print('\nConstraints') + print('\n_constraints') for constraint in self.constraints: print(constraint[0], '<', constraint[1]) - print('\nPartial Order Plan') + print('\n_partial Order Plan') print(list(reversed(list(self.toposort(self.convert(self.constraints)))))) def execute(self, display=True): - """Execute the algorithm""" - - step = 1 - while len(self.agenda) > 0: - step += 1 - # select from Agenda - try: - G, act1, possible_actions = self.find_open_precondition() - except IndexError: - print('Probably Wrong') - break + """ + Execute the algorithm with backtracking, using iterative deepening on the + number of actions in the plan. The original greedy version committed to + the first achiever it happened to iterate over and could not recover when + that action's own preconditions turned out to be unsatisfiable, so it + depended on hash ordering and often printed 'Probably Wrong' / "Couldn't + find a solution". Backtracking over both action choices and threat + resolution (promotion vs demotion), together with the deterministic + selection in find_open_precondition and a smallest-plan-first deepening + bound, makes the planner solve the standard problems reproducibly and + return a short, valid plan. + """ + pristine = self._snapshot() + for limit in range(1, self._max_plan_actions + 1): + self._restore(pristine) + if self._search([self._max_expansions], limit): + if display: + self.display_plan() + else: + return self.constraints, self.causal_links + return + print("Couldn't find a solution") + if not display: + return None, None + + def _reachable(self, source, target): + """True if target is forced to come after source by the ordering constraints""" + + stack, seen = [source], set() + while stack: + node = stack.pop() + if node == target: + return True + if node in seen: + continue + seen.add(node) + stack.extend(b for a, b in self.constraints if a == node) + return False - act0 = possible_actions[0] - # remove from Agenda - self.agenda.remove((G, act1)) + def _open_threat(self): + """ + Return an (action, causal_link) threat that is not yet resolved by the + ordering constraints, or None if every causal link is protected. A + causal link (a0, p, a1) is threatened by an action whose effect negates p + unless the action is already ordered before a0 (promotion) or after a1 + (demotion). + """ + for a0, p, a1 in self.causal_links: + for action in self.actions: + if action == a0 or action == a1: + continue + if any(self.is_a_threat(p, effect) for effect in action.effect): + if not (self._reachable(action, a0) or self._reachable(a1, action)): + return action, (a0, p, a1) + return None - # For actions with variable number of arguments, use least commitment principle - # act0_temp, bindings = self.find_action_for_precondition(G) - # act0 = self.generate_action_object(act0_temp, bindings) + def _snapshot(self): + return set(self.actions), set(self.constraints), list(self.causal_links), set(self.agenda) - # Actions = Actions U {act0} - self.actions.add(act0) + def _restore(self, snapshot): + self.actions, self.constraints, self.causal_links, self.agenda = ( + set(snapshot[0]), set(snapshot[1]), list(snapshot[2]), set(snapshot[3])) - # Constraints = add_const(start < act0, Constraints) - self.constraints = self.add_const((self.start, act0), self.constraints) + def _search(self, budget, limit): + """ + Recursively complete the partial plan, backtracking on failure. Three + kinds of choice points are explored: which action satisfies an open + precondition, how each threat is resolved (promotion vs demotion), and - + bounded by 'limit' - whether to introduce a new action at all. Returns + True and leaves the solution in self.* on success. + """ + if budget[0] <= 0: + return False + budget[0] -= 1 + + # first, resolve any outstanding threat to a causal link (choice point) + threat = self._open_threat() + if threat is not None: + action, (a0, p, a1) = threat + snapshot = self._snapshot() + for ordering in ((action, a0), (a1, action)): # promotion, then demotion + new_constraints = self.add_const(ordering, self.constraints) + if ordering in new_constraints: # ordering was consistent (acyclic and allowed) + self.constraints = new_constraints + if self._search(budget, limit): + return True + self._restore(snapshot) + return False - # for each CL E CausalLinks do - # Constraints = protect(CL, act0, Constraints) - for causal_link in self.causal_links: - self.constraints = self.protect(causal_link, act0, self.constraints) + # no open threats: a plan with an empty agenda is a complete solution + if not self.agenda: + return True - # Agenda = Agenda U {: P is a precondition of act0} - for precondition in act0.precond: - self.agenda.add((precondition, act0)) + # select from the agenda (most-constrained precondition first) + G, act1, possible_actions = self.find_open_precondition() + if G is None: # an open precondition has no achiever -> dead end + return False - # Constraints = add_const(act0 < act1, Constraints) + # number of actions already introduced, excluding the dummy Start/Finish + introduced = len(self.actions) - 2 + snapshot = self._snapshot() + # try each achiever deterministically, reusing existing actions first + for act0 in sorted(set(possible_actions), key=lambda a: (a not in self.actions, str(a))): + is_new = act0 not in self.actions + if is_new and introduced >= limit: # deepening bound on plan size + continue + self.agenda.discard((G, act1)) + self.actions.add(act0) + self.constraints = self.add_const((self.start, act0), self.constraints) self.constraints = self.add_const((act0, act1), self.constraints) - - # CausalLinks U {} - if (act0, G, act1) not in self.causal_links: - self.causal_links.append((act0, G, act1)) - - # for each A E Actions do - # Constraints = protect(, A, Constraints) - for action in self.actions: - self.constraints = self.protect((act0, G, act1), action, self.constraints) - - if step > 200: - print("Couldn't find a solution") - return None, None - - if display: - self.display_plan() - else: - return self.constraints, self.causal_links + # the causal link act0 --G--> act1 requires act0 strictly before act1 + # (and after start); add_const drops an ordering that would create a + # cycle, so reject the choice when the required ordering is not enforced + if ((act0 == act1 or self._reachable(act0, act1)) and + (act0 == self.start or self._reachable(self.start, act0))): + if (act0, G, act1) not in self.causal_links: + self.causal_links.append((act0, G, act1)) + if is_new: # a freshly introduced action contributes its own preconditions + for precondition in act0.precond: + self.agenda.add((precondition, act0)) + if self._search(budget, limit): + return True + # undo and try the next achiever + self._restore(snapshot) + return False -def spare_tire_graphPlan(): +def spare_tire_graph_plan(): """Solves the spare tire problem using GraphPlan""" return GraphPlan(spare_tire()).execute() -def three_block_tower_graphPlan(): +def three_block_tower_graph_plan(): """Solves the Sussman Anomaly problem using GraphPlan""" return GraphPlan(three_block_tower()).execute() -def air_cargo_graphPlan(): +def air_cargo_graph_plan(): """Solves the air cargo problem using GraphPlan""" return GraphPlan(air_cargo()).execute() -def have_cake_and_eat_cake_too_graphPlan(): +def have_cake_and_eat_cake_too_graph_plan(): """Solves the cake problem using GraphPlan""" - return [GraphPlan(have_cake_and_eat_cake_too()).execute()[1]] + return GraphPlan(have_cake_and_eat_cake_too()).execute() -def shopping_graphPlan(): +def shopping_graph_plan(): """Solves the shopping problem using GraphPlan""" return GraphPlan(shopping_problem()).execute() -def socks_and_shoes_graphPlan(): +def socks_and_shoes_graph_plan(): """Solves the socks and shoes problem using GraphPlan""" return GraphPlan(socks_and_shoes()).execute() -def simple_blocks_world_graphPlan(): +def simple_blocks_world_graph_plan(): """Solves the simple blocks world problem""" return GraphPlan(simple_blocks_world()).execute() @@ -1526,37 +2025,38 @@ def act(self, action): def refinements(self, library): # refinements may be (multiple) HLA themselves ... """ State is a Problem, containing the current state kb library is a - dictionary containing details for every possible refinement. e.g.: - { - 'HLA': [ - 'Go(Home, SFO)', - 'Go(Home, SFO)', - 'Drive(Home, SFOLongTermParking)', - 'Shuttle(SFOLongTermParking, SFO)', - 'Taxi(Home, SFO)' - ], - 'steps': [ - ['Drive(Home, SFOLongTermParking)', 'Shuttle(SFOLongTermParking, SFO)'], - ['Taxi(Home, SFO)'], - [], - [], - [] - ], - # empty refinements indicate a primitive action - 'precond': [ - ['At(Home) & Have(Car)'], - ['At(Home)'], - ['At(Home) & Have(Car)'], - ['At(SFOLongTermParking)'], - ['At(Home)'] - ], - 'effect': [ - ['At(SFO) & ~At(Home)'], - ['At(SFO) & ~At(Home)'], - ['At(SFOLongTermParking) & ~At(Home)'], - ['At(SFO) & ~At(SFOLongTermParking)'], - ['At(SFO) & ~At(Home)'] - ]} + dictionary containing details for every possible refinement. e.g.:: + + { + 'HLA': [ + 'Go(Home, SFO)', + 'Go(Home, SFO)', + 'Drive(Home, SFOLongTermParking)', + 'Shuttle(SFOLongTermParking, SFO)', + 'Taxi(Home, SFO)' + ], + 'steps': [ + ['Drive(Home, SFOLongTermParking)', 'Shuttle(SFOLongTermParking, SFO)'], + ['Taxi(Home, SFO)'], + [], + [], + [] + ], + # empty refinements indicate a primitive action + 'precond': [ + ['At(Home) & Have(Car)'], + ['At(Home)'], + ['At(Home) & Have(Car)'], + ['At(SFOLongTermParking)'], + ['At(Home)'] + ], + 'effect': [ + ['At(SFO) & ~At(Home)'], + ['At(SFO) & ~At(Home)'], + ['At(SFOLongTermParking) & ~At(Home)'], + ['At(SFO) & ~At(SFOLongTermParking)'], + ['At(SFO) & ~At(Home)'] + ]} """ indices = [i for i, x in enumerate(library['HLA']) if expr(x).op == self.name] for i in indices: @@ -1569,13 +2069,17 @@ def refinements(self, library): # refinements may be (multiple) HLA themselves actions.append(HLA(library['steps'][i][j], precond, effect)) yield actions - def hierarchical_search(self, hierarchy): + def hierarchical_search(self, hierarchy, max_depth=None): """ [Figure 11.5] 'Hierarchical Search, a Breadth First Search implementation of Hierarchical Forward Planning Search' The problem is a real-world problem defined by the problem class, and the hierarchy is - a dictionary of HLA - refinements (see refinements generator for details) + a dictionary of HLA - refinements (see refinements generator for details). + With the default max_depth=None the search follows Figure 11.5 exactly and, like the + textbook algorithm, may not terminate on a recursive (cyclic) hierarchy whose goal is + unreachable. Passing max_depth prunes any plan longer than that many actions, which + guarantees termination (returning None when no solution is found within the bound). """ act = Node(self.initial, None, [self.actions[0]]) frontier = deque() @@ -1595,7 +2099,9 @@ def hierarchical_search(self, hierarchy): return plan.action else: for sequence in RealWorldPlanningProblem.refinements(hla, hierarchy): # find refinements - frontier.append(Node(outcome.initial, plan, prefix + sequence + suffix)) + refined_plan = prefix + sequence + suffix + if max_depth is None or len(refined_plan) <= max_depth: + frontier.append(Node(outcome.initial, plan, refined_plan)) def result(state, actions): """The outcome of applying an action to the current problem""" @@ -1611,11 +2117,11 @@ def angelic_search(self, hierarchy, initial_plan): commit to high-level plans that work while avoiding high-level plans that don’t. The predicate MAKING-PROGRESS checks to make sure that we aren’t stuck in an infinite regression of refinements. - At top level, call ANGELIC-SEARCH with [Act] as the initialPlan. + At top level, call ANGELIC-SEARCH with [Act] as the initial_plan. InitialPlan contains a sequence of HLA's with angelic semantics - The possible effects of an angelic HLA in initialPlan are: + The possible effects of an angelic HLA in initial_plan are: ~ : effect remove $+: effect possibly add $-: effect possibly remove @@ -1634,7 +2140,7 @@ def angelic_search(self, hierarchy, initial_plan): guaranteed = self.intersects_goal(pes_reachable_set) if guaranteed and RealWorldPlanningProblem.making_progress(plan, initial_plan): final_state = guaranteed[0] # any element of guaranteed - return RealWorldPlanningProblem.decompose(hierarchy, final_state, pes_reachable_set) + return RealWorldPlanningProblem.decompose(hierarchy, plan, final_state, pes_reachable_set) # there should be at least one HLA/AngelicHLA, otherwise plan would be primitive hla, index = RealWorldPlanningProblem.find_hla(plan, hierarchy) prefix = plan.action[:index] @@ -1728,6 +2234,13 @@ def making_progress(plan, initial_plan): return True def decompose(hierarchy, plan, s_f, reachable_set): + """ + Recursively refine the high-level actions of an abstract plan into a concrete + sequence of primitive actions. Working backwards from the final state ``s_f``, + it picks an intermediate state for each pessimistic action from ``reachable_set`` + and uses angelic search to expand it, returning the assembled primitive solution + (or None if some action cannot be refined). + """ solution = [] i = max(reachable_set.keys()) while plan.action_pes: @@ -1867,7 +2380,8 @@ def convert(self, clauses): """ Converts strings into Exprs An HLA with angelic semantics can achieve the effects of simple HLA's (add / remove a variable) - and furthermore can have following effects on the variables: + and furthermore can have following effects on the variables:: + Possibly add variable ( $+ ) Possibly remove variable ( $- ) Possibly add or remove a variable ( $$ ) diff --git a/probabilistic_learning.py b/aima/probabilistic_learning.py similarity index 94% rename from probabilistic_learning.py rename to aima/probabilistic_learning.py index 1138e702d..62b2541f0 100644 --- a/probabilistic_learning.py +++ b/aima/probabilistic_learning.py @@ -2,7 +2,7 @@ import heapq -from utils import weighted_sampler, product, gaussian +from aima.utils import weighted_sampler, product, gaussian class CountingProbDist: @@ -67,6 +67,9 @@ def sample(self): def NaiveBayesLearner(dataset, continuous=True, simple=False): + """Return a naive Bayes classifier for ``dataset``, dispatching to the simple, + continuous (Gaussian), or discrete variant according to ``simple`` and + ``continuous``.""" if simple: return NaiveBayesSimple(dataset) if continuous: @@ -79,7 +82,8 @@ def NaiveBayesSimple(distribution): """ A simple naive bayes classifier that takes as input a dictionary of CountingProbDist objects and classifies items according to these distributions. - The input dictionary is in the following form: + The input dictionary is in the following form:: + (ClassName, ClassProb): CountingProbDist """ target_dist = {c_name: prob for c_name, prob in distribution.keys()} diff --git a/probability.py b/aima/probability.py similarity index 55% rename from probability.py rename to aima/probability.py index e1e77d224..07a865510 100644 --- a/probability.py +++ b/aima/probability.py @@ -1,10 +1,13 @@ """Probability models (Chapter 13-15)""" +import copy +import math +import re from collections import defaultdict from functools import reduce -from agents import Agent -from utils import * +from aima.agents import Agent +from aima.utils import * def DTAgentProgram(belief_state): @@ -158,6 +161,49 @@ def enumerate_joint(variables, e, P): return sum([enumerate_joint(rest, extend(e, Y, y), P) for y in P.values(Y)]) +# ______________________________________________________________________________ +# Independence + + +def is_independent(variables, P): + """ + Return whether a list of variables are independent given their distribution P + P is an instance of JoinProbDist + >>> P = JointProbDist(['X', 'Y']) + >>> P[0,0] = 0.25; P[0,1] = 0.5; P[1,1] = P[1,0] = 0.125 + >>> is_independent(['X', 'Y'], P) + False + """ + for var in variables: + event_vars = variables[:] + event_vars.remove(var) + event = {} + distribution = enumerate_joint_ask(var, event, P) + events = gen_possible_events(event_vars, P) + for e in events: + conditional_distr = enumerate_joint_ask(var, e, P) + if conditional_distr.prob != distribution.prob: + return False + return True + + +def gen_possible_events(vars, P): + """Generate all possible events of a collection of vars according to distribution of P""" + events = [] + + def backtrack(vars, P, temp): + if not vars: + events.append(temp) + return + var = vars[0] + for val in P.values(var): + temp[var] = val + backtrack([v for v in vars if v != var], P, copy.copy(temp)) + + backtrack(vars, P, {}) + return events + + # ______________________________________________________________________________ @@ -365,6 +411,136 @@ def __repr__(self): return repr((self.variable, ' '.join(self.parents))) +class DiscreteBayesNode: + """A node of a discrete Bayesian network whose variable may take more than two + values (unlike :class:`BayesNode`, which is boolean). + + ``values`` is the variable's domain. ``cpt`` maps each tuple of parent values + (ordered as in ``parents``) to the probabilities over ``values`` -- either a + sequence in domain order or a ``{value: prob}`` dict. A root node uses the + empty tuple ``()`` as its only key. + """ + + def __init__(self, X, parents, values, cpt): + if isinstance(parents, str): + parents = parents.split() + self.variable = X + self.parents = parents + self.values = list(values) + self.cpt = {} + for key, dist in cpt.items(): + key = key if isinstance(key, tuple) else (key,) + assert len(key) == len(self.parents) + self.cpt[key] = dist if isinstance(dist, dict) else dict(zip(self.values, dist)) + self.children = [] + + def p(self, value, event): + """Return P(X = ``value`` | parents = their values in ``event``).""" + return self.cpt[tuple(event[parent] for parent in self.parents)][value] + + def __repr__(self): + return repr((self.variable, ' '.join(self.parents))) + + +class DiscreteBayesNet: + """A Bayesian network of :class:`DiscreteBayesNode`\\ s (variables with arbitrary + finite domains). Exact inference works through the generic + :func:`enumeration_ask` / :func:`elimination_ask`, which rely only on + ``node.p`` and ``variable_values`` and so work unchanged for multi-valued nodes. + """ + + def __init__(self, node_specs=None): + self.nodes = [] + self.variables = [] + for spec in node_specs or []: + self.add(spec) + + def add(self, node_spec): + """Add a ``DiscreteBayesNode`` (or a ``(name, parents, values, cpt)`` spec); + its parents must already be in the net and its variable must not.""" + node = node_spec if isinstance(node_spec, DiscreteBayesNode) else DiscreteBayesNode(*node_spec) + assert node.variable not in self.variables + assert all(parent in self.variables for parent in node.parents) + self.nodes.append(node) + self.variables.append(node.variable) + for parent in node.parents: + self.variable_node(parent).children.append(node) + + def variable_node(self, var): + for n in self.nodes: + if n.variable == var: + return n + raise Exception("No such variable: {}".format(var)) + + def variable_values(self, var): + """Return the domain of var.""" + return self.variable_node(var).values + + def __repr__(self): + return 'DiscreteBayesNet({0!r})'.format(self.nodes) + + +def read_bif(source): + """Parse a Bayesian network in BIF (Bayesian Interchange Format) into a + :class:`DiscreteBayesNet`. ``source`` is the BIF text or an open file object. + + BIF is the format used by the Bayesian Network Repository + (https://www.bnlearn.com/bnrepository/), so this lets aima load standard + multi-valued networks such as the car-insurance ("Insurance") model. + """ + text = source.read() if hasattr(source, 'read') else source + + # variable NAME { type discrete [ k ] { v1, v2, ... }; } + domains = {name: [v.strip() for v in vals.split(',') if v.strip()] + for name, vals in re.findall(r'variable\s+(\w+)\s*\{[^}]*?\{([^}]*)\}', text)} + + # probability ( VAR [ | P1, P2, ... ] ) { table ... ; | (pv, ...) p, ... ; } + specs = {} + for header, body in re.findall(r'probability\s*\(\s*([^)]*?)\s*\)\s*\{(.*?)\}', text, re.S): + if '|' in header: + var, parent_str = header.split('|') + var, parents = var.strip(), [p.strip() for p in parent_str.split(',') if p.strip()] + else: + var, parents = header.strip(), [] + cpt = {} + table = re.search(r'table\s+([^;]+);', body) + if table: + cpt[()] = [float(x) for x in table.group(1).split(',')] + else: + for key, probs in re.findall(r'\(([^)]*)\)\s*([^;]+);', body): + cpt[tuple(v.strip() for v in key.split(',') if v.strip())] = \ + [float(x) for x in probs.split(',')] + specs[var] = (parents, domains[var], cpt) + + # add nodes parents-before-children (the BIF order need not be topological) + net, added = DiscreteBayesNet(), set() + + def add_node(name): + if name in added: + return + parents, values, cpt = specs[name] + for parent in parents: + add_node(parent) + net.add(DiscreteBayesNode(name, parents, values, cpt)) + added.add(name) + + for name in specs: + add_node(name) + return net + + +def insurance(): + """Return the car-insurance ("Insurance") Bayesian network as a + :class:`DiscreteBayesNet`, loaded from ``aima-data/insurance.bif``. + + This is the 27-variable discrete model of Binder, Koller, Russell & Kanazawa + (1997) referenced by the AIMA 4e car-insurance case study (Section 16); the + book notes the discrete conditional distributions are provided in the code + repository, and this is them. + """ + return read_bif(open_data('insurance.bif').read()) + + # Burglary example [Figure 14.2] T, F = True, False @@ -377,6 +553,95 @@ def __repr__(self): ('MaryCalls', 'Alarm', {T: 0.70, F: 0.01})]) +# ______________________________________________________________________________ +# Bayesian nets with continuous variables + + +def gaussian_probability(param, event, value): + """ + Gaussian probability of a continuous Bayesian network node on condition of + certain event and the parameters determined by the event + + :param param: parameters determined by discrete parent events of current node + :param event: a dict, continuous event of current node, the values are used + as parameters in calculating distribution + :param value: float, the value of current continuous node + :return: float, the calculated probability + + >>> param = {'sigma':0.5, 'b':1, 'a':{'h1':0.5, 'h2': 1.5}} + >>> event = {'h1':0.6, 'h2': 0.3} + >>> gaussian_probability(param, event, 1) + 0.2590351913317835 + """ + + assert isinstance(event, dict) + assert isinstance(param, dict) + buff = 0 + for k, v in event.items(): + # buffer varianle to calculate h1*a_h1 + h2*a_h2 + buff += param['a'][k] * v + res = 1 / (param['sigma'] * np.sqrt(2 * np.pi)) * np.exp(-0.5 * ((value - buff - param['b']) / param['sigma']) ** 2) + return res + + +def logistic_probability(param, event, value): + """ + Logistic probability of a discrete node in Bayesian network with continuous parents, + :param param: a dict, parameters determined by discrete parents of current node + :param event: a dict, names and values of continuous parent variables of current node + :param value: boolean, True or False + :return: int, probability + """ + + buff = 1 + for _, v in event.items(): + # buffer variable to calculate (value-mu)/sigma + + buff *= (v - param['mu']) / param['sigma'] + p = 1 - 1 / (1 + np.exp(-4 / np.sqrt(2 * np.pi) * buff)) + return p if value else 1 - p + + +class ContinuousBayesNode: + """ A Bayesian network node with continuous distribution or with continuous distributed parents """ + + def __init__(self, name, d_parents, c_parents, parameters, type): + """ + A continuous Bayesian node has two types of parents: discrete and continuous. + :param d_parents: str, name of discrete parents, value of which determines distribution parameters + :param c_parents: str, name of continuous parents, value of which is used to calculate distribution + :param parameters: a dict, parameters for distribution of current node, keys corresponds to discrete parents + :param type: str, type of current node's value, either 'd' (discrete) or 'c'(continuous) + """ + + self.parameters = parameters + self.type = type + self.d_parents = d_parents.split() + self.c_parents = c_parents.split() + self.parents = self.d_parents + self.c_parents + self.variable = name + self.children = [] + + def continuous_p(self, value, c_event, d_event): + """ + Probability given the value of current node and its parents + :param c_event: event of continuous nodes + :param d_event: event of discrete nodes + """ + assert isinstance(c_event, dict) + assert isinstance(d_event, dict) + + d_event_vals = event_values(d_event, self.d_parents) + if len(d_event_vals) == 1: + d_event_vals = d_event_vals[0] + param = self.parameters[d_event_vals] + if self.type == "c": + p = gaussian_probability(param, c_event, value) + if self.type == "d": + p = logistic_probability(param, c_event, value) + return p + + # ______________________________________________________________________________ @@ -447,6 +712,8 @@ def make_factor(var, e, bn): def pointwise_product(factors, bn): + """Multiply a sequence of factors together into a single factor over the union of their + variables, using the Bayes net ``bn`` to enumerate variable values.""" return reduce(lambda f, g: f.pointwise_product(g, bn), factors) @@ -596,7 +863,9 @@ def weighted_sample(bn, e): def gibbs_ask(X, e, bn, N=1000): - """[Figure 14.16]""" + """[Figure 14.16] Approximate P(X | e) by Gibbs sampling: from a random state + consistent with the evidence, repeatedly resample each nonevidence variable from + its Markov blanket and tally how often X takes each value.""" assert X not in e, "Query variable must be distinct from evidence" counts = {x: 0 for x in bn.variable_values(X)} # bold N in [Figure 14.16] Z = [var for var in bn.variables if var not in e] @@ -637,6 +906,8 @@ def __init__(self, transition_model, sensor_model, prior=None): self.prior = prior or [0.5, 0.5] def sensor_dist(self, ev): + """Return the sensor (observation) distribution corresponding to the evidence ``ev``: + the first row of the sensor model when ``ev`` is True, otherwise the second.""" if ev is True: return self.sensor_model[0] else: @@ -644,6 +915,9 @@ def sensor_dist(self, ev): def forward(HMM, fv, ev): + """Perform one forward (filtering) step of an HMM: project the forward message ``fv`` + through the transition model, weight it by the sensor distribution for evidence ``ev``, + and return the normalized next forward message. [Figure 15.4]""" prediction = vector_add(scalar_vector_product(fv[0], HMM.transition_model[0]), scalar_vector_product(fv[1], HMM.transition_model[1])) sensor_dist = HMM.sensor_dist(ev) @@ -652,6 +926,9 @@ def forward(HMM, fv, ev): def backward(HMM, b, ev): + """Perform one backward step of an HMM: weight the backward message ``b`` by the sensor + distribution for evidence ``ev`` and propagate it through the transition model, returning + the normalized previous backward message. [Figure 15.4]""" sensor_dist = HMM.sensor_dist(ev) prediction = element_wise_product(sensor_dist, b) @@ -727,6 +1004,67 @@ def viterbi(HMM, ev): return ml_path, ml_probabilities +def baum_welch(HMM, observations, iterations=100): + """ + [Section 20.3] + Baum-Welch algorithm: the instance of EM that learns the parameters of a + Hidden Markov Model (transition model, sensor model and prior) from a single + sequence of boolean 'observations', starting from the initial guess in 'HMM'. + Each iteration runs a (scaled) forward-backward pass to compute the smoothed + state marginals gamma_t(i) = P(X_t=i | e_1:T) and transition marginals + xi_t(i,j) = P(X_t=i, X_t+1=j | e_1:T) (E-step), then re-estimates every + parameter as the corresponding normalized expected count (M-step):: + + prior_i = gamma_0(i) + A_ij = sum_t xi_t(i, j) / sum_t gamma_t(i) + sensor_oi = sum_{t: e_t = o} gamma_t(i) / sum_t gamma_t(i) + + Returns a new HiddenMarkovModel with the learned parameters. + """ + A = np.array(HMM.transition_model, dtype=float) + prior = np.array(HMM.prior, dtype=float) + # sensor[0] = P(e=True | state), sensor[1] = P(e=False | state) + sensor = np.array(HMM.sensor_model, dtype=float) + obs = list(observations) + n, t_max = len(prior), len(obs) + + for _ in range(iterations): + # emission vectors b_t(i) = P(e_t | X_t = i), recomputed from current sensor + B = np.array([sensor[0] if e else sensor[1] for e in obs]) + + # E-step: scaled forward (alpha) and backward (beta) messages + alpha, c = np.zeros((t_max, n)), np.zeros(t_max) + alpha[0] = prior * B[0] + c[0] = alpha[0].sum() + alpha[0] /= c[0] + for t in range(1, t_max): + alpha[t] = B[t] * (alpha[t - 1] @ A) + c[t] = alpha[t].sum() + alpha[t] /= c[t] + beta = np.zeros((t_max, n)) + beta[-1] = 1 + for t in range(t_max - 2, -1, -1): + beta[t] = (A @ (B[t + 1] * beta[t + 1])) / c[t + 1] + + # smoothed state and transition marginals (normalized, so the per-step + # scaling factors cancel out) + gamma = alpha * beta + gamma /= gamma.sum(axis=1, keepdims=True) + xi = np.zeros((t_max - 1, n, n)) + for t in range(t_max - 1): + xi[t] = alpha[t][:, None] * A * B[t + 1] * beta[t + 1] + xi[t] /= xi[t].sum() + + # M-step: re-estimate every parameter from the expected counts + prior = gamma[0] + A = xi.sum(axis=0) / gamma[:-1].sum(axis=0)[:, None] + mask = np.array(obs, dtype=bool) + p_true = gamma[mask].sum(axis=0) / gamma.sum(axis=0) + sensor = np.array([p_true, 1 - p_true]) + + return HiddenMarkovModel(A.tolist(), sensor.tolist(), prior.tolist()) + + # _________________________________________________________________________ @@ -734,29 +1072,27 @@ def fixed_lag_smoothing(e_t, HMM, d, ev, t): """ [Figure 15.6] Smoothing algorithm with a fixed time lag of 'd' steps. - Online algorithm that outputs the new smoothed estimate if observation - for new time step is given. + Computes the smoothed estimate P(X_{t-d} | e_{1:t}) for the slice that lies + 'd' steps in the past, given the evidence sequence ev = [e_1, ..., e_t]. + Returns None when there is not yet enough evidence (t <= d). """ - ev.insert(0, None) + if t <= d: + return None + + T_model = np.array(HMM.transition_model) - T_model = HMM.transition_model + # forward message advanced over e_1 .. e_{t-d} f = HMM.prior - B = [[1, 0], [0, 1]] + for i in range(t - d): + f = forward(HMM, f, ev[i]) - O_t = np.diag(HMM.sensor_dist(e_t)) - if t > d: - f = forward(HMM, f, e_t) - O_tmd = np.diag(HMM.sensor_dist(ev[t - d])) - B = matrix_multiplication(np.linalg.inv(O_tmd), np.linalg.inv(T_model), B, T_model, O_t) - else: - B = matrix_multiplication(B, T_model, O_t) - t += 1 + # backward transformation accumulated over the lag window e_{t-d+1} .. e_t + B = np.eye(len(f)) + for i in range(t - d, t): + O_i = np.diag(HMM.sensor_dist(ev[i])) + B = B @ T_model @ O_i - if t > d: - # always returns a 1x2 matrix - return [normalize(i) for i in matrix_multiplication([f], B)][0] - else: - return None + return normalize((np.array(f) * (B @ np.ones(len(f)))).tolist()) # _________________________________________________________________________ @@ -800,7 +1136,121 @@ def particle_filtering(e, N, HMM): # _________________________________________________________________________ -# TODO: Implement continuous map for MonteCarlo similar to Fig25.10 from the book + + +class KalmanFilter: + """ + [Section 15.4] + Kalman filter for a linear-Gaussian dynamical system. The hidden state + evolves and is observed according to the linear-Gaussian model + + x_{t+1} = F x_t + noise, noise ~ N(0, Sigma_x) (transition model) + z_t = H x_t + noise, noise ~ N(0, Sigma_z) (sensor model) + + where F is the transition matrix, H the sensor matrix, Sigma_x the + transition (process) noise covariance and Sigma_z the sensor (measurement) + noise covariance. Because the family of Gaussians is closed under the + Bayesian filtering update, the forward message stays Gaussian and is fully + described by a mean vector and a covariance matrix at every step. + """ + + def __init__(self, transition_model, sensor_model, transition_noise, sensor_noise): + self.F = np.atleast_2d(transition_model) # transition matrix + self.H = np.atleast_2d(sensor_model) # sensor matrix + self.Sigma_x = np.atleast_2d(transition_noise) # transition noise covariance + self.Sigma_z = np.atleast_2d(sensor_noise) # sensor noise covariance + + def predict(self, mean, cov): + """Time update: project the Gaussian estimate one step forward through F.""" + mean = self.F @ mean + cov = self.F @ cov @ self.F.T + self.Sigma_x + return mean, cov + + def update(self, mean, cov, z): + """Measurement update: condition the predicted Gaussian on observation z.""" + # Kalman gain [Equation 15.21] + K = cov @ self.H.T @ np.linalg.inv(self.H @ cov @ self.H.T + self.Sigma_z) + mean = mean + K @ (np.atleast_1d(z) - self.H @ mean) + cov = (np.eye(cov.shape[0]) - K @ self.H) @ cov + return mean, cov + + def filter(self, mean, cov, z): + """One predict-then-update cycle for a single new observation z.""" + mean, cov = self.predict(mean, cov) + return self.update(mean, cov, z) + + +def kalman_filter(KF, mean0, cov0, observations): + """ + [Section 15.4] + Run the Kalman filter 'KF' over a sequence of 'observations', starting from + the Gaussian prior N(mean0, cov0). Returns, for each time step, the filtered + Gaussian estimate as a (mean, covariance) pair. + """ + mean, cov = np.atleast_1d(mean0).astype(float), np.atleast_2d(cov0).astype(float) + estimates = [] + for z in observations: + mean, cov = KF.filter(mean, cov, z) + estimates.append((mean, cov)) + + return estimates + + +# _________________________________________________________________________ + + +class DynamicBayesNet: + """ + [Section 15.5] + A dynamic Bayesian network for a stationary first-order Markov process. It is + specified by a prior network over the state variables at slice 0 and a single + transition + sensor network describing, for one time step, the distribution of + each state variable (given the previous slice) and of each evidence variable + (given the current slice). The DBN can be 'unrolled' into an ordinary BayesNet + spanning any number of slices and then queried with the exact inference + algorithms; in particular filtering is the query for the last state variable + given the whole evidence sequence. + + Each spec is a (variable, parents, cpt) triple as for a BayesNode. In a + transition spec, a parent named '_prev' refers to state variable at + the previous slice; every other parent refers to the current slice. + """ + + def __init__(self, prior, transition, sensors): + self.prior = prior + self.transition = transition + self.sensors = sensors + self.state_variables = [spec[0] for spec in prior] + self.evidence_variables = [spec[0] for spec in sensors] + + @staticmethod + def _rename(parents, t, t_prev): + """Map the parent names of a slice template to concrete unrolled names.""" + if isinstance(parents, str): + parents = parents.split() + return [f'{p[:-len("_prev")]}_{t_prev}' if p.endswith('_prev') else f'{p}_{t}' for p in parents] + + def unroll(self, steps): + """Unroll the DBN into a BayesNet over slices 0..steps (evidence at 1..steps).""" + specs = [(f'{var}_0', self._rename(parents, 0, 0), cpt) for var, parents, cpt in self.prior] + for t in range(1, steps + 1): + for var, parents, cpt in self.transition + self.sensors: + specs.append((f'{var}_{t}', self._rename(parents, t, t - 1), cpt)) + return BayesNet(specs) + + def filter(self, evidence, query, infer=elimination_ask): + """ + Filtering: the posterior over 'query' at the last slice given the whole + observation sequence. 'evidence' is a list of dicts, one per time step + t = 1, 2, ..., each mapping evidence variables to their observed values. + """ + steps = len(evidence) + net = self.unroll(steps) + e = {f'{var}_{t}': val for t, obs in enumerate(evidence, 1) for var, val in obs.items()} + return infer(f'{query}_{steps}', e, net) + + +# _________________________________________________________________________ class MCLmap: @@ -868,3 +1318,72 @@ def ray_cast(sensor_num, kin_state, m): S = weighted_sample_with_replacement(N, S_, W_) return S + + +class ContinuousMCLmap: + """[Figure 25.10] + A continuous 2-D map for Monte Carlo localization. The world is a rectangular + arena [0, width] x [0, height] containing axis-aligned rectangular obstacles. + A kinematic state is (x, y, heading) with x, y real-valued coordinates and + heading an angle in radians, so the robot is no longer snapped to a grid. + Range sensors cast rays from the robot to the nearest wall, returning + continuous distances rather than the integer step counts of the discrete + MCLmap. It exposes the same sample()/ray_cast(sensor_num, kin_state) interface + as MCLmap, so it plugs straight into monte_carlo_localization.""" + + def __init__(self, width, height, obstacles=(), + sensors=(0, math.pi / 2, math.pi, 3 * math.pi / 2)): + self.width = width + self.height = height + # each obstacle is a rectangle given as (x0, y0, x1, y1) with x0 <= x1, y0 <= y1 + self.obstacles = [tuple(o) for o in obstacles] + # sensor beam angles (radians) measured relative to the robot's heading + self.sensors = list(sensors) + self.segments = self._wall_segments() + + def _wall_segments(self): + """The arena boundary plus the four edges of every obstacle, each as a + ((x0, y0), (x1, y1)) line segment.""" + w, h = self.width, self.height + segments = [((0, 0), (w, 0)), ((w, 0), (w, h)), + ((w, h), (0, h)), ((0, h), (0, 0))] + for x0, y0, x1, y1 in self.obstacles: + segments += [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), + ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))] + return segments + + def in_free_space(self, x, y): + """True if (x, y) lies inside the arena and outside every obstacle.""" + if not (0 <= x <= self.width and 0 <= y <= self.height): + return False + return not any(x0 <= x <= x1 and y0 <= y <= y1 for x0, y0, x1, y1 in self.obstacles) + + def sample(self): + """Return a random kinematic state (x, y, heading) in free space.""" + while True: + x = random.uniform(0, self.width) + y = random.uniform(0, self.height) + if self.in_free_space(x, y): + return x, y, random.uniform(0, 2 * math.pi) + + def ray_cast(self, sensor_num, kin_state): + """Distance from the robot to the nearest wall along the given sensor. + Casts the ray (x, y) + t * (cos a, sin a), with a = heading + sensor + offset, and returns the smallest positive intersection with any wall + segment (infinity if the ray escapes the arena, which cannot happen for + a robot inside a closed arena).""" + x, y, heading = kin_state + angle = heading + self.sensors[sensor_num] + dx, dy = math.cos(angle), math.sin(angle) + nearest = math.inf + for (ax, ay), (bx, by) in self.segments: + ex, ey = bx - ax, by - ay + # ray-segment intersection: dot of segment with the ray normal (-dy, dx) + denom = ex * (-dy) + ey * dx + if abs(denom) < 1e-12: + continue # ray parallel to this wall segment + t = (ex * (y - ay) - ey * (x - ax)) / denom # distance along the ray + u = ((x - ax) * (-dy) + (y - ay) * dx) / denom # position along the segment + if t >= 0 and 0 <= u <= 1: + nearest = min(nearest, t) + return nearest diff --git a/reinforcement_learning.py b/aima/reinforcement_learning.py similarity index 66% rename from reinforcement_learning.py rename to aima/reinforcement_learning.py index 4cb91af0f..60c3a3868 100644 --- a/reinforcement_learning.py +++ b/aima/reinforcement_learning.py @@ -3,28 +3,28 @@ import random from collections import defaultdict -from mdp import MDP, policy_evaluation +from aima.mdp import MDP, policy_evaluation class PassiveDUEAgent: """ Passive (non-learning) agent that uses direct utility estimation - on a given MDP and policy. - - import sys - from mdp import sequential_decision_environment - north = (0, 1) - south = (0,-1) - west = (-1, 0) - east = (1, 0) - policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, - (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} - agent = PassiveDUEAgent(policy, sequential_decision_environment) - for i in range(200): - run_single_trial(agent,sequential_decision_environment) - agent.estimate_U() - agent.U[(0, 0)] > 0.2 - True + on a given MDP and policy:: + + import sys + from aima.mdp import sequential_decision_environment + north = (0, 1) + south = (0,-1) + west = (-1, 0) + east = (1, 0) + policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, + (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} + agent = PassiveDUEAgent(policy, sequential_decision_environment) + for i in range(200): + run_single_trial(agent,sequential_decision_environment) + agent.estimate_U() + agent.U[(0, 0)] > 0.2 + True """ def __init__(self, pi, mdp): @@ -50,6 +50,10 @@ def __call__(self, percept): return self.a def estimate_U(self): + """Update utility estimates from the most recent completed trial by direct + utility estimation: average the observed reward-to-go for each visited state, + blend it with the running estimate, and reset the trial history. Must be + called only once the MDP has reached a terminal state.""" # this function can be called only if the MDP has reached a terminal state # it will also reset the mdp history assert self.a is None, 'MDP is not in terminal state' @@ -81,24 +85,24 @@ class PassiveADPAgent: """ [Figure 21.2] Passive (non-learning) agent that uses adaptive dynamic programming - on a given MDP and policy. - - import sys - from mdp import sequential_decision_environment - north = (0, 1) - south = (0,-1) - west = (-1, 0) - east = (1, 0) - policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, - (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} - agent = PassiveADPAgent(policy, sequential_decision_environment) - for i in range(100): - run_single_trial(agent,sequential_decision_environment) - - agent.U[(0, 0)] > 0.2 - True - agent.U[(0, 1)] > 0.2 - True + on a given MDP and policy:: + + import sys + from aima.mdp import sequential_decision_environment + north = (0, 1) + south = (0,-1) + west = (-1, 0) + east = (1, 0) + policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, + (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} + agent = PassiveADPAgent(policy, sequential_decision_environment) + for i in range(100): + run_single_trial(agent,sequential_decision_environment) + + agent.U[(0, 0)] > 0.2 + True + agent.U[(0, 1)] > 0.2 + True """ class ModelMDP(MDP): @@ -166,24 +170,24 @@ class PassiveTDAgent: The abstract class for a Passive (non-learning) agent that uses temporal differences to learn utility estimates. Override update_state method to convert percept to state and reward. The mdp being provided - should be an instance of a subclass of the MDP Class. - - import sys - from mdp import sequential_decision_environment - north = (0, 1) - south = (0,-1) - west = (-1, 0) - east = (1, 0) - policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, - (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} - agent = PassiveTDAgent(policy, sequential_decision_environment, alpha=lambda n: 60./(59+n)) - for i in range(200): - run_single_trial(agent,sequential_decision_environment) - - agent.U[(0, 0)] > 0.2 - True - agent.U[(0, 1)] > 0.2 - True + should be an instance of a subclass of the MDP Class:: + + import sys + from aima.mdp import sequential_decision_environment + north = (0, 1) + south = (0,-1) + west = (-1, 0) + east = (1, 0) + policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, + (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} + agent = PassiveTDAgent(policy, sequential_decision_environment, alpha=lambda n: 60./(59+n)) + for i in range(200): + run_single_trial(agent,sequential_decision_environment) + + agent.U[(0, 0)] > 0.2 + True + agent.U[(0, 1)] > 0.2 + True """ def __init__(self, pi, mdp, alpha=None): @@ -228,24 +232,24 @@ class QLearningAgent: [Figure 21.8] An exploratory Q-learning agent. It avoids having to learn the transition model because the Q-value of a state can be related directly to those of - its neighbors. - - import sys - from mdp import sequential_decision_environment - north = (0, 1) - south = (0,-1) - west = (-1, 0) - east = (1, 0) - policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, - (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} - q_agent = QLearningAgent(sequential_decision_environment, Ne=5, Rplus=2, alpha=lambda n: 60./(59+n)) - for i in range(200): - run_single_trial(q_agent,sequential_decision_environment) - - q_agent.Q[((0, 1), (0, 1))] >= -0.5 - True - q_agent.Q[((1, 0), (0, -1))] <= 0.5 - True + its neighbors:: + + import sys + from aima.mdp import sequential_decision_environment + north = (0, 1) + south = (0,-1) + west = (-1, 0) + east = (1, 0) + policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, + (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} + q_agent = QLearningAgent(sequential_decision_environment, Ne=5, Rplus=2, alpha=lambda n: 60./(59+n)) + for i in range(200): + run_single_trial(q_agent,sequential_decision_environment) + + q_agent.Q[((0, 1), (0, 1))] >= -0.5 + True + q_agent.Q[((1, 0), (0, -1))] <= 0.5 + True """ def __init__(self, mdp, Ne, Rplus, alpha=None): @@ -290,7 +294,7 @@ def __call__(self, percept): actions_in_state = self.actions_in_state if s in terminals: - Q[s, None] = r1 + Q[s, None] = r if s is not None: Nsa[s, a] += 1 Q[s, a] += alpha(Nsa[s, a]) * (r + gamma * max(Q[s1, a1] @@ -308,6 +312,41 @@ def update_state(self, percept): return percept +class SARSALearningAgent(QLearningAgent): + """ + [Section 21.3] + An on-policy temporal-difference control agent (SARSA: State-Action-Reward- + State-Action). It is identical to the Q-learning agent except for the update + rule: instead of bootstrapping on the maximum Q-value over next actions, SARSA + bootstraps on the Q-value of the action a1 that its exploration policy will + actually take in the next state. Being on-policy, SARSA learns the value of the + policy it is following, exploration included, rather than that of the greedy + policy. + """ + + def __call__(self, percept): + s1, r1 = self.update_state(percept) + Q, Nsa, s, a, r = self.Q, self.Nsa, self.s, self.a, self.r + alpha, gamma, terminals = self.alpha, self.gamma, self.terminals + actions_in_state = self.actions_in_state + + # pick the next action with the same exploration policy as Q-learning + a1 = max(actions_in_state(s1), key=lambda a2: self.f(Q[s1, a2], Nsa[s1, a2])) + + if s in terminals: + Q[s, None] = r + if s is not None: + Nsa[s, a] += 1 + # on-policy update: bootstrap on the actually-chosen next action a1 + Q[s, a] += alpha(Nsa[s, a]) * (r + gamma * Q[s1, a1] - Q[s, a]) + if s in terminals: + self.s = self.a = self.r = None + else: + self.s, self.r = s1, r1 + self.a = a1 + return self.a + + def run_single_trial(agent_program, mdp): """Execute trial for given agent_program and mdp. mdp should be an instance of subclass diff --git a/search.py b/aima/search.py similarity index 67% rename from search.py rename to aima/search.py index 5012c1a18..f379f6d77 100644 --- a/search.py +++ b/aima/search.py @@ -9,7 +9,7 @@ import sys from collections import deque -from utils import * +from aima.utils import * class Problem: @@ -114,6 +114,15 @@ def path(self): node = node.parent return list(reversed(path_back)) + def path_states(self): + """Return the sequence of states on the path from the root to this node. + + The search functions return the goal ``Node`` (staying close to the book's + pseudocode); this is the one-call way to read off the whole solution path as + states -- ``solution()`` gives the corresponding actions. + """ + return [node.state for node in self.path()] + # We want for a queue of nodes in breadth_first_graph_search or # astar_search to have no duplicated states, so we treat nodes # with the same state as equal. [Problem: this may not be what you @@ -159,15 +168,19 @@ def __call__(self, percept): return self.seq.pop(0) def update_state(self, state, percept): + """Update the agent's world state from the latest percept (abstract).""" raise NotImplementedError def formulate_goal(self, state): + """Decide on a goal to pursue given the current state (abstract).""" raise NotImplementedError def formulate_problem(self, state, goal): + """Build a Problem instance from the current state and chosen goal (abstract).""" raise NotImplementedError def search(self, problem): + """Return a sequence of actions that solves the given problem (abstract).""" raise NotImplementedError @@ -287,11 +300,38 @@ def best_first_graph_search(problem, f, display=False): return None +def best_first_tree_search(problem, f, display=False): + """Search the nodes with the lowest f scores first, without remembering the + states already reached. This is the tree-search counterpart of + best_first_graph_search: it drops the explored set and the frontier + membership/replacement bookkeeping, so it is faster when the state space is a + tree (no repeated states) but does not terminate on a graph that contains + cycles. You specify the function f(node) that you want to minimize.""" + f = memoize(f, 'f') + node = Node(problem.initial) + frontier = PriorityQueue('min', f) + frontier.append(node) + while frontier: + node = frontier.pop() + if problem.goal_test(node.state): + if display: + print(len(frontier), "paths remain in the frontier") + return node + for child in node.expand(problem): + frontier.append(child) + return None + + def uniform_cost_search(problem, display=False): """[Figure 3.14]""" return best_first_graph_search(problem, lambda node: node.path_cost, display) +def uniform_cost_tree_search(problem, display=False): + """[Figure 3.14] Tree-search version of uniform-cost search.""" + return best_first_tree_search(problem, lambda node: node.path_cost, display) + + def depth_limited_search(problem, limit=50): """[Figure 3.17]""" @@ -327,6 +367,9 @@ def iterative_deepening_search(problem): # Pseudocode from https://webdocs.cs.ualberta.ca/%7Eholte/Publications/MM-AAAI2016.pdf def bidirectional_search(problem): + """Meet-in-the-middle (MM) bidirectional search. Searches forward from the + initial state and backward from the goal at the same time, returning the cost + of the optimal path where the two frontiers meet (np.inf if no path exists).""" e = 0 if isinstance(problem, GraphProblem): e = problem.find_min_edge() @@ -407,6 +450,7 @@ def find_key(pr_min, open_dir, g): greedy_best_first_graph_search = best_first_graph_search +greedy_best_first_tree_search = best_first_tree_search # Greedy best-first search is accomplished by specifying f(n) = h(n). @@ -420,8 +464,54 @@ def astar_search(problem, h=None, display=False): return best_first_graph_search(problem, lambda n: n.path_cost + h(n), display) +def astar_tree_search(problem, h=None, display=False): + """Tree-search version of A*: best-first tree search with f(n) = g(n) + h(n). + Faster than astar_search when the state space is a tree, since it keeps no + explored set. You need to specify the h function when you call + astar_tree_search, or else in your Problem subclass.""" + h = memoize(h or problem.h, 'h') + return best_first_tree_search(problem, lambda n: n.path_cost + h(n), display) + + +def iterative_deepening_astar_search(problem, h=None): + """[Section 3.5.3] Iterative-deepening A* search: repeatedly run a depth-first + search bounded by an f = g + h contour, raising the bound to the smallest f + that exceeded it, until a goal within the bound is found.""" + h = memoize(h or problem.h, 'h') + + def f(node): + return node.path_cost + h(node) + + def contour(node, bound): + """Depth-first search pruned at f(node) > bound. Return a goal Node, or + the smallest f-value among the nodes that exceeded the bound.""" + if f(node) > bound: + return f(node) + if problem.goal_test(node.state): + return node + minimum = np.inf + for child in node.expand(problem): + # avoid cycles along the current path + if child.state not in (ancestor.state for ancestor in node.path()): + result = contour(child, bound) + if isinstance(result, Node): + return result + minimum = min(minimum, result) + return minimum + + node = Node(problem.initial) + bound = f(node) + while True: + result = contour(node, bound) + if isinstance(result, Node): + return result + if result == np.inf: + return None + bound = result + + # ______________________________________________________________________________ -# A* heuristics +# A* heuristics class EightPuzzle(Problem): """ The problem of sliding tiles numbered from 1 to 8 on a 3x3 board, where one of the @@ -487,7 +577,7 @@ def check_solvability(self, state): return inversion % 2 == 0 def h(self, node): - """ Return the heuristic value for a given state. Default heuristic function used is + """ Return the heuristic value for a given state. Default heuristic function used is h(n) = number of misplaced tiles """ return sum(s != g for (s, g) in zip(node.state, self.goal)) @@ -495,6 +585,226 @@ def h(self, node): # ______________________________________________________________________________ +class NPuzzle(Problem): + """Generalization of the Eight Puzzle problem. The problem consists of sliding tiles numbered from 1 to N ^2 on a NxN board, + where one of the squares is a blank. The state is represented as a tuple of length N^2, + where element at index i represents the tile number at index i , where '0' denotes empty square index""" + def __init__(self, initial=None, goal=None, size=3, heuristic='hamming', shuffle=10): + """Args: + intial: intiail state of puzzle. If not passed will default to goal state + size: number of rows and columns in puzzle + heuristic: heuristic used in astat search + shuffle: number of times to perfrom random action from the initial state. + Returns: + Problem instance modeling any size of sliding puzzle""" + self.size = size + self.heuristic = heuristic + goal = goal or (tuple(range(1,size*size, 1)) + (0, )) + initial = initial or (tuple(range(1,size*size, 1)) + (0, )) + if shuffle: + initial = self.shuffle_state(shuffle, list(initial)) + assert len(goal) == size*size, "length of goal tuple should be {length}".format(length=size*size) + assert len(initial) == size * size, "length of initial tuple should be {length}".format(length=size * size) + assert self.check_solvability(initial), "The puzzle is not solvable from the given initial state!" + Problem.__init__(self, initial, goal) + + def shuffle_state(self, shuffle, state): + """Perform random action from the initial state""" + for _ in range(shuffle): + action = random.sample(self.actions(state),1)[0] + state = self.result(state,action) + return state + + def find_blank_square(self, state): + """Return the index of the blank square in a given state""" + return state.index(0) + + def actions(self, state): + """Return the actions that can be executed in the given state. + The result would be a list of maximally four directions, since there are only four possible actions + in any given state of the environment""" + possible_actions = ['UP', 'DOWN', 'LEFT', 'RIGHT'] + index_blank_square = self.find_blank_square(state) + if index_blank_square % self.size == 0: + possible_actions.remove('LEFT') + if index_blank_square < self.size: + possible_actions.remove('UP') + if index_blank_square % self.size == self.size -1 : + possible_actions.remove('RIGHT') + if index_blank_square > self.size*self.size - self.size - 1: + possible_actions.remove('DOWN') + return possible_actions + + def result(self, state, action): + """Given state and action, return a new state that is the result of the action. + Action is assumed to be a valid action in the state""" + index_blank_square = self.find_blank_square(state) + new_state = list(state) + delta = {'UP': -self.size, 'DOWN': self.size, 'LEFT': -1, 'RIGHT': 1} + neighbor = index_blank_square + delta[action] + new_state[index_blank_square], new_state[neighbor] = new_state[neighbor], new_state[index_blank_square] + return tuple(new_state) + + def goal_test(self, state): + """Given a state, return True if state is a goal state or False otherwise""" + return state == self.goal + + def check_solvability(self, state): + """Checks if the given state is solvable. Whether or not puzzle is solvable depends on size being even or odd. + If N is odd, then puzzle instance is solvable if number of inversions is even in the input state. + If N is even, puzzle instance is solvable if the blank is on an even row counting from the bottom (second-last, fourth-last, etc.) and number of inversions is odd + or the blank is on an odd row counting from the bottom (last, third-last, fifth-last, etc.) and number of inversions is even." + reference : https://www.geeksforgeeks.org/check-instance-15-puzzle-solvable/""" + solvable = True + inversions = 0 + for i in range(len(state)-1): + for j in range(i+1, len(state)): + if state[i] != 0 and state[j] != 0 and state[i] > state[j]: + inversions += 1 + if self.size % 2 != 0: + solvable = True if (inversions % 2 == 0 ) else False + elif self.find_blank_square(state) // self.size % 2 == 0 and inversions % 2 != 0 : + solvable = True + elif self.find_blank_square(state) // self.size % 2 != 0 and inversions % 2 == 0: + solvable = True + else: + solvable = False + return solvable + + def h(self, node): + """Return the heuristic value for a given state. Default heuristic function used is + h(n) = number of misplaced tiles""" + heuristics = {'hamming':self.hamming_distance_heuristic(node)} + return heuristics.get(self.heuristic) + + def hamming_distance_heuristic(self, node): + """Number of tiles that are not in their goal position (Hamming distance).""" + return hamming_distance(node.state, self.goal) + + +class TravelingSalesman(Problem): + """ The problem of finding the shortest Hamiltonian cycle (tour) that visits + every city exactly once and returns to the start.""" + + def __init__(self, all_cities, initial, goal=None): + # initial = (0), goal = some state that its first and last values are 0 + """ Define goal state and initialize a problem """ + super().__init__(initial, goal) + self.cities_matrix = self.init_distance_matrix(all_cities) + self.cities_identifiers = set(all_cities.keys()) + self.cities_amount = len(self.cities_identifiers) + self.trees_evaluation_hash = {} + + def value(self, state): + """ Define state value """ + overall_distance = 0.0 + state_max_ind = len(state) - 1 + for ind, city in enumerate(state): + if ind != state_max_ind: + overall_distance += self.cities_matrix[state[ind]][state[ind + 1]] + return overall_distance + + def actions(self, state): + """ Return the actions that can be executed in the given state. + The result would be a list""" + if len(state) == self.cities_amount: + return [0] + return self.get_unvisited_cities(state) + + def result(self, state, action): + """ Given state and action, return a new state that is the result of the action. + Action is assumed to be a valid action in the state """ + + # blank is the index of the blank square + new_state = list(state) + new_state.append(action) + return tuple(new_state) + + def goal_test(self, state): + """ Given a state, return True if state is a goal state or False, otherwise""" + return len(state) == (self.cities_amount + 1) and state[0] == state[self.cities_amount] + + def get_unvisited_cities(self, state): + """ Returns unused actions(cities) """ + return self.cities_identifiers.difference(state) + + def path_cost(self, c, state1, action, state2): + """ Return next state cost """ + return c + self.cities_matrix[state1[-1]][state2[-1]] + + def h(self, node): + """Admissible heuristic: total edge weight of the MST over the cities + that have not yet been visited.""" + h = 0.0 + unvisited_cities = self.get_unvisited_cities(node.state) + mst_evaluation = self.eval_unvisited_mst_edges_sum(unvisited_cities) + h += mst_evaluation + return h + + def eval_unvisited_mst_edges_sum(self, unvisited_nodes): + """Return the total MST edge weight over the unvisited cities (0.0 if none).""" + if not unvisited_nodes: + return 0.0 + return self.tsp_kruskal(unvisited_nodes) + + def tsp_kruskal(self, unvisited_nodes): + """ Generate MST of the graph and return sum of edges """ + forest_value = 0.0 + disjoint_sets = DisjointSets() + non_sorted_edges_list = [] + for vertex_x in unvisited_nodes: + disjoint_sets.make_set(vertex_x) + for vertex_y in unvisited_nodes: + if vertex_x < vertex_y: + non_sorted_edges_list.append((vertex_x, vertex_y)) + edges_list = sorted(non_sorted_edges_list, key=lambda edge: self.cities_matrix[edge[0]][edge[1]], reverse=False) + for vertex_u, vertex_v in edges_list: + if disjoint_sets.find(vertex_v) != disjoint_sets.find(vertex_u): + forest_value += self.cities_matrix[vertex_u][vertex_v] + disjoint_sets.union(vertex_v, vertex_u) + return forest_value + + def init_distance_matrix(self, all_cities): + """ Generate matrix of distance between all cities """ + return {row_ind: {col_ind: distance(row_coords, col_coords) for col_ind, col_coords in all_cities.items()} + for row_ind, row_coords in all_cities.items()} + + +class DisjointSets: + """Union-find (disjoint-set) structure with union by rank, used to build the + MST for the TravelingSalesman heuristic.""" + + def __init__(self): + self.parent = {} + self.rank = {} + + def make_set(self, vertex): + """Create a new singleton set whose only element is vertex.""" + self.parent[vertex] = vertex + self.rank[vertex] = 0 + + def find(self, vertex): + """Return the representative (root) of the set that vertex belongs to.""" + if self.parent[vertex] == vertex: + return vertex + return self.find(self.parent[vertex]) + + def union(self, vertex_u, vertex_v): + """Merge the sets containing vertex_u and vertex_v, attaching the lower-rank + tree under the higher-rank one (union by rank).""" + vertex_u_root = self.find(vertex_u) + vertex_v_root = self.find(vertex_v) + + if self.rank[vertex_u_root] > self.rank[vertex_v_root]: + self.parent[vertex_v_root] = vertex_u_root + elif self.rank[vertex_u_root] < self.rank[vertex_v_root]: + self.parent[vertex_u_root] = vertex_v_root + else: + self.parent[vertex_u_root] = vertex_v_root + self.rank[vertex_v_root] += 1 + + +# ______________________________________________________________________________ class PlanRoute(Problem): """ The problem of moving the Hybrid Wumpus Agent from one place to other """ @@ -533,67 +843,61 @@ def actions(self, state): def result(self, state, action): """ Given state and action, return a new state that is the result of the action. - Action is assumed to be a valid action in the state """ + Action is assumed to be a valid action in the state. A fresh state object is + returned; the input state is never mutated, so the search tree stays consistent. """ x, y = state.get_location() - proposed_loc = list() + orientation = state.get_orientation() + proposed_loc = None + new_orientation = orientation # Move Forward if action == 'Forward': - if state.get_orientation() == 'UP': + if orientation == 'UP': proposed_loc = [x, y + 1] - elif state.get_orientation() == 'DOWN': + elif orientation == 'DOWN': proposed_loc = [x, y - 1] - elif state.get_orientation() == 'LEFT': + elif orientation == 'LEFT': proposed_loc = [x - 1, y] - elif state.get_orientation() == 'RIGHT': + elif orientation == 'RIGHT': proposed_loc = [x + 1, y] else: raise Exception('InvalidOrientation') # Rotate counter-clockwise elif action == 'TurnLeft': - if state.get_orientation() == 'UP': - state.set_orientation('LEFT') - elif state.get_orientation() == 'DOWN': - state.set_orientation('RIGHT') - elif state.get_orientation() == 'LEFT': - state.set_orientation('DOWN') - elif state.get_orientation() == 'RIGHT': - state.set_orientation('UP') - else: - raise Exception('InvalidOrientation') + new_orientation = {'UP': 'LEFT', 'DOWN': 'RIGHT', 'LEFT': 'DOWN', 'RIGHT': 'UP'}[orientation] # Rotate clockwise elif action == 'TurnRight': - if state.get_orientation() == 'UP': - state.set_orientation('RIGHT') - elif state.get_orientation() == 'DOWN': - state.set_orientation('LEFT') - elif state.get_orientation() == 'LEFT': - state.set_orientation('UP') - elif state.get_orientation() == 'RIGHT': - state.set_orientation('DOWN') - else: - raise Exception('InvalidOrientation') + new_orientation = {'UP': 'RIGHT', 'DOWN': 'LEFT', 'LEFT': 'UP', 'RIGHT': 'DOWN'}[orientation] - if proposed_loc in self.allowed: - state.set_location(proposed_loc[0], [proposed_loc[1]]) + new_x, new_y = x, y + if proposed_loc is not None and proposed_loc in self.allowed: + new_x, new_y = proposed_loc[0], proposed_loc[1] - return state + return type(state)(new_x, new_y, new_orientation) def goal_test(self, state): - """ Given a state, return True if state is a goal state or False, otherwise """ - - return state.get_location() == tuple(self.goal) + """ Return True when the state matches any goal position. The goal is a + collection of [x, y] cells or of position objects; for the latter the + orientation must match too (e.g. a shooting position). """ + for goal in self.goal: + if hasattr(goal, 'get_location'): + if (state.get_location() == goal.get_location() and + state.get_orientation() == goal.get_orientation()): + return True + elif list(state.get_location()) == list(goal): + return True + return False def h(self, node): - """ Return the heuristic value for a given state.""" - - # Manhattan Heuristic Function + """ Manhattan distance from the node's location to the nearest goal cell. """ x1, y1 = node.state.get_location() - x2, y2 = self.goal - - return abs(x2 - x1) + abs(y2 - y1) + distances = [] + for goal in self.goal: + x2, y2 = goal.get_location() if hasattr(goal, 'get_location') else (goal[0], goal[1]) + distances.append(abs(x2 - x1) + abs(y2 - y1)) + return min(distances) if distances else 0 # ______________________________________________________________________________ @@ -601,7 +905,9 @@ def h(self, node): def recursive_best_first_search(problem, h=None): - """[Figure 3.26]""" + """[Figure 3.26] A linear-space best-first search: it mimics A* but only keeps the + current path, backing up the best f-value of each forgotten subtree so it knows + where to resume.""" h = memoize(h or problem.h, 'h') def RBFS(problem, node, flimit): @@ -650,6 +956,79 @@ def hill_climbing(problem): return current.state +# The following hill-climbing variants and local beam search are described in +# AIMA Section 4.1 (Local Search) but the book gives no pseudocode for them, so +# these are simple, faithful implementations of the prose descriptions. + + +def stochastic_hill_climbing(problem): + """Hill climbing that, instead of the steepest neighbor, picks *at random* + among the uphill neighbors (AIMA 4e Section 4.1.1 -- no pseudocode in the book). + """ + current = Node(problem.initial) + while True: + uphill = [node for node in current.expand(problem) + if problem.value(node.state) > problem.value(current.state)] + if not uphill: + break + current = random.choice(uphill) + return current.state + + +def first_choice_hill_climbing(problem, tries=100): + """Hill climbing that generates successors at random until it finds one better + than the current state -- useful when a state has very many successors (AIMA 4e + Section 4.1.1 -- no pseudocode in the book). + """ + current = Node(problem.initial) + while True: + neighbors = current.expand(problem) + if not neighbors: + break + better = None + for _ in range(tries): + candidate = random.choice(neighbors) + if problem.value(candidate.state) > problem.value(current.state): + better = candidate + break + if better is None: + break + current = better + return current.state + + +def random_restart_hill_climbing(problem, new_state, restarts=10): + """Run :func:`hill_climbing` from ``restarts`` random initial states (each from + the 0-argument ``new_state`` callable) and return the best state found (AIMA 4e + Section 4.1.1 -- no pseudocode in the book). Note: it sets ``problem.initial``. + """ + best = None + for _ in range(restarts): + problem.initial = new_state() + state = hill_climbing(problem) + if best is None or problem.value(state) > problem.value(best): + best = state + return best + + +def local_beam_search(problem, k=4, new_state=None, iterations=1000): + """Keep ``k`` states; at each step expand all of them and keep the ``k`` best + successors, stopping when none improves on the current best (AIMA 4e Section + 4.1.3 -- no pseudocode in the book). The initial ``k`` states come from + ``new_state`` (a 0-argument callable) or default to the problem's initial state. + """ + current = [Node(new_state() if new_state else problem.initial) for _ in range(k)] + for _ in range(iterations): + neighbors = [nb for node in current for nb in node.expand(problem)] + if not neighbors: + break + best = sorted(neighbors, key=lambda node: problem.value(node.state), reverse=True)[:k] + if problem.value(best[0].state) <= max(problem.value(node.state) for node in current): + break + current = best + return max(current, key=lambda node: problem.value(node.state)).state + + def exp_schedule(k=20, lam=0.005, limit=100): """One possible schedule function for simulated annealing""" return lambda t: (k * np.exp(-lam * t) if t < limit else 0) @@ -673,7 +1052,7 @@ def simulated_annealing(problem, schedule=exp_schedule()): def simulated_annealing_full(problem, schedule=exp_schedule()): - """ This version returns all the states encountered in reaching + """ This version returns all the states encountered in reaching the goal state.""" states = [] current = Node(problem.initial) @@ -733,6 +1112,46 @@ def and_search(states, problem, path): directions8.update({'NW': (-1, 1), 'NE': (1, 1), 'SE': (1, -1), 'SW': (-1, -1)}) +class PourProblem(Problem): + """Problem about pouring water between jugs to achieve some water level. + Each state is a tuple of levels. In the initialization, provide a tuple of + capacities, e.g. PourProblem(initial=(2, 4, 3), goals={7}, capacities=(8, 16, 32)), + which means three jugs of capacity 8, 16, 32 currently filled with 2, 4, 3 units + of water, respectively, and the goal is to get a level of 7 in any one of the jugs.""" + + def __init__(self, initial=None, goals=(), capacities=None): + super().__init__(initial, goals) + self.goals = goals + self.capacities = capacities + + def actions(self, state): + """The actions executable in this state: fill or dump any jug, or pour one into another.""" + jugs = range(len(state)) + return ([('Fill', i) for i in jugs if state[i] != self.capacities[i]] + + [('Dump', i) for i in jugs if state[i] != 0] + + [('Pour', i, j) for i in jugs for j in jugs if i != j]) + + def result(self, state, action): + """The state that results from executing this action in this state.""" + result = list(state) + act, i, j = action[0], action[1], action[-1] + if act == 'Fill': # fill jug i to its capacity + result[i] = self.capacities[i] + elif act == 'Dump': # empty jug i + result[i] = 0 + elif act == 'Pour': # pour jug i into jug j + a, b = state[i], state[j] + result[i], result[j] = ((0, a + b) if (a + b <= self.capacities[j]) + else (a + b - self.capacities[j], self.capacities[j])) + else: + raise ValueError('unknown action', action) + return tuple(result) + + def goal_test(self, state): + """True if any of the jugs has a level equal to one of the goal levels.""" + return any(level in self.goals for level in state) + + class PeakFindingProblem(Problem): """Problem of finding the highest peak in a limited grid""" @@ -768,6 +1187,46 @@ def value(self, state): return self.grid[x][y] +class GridProblem(Problem): + """Shortest-path finding on a 2-D grid. + + States are ``(x, y)`` cells. The agent steps between in-bounds, obstacle-free + cells -- 4-connected via ``directions4`` by default, or pass ``directions8`` + for 8-connected movement. Every step costs 1; ``h`` is the Manhattan distance + to the goal (admissible for the 4-connected, unit-cost case). + """ + + def __init__(self, initial, goal, width, height, obstacles=(), directions=directions4): + super().__init__(initial, goal) + self.width = width + self.height = height + self.obstacles = set(map(tuple, obstacles)) + self.directions = directions + + def passable(self, cell): + """True if ``cell`` is on the grid and not an obstacle.""" + x, y = cell + return 0 <= x < self.width and 0 <= y < self.height and tuple(cell) not in self.obstacles + + def actions(self, state): + """The directions whose neighbouring cell is on the grid and obstacle-free.""" + return [a for a, d in self.directions.items() if self.passable(vector_add(state, d))] + + def result(self, state, action): + """Step one cell in ``action``'s direction.""" + return vector_add(state, self.directions[action]) + + def path_cost(self, c, state1, action, state2): + """Unit cost per step.""" + return c + 1 + + def h(self, node): + """Manhattan distance from ``node`` (or a state) to the goal.""" + x, y = node.state if isinstance(node, Node) else node + gx, gy = self.goal + return abs(x - gx) + abs(y - gy) + + class OnlineDFSAgent: """ [Figure 4.21] @@ -791,23 +1250,23 @@ def __call__(self, percept): self.a = None else: if s1 not in self.untried.keys(): - self.untried[s1] = self.problem.actions(s1) + self.untried[s1] = list(self.problem.actions(s1)) + self.unbacktracked[s1] = [] if self.s is not None: - if s1 != self.result[(self.s, self.a)]: - self.result[(self.s, self.a)] = s1 - self.unbacktracked[s1].insert(0, self.s) + self.result[(self.s, self.a)] = s1 + self.unbacktracked[s1].insert(0, self.s) if len(self.untried[s1]) == 0: if len(self.unbacktracked[s1]) == 0: self.a = None else: # else a <- an action b such that result[s', b] = POP(unbacktracked[s']) - unbacktracked_pop = self.unbacktracked.pop(s1) + unbacktracked_pop = self.unbacktracked[s1].pop(0) for (s, b) in self.result.keys(): - if self.result[(s, b)] == unbacktracked_pop: + if s == s1 and self.result[(s, b)] == unbacktracked_pop: self.a = b break else: - self.a = self.untried.pop(s1) + self.a = self.untried[s1].pop() self.s = s1 return self.a @@ -831,9 +1290,11 @@ def __init__(self, initial, goal, graph): self.graph = graph def actions(self, state): + """Return the actions available from state (the outgoing graph edges).""" return self.graph.graph_dict[state].keys() def output(self, state, action): + """Return the state reached by taking action in state.""" return self.graph.graph_dict[state][action] def h(self, state): @@ -845,9 +1306,11 @@ def c(self, s, a, s1): return 1 def update_state(self, percept): + """Convert a percept into a state (abstract; override in subclasses).""" raise NotImplementedError def goal_test(self, state): + """Return True if state is the goal state.""" if state == self.goal: return True return False @@ -936,6 +1399,8 @@ def genetic_algorithm(population, fitness_fn, gene_pool=[0, 1], f_thres=None, ng def fitness_threshold(fitness_fn, f_thres, population): + """Return the fittest individual if its fitness reaches f_thres, otherwise None + (also None when no threshold f_thres is given).""" if not f_thres: return None @@ -961,18 +1426,24 @@ def init_population(pop_number, gene_pool, state_length): def select(r, population, fitness_fn): + """Select r individuals from the population, sampling each with probability + proportional to its fitness.""" fitnesses = map(fitness_fn, population) sampler = weighted_sampler(population, fitnesses) return [sampler() for i in range(r)] def recombine(x, y): + """Single-point crossover: combine a random-length prefix of x with the + corresponding suffix of y.""" n = len(x) c = random.randrange(0, n) return x[:c] + y[c:] def recombine_uniform(x, y): + """Uniform crossover: build a child string by taking roughly half of its genes + from x and the rest from y, at random positions.""" n = len(x) result = [0] * n indexes = random.sample(range(n), n) @@ -984,6 +1455,8 @@ def recombine_uniform(x, y): def mutate(x, gene_pool, pmut): + """With probability pmut, replace one randomly chosen gene of x with a random + gene from gene_pool; otherwise return x unchanged.""" if random.uniform(0, 1) >= pmut: return x @@ -1005,11 +1478,15 @@ def mutate(x, gene_pool, pmut): class Graph: """A graph connects nodes (vertices) by edges (links). Each edge can also - have a length associated with it. The constructor call is something like: + have a length associated with it. The constructor call is something like:: + g = Graph({'A': {'B': 1, 'C': 2}) + this makes a graph with 3 nodes, A, B, and C, with an edge of length 1 from - A to B, and an edge of length 2 from A to C. You can also do: + A to B, and an edge of length 2 from A to C. You can also do:: + g = Graph({'A': {'B': 1, 'C': 2}, directed=False) + This makes an undirected graph, so inverse links are also added. The graph stays undirected; if you add more links with g.connect('B', 'C', 3), then inverse link is also added. You can use g.nodes() to get a list of nodes, @@ -1192,6 +1669,7 @@ def result(self, state, action): return action def path_cost(self, cost_so_far, A, action, B): + """Cost so far plus the length of the edge from A to B (np.inf if no edge).""" return cost_so_far + (self.graph.get(A, B) or np.inf) def find_min_edge(self): @@ -1225,9 +1703,11 @@ class GraphProblemStochastic(GraphProblem): """ def result(self, state, action): + """Return the list of possible states that the action may lead to.""" return self.graph.get(state, action) def path_cost(self): + """Not defined for the stochastic graph problem.""" raise NotImplementedError @@ -1502,6 +1982,8 @@ def boggle_hill_climbing(board=None, ntimes=100, verbose=True): def mutate_boggle(board): + """Randomly replace one cell of a Boggle board with a random face of a random + die, returning the changed index and the old letter so the move can be undone.""" i = random.randrange(len(board)) oldc = board[i] # random.choice(boyan_best) @@ -1523,14 +2005,18 @@ def __init__(self, problem): self.found = None def actions(self, state): + """Delegate to the wrapped problem, counting the successor query.""" self.succs += 1 return self.problem.actions(state) def result(self, state, action): + """Delegate to the wrapped problem, counting the generated state.""" self.states += 1 return self.problem.result(state, action) def goal_test(self, state): + """Delegate to the wrapped problem, counting the goal test and recording + the state when it is a goal.""" self.goal_tests += 1 result = self.problem.goal_test(state) if result: @@ -1538,9 +2024,11 @@ def goal_test(self, state): return result def path_cost(self, c, state1, action, state2): + """Delegate the path cost computation to the wrapped problem.""" return self.problem.path_cost(c, state1, action, state2) def value(self, state): + """Delegate the state value computation to the wrapped problem.""" return self.problem.value(state) def __getattr__(self, attr): @@ -1558,6 +2046,9 @@ def compare_searchers(problems, header, iterative_deepening_search, depth_limited_search, recursive_best_first_search]): + """Run every searcher on every problem and print a table of the resulting + InstrumentedProblem statistics (successors / goal tests / states generated).""" + def do(searcher, problem): p = InstrumentedProblem(problem) searcher(p) diff --git a/text.py b/aima/text.py similarity index 92% rename from text.py rename to aima/text.py index 11a5731f1..1bf693ff0 100644 --- a/text.py +++ b/aima/text.py @@ -14,9 +14,9 @@ import numpy as np -import search -from probabilistic_learning import CountingProbDist -from utils import hashabledict +from aima import search +from aima.probabilistic_learning import CountingProbDist +from aima.utils import hashabledict class UnigramWordModel(CountingProbDist): @@ -83,6 +83,9 @@ def samples(self, nwords): class NgramCharModel(NgramWordModel): + """An n-gram model over characters rather than words, trained on the + character n-grams of each word (prefixed with a space marker).""" + def add_sequence(self, words): """Add an empty space to every word to catch the beginning of words.""" for word in words: @@ -90,6 +93,9 @@ def add_sequence(self, words): class UnigramCharModel(NgramCharModel): + """A unigram (single-character) probability model: the frequency + distribution of individual characters across the observed words.""" + def __init__(self, observation_sequence=None, default=0): CountingProbDist.__init__(self, default=default) self.n = 1 @@ -97,6 +103,7 @@ def __init__(self, observation_sequence=None, default=0): self.add_sequence(observation_sequence or []) def add_sequence(self, words): + """Count every character of every word in ``words`` into the model.""" for word in words: for char in word: self.add(char) @@ -154,7 +161,7 @@ def __init__(self, stopwords='the a of'): def index_collection(self, filenames): """Index a whole collection of files.""" - prefix = os.path.dirname(__file__) + prefix = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) for filename in filenames: self.index_document(open(filename).read(), os.path.relpath(filename, prefix)) @@ -209,7 +216,7 @@ def __init__(self): IRSystem.__init__(self, stopwords="how do i the a of") import os - aima_root = os.path.dirname(__file__) + aima_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) mandir = os.path.join(aima_root, 'aima-data/MAN/') man_files = [mandir + f for f in os.listdir(mandir) if f.endswith('.txt')] @@ -228,6 +235,7 @@ def __init__(self, title, url, nwords): def words(text, reg=re.compile('[a-z0-9]+')): """Return a list of the words in text, ignoring punctuation and converting everything to lowercase (to canonicalize). + >>> words("``EGAD!'' Edgar cried.") ['egad', 'edgar', 'cried'] """ @@ -236,6 +244,7 @@ def words(text, reg=re.compile('[a-z0-9]+')): def canonicalize(text): """Return a canonical text: only lowercase letters and blanks. + >>> canonicalize("``EGAD!'' Edgar cried.") 'egad edgar cried' """ @@ -393,12 +402,20 @@ def score(self, code): class PermutationDecoderProblem(search.Problem): + """A search problem for breaking a permutation (substitution) cipher. + + A state is a partial mapping from plaintext characters to cipher characters; + the search incrementally assigns the most frequent unassigned character until + every character in the decoder's domain has been mapped.""" def __init__(self, initial=None, goal=None, decoder=None): super().__init__(initial or hashabledict(), goal) self.decoder = decoder def actions(self, state): + """Yield (plain_char, cipher_char) assignments extending ``state``: pick the + highest-probability unassigned plaintext char and pair it with each unused + cipher char.""" search_list = [c for c in self.decoder.chardomain if c not in state] target_list = [c for c in alphabet if c not in state.values()] # Find the best character to replace @@ -407,6 +424,8 @@ def actions(self, state): yield (plain_char, cipher_char) def result(self, state, action): + """Return a new state extending ``state`` with the (plain, cipher) mapping + given by ``action``.""" new_state = hashabledict(state) # copy to prevent hash issues new_state[action[0]] = action[1] return new_state diff --git a/utils.py b/aima/utils.py similarity index 78% rename from utils.py rename to aima/utils.py index 3158e3793..b3aaf1c51 100644 --- a/utils.py +++ b/aima/utils.py @@ -1,5 +1,7 @@ """Provides some utilities widely used by other modules""" +from __future__ import annotations + import bisect import collections import collections.abc @@ -96,6 +98,7 @@ def extend(s, var, val): def flatten(seqs): + """Flatten a sequence of sequences into a single flat list.""" return sum(seqs, []) @@ -155,7 +158,7 @@ def element_wise_product(x, y): def matrix_multiplication(x, *y): - """Return a matrix as a matrix-multiplication of x and arbitrary number of matrices *y.""" + r"""Return a matrix as a matrix-multiplication of x and arbitrary number of matrices \*y.""" result = x for _y in y: @@ -174,7 +177,7 @@ def scalar_vector_product(x, y): return np.multiply(x, y) -def probability(p): +def probability(p: float) -> bool: """Return true with probability p.""" return p > random.uniform(0.0, 1.0) @@ -217,7 +220,7 @@ def rounder(numbers, d=4): return constructor(rounder(n, d) for n in numbers) -def num_or_str(x): # TODO: rename as `atom` +def num_or_str(x: str) -> int | float | str: # TODO: rename as `atom` """The argument is a string; convert to a number if possible, or strip it.""" try: return int(x) @@ -228,39 +231,48 @@ def num_or_str(x): # TODO: rename as `atom` return str(x).strip() -def euclidean_distance(x, y): +def euclidean_distance(x, y) -> float: + """Return the Euclidean (L2) distance between vectors x and y.""" return np.sqrt(sum((_x - _y) ** 2 for _x, _y in zip(x, y))) -def manhattan_distance(x, y): +def manhattan_distance(x, y) -> float: + """Return the Manhattan (L1) distance between vectors x and y.""" return sum(abs(_x - _y) for _x, _y in zip(x, y)) -def hamming_distance(x, y): +def hamming_distance(x, y) -> int: + """Return the number of positions at which vectors x and y differ.""" return sum(_x != _y for _x, _y in zip(x, y)) -def cross_entropy_loss(x, y): +def cross_entropy_loss(x, y) -> float: + """Return the mean binary cross-entropy loss between targets x and predictions y.""" return (-1.0 / len(x)) * sum(_x * np.log(_y) + (1 - _x) * np.log(1 - _y) for _x, _y in zip(x, y)) -def mean_squared_error_loss(x, y): +def mean_squared_error_loss(x, y) -> float: + """Return the mean squared error loss between vectors x and y.""" return (1.0 / len(x)) * sum((_x - _y) ** 2 for _x, _y in zip(x, y)) -def rms_error(x, y): +def rms_error(x, y) -> float: + """Return the root-mean-square error between vectors x and y.""" return np.sqrt(ms_error(x, y)) -def ms_error(x, y): +def ms_error(x, y) -> float: + """Return the mean of the squared differences between vectors x and y.""" return mean((_x - _y) ** 2 for _x, _y in zip(x, y)) -def mean_error(x, y): +def mean_error(x, y) -> float: + """Return the mean of the absolute differences between vectors x and y.""" return mean(abs(_x - _y) for _x, _y in zip(x, y)) -def mean_boolean_error(x, y): +def mean_boolean_error(x, y) -> float: + """Return the fraction of positions at which vectors x and y differ.""" return mean(_x != _y for _x, _y in zip(x, y)) @@ -276,7 +288,8 @@ def normalize(dist): return [(n / total) for n in dist] -def random_weights(min_value, max_value, num_weights): +def random_weights(min_value: float, max_value: float, num_weights: int) -> list: + """Return a list of num_weights random floats drawn uniformly from [min_value, max_value].""" return [random.uniform(min_value, max_value) for _ in range(num_weights)] @@ -286,58 +299,69 @@ def sigmoid(x): def sigmoid_derivative(value): + """Return the derivative of the sigmoid, given its already-computed output value.""" return value * (1 - value) -def elu(x, alpha=0.01): +def elu(x: float, alpha: float = 0.01) -> float: + """Return the Exponential Linear Unit activation of x.""" return x if x > 0 else alpha * (np.exp(x) - 1) -def elu_derivative(value, alpha=0.01): +def elu_derivative(value: float, alpha: float = 0.01) -> float: + """Return the derivative of the ELU activation, given its output value.""" return 1 if value > 0 else alpha * np.exp(value) def tanh(x): + """Return the hyperbolic tangent activation of x.""" return np.tanh(x) def tanh_derivative(value): + """Return the derivative of tanh, given its already-computed output value.""" return 1 - (value ** 2) -def leaky_relu(x, alpha=0.01): +def leaky_relu(x: float, alpha: float = 0.01) -> float: + """Return the Leaky ReLU activation of x (slope alpha for negative inputs).""" return x if x > 0 else alpha * x -def leaky_relu_derivative(value, alpha=0.01): +def leaky_relu_derivative(value: float, alpha: float = 0.01) -> float: + """Return the derivative of the Leaky ReLU activation, given its output value.""" return 1 if value > 0 else alpha -def relu(x): +def relu(x: float) -> float: + """Return the Rectified Linear Unit activation of x, i.e. max(0, x).""" return max(0, x) -def relu_derivative(value): +def relu_derivative(value: float) -> int: + """Return the derivative of the ReLU activation, given its output value.""" return 1 if value > 0 else 0 -def step(x): +def step(x: float) -> int: """Return activation value of x with sign function""" return 1 if x >= 0 else 0 -def gaussian(mean, st_dev, x): +def gaussian(mean: float, st_dev: float, x: float) -> float: """Given the mean and standard deviation of a distribution, it returns the probability of x.""" return 1 / (np.sqrt(2 * np.pi) * st_dev) * np.e ** (-0.5 * (float(x - mean) / st_dev) ** 2) def linear_kernel(x, y=None): + """Return the linear kernel (dot product) between x and y; defaults y to x.""" if y is None: y = x return np.dot(x, y.T) def polynomial_kernel(x, y=None, degree=2.0): + """Return the polynomial kernel (1 + x.y)**degree between x and y; defaults y to x.""" if y is None: y = x return (1.0 + np.dot(x, y.T)) ** degree @@ -362,14 +386,17 @@ def rbf_kernel(x, y=None, gamma=None): def turn_heading(heading, inc, headings=orientations): + """Return the heading reached by turning inc steps around the list of headings.""" return headings[(headings.index(heading) + inc) % len(headings)] def turn_right(heading): + """Return the heading obtained by turning right (clockwise) from heading.""" return turn_heading(heading, RIGHT) def turn_left(heading): + """Return the heading obtained by turning left (counter-clockwise) from heading.""" return turn_heading(heading, LEFT) @@ -463,7 +490,8 @@ def print_table(table, header=None, sep=' ', numfmt='{}'): def open_data(name, mode='r'): - aima_root = os.path.dirname(__file__) + """Open and return the file named name from the aima-data directory.""" + aima_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) aima_file = os.path.join(aima_root, *['aima-data', name]) return open(aima_file, mode=mode) @@ -658,7 +686,7 @@ def arity(expression): class PartialExpr: - """Given 'P |'==>'| Q, first form PartialExpr('==>', P), then combine with Q.""" + r"""Given 'P \|'==>'\| Q, first form PartialExpr('==>', P), then combine with Q.""" def __init__(self, op, lhs): self.op, self.lhs = op, lhs @@ -671,10 +699,11 @@ def __repr__(self): def expr(x): - """Shortcut to create an Expression. x is a str in which: + r"""Shortcut to create an Expression. x is a str in which: - identifiers are automatically defined as Symbols. - - ==> is treated as an infix |'==>'|, as are <== and <=>. + - ==> is treated as an infix \|'==>'\|, as are <== and <=>. If x is already an Expression, it is returned unchanged. Example: + >>> expr('P & Q ==> Q') ((P & Q) ==> Q) """ @@ -685,7 +714,8 @@ def expr(x): def expr_handle_infix_ops(x): - """Given a str, return a new str with ==> replaced by |'==>'|, etc. + r"""Given a str, return a new str with ==> replaced by \|'==>'\|, etc. + >>> expr_handle_infix_ops('P ==> Q') "P |'==>'| Q" """ @@ -728,6 +758,9 @@ class PriorityQueue: def __init__(self, order='min', f=lambda x: x): self.heap = [] + # monotonic tie-breaker so items with equal priority are never compared + # (which would fail for non-comparable items); ties keep insertion order + self.counter = 0 if order == 'min': self.f = f elif order == 'max': # now item with max f(x) @@ -737,7 +770,8 @@ def __init__(self, order='min', f=lambda x: x): def append(self, item): """Insert item at its correct position.""" - heapq.heappush(self.heap, (self.f(item), item)) + heapq.heappush(self.heap, (self.f(item), self.counter, item)) + self.counter += 1 def extend(self, items): """Insert each item in items at its correct position.""" @@ -748,7 +782,7 @@ def pop(self): """Pop and return the item (with min or max f(x) value) depending on the order.""" if self.heap: - return heapq.heappop(self.heap)[1] + return heapq.heappop(self.heap)[-1] else: raise Exception('Trying to pop from empty PriorityQueue.') @@ -758,12 +792,12 @@ def __len__(self): def __contains__(self, key): """Return True if the key is in PriorityQueue.""" - return any([item == key for _, item in self.heap]) + return any(item == key for *_, item in self.heap) def __getitem__(self, key): """Returns the first value associated with key in PriorityQueue. Raises KeyError if key is not present.""" - for value, item in self.heap: + for value, _, item in self.heap: if item == key: return value raise KeyError(str(key) + " is not in the priority queue") @@ -771,7 +805,7 @@ def __getitem__(self, key): def __delitem__(self, key): """Delete the first occurrence of key.""" try: - del self.heap[[item == key for _, item in self.heap].index(True)] + del self.heap[[item == key for *_, item in self.heap].index(True)] except ValueError: raise KeyError(str(key) + " is not in the priority queue") heapq.heapify(self.heap) @@ -788,3 +822,51 @@ class Bool(int): T = Bool(True) F = Bool(False) + + +# ______________________________________________________________________________ +# 4th-edition additions (kernels, convolution, Monte Carlo tree search helpers) + +def gaussian_kernel(size=3): + """Return a length-size 1D Gaussian kernel centred at the middle (fixed st_dev 0.1).""" + return [gaussian((size - 1) / 2, 0.1, x) for x in range(size)] + + +def gaussian_kernel_1D(size=3, sigma=0.5): + """Return a length-size 1D Gaussian kernel centred at the middle with st_dev sigma.""" + return [gaussian((size - 1) / 2, sigma, x) for x in range(size)] + + +def gaussian_kernel_2D(size=3, sigma=0.5): + """Return a size x size 2D Gaussian kernel with st_dev sigma, normalized to sum to 1.""" + x, y = np.mgrid[-size // 2 + 1:size // 2 + 1, -size // 2 + 1:size // 2 + 1] + g = np.exp(-((x ** 2 + y ** 2) / (2.0 * sigma ** 2))) + return g / g.sum() + + +def conv1D(x, k): + """1D convolution. x: input vector; K: kernel vector.""" + return np.convolve(x, k, mode='same') + + +def map_vector(f, x): + """Apply function f to iterable x.""" + return [map_vector(f, _x) for _x in x] if hasattr(x, '__iter__') else list(map(f, [x]))[0] + + +class MCT_Node: + """Node in the Monte Carlo search tree, keeps track of the children states.""" + + def __init__(self, parent=None, state=None, U=0, N=0): + self.__dict__.update(parent=parent, state=state, U=U, N=N) + self.children = {} + self.actions = None + + +def ucb(n, C=1.4): + """Return the UCB1 score of node n (exploitation plus C-weighted exploration term). + + Unvisited nodes (n.N == 0) score infinity so they are selected first; used to guide + selection in Monte Carlo tree search. + """ + return np.inf if n.N == 0 else n.U / n.N + C * np.sqrt(np.log(n.parent.N) / n.N) diff --git a/docs/agents.rst b/docs/agents.rst new file mode 100644 index 000000000..6003036e7 --- /dev/null +++ b/docs/agents.rst @@ -0,0 +1,8 @@ +Agents & Environments +===================== + +.. automodule:: aima.agents + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..98e1e481a --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,111 @@ +"""Sphinx configuration for the aima-python API documentation. + +The docs are built from the in-source docstrings via autodoc; heavy optional +dependencies are mocked so the build needs only Sphinx plus a few light +packages (see docs/requirements.txt), which keeps Read the Docs builds fast. +""" + +import os +import re +import sys + +# make the library importable for autodoc +sys.path.insert(0, os.path.abspath("..")) + +# -- Project information ----------------------------------------------------- + +project = "aima-python" +copyright = "aima-python contributors" +author = "aima-python contributors" + +# -- General configuration --------------------------------------------------- + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.autosummary", + "sphinx.ext.viewcode", + "sphinx.ext.mathjax", + "sphinx.ext.intersphinx", +] + +autosummary_generate = True +autodoc_member_order = "bysource" +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} + +# heavy / optional third-party deps that need not be importable to build the docs +autodoc_mock_imports = [ + "tensorflow", + "keras", + "cv2", + "qpsolvers", + "cvxopt", + "cvxpy", + "ipywidgets", + "ipythonblocks", + "IPython", + "PIL", + "qpsolvers", +] + +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# show members as ``expr()`` rather than ``aima.utils.expr()`` -- cleaner headings +add_module_names = False + +# napoleon: render Google/NumPy-style sections a bit more compactly +napoleon_use_rtype = False +napoleon_use_ivar = True + + +def _strip_doctests(app, what, name, obj, options, lines): + """Drop doctest (``>>> ...``) example blocks from the rendered API docs. + + The examples stay in the source docstrings (useful when reading the code or + running them as doctests); they are merely omitted from the HTML so the API + reference reads as clean prose instead of interactive-session transcripts. + """ + cleaned, in_block = [], False + for line in lines: + stripped = line.strip() + if stripped.startswith(">>>"): + if not in_block: + # also drop a now-dangling "Example:"/"Examples:" header + blanks + while cleaned and (not cleaned[-1].strip() + or cleaned[-1].strip().rstrip(":").lower() + in ("example", "examples", "for example")): + cleaned.pop() + # also drop a trailing "... Example:" sentence on the prose line + if cleaned: + cleaned[-1] = re.sub( + r"\.\s+(for example|examples?|e\.g\.)\s*:?\s*$", ".", + cleaned[-1], flags=re.I) + in_block = True + continue + if in_block: + if not stripped: # a blank line ends the doctest block + in_block = False + cleaned.append(line) + # otherwise: a ``...`` continuation or an expected-output line -> drop + continue + cleaned.append(line) + while cleaned and not cleaned[-1].strip(): + cleaned.pop() + lines[:] = cleaned + + +def setup(app): + app.connect("autodoc-process-docstring", _strip_doctests) + + +# -- HTML output ------------------------------------------------------------- + +html_theme = "sphinx_rtd_theme" +html_static_path = [] diff --git a/docs/csp.rst b/docs/csp.rst new file mode 100644 index 000000000..5b67c9001 --- /dev/null +++ b/docs/csp.rst @@ -0,0 +1,8 @@ +Constraint Satisfaction +======================= + +.. automodule:: aima.csp + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/games.rst b/docs/games.rst new file mode 100644 index 000000000..32302aa69 --- /dev/null +++ b/docs/games.rst @@ -0,0 +1,13 @@ +Games & Game Theory +=================== + +.. automodule:: aima.games + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.game_theory + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..69d2e1bf0 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,32 @@ +aima-python documentation +========================== + +Python implementations of the algorithms from *Artificial Intelligence: A Modern +Approach* (AIMA). This site is the API reference, generated from the in-source +docstrings; it complements the per-module Jupyter notebooks in the repository. + +The 3rd- and 4th-edition module pairs have been merged into a single version per +topic (4th-edition content, no ``4e`` suffixes), all importable from the ``aima`` +package. + +.. toctree:: + :maxdepth: 2 + :caption: API reference + + agents + searching + games + csp + logic + planning + probability + learning + language + utils + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/language.rst b/docs/language.rst new file mode 100644 index 000000000..b2a8e7ab1 --- /dev/null +++ b/docs/language.rst @@ -0,0 +1,13 @@ +Natural Language +================ + +.. automodule:: aima.nlp + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.text + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/learning.rst b/docs/learning.rst new file mode 100644 index 000000000..57bbf7102 --- /dev/null +++ b/docs/learning.rst @@ -0,0 +1,28 @@ +Learning +======== + +.. automodule:: aima.learning + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.probabilistic_learning + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.deep_learning + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.reinforcement_learning + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.perception + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/logic.rst b/docs/logic.rst new file mode 100644 index 000000000..968d895a6 --- /dev/null +++ b/docs/logic.rst @@ -0,0 +1,13 @@ +Logic & Knowledge +================= + +.. automodule:: aima.logic + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.knowledge + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/planning.rst b/docs/planning.rst new file mode 100644 index 000000000..f55ff06e9 --- /dev/null +++ b/docs/planning.rst @@ -0,0 +1,8 @@ +Planning +======== + +.. automodule:: aima.planning + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/probability.rst b/docs/probability.rst new file mode 100644 index 000000000..882b85610 --- /dev/null +++ b/docs/probability.rst @@ -0,0 +1,18 @@ +Probability, MDPs & Decisions +============================= + +.. automodule:: aima.probability + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.mdp + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.making_simple_decisions + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..17a486ef0 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,11 @@ +# Dependencies for building the documentation on Read the Docs. +# Heavy/optional runtime deps (tensorflow, keras, cv2, qpsolvers, ipywidgets, ...) +# are mocked in conf.py (autodoc_mock_imports), so only Sphinx plus the light +# scientific packages that the modules import at top level are needed here. +sphinx>=7 +sphinx_rtd_theme>=2 +numpy +scipy +matplotlib +networkx +sortedcontainers diff --git a/docs/searching.rst b/docs/searching.rst new file mode 100644 index 000000000..95e25a05f --- /dev/null +++ b/docs/searching.rst @@ -0,0 +1,8 @@ +Search +====== + +.. automodule:: aima.search + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/utils.rst b/docs/utils.rst new file mode 100644 index 000000000..7a4974639 --- /dev/null +++ b/docs/utils.rst @@ -0,0 +1,19 @@ +Utilities & Notebook Helpers +============================ + +.. automodule:: aima.utils + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.notebook_utils + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: aima.ipyviews + :members: + :undoc-members: + :show-inheritance: + + diff --git a/games.py b/games.py deleted file mode 100644 index d22b2e640..000000000 --- a/games.py +++ /dev/null @@ -1,590 +0,0 @@ -"""Games or Adversarial Search (Chapter 5)""" - -import copy -import itertools -import random -from collections import namedtuple - -import numpy as np - -from utils import vector_add - -GameState = namedtuple('GameState', 'to_move, utility, board, moves') -StochasticGameState = namedtuple('StochasticGameState', 'to_move, utility, board, moves, chance') - - -# ______________________________________________________________________________ -# MinMax Search - - -def minmax_decision(state, game): - """Given a state in a game, calculate the best move by searching - forward all the way to the terminal states. [Figure 5.3]""" - - player = game.to_move(state) - - def max_value(state): - if game.terminal_test(state): - return game.utility(state, player) - v = -np.inf - for a in game.actions(state): - v = max(v, min_value(game.result(state, a))) - return v - - def min_value(state): - if game.terminal_test(state): - return game.utility(state, player) - v = np.inf - for a in game.actions(state): - v = min(v, max_value(game.result(state, a))) - return v - - # Body of minmax_decision: - return max(game.actions(state), key=lambda a: min_value(game.result(state, a))) - - -# ______________________________________________________________________________ - - -def expect_minmax(state, game): - """ - [Figure 5.11] - Return the best move for a player after dice are thrown. The game tree - includes chance nodes along with min and max nodes. - """ - player = game.to_move(state) - - def max_value(state): - v = -np.inf - for a in game.actions(state): - v = max(v, chance_node(state, a)) - return v - - def min_value(state): - v = np.inf - for a in game.actions(state): - v = min(v, chance_node(state, a)) - return v - - def chance_node(state, action): - res_state = game.result(state, action) - if game.terminal_test(res_state): - return game.utility(res_state, player) - sum_chances = 0 - num_chances = len(game.chances(res_state)) - for chance in game.chances(res_state): - res_state = game.outcome(res_state, chance) - util = 0 - if res_state.to_move == player: - util = max_value(res_state) - else: - util = min_value(res_state) - sum_chances += util * game.probability(chance) - return sum_chances / num_chances - - # Body of expect_minmax: - return max(game.actions(state), key=lambda a: chance_node(state, a), default=None) - - -def alpha_beta_search(state, game): - """Search game to determine best action; use alpha-beta pruning. - As in [Figure 5.7], this version searches all the way to the leaves.""" - - player = game.to_move(state) - - # Functions used by alpha_beta - def max_value(state, alpha, beta): - if game.terminal_test(state): - return game.utility(state, player) - v = -np.inf - for a in game.actions(state): - v = max(v, min_value(game.result(state, a), alpha, beta)) - if v >= beta: - return v - alpha = max(alpha, v) - return v - - def min_value(state, alpha, beta): - if game.terminal_test(state): - return game.utility(state, player) - v = np.inf - for a in game.actions(state): - v = min(v, max_value(game.result(state, a), alpha, beta)) - if v <= alpha: - return v - beta = min(beta, v) - return v - - # Body of alpha_beta_search: - best_score = -np.inf - beta = np.inf - best_action = None - for a in game.actions(state): - v = min_value(game.result(state, a), best_score, beta) - if v > best_score: - best_score = v - best_action = a - return best_action - - -def alpha_beta_cutoff_search(state, game, d=4, cutoff_test=None, eval_fn=None): - """Search game to determine best action; use alpha-beta pruning. - This version cuts off search and uses an evaluation function.""" - - player = game.to_move(state) - - # Functions used by alpha_beta - def max_value(state, alpha, beta, depth): - if cutoff_test(state, depth): - return eval_fn(state) - v = -np.inf - for a in game.actions(state): - v = max(v, min_value(game.result(state, a), alpha, beta, depth + 1)) - if v >= beta: - return v - alpha = max(alpha, v) - return v - - def min_value(state, alpha, beta, depth): - if cutoff_test(state, depth): - return eval_fn(state) - v = np.inf - for a in game.actions(state): - v = min(v, max_value(game.result(state, a), alpha, beta, depth + 1)) - if v <= alpha: - return v - beta = min(beta, v) - return v - - # Body of alpha_beta_cutoff_search starts here: - # The default test cuts off at depth d or at a terminal state - cutoff_test = (cutoff_test or (lambda state, depth: depth > d or game.terminal_test(state))) - eval_fn = eval_fn or (lambda state: game.utility(state, player)) - best_score = -np.inf - beta = np.inf - best_action = None - for a in game.actions(state): - v = min_value(game.result(state, a), best_score, beta, 1) - if v > best_score: - best_score = v - best_action = a - return best_action - - -# ______________________________________________________________________________ -# Players for Games - - -def query_player(game, state): - """Make a move by querying standard input.""" - print("current state:") - game.display(state) - print("available moves: {}".format(game.actions(state))) - print("") - move = None - if game.actions(state): - move_string = input('Your move? ') - try: - move = eval(move_string) - except NameError: - move = move_string - else: - print('no legal moves: passing turn to next player') - return move - - -def random_player(game, state): - """A player that chooses a legal move at random.""" - return random.choice(game.actions(state)) if game.actions(state) else None - - -def alpha_beta_player(game, state): - return alpha_beta_search(state, game) - - -def minmax_player(game,state): - return minmax_decision(state,game) - - -def expect_minmax_player(game, state): - return expect_minmax(state, game) - - -# ______________________________________________________________________________ -# Some Sample Games - - -class Game: - """A game is similar to a problem, but it has a utility for each - state and a terminal test instead of a path cost and a goal - test. To create a game, subclass this class and implement actions, - result, utility, and terminal_test. You may override display and - successors or you can inherit their default methods. You will also - need to set the .initial attribute to the initial state; this can - be done in the constructor.""" - - def actions(self, state): - """Return a list of the allowable moves at this point.""" - raise NotImplementedError - - def result(self, state, move): - """Return the state that results from making a move from a state.""" - raise NotImplementedError - - def utility(self, state, player): - """Return the value of this final state to player.""" - raise NotImplementedError - - def terminal_test(self, state): - """Return True if this is a final state for the game.""" - return not self.actions(state) - - def to_move(self, state): - """Return the player whose move it is in this state.""" - return state.to_move - - def display(self, state): - """Print or otherwise display the state.""" - print(state) - - def __repr__(self): - return '<{}>'.format(self.__class__.__name__) - - def play_game(self, *players): - """Play an n-person, move-alternating game.""" - state = self.initial - while True: - for player in players: - move = player(self, state) - state = self.result(state, move) - if self.terminal_test(state): - self.display(state) - return self.utility(state, self.to_move(self.initial)) - - -class StochasticGame(Game): - """A stochastic game includes uncertain events which influence - the moves of players at each state. To create a stochastic game, subclass - this class and implement chances and outcome along with the other - unimplemented game class methods.""" - - def chances(self, state): - """Return a list of all possible uncertain events at a state.""" - raise NotImplementedError - - def outcome(self, state, chance): - """Return the state which is the outcome of a chance trial.""" - raise NotImplementedError - - def probability(self, chance): - """Return the probability of occurrence of a chance.""" - raise NotImplementedError - - def play_game(self, *players): - """Play an n-person, move-alternating stochastic game.""" - state = self.initial - while True: - for player in players: - chance = random.choice(self.chances(state)) - state = self.outcome(state, chance) - move = player(self, state) - state = self.result(state, move) - if self.terminal_test(state): - self.display(state) - return self.utility(state, self.to_move(self.initial)) - - -class Fig52Game(Game): - """The game represented in [Figure 5.2]. Serves as a simple test case.""" - - succs = dict(A=dict(a1='B', a2='C', a3='D'), - B=dict(b1='B1', b2='B2', b3='B3'), - C=dict(c1='C1', c2='C2', c3='C3'), - D=dict(d1='D1', d2='D2', d3='D3')) - utils = dict(B1=3, B2=12, B3=8, C1=2, C2=4, C3=6, D1=14, D2=5, D3=2) - initial = 'A' - - def actions(self, state): - return list(self.succs.get(state, {}).keys()) - - def result(self, state, move): - return self.succs[state][move] - - def utility(self, state, player): - if player == 'MAX': - return self.utils[state] - else: - return -self.utils[state] - - def terminal_test(self, state): - return state not in ('A', 'B', 'C', 'D') - - def to_move(self, state): - return 'MIN' if state in 'BCD' else 'MAX' - - -class Fig52Extended(Game): - """Similar to Fig52Game but bigger. Useful for visualisation""" - - succs = {i: dict(l=i * 3 + 1, m=i * 3 + 2, r=i * 3 + 3) for i in range(13)} - utils = dict() - - def actions(self, state): - return sorted(list(self.succs.get(state, {}).keys())) - - def result(self, state, move): - return self.succs[state][move] - - def utility(self, state, player): - if player == 'MAX': - return self.utils[state] - else: - return -self.utils[state] - - def terminal_test(self, state): - return state not in range(13) - - def to_move(self, state): - return 'MIN' if state in {1, 2, 3} else 'MAX' - - -class TicTacToe(Game): - """Play TicTacToe on an h x v board, with Max (first player) playing 'X'. - A state has the player to move, a cached utility, a list of moves in - the form of a list of (x, y) positions, and a board, in the form of - a dict of {(x, y): Player} entries, where Player is 'X' or 'O'.""" - - def __init__(self, h=3, v=3, k=3): - self.h = h - self.v = v - self.k = k - moves = [(x, y) for x in range(1, h + 1) - for y in range(1, v + 1)] - self.initial = GameState(to_move='X', utility=0, board={}, moves=moves) - - def actions(self, state): - """Legal moves are any square not yet taken.""" - return state.moves - - def result(self, state, move): - if move not in state.moves: - return state # Illegal move has no effect - board = state.board.copy() - board[move] = state.to_move - moves = list(state.moves) - moves.remove(move) - return GameState(to_move=('O' if state.to_move == 'X' else 'X'), - utility=self.compute_utility(board, move, state.to_move), - board=board, moves=moves) - - def utility(self, state, player): - """Return the value to player; 1 for win, -1 for loss, 0 otherwise.""" - return state.utility if player == 'X' else -state.utility - - def terminal_test(self, state): - """A state is terminal if it is won or there are no empty squares.""" - return state.utility != 0 or len(state.moves) == 0 - - def display(self, state): - board = state.board - for x in range(1, self.h + 1): - for y in range(1, self.v + 1): - print(board.get((x, y), '.'), end=' ') - print() - - def compute_utility(self, board, move, player): - """If 'X' wins with this move, return 1; if 'O' wins return -1; else return 0.""" - if (self.k_in_row(board, move, player, (0, 1)) or - self.k_in_row(board, move, player, (1, 0)) or - self.k_in_row(board, move, player, (1, -1)) or - self.k_in_row(board, move, player, (1, 1))): - return +1 if player == 'X' else -1 - else: - return 0 - - def k_in_row(self, board, move, player, delta_x_y): - """Return true if there is a line through move on board for player.""" - (delta_x, delta_y) = delta_x_y - x, y = move - n = 0 # n is number of moves in row - while board.get((x, y)) == player: - n += 1 - x, y = x + delta_x, y + delta_y - x, y = move - while board.get((x, y)) == player: - n += 1 - x, y = x - delta_x, y - delta_y - n -= 1 # Because we counted move itself twice - return n >= self.k - - -class ConnectFour(TicTacToe): - """A TicTacToe-like game in which you can only make a move on the bottom - row, or in a square directly above an occupied square. Traditionally - played on a 7x6 board and requiring 4 in a row.""" - - def __init__(self, h=7, v=6, k=4): - TicTacToe.__init__(self, h, v, k) - - def actions(self, state): - return [(x, y) for (x, y) in state.moves - if x == self.h or (x + 1 , y ) in state.board] - -class Gomoku(TicTacToe): - """Also known as Five in a row.""" - - def __init__(self, h=15, v=16, k=5): - TicTacToe.__init__(self, h, v, k) - - -class Backgammon(StochasticGame): - """A two player game where the goal of each player is to move all the - checkers off the board. The moves for each state are determined by - rolling a pair of dice.""" - - def __init__(self): - """Initial state of the game""" - point = {'W': 0, 'B': 0} - board = [point.copy() for index in range(24)] - board[0]['B'] = board[23]['W'] = 2 - board[5]['W'] = board[18]['B'] = 5 - board[7]['W'] = board[16]['B'] = 3 - board[11]['B'] = board[12]['W'] = 5 - self.allow_bear_off = {'W': False, 'B': False} - self.direction = {'W': -1, 'B': 1} - self.initial = StochasticGameState(to_move='W', - utility=0, - board=board, - moves=self.get_all_moves(board, 'W'), chance=None) - - def actions(self, state): - """Return a list of legal moves for a state.""" - player = state.to_move - moves = state.moves - if len(moves) == 1 and len(moves[0]) == 1: - return moves - legal_moves = [] - for move in moves: - board = copy.deepcopy(state.board) - if self.is_legal_move(board, move, state.chance, player): - legal_moves.append(move) - return legal_moves - - def result(self, state, move): - board = copy.deepcopy(state.board) - player = state.to_move - self.move_checker(board, move[0], state.chance[0], player) - if len(move) == 2: - self.move_checker(board, move[1], state.chance[1], player) - to_move = ('W' if player == 'B' else 'B') - return StochasticGameState(to_move=to_move, - utility=self.compute_utility(board, move, player), - board=board, - moves=self.get_all_moves(board, to_move), chance=None) - - def utility(self, state, player): - """Return the value to player; 1 for win, -1 for loss, 0 otherwise.""" - return state.utility if player == 'W' else -state.utility - - def terminal_test(self, state): - """A state is terminal if one player wins.""" - return state.utility != 0 - - def get_all_moves(self, board, player): - """All possible moves for a player i.e. all possible ways of - choosing two checkers of a player from the board for a move - at a given state.""" - all_points = board - taken_points = [index for index, point in enumerate(all_points) - if point[player] > 0] - if self.checkers_at_home(board, player) == 1: - return [(taken_points[0],)] - moves = list(itertools.permutations(taken_points, 2)) - moves = moves + [(index, index) for index, point in enumerate(all_points) - if point[player] >= 2] - return moves - - def display(self, state): - """Display state of the game.""" - board = state.board - player = state.to_move - print("current state : ") - for index, point in enumerate(board): - print("point : ", index, " W : ", point['W'], " B : ", point['B']) - print("to play : ", player) - - def compute_utility(self, board, move, player): - """If 'W' wins with this move, return 1; if 'B' wins return -1; else return 0.""" - util = {'W': 1, 'B': -1} - for idx in range(0, 24): - if board[idx][player] > 0: - return 0 - return util[player] - - def checkers_at_home(self, board, player): - """Return the no. of checkers at home for a player.""" - sum_range = range(0, 7) if player == 'W' else range(17, 24) - count = 0 - for idx in sum_range: - count = count + board[idx][player] - return count - - def is_legal_move(self, board, start, steps, player): - """Move is a tuple which contains starting points of checkers to be - moved during a player's turn. An on-board move is legal if both the destinations - are open. A bear-off move is the one where a checker is moved off-board. - It is legal only after a player has moved all his checkers to his home.""" - dest1, dest2 = vector_add(start, steps) - dest_range = range(0, 24) - move1_legal = move2_legal = False - if dest1 in dest_range: - if self.is_point_open(player, board[dest1]): - self.move_checker(board, start[0], steps[0], player) - move1_legal = True - else: - if self.allow_bear_off[player]: - self.move_checker(board, start[0], steps[0], player) - move1_legal = True - if not move1_legal: - return False - if dest2 in dest_range: - if self.is_point_open(player, board[dest2]): - move2_legal = True - else: - if self.allow_bear_off[player]: - move2_legal = True - return move1_legal and move2_legal - - def move_checker(self, board, start, steps, player): - """Move a checker from starting point by a given number of steps""" - dest = start + steps - dest_range = range(0, 24) - board[start][player] -= 1 - if dest in dest_range: - board[dest][player] += 1 - if self.checkers_at_home(board, player) == 15: - self.allow_bear_off[player] = True - - def is_point_open(self, player, point): - """A point is open for a player if the no. of opponent's - checkers already present on it is 0 or 1. A player can - move a checker to a point only if it is open.""" - opponent = 'B' if player == 'W' else 'W' - return point[opponent] <= 1 - - def chances(self, state): - """Return a list of all possible dice rolls at a state.""" - dice_rolls = list(itertools.combinations_with_replacement([1, 2, 3, 4, 5, 6], 2)) - return dice_rolls - - def outcome(self, state, chance): - """Return the state which is the outcome of a dice roll.""" - dice = tuple(map((self.direction[state.to_move]).__mul__, chance)) - return StochasticGameState(to_move=state.to_move, - utility=state.utility, - board=state.board, - moves=state.moves, chance=dice) - - def probability(self, chance): - """Return the probability of occurrence of a dice roll.""" - return 1 / 36 if chance[0] == chance[1] else 1 / 18 diff --git a/games4e.ipynb b/games4e.ipynb deleted file mode 100644 index 5b619f7ed..000000000 --- a/games4e.ipynb +++ /dev/null @@ -1,1667 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Game Tree Search\n", - "\n", - "We start with defining the abstract class `Game`, for turn-taking *n*-player games. We rely on, but do not define yet, the concept of a `state` of the game; we'll see later how individual games define states. For now, all we require is that a state has a `state.to_move` attribute, which gives the name of the player whose turn it is. (\"Name\" will be something like `'X'` or `'O'` for tic-tac-toe.) \n", - "\n", - "We also define `play_game`, which takes a game and a dictionary of `{player_name: strategy_function}` pairs, and plays out the game, on each turn checking `state.to_move` to see whose turn it is, and then getting the strategy function for that player and applying it to the game and the state to get a move." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from collections import namedtuple, Counter, defaultdict\n", - "import random\n", - "import math\n", - "import functools \n", - "cache = functools.lru_cache(10**6)" - ] - }, - { - "cell_type": "code", - "execution_count": 73, - "metadata": {}, - "outputs": [], - "source": [ - "class Game:\n", - " \"\"\"A game is similar to a problem, but it has a terminal test instead of \n", - " a goal test, and a utility for each terminal state. To create a game, \n", - " subclass this class and implement `actions`, `result`, `is_terminal`, \n", - " and `utility`. You will also need to set the .initial attribute to the \n", - " initial state; this can be done in the constructor.\"\"\"\n", - "\n", - " def actions(self, state):\n", - " \"\"\"Return a collection of the allowable moves from this state.\"\"\"\n", - " raise NotImplementedError\n", - "\n", - " def result(self, state, move):\n", - " \"\"\"Return the state that results from making a move from a state.\"\"\"\n", - " raise NotImplementedError\n", - "\n", - " def is_terminal(self, state):\n", - " \"\"\"Return True if this is a final state for the game.\"\"\"\n", - " return not self.actions(state)\n", - " \n", - " def utility(self, state, player):\n", - " \"\"\"Return the value of this final state to player.\"\"\"\n", - " raise NotImplementedError\n", - " \n", - "\n", - "def play_game(game, strategies: dict, verbose=False):\n", - " \"\"\"Play a turn-taking game. `strategies` is a {player_name: function} dict,\n", - " where function(state, game) is used to get the player's move.\"\"\"\n", - " state = game.initial\n", - " while not game.is_terminal(state):\n", - " player = state.to_move\n", - " move = strategies[player](game, state)\n", - " state = game.result(state, move)\n", - " if verbose: \n", - " print('Player', player, 'move:', move)\n", - " print(state)\n", - " return state" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Minimax-Based Game Search Algorithms\n", - "\n", - "We will define several game search algorithms. Each takes two inputs, the game we are playing and the current state of the game, and returns a a `(value, move)` pair, where `value` is the utility that the algorithm computes for the player whose turn it is to move, and `move` is the move itself.\n", - "\n", - "First we define `minimax_search`, which exhaustively searches the game tree to find an optimal move (assuming both players play optimally), and `alphabeta_search`, which does the same computation, but prunes parts of the tree that could not possibly have an affect on the optimnal move. " - ] - }, - { - "cell_type": "code", - "execution_count": 66, - "metadata": {}, - "outputs": [], - "source": [ - "def minimax_search(game, state):\n", - " \"\"\"Search game tree to determine best move; return (value, move) pair.\"\"\"\n", - "\n", - " player = state.to_move\n", - "\n", - " def max_value(state):\n", - " if game.is_terminal(state):\n", - " return game.utility(state, player), None\n", - " v, move = -infinity, None\n", - " for a in game.actions(state):\n", - " v2, _ = min_value(game.result(state, a))\n", - " if v2 > v:\n", - " v, move = v2, a\n", - " return v, move\n", - "\n", - " def min_value(state):\n", - " if game.is_terminal(state):\n", - " return game.utility(state, player), None\n", - " v, move = +infinity, None\n", - " for a in game.actions(state):\n", - " v2, _ = max_value(game.result(state, a))\n", - " if v2 < v:\n", - " v, move = v2, a\n", - " return v, move\n", - "\n", - " return max_value(state)\n", - "\n", - "infinity = math.inf\n", - "\n", - "def alphabeta_search(game, state):\n", - " \"\"\"Search game to determine best action; use alpha-beta pruning.\n", - " As in [Figure 5.7], this version searches all the way to the leaves.\"\"\"\n", - "\n", - " player = state.to_move\n", - "\n", - " def max_value(state, alpha, beta):\n", - " if game.is_terminal(state):\n", - " return game.utility(state, player), None\n", - " v, move = -infinity, None\n", - " for a in game.actions(state):\n", - " v2, _ = min_value(game.result(state, a), alpha, beta)\n", - " if v2 > v:\n", - " v, move = v2, a\n", - " alpha = max(alpha, v)\n", - " if v >= beta:\n", - " return v, move\n", - " return v, move\n", - "\n", - " def min_value(state, alpha, beta):\n", - " if game.is_terminal(state):\n", - " return game.utility(state, player), None\n", - " v, move = +infinity, None\n", - " for a in game.actions(state):\n", - " v2, _ = max_value(game.result(state, a), alpha, beta)\n", - " if v2 < v:\n", - " v, move = v2, a\n", - " beta = min(beta, v)\n", - " if v <= alpha:\n", - " return v, move\n", - " return v, move\n", - "\n", - " return max_value(state, -infinity, +infinity)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# A Simple Game: Tic-Tac-Toe\n", - "\n", - "We have the notion of an abstract game, we have some search functions; now it is time to define a real game; a simple one, tic-tac-toe. Moves are `(x, y)` pairs denoting squares, where `(0, 0)` is the top left, and `(2, 2)` is the bottom right (on a board of size `height=width=3`)." - ] - }, - { - "cell_type": "code", - "execution_count": 67, - "metadata": {}, - "outputs": [], - "source": [ - "class TicTacToe(Game):\n", - " \"\"\"Play TicTacToe on an `height` by `width` board, needing `k` in a row to win.\n", - " 'X' plays first against 'O'.\"\"\"\n", - "\n", - " def __init__(self, height=3, width=3, k=3):\n", - " self.k = k # k in a row\n", - " self.squares = {(x, y) for x in range(width) for y in range(height)}\n", - " self.initial = Board(height=height, width=width, to_move='X', utility=0)\n", - "\n", - " def actions(self, board):\n", - " \"\"\"Legal moves are any square not yet taken.\"\"\"\n", - " return self.squares - set(board)\n", - "\n", - " def result(self, board, square):\n", - " \"\"\"Place a marker for current player on square.\"\"\"\n", - " player = board.to_move\n", - " board = board.new({square: player}, to_move=('O' if player == 'X' else 'X'))\n", - " win = k_in_row(board, player, square, self.k)\n", - " board.utility = (0 if not win else +1 if player == 'X' else -1)\n", - " return board\n", - "\n", - " def utility(self, board, player):\n", - " \"\"\"Return the value to player; 1 for win, -1 for loss, 0 otherwise.\"\"\"\n", - " return board.utility if player == 'X' else -board.utility\n", - "\n", - " def is_terminal(self, board):\n", - " \"\"\"A board is a terminal state if it is won or there are no empty squares.\"\"\"\n", - " return board.utility != 0 or len(self.squares) == len(board)\n", - "\n", - " def display(self, board): print(board) \n", - "\n", - "\n", - "def k_in_row(board, player, square, k):\n", - " \"\"\"True if player has k pieces in a line through square.\"\"\"\n", - " def in_row(x, y, dx, dy): return 0 if board[x, y] != player else 1 + in_row(x + dx, y + dy, dx, dy)\n", - " return any(in_row(*square, dx, dy) + in_row(*square, -dx, -dy) - 1 >= k\n", - " for (dx, dy) in ((0, 1), (1, 0), (1, 1), (1, -1)))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "States in tic-tac-toe (and other games) will be represented as a `Board`, which is a subclass of `defaultdict` that in general will consist of `{(x, y): contents}` pairs, for example `{(0, 0): 'X', (1, 1): 'O'}` might be the state of the board after two moves. Besides the contents of squares, a board also has some attributes: \n", - "- `.to_move` to name the player whose move it is; \n", - "- `.width` and `.height` to give the size of the board (both 3 in tic-tac-toe, but other numbers in related games);\n", - "- possibly other attributes, as specified by keywords. \n", - "\n", - "As a `defaultdict`, the `Board` class has a `__missing__` method, which returns `empty` for squares that have no been assigned but are within the `width` × `height` boundaries, or `off` otherwise. The class has a `__hash__` method, so instances can be stored in hash tables." - ] - }, - { - "cell_type": "code", - "execution_count": 68, - "metadata": {}, - "outputs": [], - "source": [ - "class Board(defaultdict):\n", - " \"\"\"A board has the player to move, a cached utility value, \n", - " and a dict of {(x, y): player} entries, where player is 'X' or 'O'.\"\"\"\n", - " empty = '.'\n", - " off = '#'\n", - " \n", - " def __init__(self, width=8, height=8, to_move=None, **kwds):\n", - " self.__dict__.update(width=width, height=height, to_move=to_move, **kwds)\n", - " \n", - " def new(self, changes: dict, **kwds) -> 'Board':\n", - " \"Given a dict of {(x, y): contents} changes, return a new Board with the changes.\"\n", - " board = Board(width=self.width, height=self.height, **kwds)\n", - " board.update(self)\n", - " board.update(changes)\n", - " return board\n", - "\n", - " def __missing__(self, loc):\n", - " x, y = loc\n", - " if 0 <= x < self.width and 0 <= y < self.height:\n", - " return self.empty\n", - " else:\n", - " return self.off\n", - " \n", - " def __hash__(self): \n", - " return hash(tuple(sorted(self.items()))) + hash(self.to_move)\n", - " \n", - " def __repr__(self):\n", - " def row(y): return ' '.join(self[x, y] for x in range(self.width))\n", - " return '\\n'.join(map(row, range(self.height))) + '\\n'" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Players\n", - "\n", - "We need an interface for players. I'll represent a player as a `callable` that will be passed two arguments: `(game, state)` and will return a `move`.\n", - "The function `player` creates a player out of a search algorithm, but you can create your own players as functions, as is done with `random_player` below:" - ] - }, - { - "cell_type": "code", - "execution_count": 69, - "metadata": {}, - "outputs": [], - "source": [ - "def random_player(game, state): return random.choice(list(game.actions(state)))\n", - "\n", - "def player(search_algorithm):\n", - " \"\"\"A game player who uses the specified search algorithm\"\"\"\n", - " return lambda game, state: search_algorithm(game, state)[1]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Playing a Game\n", - "\n", - "We're ready to play a game. I'll set up a match between a `random_player` (who chooses randomly from the legal moves) and a `player(alphabeta_search)` (who makes the optimal alpha-beta move; practical for tic-tac-toe, but not for large games). The `player(alphabeta_search)` will never lose, but if `random_player` is lucky, it will be a tie." - ] - }, - { - "cell_type": "code", - "execution_count": 74, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Player X move: (0, 0)\n", - "X . .\n", - ". . .\n", - ". . .\n", - "\n", - "Player O move: (1, 1)\n", - "X . .\n", - ". O .\n", - ". . .\n", - "\n", - "Player X move: (1, 2)\n", - "X . .\n", - ". O .\n", - ". X .\n", - "\n", - "Player O move: (0, 1)\n", - "X . .\n", - "O O .\n", - ". X .\n", - "\n", - "Player X move: (2, 1)\n", - "X . .\n", - "O O X\n", - ". X .\n", - "\n", - "Player O move: (2, 0)\n", - "X . O\n", - "O O X\n", - ". X .\n", - "\n", - "Player X move: (2, 2)\n", - "X . O\n", - "O O X\n", - ". X X\n", - "\n", - "Player O move: (0, 2)\n", - "X . O\n", - "O O X\n", - "O X X\n", - "\n" - ] - }, - { - "data": { - "text/plain": [ - "-1" - ] - }, - "execution_count": 74, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "play_game(TicTacToe(), dict(X=random_player, O=player(alphabeta_search)), verbose=True).utility" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The alpha-beta player will never lose, but sometimes the random player can stumble into a draw. When two optimal (alpha-beta or minimax) players compete, it will always be a draw:" - ] - }, - { - "cell_type": "code", - "execution_count": 75, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Player X move: (0, 1)\n", - ". . .\n", - "X . .\n", - ". . .\n", - "\n", - "Player O move: (0, 0)\n", - "O . .\n", - "X . .\n", - ". . .\n", - "\n", - "Player X move: (2, 0)\n", - "O . X\n", - "X . .\n", - ". . .\n", - "\n", - "Player O move: (2, 1)\n", - "O . X\n", - "X . O\n", - ". . .\n", - "\n", - "Player X move: (1, 2)\n", - "O . X\n", - "X . O\n", - ". X .\n", - "\n", - "Player O move: (0, 2)\n", - "O . X\n", - "X . O\n", - "O X .\n", - "\n", - "Player X move: (1, 0)\n", - "O X X\n", - "X . O\n", - "O X .\n", - "\n", - "Player O move: (1, 1)\n", - "O X X\n", - "X O O\n", - "O X .\n", - "\n", - "Player X move: (2, 2)\n", - "O X X\n", - "X O O\n", - "O X X\n", - "\n" - ] - }, - { - "data": { - "text/plain": [ - "0" - ] - }, - "execution_count": 75, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "play_game(TicTacToe(), dict(X=player(alphabeta_search), O=player(minimax_search)), verbose=True).utility" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Connect Four\n", - "\n", - "Connect Four is a variant of tic-tac-toe, played on a larger (7 x 6) board, and with the restriction that in any column you can only play in the lowest empty square in the column." - ] - }, - { - "cell_type": "code", - "execution_count": 76, - "metadata": {}, - "outputs": [], - "source": [ - "class ConnectFour(TicTacToe):\n", - " \n", - " def __init__(self): super().__init__(width=7, height=6, k=4)\n", - "\n", - " def actions(self, board):\n", - " \"\"\"In each column you can play only the lowest empty square in the column.\"\"\"\n", - " return {(x, y) for (x, y) in self.squares - set(board)\n", - " if y == board.height - 1 or (x, y + 1) in board}" - ] - }, - { - "cell_type": "code", - "execution_count": 77, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Player X move: (2, 5)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . X . . . .\n", - "\n", - "Player O move: (1, 5)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". O X . . . .\n", - "\n", - "Player X move: (5, 5)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". O X . . X .\n", - "\n", - "Player O move: (4, 5)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". O X . O X .\n", - "\n", - "Player X move: (4, 4)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . X . .\n", - ". O X . O X .\n", - "\n", - "Player O move: (2, 4)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . O . X . .\n", - ". O X . O X .\n", - "\n", - "Player X move: (2, 3)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . X . . . .\n", - ". . O . X . .\n", - ". O X . O X .\n", - "\n", - "Player O move: (1, 4)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . X . . . .\n", - ". O O . X . .\n", - ". O X . O X .\n", - "\n", - "Player X move: (0, 5)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . X . . . .\n", - ". O O . X . .\n", - "X O X . O X .\n", - "\n", - "Player O move: (5, 4)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . X . . . .\n", - ". O O . X O .\n", - "X O X . O X .\n", - "\n", - "Player X move: (5, 3)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . X . . X .\n", - ". O O . X O .\n", - "X O X . O X .\n", - "\n", - "Player O move: (6, 5)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . X . . X .\n", - ". O O . X O .\n", - "X O X . O X O\n", - "\n", - "Player X move: (1, 3)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". X X . . X .\n", - ". O O . X O .\n", - "X O X . O X O\n", - "\n", - "Player O move: (6, 4)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". X X . . X .\n", - ". O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player X move: (5, 2)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . X .\n", - ". X X . . X .\n", - ". O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player O move: (0, 4)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . X .\n", - ". X X . . X .\n", - "O O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player X move: (0, 3)\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . X .\n", - "X X X . . X .\n", - "O O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player O move: (0, 2)\n", - ". . . . . . .\n", - ". . . . . . .\n", - "O . . . . X .\n", - "X X X . . X .\n", - "O O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player X move: (0, 1)\n", - ". . . . . . .\n", - "X . . . . . .\n", - "O . . . . X .\n", - "X X X . . X .\n", - "O O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player O move: (0, 0)\n", - "O . . . . . .\n", - "X . . . . . .\n", - "O . . . . X .\n", - "X X X . . X .\n", - "O O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player X move: (5, 1)\n", - "O . . . . . .\n", - "X . . . . X .\n", - "O . . . . X .\n", - "X X X . . X .\n", - "O O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player O move: (4, 3)\n", - "O . . . . . .\n", - "X . . . . X .\n", - "O . . . . X .\n", - "X X X . O X .\n", - "O O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player X move: (6, 3)\n", - "O . . . . . .\n", - "X . . . . X .\n", - "O . . . . X .\n", - "X X X . O X X\n", - "O O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player O move: (5, 0)\n", - "O . . . . O .\n", - "X . . . . X .\n", - "O . . . . X .\n", - "X X X . O X X\n", - "O O O . X O O\n", - "X O X . O X O\n", - "\n", - "Player X move: (3, 5)\n", - "O . . . . O .\n", - "X . . . . X .\n", - "O . . . . X .\n", - "X X X . O X X\n", - "O O O . X O O\n", - "X O X X O X O\n", - "\n", - "Player O move: (1, 2)\n", - "O . . . . O .\n", - "X . . . . X .\n", - "O O . . . X .\n", - "X X X . O X X\n", - "O O O . X O O\n", - "X O X X O X O\n", - "\n", - "Player X move: (1, 1)\n", - "O . . . . O .\n", - "X X . . . X .\n", - "O O . . . X .\n", - "X X X . O X X\n", - "O O O . X O O\n", - "X O X X O X O\n", - "\n", - "Player O move: (3, 4)\n", - "O . . . . O .\n", - "X X . . . X .\n", - "O O . . . X .\n", - "X X X . O X X\n", - "O O O O X O O\n", - "X O X X O X O\n", - "\n" - ] - }, - { - "data": { - "text/plain": [ - "-1" - ] - }, - "execution_count": 77, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "play_game(ConnectFour(), dict(X=random_player, O=random_player), verbose=True).utility" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Transposition Tables\n", - "\n", - "By treating the game tree as a tree, we can arrive at the same state through different paths, and end up duplicating effort. In state-space search, we kept a table of `reached` states to prevent this. For game-tree search, we can achieve the same effect by applying the `@cache` decorator to the `min_value` and `max_value` functions. We'll use the suffix `_tt` to indicate a function that uses these transisiton tables." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "def minimax_search_tt(game, state):\n", - " \"\"\"Search game to determine best move; return (value, move) pair.\"\"\"\n", - "\n", - " player = state.to_move\n", - "\n", - " @cache\n", - " def max_value(state):\n", - " if game.is_terminal(state):\n", - " return game.utility(state, player), None\n", - " v, move = -infinity, None\n", - " for a in game.actions(state):\n", - " v2, _ = min_value(game.result(state, a))\n", - " if v2 > v:\n", - " v, move = v2, a\n", - " return v, move\n", - "\n", - " @cache\n", - " def min_value(state):\n", - " if game.is_terminal(state):\n", - " return game.utility(state, player), None\n", - " v, move = +infinity, None\n", - " for a in game.actions(state):\n", - " v2, _ = max_value(game.result(state, a))\n", - " if v2 < v:\n", - " v, move = v2, a\n", - " return v, move\n", - "\n", - " return max_value(state)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For alpha-beta search, we can still use a cache, but it should be based just on the state, not on whatever values alpha and beta have." - ] - }, - { - "cell_type": "code", - "execution_count": 79, - "metadata": {}, - "outputs": [], - "source": [ - "def cache1(function):\n", - " \"Like lru_cache(None), but only considers the first argument of function.\"\n", - " cache = {}\n", - " def wrapped(x, *args):\n", - " if x not in cache:\n", - " cache[x] = function(x, *args)\n", - " return cache[x]\n", - " return wrapped\n", - "\n", - "def alphabeta_search_tt(game, state):\n", - " \"\"\"Search game to determine best action; use alpha-beta pruning.\n", - " As in [Figure 5.7], this version searches all the way to the leaves.\"\"\"\n", - "\n", - " player = state.to_move\n", - "\n", - " @cache1\n", - " def max_value(state, alpha, beta):\n", - " if game.is_terminal(state):\n", - " return game.utility(state, player), None\n", - " v, move = -infinity, None\n", - " for a in game.actions(state):\n", - " v2, _ = min_value(game.result(state, a), alpha, beta)\n", - " if v2 > v:\n", - " v, move = v2, a\n", - " alpha = max(alpha, v)\n", - " if v >= beta:\n", - " return v, move\n", - " return v, move\n", - "\n", - " @cache1\n", - " def min_value(state, alpha, beta):\n", - " if game.is_terminal(state):\n", - " return game.utility(state, player), None\n", - " v, move = +infinity, None\n", - " for a in game.actions(state):\n", - " v2, _ = max_value(game.result(state, a), alpha, beta)\n", - " if v2 < v:\n", - " v, move = v2, a\n", - " beta = min(beta, v)\n", - " if v <= alpha:\n", - " return v, move\n", - " return v, move\n", - "\n", - " return max_value(state, -infinity, +infinity)" - ] - }, - { - "cell_type": "code", - "execution_count": 81, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CPU times: user 593 ms, sys: 52 ms, total: 645 ms\n", - "Wall time: 655 ms\n" - ] - }, - { - "data": { - "text/plain": [ - "O X X\n", - "X O O\n", - "O X X" - ] - }, - "execution_count": 81, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "%time play_game(TicTacToe(), {'X':player(alphabeta_search_tt), 'O':player(minimax_search_tt)})" - ] - }, - { - "cell_type": "code", - "execution_count": 82, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CPU times: user 3.07 s, sys: 30.7 ms, total: 3.1 s\n", - "Wall time: 3.15 s\n" - ] - }, - { - "data": { - "text/plain": [ - "O X X\n", - "X O O\n", - "O X X" - ] - }, - "execution_count": 82, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "%time play_game(TicTacToe(), {'X':player(alphabeta_search), 'O':player(minimax_search)})" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Heuristic Cutoffs" - ] - }, - { - "cell_type": "code", - "execution_count": 63, - "metadata": {}, - "outputs": [], - "source": [ - "def cutoff_depth(d):\n", - " \"\"\"A cutoff function that searches to depth d.\"\"\"\n", - " return lambda game, state, depth: depth > d\n", - "\n", - "def h_alphabeta_search(game, state, cutoff=cutoff_depth(6), h=lambda s, p: 0):\n", - " \"\"\"Search game to determine best action; use alpha-beta pruning.\n", - " As in [Figure 5.7], this version searches all the way to the leaves.\"\"\"\n", - "\n", - " player = state.to_move\n", - "\n", - " @cache1\n", - " def max_value(state, alpha, beta, depth):\n", - " if game.is_terminal(state):\n", - " return game.utility(state, player), None\n", - " if cutoff(game, state, depth):\n", - " return h(state, player), None\n", - " v, move = -infinity, None\n", - " for a in game.actions(state):\n", - " v2, _ = min_value(game.result(state, a), alpha, beta, depth+1)\n", - " if v2 > v:\n", - " v, move = v2, a\n", - " alpha = max(alpha, v)\n", - " if v >= beta:\n", - " return v, move\n", - " return v, move\n", - "\n", - " @cache1\n", - " def min_value(state, alpha, beta, depth):\n", - " if game.is_terminal(state):\n", - " return game.utility(state, player), None\n", - " if cutoff(game, state, depth):\n", - " return h(state, player), None\n", - " v, move = +infinity, None\n", - " for a in game.actions(state):\n", - " v2, _ = max_value(game.result(state, a), alpha, beta, depth + 1)\n", - " if v2 < v:\n", - " v, move = v2, a\n", - " beta = min(beta, v)\n", - " if v <= alpha:\n", - " return v, move\n", - " return v, move\n", - "\n", - " return max_value(state, -infinity, +infinity, 0)" - ] - }, - { - "cell_type": "code", - "execution_count": 54, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CPU times: user 367 ms, sys: 7.9 ms, total: 375 ms\n", - "Wall time: 375 ms\n" - ] - }, - { - "data": { - "text/plain": [ - "O X X\n", - "X O O\n", - "O X X" - ] - }, - "execution_count": 54, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "%time play_game(TicTacToe(), {'X':player(h_alphabeta_search), 'O':player(h_alphabeta_search)})" - ] - }, - { - "cell_type": "code", - "execution_count": 60, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . X\n", - ". . . . . X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . O X\n", - ". . . . . X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . O X\n", - ". . . . X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". . . . . O X\n", - ". . . . X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". . . . X O X\n", - ". . . . X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". . . . X O X\n", - ". O . . X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". X . . X O X\n", - ". O . . X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . O O\n", - ". X . . X O X\n", - ". O . . X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . X O O\n", - ". X . . X O X\n", - ". O . . X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . X O O\n", - ". X . . X O X\n", - ". O . O X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . X .\n", - ". . . . X O O\n", - ". X . . X O X\n", - ". O . O X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". . . . . X .\n", - ". . . . X O O\n", - ". X . . X O X\n", - ". O . O X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". . . . . X .\n", - ". X . . X O O\n", - ". X . . X O X\n", - ". O . O X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". . . . . X O\n", - ". X . . X O O\n", - ". X . . X O X\n", - ". O . O X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". X . . . X O\n", - ". X . . X O O\n", - ". X . . X O X\n", - ". O . O X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". X . . . X O\n", - ". X . . X O O\n", - ". X . . X O X\n", - ". O O O X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". X . . . X O\n", - ". X . . X O O\n", - ". X . . X O X\n", - "X O O O X X O\n", - "\n", - ". . . . . . .\n", - ". . . . . O O\n", - ". X . . . X O\n", - ". X . . X O O\n", - ". X . . X O X\n", - "X O O O X X O\n", - "\n", - ". . . . . . X\n", - ". . . . . O O\n", - ". X . . . X O\n", - ". X . . X O O\n", - ". X . . X O X\n", - "X O O O X X O\n", - "\n", - ". . . . . . X\n", - ". . . . . O O\n", - ". X . . . X O\n", - ". X . . X O O\n", - "O X . . X O X\n", - "X O O O X X O\n", - "\n", - ". . . . . X X\n", - ". . . . . O O\n", - ". X . . . X O\n", - ". X . . X O O\n", - "O X . . X O X\n", - "X O O O X X O\n", - "\n", - ". . . . . X X\n", - ". . . . . O O\n", - ". X . . . X O\n", - ". X . . X O O\n", - "O X . O X O X\n", - "X O O O X X O\n", - "\n", - ". . . . . X X\n", - ". . . . . O O\n", - ". X . . . X O\n", - ". X . X X O O\n", - "O X . O X O X\n", - "X O O O X X O\n", - "\n", - ". . . . . X X\n", - ". . . . . O O\n", - ". X . . O X O\n", - ". X . X X O O\n", - "O X . O X O X\n", - "X O O O X X O\n", - "\n", - ". . . . . X X\n", - ". . . . . O O\n", - ". X . X O X O\n", - ". X . X X O O\n", - "O X . O X O X\n", - "X O O O X X O\n", - "\n", - ". . . . . X X\n", - ". . . . O O O\n", - ". X . X O X O\n", - ". X . X X O O\n", - "O X . O X O X\n", - "X O O O X X O\n", - "\n", - ". . . . . X X\n", - ". . . X O O O\n", - ". X . X O X O\n", - ". X . X X O O\n", - "O X . O X O X\n", - "X O O O X X O\n", - "\n", - ". . . . O X X\n", - ". . . X O O O\n", - ". X . X O X O\n", - ". X . X X O O\n", - "O X . O X O X\n", - "X O O O X X O\n", - "\n", - ". . . X O X X\n", - ". . . X O O O\n", - ". X . X O X O\n", - ". X . X X O O\n", - "O X . O X O X\n", - "X O O O X X O\n", - "\n", - "CPU times: user 8.82 s, sys: 146 ms, total: 8.96 s\n", - "Wall time: 9.19 s\n" - ] - }, - { - "data": { - "text/plain": [ - "1" - ] - }, - "execution_count": 60, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "%time play_game(ConnectFour(), {'X':player(h_alphabeta_search), 'O':random_player}, verbose=True).utility" - ] - }, - { - "cell_type": "code", - "execution_count": 61, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". . . . . X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". . . . X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . O .\n", - ". . O . X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . X O .\n", - ". . O . X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . X O .\n", - ". . O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . X O .\n", - ". X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". O . . X O .\n", - ". X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". X . . . . .\n", - ". O . . X O .\n", - ". X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". O . . . . .\n", - ". X . . . . .\n", - ". O . . X O .\n", - ". X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". O . . . . .\n", - ". X . . . . .\n", - ". O . . X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". O . . . . .\n", - ". X . . . . .\n", - "O O . . X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". O . . . . .\n", - ". X . . X . .\n", - "O O . . X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . . . .\n", - ". O . . O . .\n", - ". X . . X . .\n", - "O O . . X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . X . .\n", - ". O . . O . .\n", - ". X . . X . .\n", - "O O . . X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . X . .\n", - ". O . . O . .\n", - ". X . . X O .\n", - "O O . . X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . X . .\n", - ". O . . O X .\n", - ". X . . X O .\n", - "O O . . X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . X . .\n", - ". O . . O X .\n", - ". X . . X O .\n", - "O O O . X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . X . .\n", - ". O . . O X .\n", - ". X . . X O .\n", - "O O O X X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . X O .\n", - ". O . . O X .\n", - ". X . . X O .\n", - "O O O X X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . X O .\n", - ". O . . O X .\n", - ". X . X X O .\n", - "O O O X X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . . X O .\n", - ". O . O O X .\n", - ". X . X X O .\n", - "O O O X X O .\n", - "X X O O X X .\n", - "\n", - ". . . . . . .\n", - ". . . X X O .\n", - ". O . O O X .\n", - ". X . X X O .\n", - "O O O X X O .\n", - "X X O O X X .\n", - "\n", - ". . . O . . .\n", - ". . . X X O .\n", - ". O . O O X .\n", - ". X . X X O .\n", - "O O O X X O .\n", - "X X O O X X .\n", - "\n", - ". . . O . . .\n", - ". . . X X O .\n", - ". O . O O X .\n", - ". X X X X O .\n", - "O O O X X O .\n", - "X X O O X X .\n", - "\n", - "CPU times: user 12.1 s, sys: 237 ms, total: 12.4 s\n", - "Wall time: 12.9 s\n" - ] - }, - { - "data": { - "text/plain": [ - "1" - ] - }, - "execution_count": 61, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "%time play_game(ConnectFour(), {'X':player(h_alphabeta_search), 'O':player(h_alphabeta_search)}, verbose=True).utility" - ] - }, - { - "cell_type": "code", - "execution_count": 83, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Result states: 6,589; Terminal tests: 3,653; for alphabeta_search_tt\n", - "Result states: 25,703; Terminal tests: 25,704; for alphabeta_search\n", - "Result states: 4,687; Terminal tests: 2,805; for h_alphabeta_search\n", - "Result states: 16,167; Terminal tests: 5,478; for minimax_search_tt\n" - ] - } - ], - "source": [ - "class CountCalls:\n", - " \"\"\"Delegate all attribute gets to the object, and count them in ._counts\"\"\"\n", - " def __init__(self, obj):\n", - " self._object = obj\n", - " self._counts = Counter()\n", - " \n", - " def __getattr__(self, attr):\n", - " \"Delegate to the original object, after incrementing a counter.\"\n", - " self._counts[attr] += 1\n", - " return getattr(self._object, attr)\n", - " \n", - "def report(game, searchers):\n", - " for searcher in searchers:\n", - " game = CountCalls(game)\n", - " searcher(game, game.initial)\n", - " print('Result states: {:7,d}; Terminal tests: {:7,d}; for {}'.format(\n", - " game._counts['result'], game._counts['is_terminal'], searcher.__name__))\n", - " \n", - "report(TicTacToe(), (alphabeta_search_tt, alphabeta_search, h_alphabeta_search, minimax_search_tt))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Monte Carlo Tree Search" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "class Node:\n", - " def __init__(self, parent, )\n", - "def mcts(state, game, N=1000):" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Heuristic Search Algorithms" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t = CountCalls(TicTacToe())\n", - " \n", - "play_game(t, dict(X=minimax_player, O=minimax_player), verbose=True)\n", - "t._counts" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for tactic in (three, fork, center, opposite_corner, corner, any):\n", - " for s in squares:\n", - " if tactic(board, s,player): return s\n", - " for s ins quares:\n", - " if tactic(board, s, opponent): return s" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "\n", - "\n", - "def ucb(U, N, C=2**0.5, parentN=100):\n", - " return round(U/N + C * math.sqrt(math.log(parentN)/N), 2)\n", - "\n", - "{C: (ucb(60, 79, C), ucb(1, 10, C), ucb(2, 11, C)) \n", - " for C in (1.4, 1.5)}\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def ucb(U, N, parentN=100, C=2):\n", - " return U/N + C * math.sqrt(math.log(parentN)/N)\n", - "\n", - "\n", - "C = 1.4 \n", - "\n", - "class Node:\n", - " def __init__(self, name, children=(), U=0, N=0, parent=None, p=0.5):\n", - " self.__dict__.update(name=name, U=U, N=N, parent=parent, children=children, p=p)\n", - " for c in children:\n", - " c.parent = self\n", - " \n", - " def __repr__(self):\n", - " return '{}:{}/{}={:.0%}{}'.format(self.name, self.U, self.N, self.U/self.N, self.children)\n", - " \n", - "def select(n):\n", - " if n.children:\n", - " return select(max(n.children, key=ucb))\n", - " else:\n", - " return n\n", - " \n", - "def back(n, amount):\n", - " if n:\n", - " n.N += 1\n", - " n.U += amount\n", - " back(n.parent, 1 - amount)\n", - " \n", - " \n", - "def one(root): \n", - " n = select(root)\n", - " amount = int(random.uniform(0, 1) < n.p)\n", - " back(n, amount)\n", - " \n", - "def ucb(n): \n", - " return (float('inf') if n.N == 0 else\n", - " n.U / n.N + C * math.sqrt(math.log(n.parent.N)/n.N))\n", - "\n", - "\n", - "tree = Node('root', [Node('a', p=.8, children=[Node('a1', p=.05), \n", - " Node('a2', p=.25,\n", - " children=[Node('a2a', p=.7), Node('a2b')])]),\n", - " Node('b', p=.5, children=[Node('b1', p=.6,\n", - " children=[Node('b1a', p=.3), Node('b1b')]), \n", - " Node('b2', p=.4)]),\n", - " Node('c', p=.1)])\n", - "\n", - "for i in range(100):\n", - " one(tree); \n", - "for c in tree.children: print(c)\n", - "'select', select(tree), 'tree', tree\n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "us = (100, 50, 25, 10, 5, 1)\n", - "infinity = float('inf')\n", - "\n", - "@lru_cache(None)\n", - "def f1(n, denom):\n", - " return (0 if n == 0 else\n", - " infinity if n < 0 or not denom else\n", - " min(1 + f1(n - denom[0], denom),\n", - " f1(n, denom[1:])))\n", - " \n", - "@lru_cache(None)\n", - "def f2(n, denom):\n", - " @lru_cache(None)\n", - " def f(n):\n", - " return (0 if n == 0 else\n", - " infinity if n < 0 else\n", - " 1 + min(f(n - d) for d in denom))\n", - " return f(n)\n", - "\n", - "@lru_cache(None)\n", - "def f3(n, denom):\n", - " return (0 if n == 0 else\n", - " infinity if n < 0 or not denom else\n", - " min(k + f2(n - k * denom[0], denom[1:]) \n", - " for k in range(1 + n // denom[0])))\n", - " \n", - "\n", - "def g(n, d=us): return f1(n, d), f2(n, d), f3(n, d)\n", - " \n", - "n = 12345\n", - "%time f1(n, us)\n", - "%time f2(n, us)\n", - "%time f3(n, us)\n", - " " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.7.2" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/gui/eight_puzzle.py b/gui/eight_puzzle.py index 5733228d7..dedb2f666 100644 --- a/gui/eight_puzzle.py +++ b/gui/eight_puzzle.py @@ -4,7 +4,7 @@ from functools import partial from tkinter import * -from search import astar_search, EightPuzzle +from aima.search import astar_search, EightPuzzle sys.path.append(os.path.join(os.path.dirname(__file__), '..')) diff --git a/gui/genetic_algorithm_example.py b/gui/genetic_algorithm_example.py index c987151c8..aa9303410 100644 --- a/gui/genetic_algorithm_example.py +++ b/gui/genetic_algorithm_example.py @@ -8,13 +8,13 @@ # Displays a progress bar that indicates the amount of completion of the algorithm # Displays the first few individuals of the current generation +import sys import os.path from tkinter import * from tkinter import ttk -import search - sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +from aima import search LARGE_FONT = ('Verdana', 12) EXTRA_LARGE_FONT = ('Consolas', 36, 'bold') diff --git a/gui/grid_mdp.py b/gui/grid_mdp.py index e60b49247..c5e5d2e28 100644 --- a/gui/grid_mdp.py +++ b/gui/grid_mdp.py @@ -13,7 +13,7 @@ from matplotlib.figure import Figure from matplotlib.ticker import MaxNLocator -from mdp import * +from aima.mdp import * sys.path.append(os.path.join(os.path.dirname(__file__), '..')) diff --git a/gui/romania_problem.py b/gui/romania_problem.py index 9ec94099d..f5a7ae8b5 100644 --- a/gui/romania_problem.py +++ b/gui/romania_problem.py @@ -1,8 +1,8 @@ from copy import deepcopy -from tkinter import * +import tkinter as tk -from search import * -from utils import PriorityQueue +from aima.search import * +from aima.utils import PriorityQueue sys.path.append(os.path.join(os.path.dirname(__file__), '..')) @@ -28,7 +28,7 @@ def create_map(root): width = 750 height = 670 margin = 5 - city_map = Canvas(root, width=width, height=height) + city_map = tk.Canvas(root, width=width, height=height) city_map.pack() # Since lines have to be drawn between particular points, we need to list @@ -274,31 +274,31 @@ def make_rectangle(map, x0, y0, margin, city_name): x0 - 2 * margin, y0 - 2 * margin, text=city_name, - anchor=E) + anchor=tk.E) else: map.create_text( x0 - 2 * margin, y0 - 2 * margin, text=city_name, - anchor=SE) + anchor=tk.SE) city_coord.update({city_name: rect}) def make_legend(map): rect1 = map.create_rectangle(600, 100, 610, 110, fill="white") - text1 = map.create_text(615, 105, anchor=W, text="Un-explored") + text1 = map.create_text(615, 105, anchor=tk.W, text="Un-explored") rect2 = map.create_rectangle(600, 115, 610, 125, fill="orange") - text2 = map.create_text(615, 120, anchor=W, text="Frontier") + text2 = map.create_text(615, 120, anchor=tk.W, text="Frontier") rect3 = map.create_rectangle(600, 130, 610, 140, fill="red") - text3 = map.create_text(615, 135, anchor=W, text="Currently Exploring") + text3 = map.create_text(615, 135, anchor=tk.W, text="Currently Exploring") rect4 = map.create_rectangle(600, 145, 610, 155, fill="grey") - text4 = map.create_text(615, 150, anchor=W, text="Explored") + text4 = map.create_text(615, 150, anchor=tk.W, text="Explored") rect5 = map.create_rectangle(600, 160, 610, 170, fill="dark green") - text5 = map.create_text(615, 165, anchor=W, text="Final Solution") + text5 = map.create_text(615, 165, anchor=tk.W, text="Final Solution") def tree_search(problem): @@ -622,32 +622,31 @@ def reset_map(): # TODO: Add more search algorithms in the OptionMenu if __name__ == "__main__": - global algo, start, goal, next_button - root = Tk() + root = tk.Tk() root.title("Road Map of Romania") root.geometry("950x1150") - algo = StringVar(root) - start = StringVar(root) - goal = StringVar(root) + algo = tk.StringVar(root) + start = tk.StringVar(root) + goal = tk.StringVar(root) algo.set("Breadth-First Tree Search") start.set('Arad') goal.set('Bucharest') cities = sorted(romania_map.locations.keys()) - algorithm_menu = OptionMenu( + algorithm_menu = tk.OptionMenu( root, algo, "Breadth-First Tree Search", "Depth-First Tree Search", "Breadth-First Graph Search", "Depth-First Graph Search", "Uniform Cost Search", "A* - Search") - Label(root, text="\n Search Algorithm").pack() + tk.Label(root, text="\n Search Algorithm").pack() algorithm_menu.pack() - Label(root, text="\n Start City").pack() - start_menu = OptionMenu(root, start, *cities) + tk.Label(root, text="\n Start City").pack() + start_menu = tk.OptionMenu(root, start, *cities) start_menu.pack() - Label(root, text="\n Goal City").pack() - goal_menu = OptionMenu(root, goal, *cities) + tk.Label(root, text="\n Goal City").pack() + goal_menu = tk.OptionMenu(root, goal, *cities) goal_menu.pack() - frame1 = Frame(root) - next_button = Button( + frame1 = tk.Frame(root) + next_button = tk.Button( frame1, width=6, height=2, @@ -655,9 +654,9 @@ def reset_map(): command=on_click, padx=2, pady=2, - relief=GROOVE) - next_button.pack(side=RIGHT) - reset_button = Button( + relief=tk.GROOVE) + next_button.pack(side=tk.RIGHT) + reset_button = tk.Button( frame1, width=6, height=2, @@ -665,8 +664,8 @@ def reset_map(): command=reset_map, padx=2, pady=2, - relief=GROOVE) - reset_button.pack(side=RIGHT) - frame1.pack(side=BOTTOM) + relief=tk.GROOVE) + reset_button.pack(side=tk.RIGHT) + frame1.pack(side=tk.BOTTOM) create_map(root) root.mainloop() diff --git a/gui/tic-tac-toe.py b/gui/tic-tac-toe.py index 66d9d6e75..19c393d9a 100644 --- a/gui/tic-tac-toe.py +++ b/gui/tic-tac-toe.py @@ -1,7 +1,7 @@ import os.path from tkinter import * -from games import minmax_decision, alpha_beta_player, random_player, TicTacToe +from aima.games import minmax_decision, alpha_beta_player, random_player, TicTacToe # "gen_state" can be used to generate a game state to apply the algorithm from tests.test_games import gen_state diff --git a/gui/tsp.py b/gui/tsp.py index 590fff354..a577c8a75 100644 --- a/gui/tsp.py +++ b/gui/tsp.py @@ -1,8 +1,8 @@ from tkinter import * from tkinter import messagebox -import utils -from search import * +from aima import utils +from aima.search import * sys.path.append(os.path.join(os.path.dirname(__file__), '..')) @@ -260,7 +260,7 @@ def fitness_fn(state): while True: population = [mutate(recombine(*select(2, population, fitness_fn)), self.mutation_rate.get()) for _ in range(len(population))] - current_best = np.argmax(population, key=fitness_fn) + current_best = utils.argmax_random_tie(population, key=fitness_fn) if fitness_fn(current_best) > fitness_fn(all_time_best): all_time_best = current_best self.cost.set("Cost = " + str('%0.3f' % (-1 * problem.value(all_time_best)))) @@ -294,7 +294,7 @@ def find_neighbors(state, number_of_neighbors=100): current = Node(problem.initial) while True: neighbors = find_neighbors(current.state, self.no_of_neighbors.get()) - neighbor = np.argmax_random_tie(neighbors, key=lambda node: problem.value(node.state)) + neighbor = utils.argmax_random_tie(neighbors, key=lambda node: problem.value(node.state)) map_canvas.delete('poly') points = [] for city in current.state: diff --git a/gui/vacuum_agent.py b/gui/vacuum_agent.py index b07dab282..e21626c15 100644 --- a/gui/vacuum_agent.py +++ b/gui/vacuum_agent.py @@ -1,7 +1,7 @@ import os.path from tkinter import * -from agents import * +from aima.agents import * sys.path.append(os.path.join(os.path.dirname(__file__), '..')) diff --git a/gui/xy_vacuum_environment.py b/gui/xy_vacuum_environment.py index 093abc6c3..9ada8829d 100644 --- a/gui/xy_vacuum_environment.py +++ b/gui/xy_vacuum_environment.py @@ -1,7 +1,7 @@ import os.path from tkinter import * -from agents import * +from aima.agents import * sys.path.append(os.path.join(os.path.dirname(__file__), '..')) diff --git a/images/knowledge_FOIL_grandparent.png b/images/knowledge_foil_grandparent.png similarity index 100% rename from images/knowledge_FOIL_grandparent.png rename to images/knowledge_foil_grandparent.png diff --git a/images/pluralityLearner_plot.png b/images/plurality_learner_plot.png similarity index 100% rename from images/pluralityLearner_plot.png rename to images/plurality_learner_plot.png diff --git a/learning4e.py b/learning4e.py deleted file mode 100644 index 12c0defa5..000000000 --- a/learning4e.py +++ /dev/null @@ -1,1039 +0,0 @@ -"""Learning from examples (Chapters 18)""" - -import copy -from collections import defaultdict -from statistics import stdev - -from qpsolvers import solve_qp - -from deep_learning4e import Sigmoid -from probabilistic_learning import NaiveBayesLearner -from utils4e import * - - -class DataSet: - """ - A data set for a machine learning problem. It has the following fields: - - d.examples A list of examples. Each one is a list of attribute values. - d.attrs A list of integers to index into an example, so example[attr] - gives a value. Normally the same as range(len(d.examples[0])). - d.attr_names Optional list of mnemonic names for corresponding attrs. - d.target The attribute that a learning algorithm will try to predict. - By default the final attribute. - d.inputs The list of attrs without the target. - d.values A list of lists: each sublist is the set of possible - values for the corresponding attribute. If initially None, - it is computed from the known examples by self.set_problem. - If not None, an erroneous value raises ValueError. - d.distance A function from a pair of examples to a non-negative number. - Should be symmetric, etc. Defaults to mean_boolean_error - since that can handle any field types. - d.name Name of the data set (for output display only). - d.source URL or other source where the data came from. - d.exclude A list of attribute indexes to exclude from d.inputs. Elements - of this list can either be integers (attrs) or attr_names. - - Normally, you call the constructor and you're done; then you just - access fields like d.examples and d.target and d.inputs. - """ - - def __init__(self, examples=None, attrs=None, attr_names=None, target=-1, inputs=None, - values=None, distance=mean_boolean_error, name='', source='', exclude=()): - """ - Accepts any of DataSet's fields. Examples can also be a - string or file from which to parse examples using parse_csv. - Optional parameter: exclude, as documented in .set_problem(). - >>> DataSet(examples='1, 2, 3') - - """ - self.name = name - self.source = source - self.values = values - self.distance = distance - self.got_values_flag = bool(values) - - # initialize .examples from string or list or data directory - if isinstance(examples, str): - self.examples = parse_csv(examples) - elif examples is None: - self.examples = parse_csv(open_data(name + '.csv').read()) - else: - self.examples = examples - - # attrs are the indices of examples, unless otherwise stated. - if self.examples is not None and attrs is None: - attrs = list(range(len(self.examples[0]))) - - self.attrs = attrs - - # initialize .attr_names from string, list, or by default - if isinstance(attr_names, str): - self.attr_names = attr_names.split() - else: - self.attr_names = attr_names or attrs - self.set_problem(target, inputs=inputs, exclude=exclude) - - def set_problem(self, target, inputs=None, exclude=()): - """ - Set (or change) the target and/or inputs. - This way, one DataSet can be used multiple ways. inputs, if specified, - is a list of attributes, or specify exclude as a list of attributes - to not use in inputs. Attributes can be -n .. n, or an attr_name. - Also computes the list of possible values, if that wasn't done yet. - """ - self.target = self.attr_num(target) - exclude = list(map(self.attr_num, exclude)) - if inputs: - self.inputs = remove_all(self.target, inputs) - else: - self.inputs = [a for a in self.attrs if a != self.target and a not in exclude] - if not self.values: - self.update_values() - self.check_me() - - def check_me(self): - """Check that my fields make sense.""" - assert len(self.attr_names) == len(self.attrs) - assert self.target in self.attrs - assert self.target not in self.inputs - assert set(self.inputs).issubset(set(self.attrs)) - if self.got_values_flag: - # only check if values are provided while initializing DataSet - list(map(self.check_example, self.examples)) - - def add_example(self, example): - """Add an example to the list of examples, checking it first.""" - self.check_example(example) - self.examples.append(example) - - def check_example(self, example): - """Raise ValueError if example has any invalid values.""" - if self.values: - for a in self.attrs: - if example[a] not in self.values[a]: - raise ValueError('Bad value {} for attribute {} in {}' - .format(example[a], self.attr_names[a], example)) - - def attr_num(self, attr): - """Returns the number used for attr, which can be a name, or -n .. n-1.""" - if isinstance(attr, str): - return self.attr_names.index(attr) - elif attr < 0: - return len(self.attrs) + attr - else: - return attr - - def update_values(self): - self.values = list(map(unique, zip(*self.examples))) - - def sanitize(self, example): - """Return a copy of example, with non-input attributes replaced by None.""" - return [attr_i if i in self.inputs else None for i, attr_i in enumerate(example)][:-1] - - def classes_to_numbers(self, classes=None): - """Converts class names to numbers.""" - if not classes: - # if classes were not given, extract them from values - classes = sorted(self.values[self.target]) - for item in self.examples: - item[self.target] = classes.index(item[self.target]) - - def remove_examples(self, value=''): - """Remove examples that contain given value.""" - self.examples = [x for x in self.examples if value not in x] - self.update_values() - - def split_values_by_classes(self): - """Split values into buckets according to their class.""" - buckets = defaultdict(lambda: []) - target_names = self.values[self.target] - - for v in self.examples: - item = [a for a in v if a not in target_names] # remove target from item - buckets[v[self.target]].append(item) # add item to bucket of its class - - return buckets - - def find_means_and_deviations(self): - """ - Finds the means and standard deviations of self.dataset. - means : a dictionary for each class/target. Holds a list of the means - of the features for the class. - deviations: a dictionary for each class/target. Holds a list of the sample - standard deviations of the features for the class. - """ - target_names = self.values[self.target] - feature_numbers = len(self.inputs) - - item_buckets = self.split_values_by_classes() - - means = defaultdict(lambda: [0] * feature_numbers) - deviations = defaultdict(lambda: [0] * feature_numbers) - - for t in target_names: - # find all the item feature values for item in class t - features = [[] for _ in range(feature_numbers)] - for item in item_buckets[t]: - for i in range(feature_numbers): - features[i].append(item[i]) - - # calculate means and deviations fo the class - for i in range(feature_numbers): - means[t][i] = mean(features[i]) - deviations[t][i] = stdev(features[i]) - - return means, deviations - - def __repr__(self): - return ''.format(self.name, len(self.examples), len(self.attrs)) - - -def parse_csv(input, delim=','): - r""" - Input is a string consisting of lines, each line has comma-delimited - fields. Convert this into a list of lists. Blank lines are skipped. - Fields that look like numbers are converted to numbers. - The delim defaults to ',' but '\t' and None are also reasonable values. - >>> parse_csv('1, 2, 3 \n 0, 2, na') - [[1, 2, 3], [0, 2, 'na']] - """ - lines = [line for line in input.splitlines() if line.strip()] - return [list(map(num_or_str, line.split(delim))) for line in lines] - - -def err_ratio(learner, dataset, examples=None): - """ - Return the proportion of the examples that are NOT correctly predicted. - verbose - 0: No output; 1: Output wrong; 2 (or greater): Output correct - """ - examples = examples or dataset.examples - if len(examples) == 0: - return 0.0 - right = 0 - for example in examples: - desired = example[dataset.target] - output = learner.predict(dataset.sanitize(example)) - if np.allclose(output, desired): - right += 1 - return 1 - (right / len(examples)) - - -def grade_learner(learner, tests): - """ - Grades the given learner based on how many tests it passes. - tests is a list with each element in the form: (values, output). - """ - return mean(int(learner.predict(X) == y) for X, y in tests) - - -def train_test_split(dataset, start=None, end=None, test_split=None): - """ - If you are giving 'start' and 'end' as parameters, - then it will return the testing set from index 'start' to 'end' - and the rest for training. - If you give 'test_split' as a parameter then it will return - test_split * 100% as the testing set and the rest as - training set. - """ - examples = dataset.examples - if test_split is None: - train = examples[:start] + examples[end:] - val = examples[start:end] - else: - total_size = len(examples) - val_size = int(total_size * test_split) - train_size = total_size - val_size - train = examples[:train_size] - val = examples[train_size:total_size] - - return train, val - - -def model_selection(learner, dataset, k=10, trials=1): - """ - [Figure 18.8] - Return the optimal value of size having minimum error on validation set. - err: a validation error array, indexed by size - """ - errs = [] - size = 1 - while True: - err = cross_validation(learner, dataset, size, k, trials) - # check for convergence provided err_val is not empty - if err and not np.isclose(err[-1], err, rtol=1e-6): - best_size = 0 - min_val = np.inf - i = 0 - while i < size: - if errs[i] < min_val: - min_val = errs[i] - best_size = i - i += 1 - return learner(dataset, best_size) - errs.append(err) - size += 1 - - -def cross_validation(learner, dataset, size=None, k=10, trials=1): - """ - Do k-fold cross_validate and return their mean. - That is, keep out 1/k of the examples for testing on each of k runs. - Shuffle the examples first; if trials > 1, average over several shuffles. - Returns Training error - """ - k = k or len(dataset.examples) - if trials > 1: - trial_errs = 0 - for t in range(trials): - errs = cross_validation(learner, dataset, size, k, trials) - trial_errs += errs - return trial_errs / trials - else: - fold_errs = 0 - n = len(dataset.examples) - examples = dataset.examples - random.shuffle(dataset.examples) - for fold in range(k): - train_data, val_data = train_test_split(dataset, fold * (n // k), (fold + 1) * (n // k)) - dataset.examples = train_data - h = learner(dataset, size) - fold_errs += err_ratio(h, dataset, train_data) - # reverting back to original once test is completed - dataset.examples = examples - return fold_errs / k - - -def leave_one_out(learner, dataset, size=None): - """Leave one out cross-validation over the dataset.""" - return cross_validation(learner, dataset, size, len(dataset.examples)) - - -def learning_curve(learner, dataset, trials=10, sizes=None): - if sizes is None: - sizes = list(range(2, len(dataset.examples) - trials, 2)) - - def score(learner, size): - random.shuffle(dataset.examples) - return cross_validation(learner, dataset, size, trials) - - return [(size, mean([score(learner, size) for _ in range(trials)])) for size in sizes] - - -class PluralityLearner: - """ - A very dumb algorithm: always pick the result that was most popular - in the training data. Makes a baseline for comparison. - """ - - def __init__(self, dataset): - self.most_popular = mode([e[dataset.target] for e in dataset.examples]) - - def predict(self, example): - """Always return same result: the most popular from the training set.""" - return self.most_popular - - -class DecisionFork: - """ - A fork of a decision tree holds an attribute to test, and a dict - of branches, one for each of the attribute's values. - """ - - def __init__(self, attr, attr_name=None, default_child=None, branches=None): - """Initialize by saying what attribute this node tests.""" - self.attr = attr - self.attr_name = attr_name or attr - self.default_child = default_child - self.branches = branches or {} - - def __call__(self, example): - """Given an example, classify it using the attribute and the branches.""" - attr_val = example[self.attr] - if attr_val in self.branches: - return self.branches[attr_val](example) - else: - # return default class when attribute is unknown - return self.default_child(example) - - def add(self, val, subtree): - """Add a branch. If self.attr = val, go to the given subtree.""" - self.branches[val] = subtree - - def display(self, indent=0): - name = self.attr_name - print('Test', name) - for (val, subtree) in self.branches.items(): - print(' ' * 4 * indent, name, '=', val, '==>', end=' ') - subtree.display(indent + 1) - - def __repr__(self): - return 'DecisionFork({0!r}, {1!r}, {2!r})'.format(self.attr, self.attr_name, self.branches) - - -class DecisionLeaf: - """A leaf of a decision tree holds just a result.""" - - def __init__(self, result): - self.result = result - - def __call__(self, example): - return self.result - - def display(self): - print('RESULT =', self.result) - - def __repr__(self): - return repr(self.result) - - -class DecisionTreeLearner: - """[Figure 18.5]""" - - def __init__(self, dataset): - self.dataset = dataset - self.tree = self.decision_tree_learning(dataset.examples, dataset.inputs) - - def decision_tree_learning(self, examples, attrs, parent_examples=()): - if len(examples) == 0: - return self.plurality_value(parent_examples) - if self.all_same_class(examples): - return DecisionLeaf(examples[0][self.dataset.target]) - if len(attrs) == 0: - return self.plurality_value(examples) - A = self.choose_attribute(attrs, examples) - tree = DecisionFork(A, self.dataset.attr_names[A], self.plurality_value(examples)) - for (v_k, exs) in self.split_by(A, examples): - subtree = self.decision_tree_learning(exs, remove_all(A, attrs), examples) - tree.add(v_k, subtree) - return tree - - def plurality_value(self, examples): - """ - Return the most popular target value for this set of examples. - (If target is binary, this is the majority; otherwise plurality). - """ - popular = argmax_random_tie(self.dataset.values[self.dataset.target], - key=lambda v: self.count(self.dataset.target, v, examples)) - return DecisionLeaf(popular) - - def count(self, attr, val, examples): - """Count the number of examples that have example[attr] = val.""" - return sum(e[attr] == val for e in examples) - - def all_same_class(self, examples): - """Are all these examples in the same target class?""" - class0 = examples[0][self.dataset.target] - return all(e[self.dataset.target] == class0 for e in examples) - - def choose_attribute(self, attrs, examples): - """Choose the attribute with the highest information gain.""" - return argmax_random_tie(attrs, key=lambda a: self.information_gain(a, examples)) - - def information_gain(self, attr, examples): - """Return the expected reduction in entropy from splitting by attr.""" - - def I(examples): - return information_content([self.count(self.dataset.target, v, examples) - for v in self.dataset.values[self.dataset.target]]) - - n = len(examples) - remainder = sum((len(examples_i) / n) * I(examples_i) - for (v, examples_i) in self.split_by(attr, examples)) - return I(examples) - remainder - - def split_by(self, attr, examples): - """Return a list of (val, examples) pairs for each val of attr.""" - return [(v, [e for e in examples if e[attr] == v]) for v in self.dataset.values[attr]] - - def predict(self, x): - return self.tree(x) - - -def information_content(values): - """Number of bits to represent the probability distribution in values.""" - probabilities = normalize(remove_all(0, values)) - return sum(-p * np.log2(p) for p in probabilities) - - -class DecisionListLearner: - """ - [Figure 18.11] - A decision list implemented as a list of (test, value) pairs. - """ - - def __init__(self, dataset): - self.predict.decision_list = self.decision_list_learning(set(dataset.examples)) - - def decision_list_learning(self, examples): - if not examples: - return [(True, False)] - t, o, examples_t = self.find_examples(examples) - if not t: - raise Exception - return [(t, o)] + self.decision_list_learning(examples - examples_t) - - def find_examples(self, examples): - """ - Find a set of examples that all have the same outcome under - some test. Return a tuple of the test, outcome, and examples. - """ - raise NotImplementedError - - def passes(self, example, test): - """Does the example pass the test?""" - raise NotImplementedError - - def predict(self, example): - """Predict the outcome for the first passing test.""" - for test, outcome in self.predict.decision_list: - if self.passes(example, test): - return outcome - - -class NearestNeighborLearner: - """k-NearestNeighbor: the k nearest neighbors vote.""" - - def __init__(self, dataset, k=1): - self.dataset = dataset - self.k = k - - def predict(self, example): - """Find the k closest items, and have them vote for the best.""" - best = heapq.nsmallest(self.k, ((self.dataset.distance(e, example), e) for e in self.dataset.examples)) - return mode(e[self.dataset.target] for (d, e) in best) - - -class SVC: - - def __init__(self, kernel=linear_kernel, C=1.0, verbose=False): - self.kernel = kernel - self.C = C # hyper-parameter - self.sv_idx, self.sv, self.sv_y = np.zeros(0), np.zeros(0), np.zeros(0) - self.alphas = np.zeros(0) - self.w = None - self.b = 0.0 # intercept - self.verbose = verbose - - def fit(self, X, y): - """ - Trains the model by solving a quadratic programming problem. - :param X: array of size [n_samples, n_features] holding the training samples - :param y: array of size [n_samples] holding the class labels - """ - # In QP formulation (dual): m variables, 2m+1 constraints (1 equation, 2m inequations) - self.solve_qp(X, y) - sv = self.alphas > 1e-5 - self.sv_idx = np.arange(len(self.alphas))[sv] - self.sv, self.sv_y, self.alphas = X[sv], y[sv], self.alphas[sv] - - if self.kernel == linear_kernel: - self.w = np.dot(self.alphas * self.sv_y, self.sv) - - for n in range(len(self.alphas)): - self.b += self.sv_y[n] - self.b -= np.sum(self.alphas * self.sv_y * self.K[self.sv_idx[n], sv]) - self.b /= len(self.alphas) - return self - - def solve_qp(self, X, y): - """ - Solves a quadratic programming problem. In QP formulation (dual): - m variables, 2m+1 constraints (1 equation, 2m inequations). - :param X: array of size [n_samples, n_features] holding the training samples - :param y: array of size [n_samples] holding the class labels - """ - m = len(y) # m = n_samples - self.K = self.kernel(X) # gram matrix - P = self.K * np.outer(y, y) - q = -np.ones(m) - lb = np.zeros(m) # lower bounds - ub = np.ones(m) * self.C # upper bounds - A = y.astype(np.float64) # equality matrix - b = np.zeros(1) # equality vector - self.alphas = solve_qp(P, q, A=A, b=b, lb=lb, ub=ub, solver='cvxopt', - sym_proj=True, verbose=self.verbose) - - def predict_score(self, X): - """ - Predicts the score for a given example. - """ - if self.w is None: - return np.dot(self.alphas * self.sv_y, self.kernel(self.sv, X)) + self.b - return np.dot(X, self.w) + self.b - - def predict(self, X): - """ - Predicts the class of a given example. - """ - return np.sign(self.predict_score(X)) - - -class SVR: - - def __init__(self, kernel=linear_kernel, C=1.0, epsilon=0.1, verbose=False): - self.kernel = kernel - self.C = C # hyper-parameter - self.epsilon = epsilon # epsilon insensitive loss value - self.sv_idx, self.sv = np.zeros(0), np.zeros(0) - self.alphas_p, self.alphas_n = np.zeros(0), np.zeros(0) - self.w = None - self.b = 0.0 # intercept - self.verbose = verbose - - def fit(self, X, y): - """ - Trains the model by solving a quadratic programming problem. - :param X: array of size [n_samples, n_features] holding the training samples - :param y: array of size [n_samples] holding the class labels - """ - # In QP formulation (dual): m variables, 2m+1 constraints (1 equation, 2m inequations) - self.solve_qp(X, y) - - sv = np.logical_or(self.alphas_p > 1e-5, self.alphas_n > 1e-5) - self.sv_idx = np.arange(len(self.alphas_p))[sv] - self.sv, sv_y = X[sv], y[sv] - self.alphas_p, self.alphas_n = self.alphas_p[sv], self.alphas_n[sv] - - if self.kernel == linear_kernel: - self.w = np.dot(self.alphas_p - self.alphas_n, self.sv) - - for n in range(len(self.alphas_p)): - self.b += sv_y[n] - self.b -= np.sum((self.alphas_p - self.alphas_n) * self.K[self.sv_idx[n], sv]) - self.b -= self.epsilon - self.b /= len(self.alphas_p) - - return self - - def solve_qp(self, X, y): - """ - Solves a quadratic programming problem. In QP formulation (dual): - m variables, 2m+1 constraints (1 equation, 2m inequations). - :param X: array of size [n_samples, n_features] holding the training samples - :param y: array of size [n_samples] holding the class labels - """ - m = len(y) # m = n_samples - self.K = self.kernel(X) # gram matrix - P = np.vstack((np.hstack((self.K, -self.K)), # alphas_p, alphas_n - np.hstack((-self.K, self.K)))) # alphas_n, alphas_p - q = np.hstack((-y, y)) + self.epsilon - lb = np.zeros(2 * m) # lower bounds - ub = np.ones(2 * m) * self.C # upper bounds - A = np.hstack((np.ones(m), -np.ones(m))) # equality matrix - b = np.zeros(1) # equality vector - alphas = solve_qp(P, q, A=A, b=b, lb=lb, ub=ub, solver='cvxopt', - sym_proj=True, verbose=self.verbose) - self.alphas_p = alphas[:m] - self.alphas_n = alphas[m:] - - def predict(self, X): - if self.kernel != linear_kernel: - return np.dot(self.alphas_p - self.alphas_n, self.kernel(self.sv, X)) + self.b - return np.dot(X, self.w) + self.b - - -class MultiClassLearner: - - def __init__(self, clf, decision_function='ovr'): - self.clf = clf - self.decision_function = decision_function - self.n_class, self.classifiers = 0, [] - - def fit(self, X, y): - """ - Trains n_class or n_class * (n_class - 1) / 2 classifiers - according to the training method, ovr or ovo respectively. - :param X: array of size [n_samples, n_features] holding the training samples - :param y: array of size [n_samples] holding the class labels - :return: array of classifiers - """ - labels = np.unique(y) - self.n_class = len(labels) - if self.decision_function == 'ovr': # one-vs-rest method - for label in labels: - y1 = np.array(y) - y1[y1 != label] = -1.0 - y1[y1 == label] = 1.0 - self.clf.fit(X, y1) - self.classifiers.append(copy.deepcopy(self.clf)) - elif self.decision_function == 'ovo': # use one-vs-one method - n_labels = len(labels) - for i in range(n_labels): - for j in range(i + 1, n_labels): - neg_id, pos_id = y == labels[i], y == labels[j] - X1, y1 = np.r_[X[neg_id], X[pos_id]], np.r_[y[neg_id], y[pos_id]] - y1[y1 == labels[i]] = -1.0 - y1[y1 == labels[j]] = 1.0 - self.clf.fit(X1, y1) - self.classifiers.append(copy.deepcopy(self.clf)) - else: - return ValueError("Decision function must be either 'ovr' or 'ovo'.") - return self - - def predict(self, X): - """ - Predicts the class of a given example according to the training method. - """ - n_samples = len(X) - if self.decision_function == 'ovr': # one-vs-rest method - assert len(self.classifiers) == self.n_class - score = np.zeros((n_samples, self.n_class)) - for i in range(self.n_class): - clf = self.classifiers[i] - score[:, i] = clf.predict_score(X) - return np.argmax(score, axis=1) - elif self.decision_function == 'ovo': # use one-vs-one method - assert len(self.classifiers) == self.n_class * (self.n_class - 1) / 2 - vote = np.zeros((n_samples, self.n_class)) - clf_id = 0 - for i in range(self.n_class): - for j in range(i + 1, self.n_class): - res = self.classifiers[clf_id].predict(X) - vote[res < 0, i] += 1.0 # negative sample: class i - vote[res > 0, j] += 1.0 # positive sample: class j - clf_id += 1 - return np.argmax(vote, axis=1) - else: - return ValueError("Decision function must be either 'ovr' or 'ovo'.") - - -def LinearLearner(dataset, learning_rate=0.01, epochs=100): - """ - [Section 18.6.3] - Linear classifier with hard threshold. - """ - idx_i = dataset.inputs - idx_t = dataset.target - examples = dataset.examples - num_examples = len(examples) - - # X transpose - X_col = [dataset.values[i] for i in idx_i] # vertical columns of X - - # add dummy - ones = [1 for _ in range(len(examples))] - X_col = [ones] + X_col - - # initialize random weights - num_weights = len(idx_i) + 1 - w = random_weights(min_value=-0.5, max_value=0.5, num_weights=num_weights) - - for epoch in range(epochs): - err = [] - # pass over all examples - for example in examples: - x = [1] + example - y = np.dot(w, x) - t = example[idx_t] - err.append(t - y) - - # update weights - for i in range(len(w)): - w[i] = w[i] + learning_rate * (np.dot(err, X_col[i]) / num_examples) - - def predict(example): - x = [1] + example - return np.dot(w, x) - - return predict - - -def LogisticLinearLeaner(dataset, learning_rate=0.01, epochs=100): - """ - [Section 18.6.4] - Linear classifier with logistic regression. - """ - idx_i = dataset.inputs - idx_t = dataset.target - examples = dataset.examples - num_examples = len(examples) - - # X transpose - X_col = [dataset.values[i] for i in idx_i] # vertical columns of X - - # add dummy - ones = [1 for _ in range(len(examples))] - X_col = [ones] + X_col - - # initialize random weights - num_weights = len(idx_i) + 1 - w = random_weights(min_value=-0.5, max_value=0.5, num_weights=num_weights) - - for epoch in range(epochs): - err = [] - h = [] - # pass over all examples - for example in examples: - x = [1] + example - y = Sigmoid()(np.dot(w, x)) - h.append(Sigmoid().derivative(y)) - t = example[idx_t] - err.append(t - y) - - # update weights - for i in range(len(w)): - buffer = [x * y for x, y in zip(err, h)] - w[i] = w[i] + learning_rate * (np.dot(buffer, X_col[i]) / num_examples) - - def predict(example): - x = [1] + example - return Sigmoid()(np.dot(w, x)) - - return predict - - -class EnsembleLearner: - """Given a list of learning algorithms, have them vote.""" - - def __init__(self, learners): - self.learners = learners - - def train(self, dataset): - self.predictors = [learner(dataset) for learner in self.learners] - - def predict(self, example): - return mode(predictor.predict(example) for predictor in self.predictors) - - -def ada_boost(dataset, L, K): - """[Figure 18.34]""" - - examples, target = dataset.examples, dataset.target - n = len(examples) - eps = 1 / (2 * n) - w = [1 / n] * n - h, z = [], [] - for k in range(K): - h_k = L(dataset, w) - h.append(h_k) - error = sum(weight for example, weight in zip(examples, w) if example[target] != h_k.predict(example[:-1])) - # avoid divide-by-0 from either 0% or 100% error rates - error = np.clip(error, eps, 1 - eps) - for j, example in enumerate(examples): - if example[target] == h_k.predict(example[:-1]): - w[j] *= error / (1 - error) - w = normalize(w) - z.append(np.log((1 - error) / error)) - return weighted_majority(h, z) - - -class weighted_majority: - """Return a predictor that takes a weighted vote.""" - - def __init__(self, predictors, weights): - self.predictors = predictors - self.weights = weights - - def predict(self, example): - return weighted_mode((predictor.predict(example) for predictor in self.predictors), self.weights) - - -def weighted_mode(values, weights): - """ - Return the value with the greatest total weight. - >>> weighted_mode('abbaa', [1, 2, 3, 1, 2]) - 'b' - """ - totals = defaultdict(int) - for v, w in zip(values, weights): - totals[v] += w - return max(totals, key=totals.__getitem__) - - -class RandomForest: - """An ensemble of Decision Trees trained using bagging and feature bagging.""" - - def __init__(self, dataset, n=5): - self.dataset = dataset - self.n = n - self.predictors = [DecisionTreeLearner(DataSet(examples=self.data_bagging(), attrs=self.dataset.attrs, - attr_names=self.dataset.attr_names, target=self.dataset.target, - inputs=self.feature_bagging())) for _ in range(self.n)] - - def data_bagging(self, m=0): - """Sample m examples with replacement""" - n = len(self.dataset.examples) - return weighted_sample_with_replacement(m or n, self.dataset.examples, [1] * n) - - def feature_bagging(self, p=0.7): - """Feature bagging with probability p to retain an attribute""" - inputs = [i for i in self.dataset.inputs if probability(p)] - return inputs or self.dataset.inputs - - def predict(self, example): - return mode(predictor.predict(example) for predictor in self.predictors) - - -def WeightedLearner(unweighted_learner): - """ - [Page 749 footnote 14] - Given a learner that takes just an unweighted dataset, return - one that takes also a weight for each example. - """ - - def train(dataset, weights): - dataset = replicated_dataset(dataset, weights) - n_samples, n_features = len(dataset.examples), dataset.target - X, y = (np.array([x[:n_features] for x in dataset.examples]), - np.array([x[n_features] for x in dataset.examples])) - return unweighted_learner.fit(X, y) - - return train - - -def replicated_dataset(dataset, weights, n=None): - """Copy dataset, replicating each example in proportion to its weight.""" - n = n or len(dataset.examples) - result = copy.copy(dataset) - result.examples = weighted_replicate(dataset.examples, weights, n) - return result - - -def weighted_replicate(seq, weights, n): - """ - Return n selections from seq, with the count of each element of - seq proportional to the corresponding weight (filling in fractions - randomly). - >>> weighted_replicate('ABC', [1, 2, 1], 4) - ['A', 'B', 'B', 'C'] - """ - assert len(seq) == len(weights) - weights = normalize(weights) - wholes = [int(w * n) for w in weights] - fractions = [(w * n) % 1 for w in weights] - return (flatten([x] * nx for x, nx in zip(seq, wholes)) + - weighted_sample_with_replacement(n - sum(wholes), seq, fractions)) - - -# metrics - -def accuracy_score(y_pred, y_true): - assert y_pred.shape == y_true.shape - return np.mean(np.equal(y_pred, y_true)) - - -def r2_score(y_pred, y_true): - assert y_pred.shape == y_true.shape - return 1. - (np.sum(np.square(y_pred - y_true)) / # sum of square of residuals - np.sum(np.square(y_true - np.mean(y_true)))) # total sum of squares - - -# datasets - -orings = DataSet(name='orings', target='Distressed', attr_names='Rings Distressed Temp Pressure Flightnum') - -zoo = DataSet(name='zoo', target='type', exclude=['name'], - attr_names='name hair feathers eggs milk airborne aquatic predator toothed backbone ' - 'breathes venomous fins legs tail domestic catsize type') - -iris = DataSet(name='iris', target='class', attr_names='sepal-len sepal-width petal-len petal-width class') - - -def RestaurantDataSet(examples=None): - """ - [Figure 18.3] - Build a DataSet of Restaurant waiting examples. - """ - return DataSet(name='restaurant', target='Wait', examples=examples, - attr_names='Alternate Bar Fri/Sat Hungry Patrons Price Raining Reservation Type WaitEstimate Wait') - - -restaurant = RestaurantDataSet() - - -def T(attr_name, branches): - branches = {value: (child if isinstance(child, DecisionFork) else DecisionLeaf(child)) - for value, child in branches.items()} - return DecisionFork(restaurant.attr_num(attr_name), attr_name, print, branches) - - -""" -[Figure 18.2] -A decision tree for deciding whether to wait for a table at a hotel. -""" - -waiting_decision_tree = T('Patrons', - {'None': 'No', 'Some': 'Yes', - 'Full': T('WaitEstimate', - {'>60': 'No', '0-10': 'Yes', - '30-60': T('Alternate', - {'No': T('Reservation', - {'Yes': 'Yes', - 'No': T('Bar', {'No': 'No', - 'Yes': 'Yes'})}), - 'Yes': T('Fri/Sat', {'No': 'No', 'Yes': 'Yes'})}), - '10-30': T('Hungry', - {'No': 'Yes', - 'Yes': T('Alternate', - {'No': 'Yes', - 'Yes': T('Raining', - {'No': 'No', - 'Yes': 'Yes'})})})})}) - - -def SyntheticRestaurant(n=20): - """Generate a DataSet with n examples.""" - - def gen(): - example = list(map(random.choice, restaurant.values)) - example[restaurant.target] = waiting_decision_tree(example) - return example - - return RestaurantDataSet([gen() for _ in range(n)]) - - -def Majority(k, n): - """ - Return a DataSet with n k-bit examples of the majority problem: - k random bits followed by a 1 if more than half the bits are 1, else 0. - """ - examples = [] - for i in range(n): - bits = [random.choice([0, 1]) for _ in range(k)] - bits.append(int(sum(bits) > k / 2)) - examples.append(bits) - return DataSet(name='majority', examples=examples) - - -def Parity(k, n, name='parity'): - """ - Return a DataSet with n k-bit examples of the parity problem: - k random bits followed by a 1 if an odd number of bits are 1, else 0. - """ - examples = [] - for i in range(n): - bits = [random.choice([0, 1]) for _ in range(k)] - bits.append(sum(bits) % 2) - examples.append(bits) - return DataSet(name=name, examples=examples) - - -def Xor(n): - """Return a DataSet with n examples of 2-input xor.""" - return Parity(2, n, name='xor') - - -def ContinuousXor(n): - """2 inputs are chosen uniformly from (0.0 .. 2.0]; output is xor of ints.""" - examples = [] - for i in range(n): - x, y = [random.uniform(0.0, 2.0) for _ in '12'] - examples.append([x, y, x != y]) - return DataSet(name='continuous xor', examples=examples) - - -def compare(algorithms=None, datasets=None, k=10, trials=1): - """ - Compare various learners on various datasets using cross-validation. - Print results as a table. - """ - # default list of algorithms - algorithms = algorithms or [PluralityLearner, NaiveBayesLearner, NearestNeighborLearner, DecisionTreeLearner] - - # default list of datasets - datasets = datasets or [iris, orings, zoo, restaurant, SyntheticRestaurant(20), - Majority(7, 100), Parity(7, 100), Xor(100)] - - print_table([[a.__name__.replace('Learner', '')] + [cross_validation(a, d, k=k, trials=trials) for d in datasets] - for a in algorithms], header=[''] + [d.name[0:7] for d in datasets], numfmt='%.2f') diff --git a/lite/README.md b/lite/README.md new file mode 100644 index 000000000..4186f3319 --- /dev/null +++ b/lite/README.md @@ -0,0 +1,95 @@ +# aima-python in the browser (JupyterLite proof-of-concept) + +This folder builds a **JupyterLite** site that runs a few `aima` notebooks +entirely in the browser via [Pyodide](https://pyodide.org) — no server, no +local install. It is the first concrete step of the "Python web companion" +discussed in [issue #1072](https://github.com/aimacode/aima-python/issues/1072). + +When deployed, it lives next to the API docs on GitHub Pages: + +- API docs: +- Try in browser: + +## What runs (and what does not) + +Pyodide ships scientific-Python wheels (numpy, scipy, matplotlib, networkx, +pandas, …), so the *lightweight* parts of `aima` work in the browser. The +heavy native dependencies in `requirements.txt` — TensorFlow/Keras, OpenCV +(`cv2`), `cvxopt`, `qpsolvers` — are **not** available in Pyodide, so notebooks +that need them (deep learning, parts of perception, LP-based game theory) +cannot run here. The `aima` wheel is therefore installed with `deps=False` and +the demo notebooks are restricted to modules that import only Pyodide-provided +packages. + +The companion ships a notebook per Pyodide-compatible module: + +- `content/agents.ipynb` — reflex vs. model-based agents in the vacuum world (`aima.agents`). +- `content/search.ipynb` — BFS / A* on the Romania map (`aima.search`). +- `content/games.ipynb` — minimax / alpha-beta on Tic-Tac-Toe (`aima.games`). +- `content/csp.ipynb` — map colouring (AC-3 / backtracking) and N-queens (min-conflicts) (`aima.csp`). +- `content/logic.ipynb` — propositional model checking / DPLL and first-order forward chaining (`aima.logic`). +- `content/planning.ipynb` — STRIPS planning (spare tire, air cargo) with GraphPlan (`aima.planning`). +- `content/probability.ipynb` — exact and approximate inference on the burglary network (`aima.probability`). +- `content/mdp.ipynb` — value / policy iteration on the 4x3 grid world (`aima.mdp`). +- `content/reinforcement_learning.ipynb` — Q-learning on the grid world (`aima.reinforcement_learning`). +- `content/learning.ipynb` — decision tree and naive Bayes on an inline dataset (`aima.learning`). +- `content/knowledge.ipynb` — current-best-hypothesis learning (`aima.knowledge`). +- `content/nlp.ipynb` — probabilistic CYK parsing (`aima.nlp`). +- `content/text.ipynb` — unigram / bigram language models (`aima.text`). +- `content/game_theory.ipynb` — Nash equilibria, zero-sum games, Shapley value (`aima.game_theory`). + +`content/Welcome.ipynb` shows the one-cell install pattern used by every notebook. + +Most modules need only numpy; `aima.logic`/`aima.planning` need `networkx`, +`aima.csp`/`aima.planning` need `sortedcontainers` (both pure-Python and shipped +with Pyodide — preloaded for the kernel in `jupyter-lite.json`). `game_theory.ipynb` +additionally `piplite.install("scipy")`s for its linear-program solver. + +A few modules stay out of the companion because they need native wheels Pyodide +does not provide: `deep_learning` (TensorFlow/Keras), `perception` (OpenCV), and +the SVM path in `learning` (`cvxopt`/`qpsolvers`). + +To make the importable modules load cleanly in the browser, two optional +dependencies are now imported lazily rather than at module top level: the +`ipythonblocks` GUI dependency in `aima.agents` (so `logic`/`csp`/`planning`/... +import without it) and `qpsolvers` in `aima.learning` (needed only by SVM). The +example datasets `learning` builds at import time are also guarded so the module +imports when the `aima-data` files are absent. + +## Build locally + +```bash +cd lite +./build.sh # builds the aima wheel + runs `jupyter lite build` +python -m http.server -d _output 8000 # then open http://localhost:8000 +``` + +`build.sh` installs the build toolchain from `requirements-lite.txt`, builds an +`aima` wheel from the repo root, and bundles it into the JupyterLite site so the +notebooks can `piplite.install("aima", deps=False)` offline. + +## Verify in a real browser + +`build.sh` proves the static site *builds*; `verify_browser.py` proves every +notebook actually *runs* in Pyodide. It serves `_output`, loads real Pyodide in +headless Chromium, installs the freshly-built wheel and executes each notebook's +real code cells: + +```bash +pip install playwright nbformat +python -m playwright install chromium +cd lite && ./build.sh && python verify_browser.py +``` + +It exits non-zero if any notebook errors. All 15 notebooks currently pass. + +## Status / next steps + +This is a **proof of concept** (issue #1072 stays open until it is a complete +companion). In-browser execution of all 15 notebooks has been verified with +`verify_browser.py` (headless Chromium + real Pyodide + the deployed wheel). +Remaining ideas: + +- Wire `verify_browser.py` into CI (it needs a headless browser, so it would be a + separate, heavier job than the build/deploy). +- Grow this into a full MyST / Jupyter Book textbook companion. diff --git a/lite/build.sh b/lite/build.sh new file mode 100755 index 000000000..092c33b9f --- /dev/null +++ b/lite/build.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Build the JupyterLite proof-of-concept site (see README.md). +# +# Usage: ./build.sh [OUTPUT_DIR] +# OUTPUT_DIR defaults to lite/_output. CI passes docs/_build/html/lite so the +# companion is published under the GitHub Pages site at /lite/. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +OUTPUT_DIR="${1:-$HERE/_output}" +# resolve to an absolute path (jupyter lite build runs from $HERE) +mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" + +# 1. Build an aima wheel from the repo root and drop it next to the lite config +# so piplite can install it offline (deps=False, since TF/keras/cv2/cvxopt +# are not available in Pyodide). +rm -f "$HERE"/aima-*.whl +python -m build --wheel --outdir "$HERE" "$ROOT" + +# 2. Build the static JupyterLite site. +cd "$HERE" +jupyter lite build --output-dir "$OUTPUT_DIR" + +echo +echo "Built JupyterLite site at: $OUTPUT_DIR" +echo "Serve it locally with:" +echo " python -m http.server -d \"$OUTPUT_DIR\" 8000" diff --git a/lite/content/Welcome.ipynb b/lite/content/Welcome.ipynb new file mode 100644 index 000000000..8f567519a --- /dev/null +++ b/lite/content/Welcome.ipynb @@ -0,0 +1,49 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# aima-python in the browser\n", + "\n", + "This is a [JupyterLite](https://jupyterlite.readthedocs.io) site running\n", + "entirely in your browser via [Pyodide](https://pyodide.org) — there is no\n", + "server. It is a proof of concept for a Python web companion to *Artificial\n", + "Intelligence: A Modern Approach* (see\n", + "[issue #1072](https://github.com/aimacode/aima-python/issues/1072)).\n", + "\n", + "Every notebook starts by installing the `aima` package into the browser kernel\n", + "with the cell below. Pyodide already provides numpy/scipy/matplotlib/networkx,\n", + "so `aima`'s heavy native dependencies (TensorFlow/Keras, OpenCV, cvxopt) are\n", + "skipped with `deps=False`; the demo notebooks use only the lightweight modules.\n", + "\n", + "Open **search.ipynb** and **games.ipynb** from the file browser on the left." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)\n", + "\n", + "from aima.search import romania_map\n", + "print(\"aima loaded — Romania map has\", len(romania_map.locations), \"cities\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/agents.ipynb b/lite/content/agents.ipynb new file mode 100644 index 000000000..d533c152c --- /dev/null +++ b/lite/content/agents.ipynb @@ -0,0 +1,64 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2ccc4349", + "metadata": {}, + "source": [ + "# Agents and environments in the browser\n", + "\n", + "A reflex vs. model-based agent in the vacuum world (chapter 2), running fully in your browser with `aima.agents`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47375431", + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "id": "588ca436", + "metadata": {}, + "source": [ + "## The two-square vacuum world\n", + "\n", + "A simple reflex agent senses only its current square; a model-based agent remembers which squares it has already cleaned. We compare their average score over random starting configurations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b51257f", + "metadata": {}, + "outputs": [], + "source": [ + "from aima.agents import (TrivialVacuumEnvironment, ReflexVacuumAgent,\n", + " ModelBasedVacuumAgent, compare_agents)\n", + "\n", + "scores = compare_agents(TrivialVacuumEnvironment,\n", + " [ReflexVacuumAgent, ModelBasedVacuumAgent],\n", + " n=10, steps=100)\n", + "for agent, score in scores:\n", + " print(f\"{agent.__name__:24s} average score: {score:6.1f}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/csp.ipynb b/lite/content/csp.ipynb new file mode 100644 index 000000000..505ba9e53 --- /dev/null +++ b/lite/content/csp.ipynb @@ -0,0 +1,84 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Constraint satisfaction in the browser\n", + "\n", + "Map colouring and N-queens from chapter 6, running fully in your browser with\n", + "`aima.csp`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Map colouring\n", + "\n", + "Colour the map of Australia with three colours so that no two neighbouring\n", + "regions share one. First we enforce arc consistency (AC-3), then solve by\n", + "backtracking search." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from aima.csp import australia_csp, AC3, backtracking_search\n", + "\n", + "consistent, checks = AC3(australia_csp)\n", + "print(\"AC-3 arc-consistent:\", consistent, \" (\", checks, \"constraint checks )\")\n", + "print(\"backtracking solution:\", backtracking_search(australia_csp))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## N-queens\n", + "\n", + "Place 8 non-attacking queens by local search (min-conflicts). The solution\n", + "maps each column to the row of its queen." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "from aima.csp import NQueensCSP, min_conflicts\n", + "\n", + "random.seed(0)\n", + "solution = min_conflicts(NQueensCSP(8), max_steps=1000)\n", + "print(\"8-queens (column -> row):\", solution)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/game_theory.ipynb b/lite/content/game_theory.ipynb new file mode 100644 index 000000000..fbb21aa91 --- /dev/null +++ b/lite/content/game_theory.ipynb @@ -0,0 +1,102 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2a692917", + "metadata": {}, + "source": [ + "# Game theory in the browser\n", + "\n", + "Pure-strategy Nash equilibria, zero-sum games and the Shapley value (chapter 18), running fully in your browser with `aima.game_theory`." + ] + }, + { + "cell_type": "markdown", + "id": "27833b2a", + "metadata": {}, + "source": [ + "`aima.game_theory` uses SciPy for the linear program behind zero-sum games, so we load it alongside `aima`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ea7846ee", + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"scipy\")\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "id": "c6470bac", + "metadata": {}, + "source": [ + "## Pure-strategy Nash equilibria\n", + "\n", + "Payoffs as utilities, indexed `[row player][column player]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d78b2ad1", + "metadata": {}, + "outputs": [], + "source": [ + "from aima.game_theory import pure_nash_equilibria, solve_zero_sum_game, shapley_value\n", + "\n", + "# prisoner's dilemma (0 = testify, 1 = refuse)\n", + "ALI = [[-5, 0], [-10, -1]]\n", + "BO = [[-5, -10], [0, -1]]\n", + "print('prisoner\\'s dilemma Nash:', pure_nash_equilibria(ALI, BO))\n", + "print('matching pennies Nash: ', pure_nash_equilibria([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]))\n", + "print('coordination game Nash: ', pure_nash_equilibria([[2, 0], [0, 1]], [[2, 0], [0, 1]]))" + ] + }, + { + "cell_type": "markdown", + "id": "148ca176", + "metadata": {}, + "source": [ + "## Zero-sum games and cooperative value\n", + "\n", + "Two-finger Morra is a zero-sum game with value -1/12; the Shapley value splits the gains of the three-player glove game fairly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31cdf1f3", + "metadata": {}, + "outputs": [], + "source": [ + "value, row, col = solve_zero_sum_game([[2, -3], [-3, 4]])\n", + "print('two-finger Morra value:', round(value, 4), ' row strategy:', [round(x, 3) for x in row])\n", + "\n", + "# glove game: players 1,2 own a left glove each, player 3 a right glove;\n", + "# a pair is worth 1\n", + "def gloves(coalition):\n", + " left = {1, 2} & set(coalition)\n", + " right = {3} & set(coalition)\n", + " return min(len(left), len(right))\n", + "print('Shapley value of the glove game:', shapley_value([1, 2, 3], gloves))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/games.ipynb b/lite/content/games.ipynb new file mode 100644 index 000000000..c2eb461c9 --- /dev/null +++ b/lite/content/games.ipynb @@ -0,0 +1,67 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Adversarial search in the browser\n", + "\n", + "Minimax and alpha-beta on Tic-Tac-Toe from chapter 5, running fully in your\n", + "browser with `aima.games`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from aima.games import TicTacToe, minmax_decision, alpha_beta_search\n", + "\n", + "game = TicTacToe()\n", + "state = game.initial\n", + "print(\"minimax optimal first move: \", minmax_decision(state, game))\n", + "print(\"alpha-beta optimal first move:\", alpha_beta_search(state, game))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "from aima.games import alpha_beta_player, random_player\n", + "\n", + "# An optimal alpha-beta player never loses: against a random opponent it wins\n", + "# or draws every game (utility >= 0 for the first player).\n", + "random.seed(0)\n", + "results = [game.play_game(alpha_beta_player, random_player) for _ in range(10)]\n", + "print(\"alpha-beta vs random, 10 games (first-player utility):\", results)\n", + "print(\"never loses:\", all(u >= 0 for u in results))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/knowledge.ipynb b/lite/content/knowledge.ipynb new file mode 100644 index 000000000..4ab4f913d --- /dev/null +++ b/lite/content/knowledge.ipynb @@ -0,0 +1,72 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6433848c", + "metadata": {}, + "source": [ + "# Knowledge in learning, in the browser\n", + "\n", + "Current-best-hypothesis learning (chapter 19) running fully in your browser with `aima.knowledge`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed8fbc35", + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "id": "6b6b3c18", + "metadata": {}, + "source": [ + "## Learning a concept from examples\n", + "\n", + "Should this animal stay dry? Each example is described by attributes and a boolean `GOAL`; `current_best_learning` searches for a hypothesis (a disjunction of attribute constraints) consistent with all of them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a8d03da", + "metadata": {}, + "outputs": [], + "source": [ + "from aima.knowledge import current_best_learning, guess_value\n", + "\n", + "examples = [\n", + " {'Species': 'Cat', 'Rain': 'Yes', 'Coat': 'No', 'GOAL': True},\n", + " {'Species': 'Cat', 'Rain': 'Yes', 'Coat': 'Yes', 'GOAL': True},\n", + " {'Species': 'Dog', 'Rain': 'Yes', 'Coat': 'Yes', 'GOAL': True},\n", + " {'Species': 'Dog', 'Rain': 'Yes', 'Coat': 'No', 'GOAL': False},\n", + " {'Species': 'Dog', 'Rain': 'No', 'Coat': 'No', 'GOAL': False},\n", + " {'Species': 'Cat', 'Rain': 'No', 'Coat': 'No', 'GOAL': False},\n", + " {'Species': 'Cat', 'Rain': 'No', 'Coat': 'Yes', 'GOAL': True}]\n", + "\n", + "h = current_best_learning(examples, [{'Species': 'Cat'}])\n", + "predictions = [guess_value(e, h) for e in examples]\n", + "actual = [e['GOAL'] for e in examples]\n", + "print('predictions:', predictions)\n", + "print('all correct:', predictions == actual)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/learning.ipynb b/lite/content/learning.ipynb new file mode 100644 index 000000000..e50adf74d --- /dev/null +++ b/lite/content/learning.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "14ed9989", + "metadata": {}, + "source": [ + "# Learning from examples in the browser\n", + "\n", + "Decision-tree and naive-Bayes classifiers (chapters 19-20) running fully in your browser with `aima.learning`. We build a small dataset inline so no data files are needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f1978e2", + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "id": "ba4b1402", + "metadata": {}, + "source": [ + "## The \"play tennis\" dataset\n", + "\n", + "A classic toy dataset: predict whether to play tennis from the weather." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9e3ee8d7", + "metadata": {}, + "outputs": [], + "source": [ + "from aima.learning import DataSet, DecisionTreeLearner\n", + "from aima.probabilistic_learning import NaiveBayesLearner\n", + "\n", + "examples = [\n", + " ['Sunny', 'Hot', 'High', 'Weak', 'No'], ['Sunny', 'Hot', 'High', 'Strong', 'No'],\n", + " ['Overcast', 'Hot', 'High', 'Weak', 'Yes'], ['Rain', 'Mild', 'High', 'Weak', 'Yes'],\n", + " ['Rain', 'Cool', 'Normal', 'Weak', 'Yes'], ['Rain', 'Cool', 'Normal', 'Strong', 'No'],\n", + " ['Overcast', 'Cool', 'Normal', 'Strong', 'Yes'], ['Sunny', 'Mild', 'High', 'Weak', 'No'],\n", + " ['Sunny', 'Cool', 'Normal', 'Weak', 'Yes'], ['Rain', 'Mild', 'Normal', 'Weak', 'Yes'],\n", + " ['Sunny', 'Mild', 'Normal', 'Strong', 'Yes'], ['Overcast', 'Mild', 'High', 'Strong', 'Yes'],\n", + " ['Overcast', 'Hot', 'Normal', 'Weak', 'Yes'], ['Rain', 'Mild', 'High', 'Strong', 'No']]\n", + "tennis = DataSet(name='tennis', examples=examples,\n", + " attr_names='Outlook Temp Humidity Wind PlayTennis', target='PlayTennis')" + ] + }, + { + "cell_type": "markdown", + "id": "165a1f98", + "metadata": {}, + "source": [ + "## Decision tree vs. naive Bayes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14f3bdb8", + "metadata": {}, + "outputs": [], + "source": [ + "tree = DecisionTreeLearner(tennis)\n", + "bayes = NaiveBayesLearner(tennis, continuous=False)\n", + "\n", + "for day in [['Overcast', 'Hot', 'Normal', 'Weak'],\n", + " ['Sunny', 'Hot', 'High', 'Strong'],\n", + " ['Rain', 'Cool', 'Normal', 'Weak']]:\n", + " print(day, '-> tree:', tree(day), '| naive Bayes:', bayes(day))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/logic.ipynb b/lite/content/logic.ipynb new file mode 100644 index 000000000..9571503c3 --- /dev/null +++ b/lite/content/logic.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Logic in the browser\n", + "\n", + "Propositional and first-order reasoning from chapters 7-9, running fully in\n", + "your browser with `aima.logic`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Propositional logic\n", + "\n", + "Model checking by truth-table enumeration, and satisfiability via DPLL." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from aima.logic import expr, tt_entails, dpll_satisfiable\n", + "\n", + "# modus ponens: does P & (P => Q) entail Q ?\n", + "print(\"(P & (P ==> Q)) |= Q :\", tt_entails(expr(\"(P & (P ==> Q))\"), expr(\"Q\")))\n", + "\n", + "# satisfiability\n", + "print(\"P | ~P satisfiable :\", bool(dpll_satisfiable(expr(\"P | ~P\"))))\n", + "print(\"P & ~P satisfiable :\", bool(dpll_satisfiable(expr(\"P & ~P\"))))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## First-order logic\n", + "\n", + "The classic \"West is a criminal\" knowledge base (section 9.3), solved by\n", + "forward chaining." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from aima.logic import expr, FolKB, fol_fc_ask\n", + "\n", + "clauses = [\n", + " \"(American(x) & Weapon(y) & Sells(x, y, z) & Hostile(z)) ==> Criminal(x)\",\n", + " \"Owns(Nono, M1)\",\n", + " \"Missile(M1)\",\n", + " \"(Missile(x) & Owns(Nono, x)) ==> Sells(West, x, Nono)\",\n", + " \"Missile(x) ==> Weapon(x)\",\n", + " \"Enemy(x, America) ==> Hostile(x)\",\n", + " \"American(West)\",\n", + " \"Enemy(Nono, America)\",\n", + "]\n", + "kb = FolKB([expr(c) for c in clauses])\n", + "print(\"Who is a criminal? \", list(fol_fc_ask(kb, expr(\"Criminal(x)\"))))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/mdp.ipynb b/lite/content/mdp.ipynb new file mode 100644 index 000000000..358dc968c --- /dev/null +++ b/lite/content/mdp.ipynb @@ -0,0 +1,83 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "960fe9fb", + "metadata": {}, + "source": [ + "# Markov decision processes in the browser\n", + "\n", + "Value iteration and policy iteration on the 4x3 grid world (chapter 17), running fully in your browser with `aima.mdp`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7fed7058", + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "id": "3128ce5a", + "metadata": {}, + "source": [ + "## The 4x3 grid world\n", + "\n", + "An agent moves on a grid with a +1 goal, a -1 trap and a wall; actions succeed 80% of the time. Value iteration computes the utility of every state, and `best_policy` reads off the optimal action." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d0582d17", + "metadata": {}, + "outputs": [], + "source": [ + "from aima.mdp import sequential_decision_environment as grid\n", + "from aima.mdp import value_iteration, best_policy, policy_iteration\n", + "\n", + "U = value_iteration(grid)\n", + "pi = best_policy(grid, U)\n", + "print('optimal policy (value iteration):')\n", + "print('\\n'.join(' '.join(c if c is not None else ' ' for c in row) for row in grid.to_arrows(pi)))" + ] + }, + { + "cell_type": "markdown", + "id": "aa5af733", + "metadata": {}, + "source": [ + "Policy iteration converges to the same optimal policy by a different route." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9708866c", + "metadata": {}, + "outputs": [], + "source": [ + "pi2 = policy_iteration(grid)\n", + "print('optimal policy (policy iteration):')\n", + "print('\\n'.join(' '.join(c if c is not None else ' ' for c in row) for row in grid.to_arrows(pi2)))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/nlp.ipynb b/lite/content/nlp.ipynb new file mode 100644 index 000000000..fefb5f56a --- /dev/null +++ b/lite/content/nlp.ipynb @@ -0,0 +1,69 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "45bc2d37", + "metadata": {}, + "source": [ + "# Natural language parsing in the browser\n", + "\n", + "Probabilistic CYK parsing (chapters 22-23) running fully in your browser with `aima.nlp`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "daa724fc", + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "id": "7d400142", + "metadata": {}, + "source": [ + "## CYK parsing\n", + "\n", + "Given a probabilistic grammar in Chomsky normal form, the CYK algorithm fills a chart of the most likely subtrees spanning each substring." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f0fa48ef", + "metadata": {}, + "outputs": [], + "source": [ + "from aima import nlp\n", + "from aima.nlp import CYK_parse\n", + "\n", + "words = ['the', 'robot', 'is', 'good']\n", + "chart = CYK_parse(words, nlp.E_Prob_Chomsky)\n", + "print('parsing:', ' '.join(words))\n", + "print('chart holds', len(chart), 'scored (symbol, span) entries')\n", + "\n", + "# most likely category spanning the whole sentence\n", + "n = len(words)\n", + "spanning = {sym: p for (sym, start, length), p in chart.items() if start == 0 and length == n}\n", + "best = max(spanning, key=spanning.get)\n", + "print('best category for the full span:', best, 'with probability', round(spanning[best], 6))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/planning.ipynb b/lite/content/planning.ipynb new file mode 100644 index 000000000..7945e46f1 --- /dev/null +++ b/lite/content/planning.ipynb @@ -0,0 +1,79 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Classical planning in the browser\n", + "\n", + "STRIPS-style planning (chapter 11) solved with GraphPlan, running fully in\n", + "your browser with `aima.planning`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Spare tire\n", + "\n", + "Replace a flat tire with the spare from the trunk. `GraphPlan` returns a\n", + "layered plan; `linearize` flattens it into an executable action sequence." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from aima.planning import spare_tire, air_cargo, GraphPlan, linearize\n", + "\n", + "plan = GraphPlan(spare_tire()).execute()\n", + "print(\"spare tire plan:\", linearize(plan))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Air cargo\n", + "\n", + "Fly two cargos to their destinations (Figure 11.2) — a larger problem with\n", + "load/fly/unload actions across two airports." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plan = GraphPlan(air_cargo()).execute()\n", + "for step, action in enumerate(linearize(plan), 1):\n", + " print(f\"{step}. {action}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/probability.ipynb b/lite/content/probability.ipynb new file mode 100644 index 000000000..a629ba252 --- /dev/null +++ b/lite/content/probability.ipynb @@ -0,0 +1,84 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Probabilistic reasoning in the browser\n", + "\n", + "Exact and approximate inference on a Bayesian network (chapters 12-13),\n", + "running fully in your browser with `aima.probability`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The burglary network\n", + "\n", + "The classic alarm network (Figure 12.2): a burglary or an earthquake may set\n", + "off the alarm, which John and Mary may then report. Given that **both** John\n", + "and Mary call, how likely is a burglary?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from aima.probability import burglary, enumeration_ask, elimination_ask\n", + "\n", + "evidence = dict(JohnCalls=True, MaryCalls=True)\n", + "print(\"enumeration P(Burglary | J, M):\", enumeration_ask(\"Burglary\", evidence, burglary).show_approx())\n", + "print(\"elimination P(Burglary | J, M):\", elimination_ask(\"Burglary\", evidence, burglary).show_approx())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Approximate inference\n", + "\n", + "Both exact methods agree on P(Burglary | J, M) ≈ 0.284. Likelihood weighting\n", + "approximates the same posterior by sampling." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "from aima.probability import likelihood_weighting\n", + "\n", + "random.seed(42)\n", + "print(\"likelihood weighting (10000 samples):\",\n", + " likelihood_weighting(\"Burglary\", evidence, burglary, 10000).show_approx())" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/reinforcement_learning.ipynb b/lite/content/reinforcement_learning.ipynb new file mode 100644 index 000000000..17ea5a6ad --- /dev/null +++ b/lite/content/reinforcement_learning.ipynb @@ -0,0 +1,64 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "556d6850", + "metadata": {}, + "source": [ + "# Reinforcement learning in the browser\n", + "\n", + "Q-learning on the 4x3 grid world (chapter 22), running fully in your browser with `aima.reinforcement_learning`. The agent has no model of the environment and learns purely from experience." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "008e0c81", + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "663a6866", + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "from aima.mdp import sequential_decision_environment as grid\n", + "from aima.reinforcement_learning import QLearningAgent, run_single_trial\n", + "\n", + "random.seed(0)\n", + "agent = QLearningAgent(grid, Ne=5, Rplus=2,\n", + " alpha=lambda n: 60 / (59 + n))\n", + "for _ in range(300):\n", + " run_single_trial(agent, grid)\n", + "\n", + "# derive a greedy policy from the learned action-value function Q\n", + "U = {}\n", + "for (state, action), q in agent.Q.items():\n", + " if q > U.get(state, float('-inf')):\n", + " U[state] = q\n", + "print('learned', len(agent.Q), 'state-action values over 300 trials')\n", + "print('estimated utility of the start state (0, 0):',\n", + " round(max(agent.Q[((0, 0), a)] for a in grid.actions((0, 0))), 3))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/search.ipynb b/lite/content/search.ipynb new file mode 100644 index 000000000..80c5d4f21 --- /dev/null +++ b/lite/content/search.ipynb @@ -0,0 +1,64 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Search in the browser\n", + "\n", + "Uninformed and informed search on the Romania map from chapter 3, running\n", + "fully in your browser with `aima.search`. Run the cells top to bottom." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from aima.search import GraphProblem, romania_map\n", + "from aima.search import breadth_first_graph_search, uniform_cost_search, astar_search\n", + "\n", + "problem = GraphProblem(\"Arad\", \"Bucharest\", romania_map)\n", + "\n", + "for name, algorithm in [\n", + " (\"Breadth-first\", breadth_first_graph_search),\n", + " (\"Uniform-cost\", uniform_cost_search),\n", + " (\"A* (straight-line heuristic)\", astar_search),\n", + "]:\n", + " goal = algorithm(problem)\n", + " print(f\"{name:30s} {['Arad'] + goal.solution()} (cost {goal.path_cost})\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A* finds the optimal route Arad → Sibiu → Rimnicu → Pitesti → Bucharest\n", + "(cost 418), while breadth-first returns a shorter-in-hops but costlier path." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/content/text.ipynb b/lite/content/text.ipynb new file mode 100644 index 000000000..ba2473391 --- /dev/null +++ b/lite/content/text.ipynb @@ -0,0 +1,68 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "24d93d74", + "metadata": {}, + "source": [ + "# Language models in the browser\n", + "\n", + "Unigram and bigram word models (chapter 22) running fully in your browser with `aima.text`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a033624c", + "metadata": {}, + "outputs": [], + "source": [ + "import piplite\n", + "await piplite.install(\"aima\", deps=False)" + ] + }, + { + "cell_type": "markdown", + "id": "9a4a1e3d", + "metadata": {}, + "source": [ + "## Counting n-grams\n", + "\n", + "We build word models from a short inline corpus and inspect the most frequent words and word pairs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c4b9474", + "metadata": {}, + "outputs": [], + "source": [ + "from aima.text import UnigramWordModel, NgramWordModel, words\n", + "\n", + "corpus = words(\n", + " 'the quick brown fox jumps over the lazy dog '\n", + " 'the fox is quick and the dog is lazy '\n", + " 'the quick dog jumps over the lazy fox')\n", + "\n", + "unigram = UnigramWordModel(corpus)\n", + "bigram = NgramWordModel(2, corpus)\n", + "print('most frequent words: ', unigram.top(3))\n", + "print('most frequent bigrams:', bigram.top(3))\n", + "print(\"P('quick') =\", round(unigram['quick'], 4))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/jupyter-lite.json b/lite/jupyter-lite.json new file mode 100644 index 000000000..077e57a72 --- /dev/null +++ b/lite/jupyter-lite.json @@ -0,0 +1,11 @@ +{ + "jupyter-config-data": { + "litePluginSettings": { + "@jupyterlite/pyodide-kernel-extension:kernel": { + "loadPyodideOptions": { + "packages": ["numpy", "networkx", "sortedcontainers"] + } + } + } + } +} diff --git a/lite/jupyter_lite_config.json b/lite/jupyter_lite_config.json new file mode 100644 index 000000000..20d32e53c --- /dev/null +++ b/lite/jupyter_lite_config.json @@ -0,0 +1,8 @@ +{ + "LiteBuildConfig": { + "contents": ["content"] + }, + "PipliteAddon": { + "piplite_urls": ["aima-4.0.0-py3-none-any.whl"] + } +} diff --git a/lite/requirements-lite.txt b/lite/requirements-lite.txt new file mode 100644 index 000000000..1b0b9376a --- /dev/null +++ b/lite/requirements-lite.txt @@ -0,0 +1,7 @@ +# Build-time toolchain for the JupyterLite proof-of-concept (see README.md). +# These are NOT runtime deps of aima; they only build the static browser site. +jupyterlite-core==0.8.0 +jupyterlite-pyodide-kernel==0.8.1 +jupyterlite==0.8.0 +jupyter-server +build diff --git a/lite/verify_browser.py b/lite/verify_browser.py new file mode 100644 index 000000000..31b6c4921 --- /dev/null +++ b/lite/verify_browser.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Run every companion notebook in a real headless browser (Pyodide WASM). + +This is the in-browser counterpart to the build: `build.sh` proves the static +site *builds*; this proves every notebook's code actually *runs* in Pyodide. + +It serves the locally-built site (`lite/_output`, so run `./build.sh` first), +loads real Pyodide in headless Chromium, installs the freshly-built `aima` wheel +from the bundled offline index, and executes each notebook's real code cells +(the `piplite` install cell included, via a micropip-backed shim). `ipython` is +loaded to mirror the JupyterLite kernel, which always provides it. + +Setup (one-off): + pip install playwright nbformat + python -m playwright install chromium + +Usage: + cd lite && ./build.sh && python verify_browser.py +""" +import asyncio +import functools +import glob +import http.server +import os +import pathlib +import threading + +import nbformat +from playwright.async_api import async_playwright + +HERE = pathlib.Path(__file__).resolve().parent +SITE = HERE / "_output" +CONTENT = HERE / "content" +PORT = 8765 +PYODIDE = "https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js" + +# notebooks first, in a sensible reading order; any extras are appended +PREFERRED = ["Welcome", "agents", "search", "games", "csp", "logic", "planning", + "probability", "mdp", "reinforcement_learning", "learning", + "knowledge", "nlp", "text", "game_theory"] + +HTML = """ +""" + + +def serve(directory): + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(directory)) + httpd = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd + + +async def run_cell(page, code): + return await page.evaluate( + """async (code) => { + window.OUT = []; + try { + await pyodide.runPythonAsync(code, {globals: window.__ns}); + return {ok: true, out: window.OUT.join('')}; + } catch (e) { + return {ok: false, out: window.OUT.join('') + '\\n' + e.toString()}; + } + }""", code) + + +def notebook_order(): + names = [p.stem for p in CONTENT.glob("*.ipynb")] + ordered = [n for n in PREFERRED if n in names] + return ordered + sorted(n for n in names if n not in PREFERRED) + + +async def main(): + if not SITE.exists(): + raise SystemExit("lite/_output not found — run ./build.sh first") + wheels = glob.glob(str(SITE / "pypi" / "aima-*.whl")) + if not wheels: + raise SystemExit("no aima wheel in lite/_output/pypi — run ./build.sh first") + wheel_name = os.path.basename(wheels[0]) + + html = HTML.format(pyodide=PYODIDE, wheel_url=f"pypi/{wheel_name}", wheel_name=wheel_name) + (SITE / "verify.html").write_text(html) + serve(SITE) + + results = {} + async with async_playwright() as p: + browser = await p.chromium.launch() + page = await browser.new_page() + page.on("pageerror", lambda e: print("PAGEERROR:", e)) + await page.goto(f"http://127.0.0.1:{PORT}/verify.html") + try: + await page.wait_for_function("window.pyReady === true", timeout=180000) + except Exception: + raise SystemExit("boot failed: " + await page.evaluate("() => window.bootErr")) + print(f"Pyodide booted, {wheel_name} installed.\n") + + for name in notebook_order(): + await page.evaluate("() => { if (window.__ns) window.__ns.destroy();" + " window.__ns = pyodide.globals.get('dict')(); }") + nb = nbformat.read(str(CONTENT / f"{name}.ipynb"), as_version=4) + ok, out = True, "" + for cell in nb.cells: + if cell.cell_type != "code": + continue + r = await run_cell(page, cell.source) + out += r["out"] + if not r["ok"]: + ok = False + break + results[name] = ok + print(f" {'PASS' if ok else 'FAIL'} {name}") + if not ok: + print("\n".join(" " + l for l in out.strip().splitlines()[-8:])) + await browser.close() + + passed = sum(results.values()) + print(f"\n{passed}/{len(results)} notebooks ran successfully in a real browser") + raise SystemExit(0 if passed == len(results) else 1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/logic4e.py b/logic4e.py deleted file mode 100644 index 75608ad74..000000000 --- a/logic4e.py +++ /dev/null @@ -1,1665 +0,0 @@ -"""Representations and Inference for Logic (Chapters 7-10) - -Covers both Propositional and First-Order Logic. First we have four -important data types: - - KB Abstract class holds a knowledge base of logical expressions - KB_Agent Abstract class subclasses agents.Agent - Expr A logical expression, imported from utils.py - substitution Implemented as a dictionary of var:value pairs, {x:1, y:x} - -Be careful: some functions take an Expr as argument, and some take a KB. - -Logical expressions can be created with Expr or expr, imported from utils, TODO -or with expr, which adds the capability to write a string that uses -the connectives ==>, <==, <=>, or <=/=>. But be careful: these have the -operator precedence of commas; you may need to add parents to make precedence work. -See logic.ipynb for examples. - -Then we implement various functions for doing logical inference: - - pl_true Evaluate a propositional logical sentence in a model - tt_entails Say if a statement is entailed by a KB - pl_resolution Do resolution on propositional sentences - dpll_satisfiable See if a propositional sentence is satisfiable - WalkSAT Try to find a solution for a set of clauses - -And a few other functions: - - to_cnf Convert to conjunctive normal form - unify Do unification of two FOL sentences - diff, simp Symbolic differentiation and simplification -""" -import itertools -import random -from collections import defaultdict - -from agents import Agent, Glitter, Bump, Stench, Breeze, Scream -from search import astar_search, PlanRoute -from utils4e import remove_all, unique, first, probability, isnumber, issequence, Expr, expr, subexpressions - - -# ______________________________________________________________________________ -# Chapter 7 Logical Agents -# 7.1 Knowledge Based Agents - - -class KB: - """ - A knowledge base to which you can tell and ask sentences. - To create a KB, subclass this class and implement tell, ask_generator, and retract. - Ask_generator: - For a Propositional Logic KB, ask(P & Q) returns True or False, but for an - FOL KB, something like ask(Brother(x, y)) might return many substitutions - such as {x: Cain, y: Abel}, {x: Abel, y: Cain}, {x: George, y: Jeb}, etc. - So ask_generator generates these one at a time, and ask either returns the - first one or returns False. - """ - - def __init__(self, sentence=None): - raise NotImplementedError - - def tell(self, sentence): - """Add the sentence to the KB.""" - raise NotImplementedError - - def ask(self, query): - """Return a substitution that makes the query true, or, failing that, return False.""" - return first(self.ask_generator(query), default=False) - - def ask_generator(self, query): - """Yield all the substitutions that make query true.""" - raise NotImplementedError - - def retract(self, sentence): - """Remove sentence from the KB.""" - raise NotImplementedError - - -class PropKB(KB): - """A KB for propositional logic. Inefficient, with no indexing.""" - - def __init__(self, sentence=None): - self.clauses = [] - if sentence: - self.tell(sentence) - - def tell(self, sentence): - """Add the sentence's clauses to the KB.""" - self.clauses.extend(conjuncts(to_cnf(sentence))) - - def ask_generator(self, query): - """Yield the empty substitution {} if KB entails query; else no results.""" - if tt_entails(Expr('&', *self.clauses), query): - yield {} - - def ask_if_true(self, query): - """Return True if the KB entails query, else return False.""" - for _ in self.ask_generator(query): - return True - return False - - def retract(self, sentence): - """Remove the sentence's clauses from the KB.""" - for c in conjuncts(to_cnf(sentence)): - if c in self.clauses: - self.clauses.remove(c) - - -def KB_AgentProgram(KB): - """A generic logical knowledge-based agent program. [Figure 7.1]""" - steps = itertools.count() - - def program(percept): - t = next(steps) - KB.tell(make_percept_sentence(percept, t)) - action = KB.ask(make_action_query(t)) - KB.tell(make_action_sentence(action, t)) - return action - - def make_percept_sentence(percept, t): - return Expr("Percept")(percept, t) - - def make_action_query(t): - return expr("ShouldDo(action, {})".format(t)) - - def make_action_sentence(action, t): - return Expr("Did")(action[expr('action')], t) - - return program - - -# _____________________________________________________________________________ -# 7.2 The Wumpus World - - -# Expr functions for WumpusKB and HybridWumpusAgent - - -def facing_east(time): - return Expr('FacingEast', time) - - -def facing_west(time): - return Expr('FacingWest', time) - - -def facing_north(time): - return Expr('FacingNorth', time) - - -def facing_south(time): - return Expr('FacingSouth', time) - - -def wumpus(x, y): - return Expr('W', x, y) - - -def pit(x, y): - return Expr('P', x, y) - - -def breeze(x, y): - return Expr('B', x, y) - - -def stench(x, y): - return Expr('S', x, y) - - -def wumpus_alive(time): - return Expr('WumpusAlive', time) - - -def have_arrow(time): - return Expr('HaveArrow', time) - - -def percept_stench(time): - return Expr('Stench', time) - - -def percept_breeze(time): - return Expr('Breeze', time) - - -def percept_glitter(time): - return Expr('Glitter', time) - - -def percept_bump(time): - return Expr('Bump', time) - - -def percept_scream(time): - return Expr('Scream', time) - - -def move_forward(time): - return Expr('Forward', time) - - -def shoot(time): - return Expr('Shoot', time) - - -def turn_left(time): - return Expr('TurnLeft', time) - - -def turn_right(time): - return Expr('TurnRight', time) - - -def ok_to_move(x, y, time): - return Expr('OK', x, y, time) - - -def location(x, y, time=None): - if time is None: - return Expr('L', x, y) - else: - return Expr('L', x, y, time) - - -# Symbols - - -def implies(lhs, rhs): - return Expr('==>', lhs, rhs) - - -def equiv(lhs, rhs): - return Expr('<=>', lhs, rhs) - - -# Helper Function - - -def new_disjunction(sentences): - t = sentences[0] - for i in range(1, len(sentences)): - t |= sentences[i] - return t - - -# ______________________________________________________________________________ -# 7.4 Propositional Logic - - -def is_symbol(s): - """A string s is a symbol if it starts with an alphabetic char. - >>> is_symbol('R2D2') - True - """ - return isinstance(s, str) and s[:1].isalpha() - - -def is_var_symbol(s): - """A logic variable symbol is an initial-lowercase string. - >>> is_var_symbol('EXE') - False - """ - return is_symbol(s) and s[0].islower() - - -def is_prop_symbol(s): - """A proposition logic symbol is an initial-uppercase string. - >>> is_prop_symbol('exe') - False - """ - return is_symbol(s) and s[0].isupper() - - -def variables(s): - """Return a set of the variables in expression s. - >>> variables(expr('F(x, x) & G(x, y) & H(y, z) & R(A, z, 2)')) == {x, y, z} - True - """ - return {x for x in subexpressions(s) if is_variable(x)} - - -def is_definite_clause(s): - """ - Returns True for exprs s of the form A & B & ... & C ==> D, - where all literals are positive. In clause form, this is - ~A | ~B | ... | ~C | D, where exactly one clause is positive. - >>> is_definite_clause(expr('Farmer(Mac)')) - True - """ - if is_symbol(s.op): - return True - elif s.op == '==>': - antecedent, consequent = s.args - return (is_symbol(consequent.op) and - all(is_symbol(arg.op) for arg in conjuncts(antecedent))) - else: - return False - - -def parse_definite_clause(s): - """Return the antecedents and the consequent of a definite clause.""" - assert is_definite_clause(s) - if is_symbol(s.op): - return [], s - else: - antecedent, consequent = s.args - return conjuncts(antecedent), consequent - - -# Useful constant Exprs used in examples and code: -A, B, C, D, E, F, G, P, Q, x, y, z = map(Expr, 'ABCDEFGPQxyz') - - -# ______________________________________________________________________________ -# 7.4.4 A simple inference procedure - - -def tt_entails(kb, alpha): - """ - Does kb entail the sentence alpha? Use truth tables. For propositional - kb's and sentences. [Figure 7.10]. Note that the 'kb' should be an - Expr which is a conjunction of clauses. - >>> tt_entails(expr('P & Q'), expr('Q')) - True - """ - assert not variables(alpha) - symbols = list(prop_symbols(kb & alpha)) - return tt_check_all(kb, alpha, symbols, {}) - - -def tt_check_all(kb, alpha, symbols, model): - """Auxiliary routine to implement tt_entails.""" - if not symbols: - if pl_true(kb, model): - result = pl_true(alpha, model) - assert result in (True, False) - return result - else: - return True - else: - P, rest = symbols[0], symbols[1:] - return (tt_check_all(kb, alpha, rest, extend(model, P, True)) and - tt_check_all(kb, alpha, rest, extend(model, P, False))) - - -def prop_symbols(x): - """Return the set of all propositional symbols in x.""" - if not isinstance(x, Expr): - return set() - elif is_prop_symbol(x.op): - return {x} - else: - return {symbol for arg in x.args for symbol in prop_symbols(arg)} - - -def constant_symbols(x): - """Return the set of all constant symbols in x.""" - if not isinstance(x, Expr): - return set() - elif is_prop_symbol(x.op) and not x.args: - return {x} - else: - return {symbol for arg in x.args for symbol in constant_symbols(arg)} - - -def predicate_symbols(x): - """ - Return a set of (symbol_name, arity) in x. - All symbols (even functional) with arity > 0 are considered. - """ - if not isinstance(x, Expr) or not x.args: - return set() - pred_set = {(x.op, len(x.args))} if is_prop_symbol(x.op) else set() - pred_set.update({symbol for arg in x.args for symbol in predicate_symbols(arg)}) - return pred_set - - -def tt_true(s): - """Is a propositional sentence a tautology? - >>> tt_true('P | ~P') - True - """ - s = expr(s) - return tt_entails(True, s) - - -def pl_true(exp, model={}): - """ - Return True if the propositional logic expression is true in the model, - and False if it is false. If the model does not specify the value for - every proposition, this may return None to indicate 'not obvious'; - this may happen even when the expression is tautological. - >>> pl_true(P, {}) is None - True - """ - if exp in (True, False): - return exp - op, args = exp.op, exp.args - if is_prop_symbol(op): - return model.get(exp) - elif op == '~': - p = pl_true(args[0], model) - if p is None: - return None - else: - return not p - elif op == '|': - result = False - for arg in args: - p = pl_true(arg, model) - if p is True: - return True - if p is None: - result = None - return result - elif op == '&': - result = True - for arg in args: - p = pl_true(arg, model) - if p is False: - return False - if p is None: - result = None - return result - p, q = args - if op == '==>': - return pl_true(~p | q, model) - elif op == '<==': - return pl_true(p | ~q, model) - pt = pl_true(p, model) - if pt is None: - return None - qt = pl_true(q, model) - if qt is None: - return None - if op == '<=>': - return pt == qt - elif op == '^': # xor or 'not equivalent' - return pt != qt - else: - raise ValueError("illegal operator in logic expression" + str(exp)) - - -# ______________________________________________________________________________ -# 7.5 Propositional Theorem Proving - - -def to_cnf(s): - """Convert a propositional logical sentence to conjunctive normal form. - That is, to the form ((A | ~B | ...) & (B | C | ...) & ...) [p. 253] - >>> to_cnf('~(B | C)') - (~B & ~C) - """ - s = expr(s) - if isinstance(s, str): - s = expr(s) - s = eliminate_implications(s) # Steps 1, 2 from p. 253 - s = move_not_inwards(s) # Step 3 - return distribute_and_over_or(s) # Step 4 - - -def eliminate_implications(s): - """Change implications into equivalent form with only &, |, and ~ as logical operators.""" - s = expr(s) - if not s.args or is_symbol(s.op): - return s # Atoms are unchanged. - args = list(map(eliminate_implications, s.args)) - a, b = args[0], args[-1] - if s.op == '==>': - return b | ~a - elif s.op == '<==': - return a | ~b - elif s.op == '<=>': - return (a | ~b) & (b | ~a) - elif s.op == '^': - assert len(args) == 2 # TODO: relax this restriction - return (a & ~b) | (~a & b) - else: - assert s.op in ('&', '|', '~') - return Expr(s.op, *args) - - -def move_not_inwards(s): - """Rewrite sentence s by moving negation sign inward. - >>> move_not_inwards(~(A | B)) - (~A & ~B) - """ - s = expr(s) - if s.op == '~': - def NOT(b): - return move_not_inwards(~b) - - a = s.args[0] - if a.op == '~': - return move_not_inwards(a.args[0]) # ~~A ==> A - if a.op == '&': - return associate('|', list(map(NOT, a.args))) - if a.op == '|': - return associate('&', list(map(NOT, a.args))) - return s - elif is_symbol(s.op) or not s.args: - return s - else: - return Expr(s.op, *list(map(move_not_inwards, s.args))) - - -def distribute_and_over_or(s): - """Given a sentence s consisting of conjunctions and disjunctions - of literals, return an equivalent sentence in CNF. - >>> distribute_and_over_or((A & B) | C) - ((A | C) & (B | C)) - """ - s = expr(s) - if s.op == '|': - s = associate('|', s.args) - if s.op != '|': - return distribute_and_over_or(s) - if len(s.args) == 0: - return False - if len(s.args) == 1: - return distribute_and_over_or(s.args[0]) - conj = first(arg for arg in s.args if arg.op == '&') - if not conj: - return s - others = [a for a in s.args if a is not conj] - rest = associate('|', others) - return associate('&', [distribute_and_over_or(c | rest) - for c in conj.args]) - elif s.op == '&': - return associate('&', list(map(distribute_and_over_or, s.args))) - else: - return s - - -def associate(op, args): - """Given an associative op, return an expression with the same - meaning as Expr(op, *args), but flattened -- that is, with nested - instances of the same op promoted to the top level. - >>> associate('&', [(A&B),(B|C),(B&C)]) - (A & B & (B | C) & B & C) - >>> associate('|', [A|(B|(C|(A&B)))]) - (A | B | C | (A & B)) - """ - args = dissociate(op, args) - if len(args) == 0: - return _op_identity[op] - elif len(args) == 1: - return args[0] - else: - return Expr(op, *args) - - -_op_identity = {'&': True, '|': False, '+': 0, '*': 1} - - -def dissociate(op, args): - """Given an associative op, return a flattened list result such - that Expr(op, *result) means the same as Expr(op, *args). - >>> dissociate('&', [A & B]) - [A, B] - """ - result = [] - - def collect(subargs): - for arg in subargs: - if arg.op == op: - collect(arg.args) - else: - result.append(arg) - - collect(args) - return result - - -def conjuncts(s): - """Return a list of the conjuncts in the sentence s. - >>> conjuncts(A & B) - [A, B] - >>> conjuncts(A | B) - [(A | B)] - """ - return dissociate('&', [s]) - - -def disjuncts(s): - """Return a list of the disjuncts in the sentence s. - >>> disjuncts(A | B) - [A, B] - >>> disjuncts(A & B) - [(A & B)] - """ - return dissociate('|', [s]) - - -# ______________________________________________________________________________ - - -def pl_resolution(KB, alpha): - """ - Propositional-logic resolution: say if alpha follows from KB. [Figure 7.12] - >>> pl_resolution(horn_clauses_KB, A) - True - """ - clauses = KB.clauses + conjuncts(to_cnf(~alpha)) - new = set() - while True: - n = len(clauses) - pairs = [(clauses[i], clauses[j]) - for i in range(n) for j in range(i + 1, n)] - for (ci, cj) in pairs: - resolvents = pl_resolve(ci, cj) - if False in resolvents: - return True - new = new.union(set(resolvents)) - if new.issubset(set(clauses)): - return False - for c in new: - if c not in clauses: - clauses.append(c) - - -def pl_resolve(ci, cj): - """Return all clauses that can be obtained by resolving clauses ci and cj.""" - clauses = [] - for di in disjuncts(ci): - for dj in disjuncts(cj): - if di == ~dj or ~di == dj: - dnew = unique(remove_all(di, disjuncts(ci)) + - remove_all(dj, disjuncts(cj))) - clauses.append(associate('|', dnew)) - return clauses - - -# ______________________________________________________________________________ -# 7.5.4 Forward and backward chaining - - -class PropDefiniteKB(PropKB): - """A KB of propositional definite clauses.""" - - def tell(self, sentence): - """Add a definite clause to this KB.""" - assert is_definite_clause(sentence), "Must be definite clause" - self.clauses.append(sentence) - - def ask_generator(self, query): - """Yield the empty substitution if KB implies query; else nothing.""" - if pl_fc_entails(self.clauses, query): - yield {} - - def retract(self, sentence): - self.clauses.remove(sentence) - - def clauses_with_premise(self, p): - """Return a list of the clauses in KB that have p in their premise. - This could be cached away for O(1) speed, but we'll recompute it.""" - return [c for c in self.clauses - if c.op == '==>' and p in conjuncts(c.args[0])] - - -def pl_fc_entails(KB, q): - """Use forward chaining to see if a PropDefiniteKB entails symbol q. - [Figure 7.15] - >>> pl_fc_entails(horn_clauses_KB, expr('Q')) - True - """ - count = {c: len(conjuncts(c.args[0])) - for c in KB.clauses - if c.op == '==>'} - inferred = defaultdict(bool) - agenda = [s for s in KB.clauses if is_prop_symbol(s.op)] - while agenda: - p = agenda.pop() - if p == q: - return True - if not inferred[p]: - inferred[p] = True - for c in KB.clauses_with_premise(p): - count[c] -= 1 - if count[c] == 0: - agenda.append(c.args[1]) - return False - - -""" [Figure 7.13] -Simple inference in a wumpus world example -""" -wumpus_world_inference = expr("(B11 <=> (P12 | P21)) & ~B11") - -""" [Figure 7.16] -Propositional Logic Forward Chaining example -""" -horn_clauses_KB = PropDefiniteKB() -for s in "P==>Q; (L&M)==>P; (B&L)==>M; (A&P)==>L; (A&B)==>L; A;B".split(';'): - horn_clauses_KB.tell(expr(s)) - -""" -Definite clauses KB example -""" -definite_clauses_KB = PropDefiniteKB() -for clause in ['(B & F)==>E', '(A & E & F)==>G', '(B & C)==>F', '(A & B)==>D', '(E & F)==>H', '(H & I)==>J', 'A', 'B', - 'C']: - definite_clauses_KB.tell(expr(clause)) - - -# ______________________________________________________________________________ -# 7.6 Effective Propositional Model Checking -# DPLL-Satisfiable [Figure 7.17] - - -def dpll_satisfiable(s): - """Check satisfiability of a propositional sentence. - This differs from the book code in two ways: (1) it returns a model - rather than True when it succeeds; this is more useful. (2) The - function find_pure_symbol is passed a list of unknown clauses, rather - than a list of all clauses and the model; this is more efficient. - >>> dpll_satisfiable(A |'<=>'| B) == {A: True, B: True} - True - """ - clauses = conjuncts(to_cnf(s)) - symbols = list(prop_symbols(s)) - return dpll(clauses, symbols, {}) - - -def dpll(clauses, symbols, model): - """See if the clauses are true in a partial model.""" - unknown_clauses = [] # clauses with an unknown truth value - for c in clauses: - val = pl_true(c, model) - if val is False: - return False - if val is not True: - unknown_clauses.append(c) - if not unknown_clauses: - return model - P, value = find_pure_symbol(symbols, unknown_clauses) - if P: - return dpll(clauses, remove_all(P, symbols), extend(model, P, value)) - P, value = find_unit_clause(clauses, model) - if P: - return dpll(clauses, remove_all(P, symbols), extend(model, P, value)) - if not symbols: - raise TypeError("Argument should be of the type Expr.") - P, symbols = symbols[0], symbols[1:] - return (dpll(clauses, symbols, extend(model, P, True)) or - dpll(clauses, symbols, extend(model, P, False))) - - -def find_pure_symbol(symbols, clauses): - """ - Find a symbol and its value if it appears only as a positive literal - (or only as a negative) in clauses. - >>> find_pure_symbol([A, B, C], [A|~B,~B|~C,C|A]) - (A, True) - """ - for s in symbols: - found_pos, found_neg = False, False - for c in clauses: - if not found_pos and s in disjuncts(c): - found_pos = True - if not found_neg and ~s in disjuncts(c): - found_neg = True - if found_pos != found_neg: - return s, found_pos - return None, None - - -def find_unit_clause(clauses, model): - """ - Find a forced assignment if possible from a clause with only 1 - variable not bound in the model. - >>> find_unit_clause([A|B|C, B|~C, ~A|~B], {A:True}) - (B, False) - """ - for clause in clauses: - P, value = unit_clause_assign(clause, model) - if P: - return P, value - return None, None - - -def unit_clause_assign(clause, model): - """Return a single variable/value pair that makes clause true in - the model, if possible. - >>> unit_clause_assign(A|B|C, {A:True}) - (None, None) - >>> unit_clause_assign(B|~C, {A:True}) - (None, None) - >>> unit_clause_assign(~A|~B, {A:True}) - (B, False) - """ - P, value = None, None - for literal in disjuncts(clause): - sym, positive = inspect_literal(literal) - if sym in model: - if model[sym] == positive: - return None, None # clause already True - elif P: - return None, None # more than 1 unbound variable - else: - P, value = sym, positive - return P, value - - -def inspect_literal(literal): - """The symbol in this literal, and the value it should take to - make the literal true. - >>> inspect_literal(P) - (P, True) - >>> inspect_literal(~P) - (P, False) - """ - if literal.op == '~': - return literal.args[0], False - else: - return literal, True - - -# ______________________________________________________________________________ -# 7.6.2 Local search algorithms -# Walk-SAT [Figure 7.18] - - -def WalkSAT(clauses, p=0.5, max_flips=10000): - """ - Checks for satisfiability of all clauses by randomly flipping values of variables - >>> WalkSAT([A & ~A], 0.5, 100) is None - True - """ - # Set of all symbols in all clauses - symbols = {sym for clause in clauses for sym in prop_symbols(clause)} - # model is a random assignment of true/false to the symbols in clauses - model = {s: random.choice([True, False]) for s in symbols} - for i in range(max_flips): - satisfied, unsatisfied = [], [] - for clause in clauses: - (satisfied if pl_true(clause, model) else unsatisfied).append(clause) - if not unsatisfied: # if model satisfies all the clauses - return model - clause = random.choice(unsatisfied) - if probability(p): - sym = random.choice(list(prop_symbols(clause))) - else: - # Flip the symbol in clause that maximizes number of sat. clauses - def sat_count(sym): - # Return the the number of clauses satisfied after flipping the symbol. - model[sym] = not model[sym] - count = len([clause for clause in clauses if pl_true(clause, model)]) - model[sym] = not model[sym] - return count - - sym = max(prop_symbols(clause), key=sat_count) - model[sym] = not model[sym] - # If no solution is found within the flip limit, we return failure - return None - - -# ______________________________________________________________________________ -# 7.7 Agents Based on Propositional Logic -# 7.7.1 The current state of the world - - -class WumpusKB(PropKB): - """ - Create a Knowledge Base that contains the atemporal "Wumpus physics" and temporal rules with time zero. - """ - - def __init__(self, dimrow): - super().__init__() - self.dimrow = dimrow - self.tell(~wumpus(1, 1)) - self.tell(~pit(1, 1)) - - for y in range(1, dimrow + 1): - for x in range(1, dimrow + 1): - - pits_in = list() - wumpus_in = list() - - if x > 1: # West room exists - pits_in.append(pit(x - 1, y)) - wumpus_in.append(wumpus(x - 1, y)) - - if y < dimrow: # North room exists - pits_in.append(pit(x, y + 1)) - wumpus_in.append(wumpus(x, y + 1)) - - if x < dimrow: # East room exists - pits_in.append(pit(x + 1, y)) - wumpus_in.append(wumpus(x + 1, y)) - - if y > 1: # South room exists - pits_in.append(pit(x, y - 1)) - wumpus_in.append(wumpus(x, y - 1)) - - self.tell(equiv(breeze(x, y), new_disjunction(pits_in))) - self.tell(equiv(stench(x, y), new_disjunction(wumpus_in))) - - # Rule that describes existence of at least one Wumpus - wumpus_at_least = list() - for x in range(1, dimrow + 1): - for y in range(1, dimrow + 1): - wumpus_at_least.append(wumpus(x, y)) - - self.tell(new_disjunction(wumpus_at_least)) - - # Rule that describes existence of at most one Wumpus - for i in range(1, dimrow + 1): - for j in range(1, dimrow + 1): - for u in range(1, dimrow + 1): - for v in range(1, dimrow + 1): - if i != u or j != v: - self.tell(~wumpus(i, j) | ~wumpus(u, v)) - - # Temporal rules at time zero - self.tell(location(1, 1, 0)) - for i in range(1, dimrow + 1): - for j in range(1, dimrow + 1): - self.tell(implies(location(i, j, 0), equiv(percept_breeze(0), breeze(i, j)))) - self.tell(implies(location(i, j, 0), equiv(percept_stench(0), stench(i, j)))) - if i != 1 or j != 1: - self.tell(~location(i, j, 0)) - - self.tell(wumpus_alive(0)) - self.tell(have_arrow(0)) - self.tell(facing_east(0)) - self.tell(~facing_north(0)) - self.tell(~facing_south(0)) - self.tell(~facing_west(0)) - - def make_action_sentence(self, action, time): - actions = [move_forward(time), shoot(time), turn_left(time), turn_right(time)] - - for a in actions: - if action is a: - self.tell(action) - else: - self.tell(~a) - - def make_percept_sentence(self, percept, time): - # Glitter, Bump, Stench, Breeze, Scream - flags = [0, 0, 0, 0, 0] - - # Things perceived - if isinstance(percept, Glitter): - flags[0] = 1 - self.tell(percept_glitter(time)) - elif isinstance(percept, Bump): - flags[1] = 1 - self.tell(percept_bump(time)) - elif isinstance(percept, Stench): - flags[2] = 1 - self.tell(percept_stench(time)) - elif isinstance(percept, Breeze): - flags[3] = 1 - self.tell(percept_breeze(time)) - elif isinstance(percept, Scream): - flags[4] = 1 - self.tell(percept_scream(time)) - - # Things not perceived - for i in range(len(flags)): - if flags[i] == 0: - if i == 0: - self.tell(~percept_glitter(time)) - elif i == 1: - self.tell(~percept_bump(time)) - elif i == 2: - self.tell(~percept_stench(time)) - elif i == 3: - self.tell(~percept_breeze(time)) - elif i == 4: - self.tell(~percept_scream(time)) - - def add_temporal_sentences(self, time): - if time == 0: - return - t = time - 1 - - # current location rules - for i in range(1, self.dimrow + 1): - for j in range(1, self.dimrow + 1): - self.tell(implies(location(i, j, time), equiv(percept_breeze(time), breeze(i, j)))) - self.tell(implies(location(i, j, time), equiv(percept_stench(time), stench(i, j)))) - - s = list() - - s.append( - equiv( - location(i, j, time), location(i, j, time) & ~move_forward(time) | percept_bump(time))) - - if i != 1: - s.append(location(i - 1, j, t) & facing_east(t) & move_forward(t)) - - if i != self.dimrow: - s.append(location(i + 1, j, t) & facing_west(t) & move_forward(t)) - - if j != 1: - s.append(location(i, j - 1, t) & facing_north(t) & move_forward(t)) - - if j != self.dimrow: - s.append(location(i, j + 1, t) & facing_south(t) & move_forward(t)) - - # add sentence about location i,j - self.tell(new_disjunction(s)) - - # add sentence about safety of location i,j - self.tell( - equiv(ok_to_move(i, j, time), ~pit(i, j) & ~wumpus(i, j) & wumpus_alive(time)) - ) - - # Rules about current orientation - - a = facing_north(t) & turn_right(t) - b = facing_south(t) & turn_left(t) - c = facing_east(t) & ~turn_left(t) & ~turn_right(t) - s = equiv(facing_east(time), a | b | c) - self.tell(s) - - a = facing_north(t) & turn_left(t) - b = facing_south(t) & turn_right(t) - c = facing_west(t) & ~turn_left(t) & ~turn_right(t) - s = equiv(facing_west(time), a | b | c) - self.tell(s) - - a = facing_east(t) & turn_left(t) - b = facing_west(t) & turn_right(t) - c = facing_north(t) & ~turn_left(t) & ~turn_right(t) - s = equiv(facing_north(time), a | b | c) - self.tell(s) - - a = facing_west(t) & turn_left(t) - b = facing_east(t) & turn_right(t) - c = facing_south(t) & ~turn_left(t) & ~turn_right(t) - s = equiv(facing_south(time), a | b | c) - self.tell(s) - - # Rules about last action - self.tell(equiv(move_forward(t), ~turn_right(t) & ~turn_left(t))) - - # Rule about the arrow - self.tell(equiv(have_arrow(time), have_arrow(t) & ~shoot(t))) - - # Rule about Wumpus (dead or alive) - self.tell(equiv(wumpus_alive(time), wumpus_alive(t) & ~percept_scream(time))) - - def ask_if_true(self, query): - return pl_resolution(self, query) - - -# ______________________________________________________________________________ - - -class WumpusPosition: - def __init__(self, x, y, orientation): - self.X = x - self.Y = y - self.orientation = orientation - - def get_location(self): - return self.X, self.Y - - def set_location(self, x, y): - self.X = x - self.Y = y - - def get_orientation(self): - return self.orientation - - def set_orientation(self, orientation): - self.orientation = orientation - - def __eq__(self, other): - if (other.get_location() == self.get_location() and - other.get_orientation() == self.get_orientation()): - return True - else: - return False - - -# ______________________________________________________________________________ -# 7.7.2 A hybrid agent - - -class HybridWumpusAgent(Agent): - """An agent for the wumpus world that does logical inference. [Figure 7.20]""" - - def __init__(self, dimentions): - self.dimrow = dimentions - self.kb = WumpusKB(self.dimrow) - self.t = 0 - self.plan = list() - self.current_position = WumpusPosition(1, 1, 'UP') - super().__init__(self.execute) - - def execute(self, percept): - self.kb.make_percept_sentence(percept, self.t) - self.kb.add_temporal_sentences(self.t) - - temp = list() - - for i in range(1, self.dimrow + 1): - for j in range(1, self.dimrow + 1): - if self.kb.ask_if_true(location(i, j, self.t)): - temp.append(i) - temp.append(j) - - if self.kb.ask_if_true(facing_north(self.t)): - self.current_position = WumpusPosition(temp[0], temp[1], 'UP') - elif self.kb.ask_if_true(facing_south(self.t)): - self.current_position = WumpusPosition(temp[0], temp[1], 'DOWN') - elif self.kb.ask_if_true(facing_west(self.t)): - self.current_position = WumpusPosition(temp[0], temp[1], 'LEFT') - elif self.kb.ask_if_true(facing_east(self.t)): - self.current_position = WumpusPosition(temp[0], temp[1], 'RIGHT') - - safe_points = list() - for i in range(1, self.dimrow + 1): - for j in range(1, self.dimrow + 1): - if self.kb.ask_if_true(ok_to_move(i, j, self.t)): - safe_points.append([i, j]) - - if self.kb.ask_if_true(percept_glitter(self.t)): - goals = list() - goals.append([1, 1]) - self.plan.append('Grab') - actions = self.plan_route(self.current_position, goals, safe_points) - self.plan.extend(actions) - self.plan.append('Climb') - - if len(self.plan) == 0: - unvisited = list() - for i in range(1, self.dimrow + 1): - for j in range(1, self.dimrow + 1): - for k in range(self.t): - if self.kb.ask_if_true(location(i, j, k)): - unvisited.append([i, j]) - unvisited_and_safe = list() - for u in unvisited: - for s in safe_points: - if u not in unvisited_and_safe and s == u: - unvisited_and_safe.append(u) - - temp = self.plan_route(self.current_position, unvisited_and_safe, safe_points) - self.plan.extend(temp) - - if len(self.plan) == 0 and self.kb.ask_if_true(have_arrow(self.t)): - possible_wumpus = list() - for i in range(1, self.dimrow + 1): - for j in range(1, self.dimrow + 1): - if not self.kb.ask_if_true(wumpus(i, j)): - possible_wumpus.append([i, j]) - - temp = self.plan_shot(self.current_position, possible_wumpus, safe_points) - self.plan.extend(temp) - - if len(self.plan) == 0: - not_unsafe = list() - for i in range(1, self.dimrow + 1): - for j in range(1, self.dimrow + 1): - if not self.kb.ask_if_true(ok_to_move(i, j, self.t)): - not_unsafe.append([i, j]) - temp = self.plan_route(self.current_position, not_unsafe, safe_points) - self.plan.extend(temp) - - if len(self.plan) == 0: - start = list() - start.append([1, 1]) - temp = self.plan_route(self.current_position, start, safe_points) - self.plan.extend(temp) - self.plan.append('Climb') - - action = self.plan[0] - self.plan = self.plan[1:] - self.kb.make_action_sentence(action, self.t) - self.t += 1 - - return action - - def plan_route(self, current, goals, allowed): - problem = PlanRoute(current, goals, allowed, self.dimrow) - return astar_search(problem).solution() - - def plan_shot(self, current, goals, allowed): - shooting_positions = set() - - for loc in goals: - x = loc[0] - y = loc[1] - for i in range(1, self.dimrow + 1): - if i < x: - shooting_positions.add(WumpusPosition(i, y, 'EAST')) - if i > x: - shooting_positions.add(WumpusPosition(i, y, 'WEST')) - if i < y: - shooting_positions.add(WumpusPosition(x, i, 'NORTH')) - if i > y: - shooting_positions.add(WumpusPosition(x, i, 'SOUTH')) - - # Can't have a shooting position from any of the rooms the Wumpus could reside - orientations = ['EAST', 'WEST', 'NORTH', 'SOUTH'] - for loc in goals: - for orientation in orientations: - shooting_positions.remove(WumpusPosition(loc[0], loc[1], orientation)) - - actions = list() - actions.extend(self.plan_route(current, shooting_positions, allowed)) - actions.append('Shoot') - return actions - - -# ______________________________________________________________________________ -# 7.7.4 Making plans by propositional inference - - -def SAT_plan(init, transition, goal, t_max, SAT_solver=dpll_satisfiable): - """Converts a planning problem to Satisfaction problem by translating it to a cnf sentence. - [Figure 7.22] - >>> transition = {'A': {'Left': 'A', 'Right': 'B'}, 'B': {'Left': 'A', 'Right': 'C'}, 'C': {'Left': 'B', 'Right': 'C'}} - >>> SAT_plan('A', transition, 'C', 2) is None - True - """ - - # Functions used by SAT_plan - def translate_to_SAT(init, transition, goal, time): - clauses = [] - states = [state for state in transition] - - # Symbol claiming state s at time t - state_counter = itertools.count() - for s in states: - for t in range(time + 1): - state_sym[s, t] = Expr("State_{}".format(next(state_counter))) - - # Add initial state axiom - clauses.append(state_sym[init, 0]) - - # Add goal state axiom - clauses.append(state_sym[goal, time]) - - # All possible transitions - transition_counter = itertools.count() - for s in states: - for action in transition[s]: - s_ = transition[s][action] - for t in range(time): - # Action 'action' taken from state 's' at time 't' to reach 's_' - action_sym[s, action, t] = Expr( - "Transition_{}".format(next(transition_counter))) - - # Change the state from s to s_ - clauses.append(action_sym[s, action, t] | '==>' | state_sym[s, t]) - clauses.append(action_sym[s, action, t] | '==>' | state_sym[s_, t + 1]) - - # Allow only one state at any time - for t in range(time + 1): - # must be a state at any time - clauses.append(associate('|', [state_sym[s, t] for s in states])) - - for s in states: - for s_ in states[states.index(s) + 1:]: - # for each pair of states s, s_ only one is possible at time t - clauses.append((~state_sym[s, t]) | (~state_sym[s_, t])) - - # Restrict to one transition per timestep - for t in range(time): - # list of possible transitions at time t - transitions_t = [tr for tr in action_sym if tr[2] == t] - - # make sure at least one of the transitions happens - clauses.append(associate('|', [action_sym[tr] for tr in transitions_t])) - - for tr in transitions_t: - for tr_ in transitions_t[transitions_t.index(tr) + 1:]: - # there cannot be two transitions tr and tr_ at time t - clauses.append(~action_sym[tr] | ~action_sym[tr_]) - - # Combine the clauses to form the cnf - return associate('&', clauses) - - def extract_solution(model): - true_transitions = [t for t in action_sym if model[action_sym[t]]] - # Sort transitions based on time, which is the 3rd element of the tuple - true_transitions.sort(key=lambda x: x[2]) - return [action for s, action, time in true_transitions] - - # Body of SAT_plan algorithm - for t in range(t_max): - # dictionaries to help extract the solution from model - state_sym = {} - action_sym = {} - - cnf = translate_to_SAT(init, transition, goal, t) - model = SAT_solver(cnf) - if model is not False: - return extract_solution(model) - return None - - -# ______________________________________________________________________________ -# Chapter 9 Inference in First Order Logic -# 9.2 Unification and First Order Inference -# 9.2.1 Unification - - -def unify(x, y, s={}): - """Unify expressions x,y with substitution s; return a substitution that - would make x,y equal, or None if x,y can not unify. x and y can be - variables (e.g. Expr('x')), constants, lists, or Exprs. [Figure 9.1] - >>> unify(x, 3, {}) - {x: 3} - """ - if s is None: - return None - elif x == y: - return s - elif is_variable(x): - return unify_var(x, y, s) - elif is_variable(y): - return unify_var(y, x, s) - elif isinstance(x, Expr) and isinstance(y, Expr): - return unify(x.args, y.args, unify(x.op, y.op, s)) - elif isinstance(x, str) or isinstance(y, str): - return None - elif issequence(x) and issequence(y) and len(x) == len(y): - if not x: - return s - return unify(x[1:], y[1:], unify(x[0], y[0], s)) - else: - return None - - -def is_variable(x): - """A variable is an Expr with no args and a lowercase symbol as the op.""" - return isinstance(x, Expr) and not x.args and x.op[0].islower() - - -def unify_var(var, x, s): - if var in s: - return unify(s[var], x, s) - elif x in s: - return unify(var, s[x], s) - elif occur_check(var, x, s): - return None - else: - return extend(s, var, x) - - -def occur_check(var, x, s): - """Return true if variable var occurs anywhere in x - (or in subst(s, x), if s has a binding for x).""" - if var == x: - return True - elif is_variable(x) and x in s: - return occur_check(var, s[x], s) - elif isinstance(x, Expr): - return (occur_check(var, x.op, s) or - occur_check(var, x.args, s)) - elif isinstance(x, (list, tuple)): - return first(e for e in x if occur_check(var, e, s)) - else: - return False - - -def extend(s, var, val): - """Copy the substitution s and extend it by setting var to val; return copy. - >>> extend({x: 1}, y, 2) == {x: 1, y: 2} - True - """ - s2 = s.copy() - s2[var] = val - return s2 - - -# 9.2.2 Storage and retrieval - - -class FolKB(KB): - """A knowledge base consisting of first-order definite clauses. - >>> kb0 = FolKB([expr('Farmer(Mac)'), expr('Rabbit(Pete)'), - ... expr('(Rabbit(r) & Farmer(f)) ==> Hates(f, r)')]) - >>> kb0.tell(expr('Rabbit(Flopsie)')) - >>> kb0.retract(expr('Rabbit(Pete)')) - >>> kb0.ask(expr('Hates(Mac, x)'))[x] - Flopsie - >>> kb0.ask(expr('Wife(Pete, x)')) - False - """ - - def __init__(self, initial_clauses=None): - self.clauses = [] # inefficient: no indexing - if initial_clauses: - for clause in initial_clauses: - self.tell(clause) - - def tell(self, sentence): - if is_definite_clause(sentence): - self.clauses.append(sentence) - else: - raise Exception("Not a definite clause: {}".format(sentence)) - - def ask_generator(self, query): - return fol_bc_ask(self, query) - - def retract(self, sentence): - self.clauses.remove(sentence) - - def fetch_rules_for_goal(self, goal): - return self.clauses - - -# ______________________________________________________________________________ -# 9.3 Forward Chaining -# 9.3.2 A simple forward-chaining algorithm - - -def fol_fc_ask(KB, alpha): - """A simple forward-chaining algorithm. [Figure 9.3]""" - kb_consts = list({c for clause in KB.clauses for c in constant_symbols(clause)}) - - def enum_subst(p): - query_vars = list({v for clause in p for v in variables(clause)}) - for assignment_list in itertools.product(kb_consts, repeat=len(query_vars)): - theta = {x: y for x, y in zip(query_vars, assignment_list)} - yield theta - - # check if we can answer without new inferences - for q in KB.clauses: - phi = unify(q, alpha, {}) - if phi is not None: - yield phi - - while True: - new = [] - for rule in KB.clauses: - p, q = parse_definite_clause(rule) - for theta in enum_subst(p): - if set(subst(theta, p)).issubset(set(KB.clauses)): - q_ = subst(theta, q) - if all([unify(x, q_, {}) is None for x in KB.clauses + new]): - new.append(q_) - phi = unify(q_, alpha, {}) - if phi is not None: - yield phi - if not new: - break - for clause in new: - KB.tell(clause) - return None - - -def subst(s, x): - """Substitute the substitution s into the expression x. - >>> subst({x: 42, y:0}, F(x) + y) - (F(42) + 0) - """ - if isinstance(x, list): - return [subst(s, xi) for xi in x] - elif isinstance(x, tuple): - return tuple([subst(s, xi) for xi in x]) - elif not isinstance(x, Expr): - return x - elif is_var_symbol(x.op): - return s.get(x, x) - else: - return Expr(x.op, *[subst(s, arg) for arg in x.args]) - - -def standardize_variables(sentence, dic=None): - """Replace all the variables in sentence with new variables.""" - if dic is None: - dic = {} - if not isinstance(sentence, Expr): - return sentence - elif is_var_symbol(sentence.op): - if sentence in dic: - return dic[sentence] - else: - v = Expr('v_{}'.format(next(standardize_variables.counter))) - dic[sentence] = v - return v - else: - return Expr(sentence.op, - *[standardize_variables(a, dic) for a in sentence.args]) - - -standardize_variables.counter = itertools.count() - - -# __________________________________________________________________ -# 9.4 Backward Chaining - - -def fol_bc_ask(KB, query): - """A simple backward-chaining algorithm for first-order logic. [Figure 9.6] - KB should be an instance of FolKB, and query an atomic sentence.""" - return fol_bc_or(KB, query, {}) - - -def fol_bc_or(KB, goal, theta): - for rule in KB.fetch_rules_for_goal(goal): - lhs, rhs = parse_definite_clause(standardize_variables(rule)) - for theta1 in fol_bc_and(KB, lhs, unify(rhs, goal, theta)): - yield theta1 - - -def fol_bc_and(KB, goals, theta): - if theta is None: - pass - elif not goals: - yield theta - else: - first, rest = goals[0], goals[1:] - for theta1 in fol_bc_or(KB, subst(theta, first), theta): - for theta2 in fol_bc_and(KB, rest, theta1): - yield theta2 - - -# ______________________________________________________________________________ -# A simple KB that defines the relevant conditions of the Wumpus World as in Fig 7.4. -# See Sec. 7.4.3 -wumpus_kb = PropKB() - -P11, P12, P21, P22, P31, B11, B21 = expr('P11, P12, P21, P22, P31, B11, B21') -wumpus_kb.tell(~P11) -wumpus_kb.tell(B11 | '<=>' | (P12 | P21)) -wumpus_kb.tell(B21 | '<=>' | (P11 | P22 | P31)) -wumpus_kb.tell(~B11) -wumpus_kb.tell(B21) - -test_kb = FolKB( - map(expr, ['Farmer(Mac)', - 'Rabbit(Pete)', - 'Mother(MrsMac, Mac)', - 'Mother(MrsRabbit, Pete)', - '(Rabbit(r) & Farmer(f)) ==> Hates(f, r)', - '(Mother(m, c)) ==> Loves(m, c)', - '(Mother(m, r) & Rabbit(r)) ==> Rabbit(m)', - '(Farmer(f)) ==> Human(f)', - # Note that this order of conjuncts - # would result in infinite recursion: - # '(Human(h) & Mother(m, h)) ==> Human(m)' - '(Mother(m, h) & Human(h)) ==> Human(m)'])) - -crime_kb = FolKB( - map(expr, ['(American(x) & Weapon(y) & Sells(x, y, z) & Hostile(z)) ==> Criminal(x)', - 'Owns(Nono, M1)', - 'Missile(M1)', - '(Missile(x) & Owns(Nono, x)) ==> Sells(West, x, Nono)', - 'Missile(x) ==> Weapon(x)', - 'Enemy(x, America) ==> Hostile(x)', - 'American(West)', - 'Enemy(Nono, America)'])) - - -# ______________________________________________________________________________ - -# Example application (not in the book). -# You can use the Expr class to do symbolic differentiation. This used to be -# a part of AI; now it is considered a separate field, Symbolic Algebra. - - -def diff(y, x): - """Return the symbolic derivative, dy/dx, as an Expr. - However, you probably want to simplify the results with simp. - >>> diff(x * x, x) - ((x * 1) + (x * 1)) - """ - if y == x: - return 1 - elif not y.args: - return 0 - else: - u, op, v = y.args[0], y.op, y.args[-1] - if op == '+': - return diff(u, x) + diff(v, x) - elif op == '-' and len(y.args) == 1: - return -diff(u, x) - elif op == '-': - return diff(u, x) - diff(v, x) - elif op == '*': - return u * diff(v, x) + v * diff(u, x) - elif op == '/': - return (v * diff(u, x) - u * diff(v, x)) / (v * v) - elif op == '**' and isnumber(x.op): - return (v * u ** (v - 1) * diff(u, x)) - elif op == '**': - return (v * u ** (v - 1) * diff(u, x) + - u ** v * Expr('log')(u) * diff(v, x)) - elif op == 'log': - return diff(u, x) / u - else: - raise ValueError("Unknown op: {} in diff({}, {})".format(op, y, x)) - - -def simp(x): - """Simplify the expression x.""" - if isnumber(x) or not x.args: - return x - args = list(map(simp, x.args)) - u, op, v = args[0], x.op, args[-1] - if op == '+': - if v == 0: - return u - if u == 0: - return v - if u == v: - return 2 * u - if u == -v or v == -u: - return 0 - elif op == '-' and len(args) == 1: - if u.op == '-' and len(u.args) == 1: - return u.args[0] # --y ==> y - elif op == '-': - if v == 0: - return u - if u == 0: - return -v - if u == v: - return 0 - if u == -v or v == -u: - return 0 - elif op == '*': - if u == 0 or v == 0: - return 0 - if u == 1: - return v - if v == 1: - return u - if u == v: - return u ** 2 - elif op == '/': - if u == 0: - return 0 - if v == 0: - return Expr('Undefined') - if u == v: - return 1 - if u == -v or v == -u: - return 0 - elif op == '**': - if u == 0: - return 0 - if v == 0: - return 1 - if u == 1: - return 1 - if v == 1: - return u - elif op == 'log': - if u == 1: - return 0 - else: - raise ValueError("Unknown op: " + op) - # If we fall through to here, we can not simplify further - return Expr(op, *args) - - -def d(y, x): - """Differentiate and then simplify. - >>> d(x * x - x, x) - ((2 * x) - 1) - """ - return simp(diff(y, x)) diff --git a/making_simple_decision4e.py b/making_simple_decision4e.py deleted file mode 100644 index 4a35f94bd..000000000 --- a/making_simple_decision4e.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Making Simple Decisions (Chapter 15)""" - -import random - -from agents import Agent -from probability import BayesNet -from utils4e import vector_add, weighted_sample_with_replacement - - -class DecisionNetwork(BayesNet): - """An abstract class for a decision network as a wrapper for a BayesNet. - Represents an agent's current state, its possible actions, reachable states - and utilities of those states.""" - - def __init__(self, action, infer): - """action: a single action node - infer: the preferred method to carry out inference on the given BayesNet""" - super().__init__() - self.action = action - self.infer = infer - - def best_action(self): - """Return the best action in the network""" - return self.action - - def get_utility(self, action, state): - """Return the utility for a particular action and state in the network""" - raise NotImplementedError - - def get_expected_utility(self, action, evidence): - """Compute the expected utility given an action and evidence""" - u = 0.0 - prob_dist = self.infer(action, evidence, self).prob - for item, _ in prob_dist.items(): - u += prob_dist[item] * self.get_utility(action, item) - - return u - - -class InformationGatheringAgent(Agent): - """A simple information gathering agent. The agent works by repeatedly selecting - the observation with the highest information value, until the cost of the next - observation is greater than its expected benefit. [Figure 16.9]""" - - def __init__(self, decnet, infer, initial_evidence=None): - """decnet: a decision network - infer: the preferred method to carry out inference on the given decision network - initial_evidence: initial evidence""" - super().__init__() - self.decnet = decnet - self.infer = infer - self.observation = initial_evidence or [] - self.variables = self.decnet.nodes - - def integrate_percept(self, percept): - """Integrate the given percept into the decision network""" - raise NotImplementedError - - def execute(self, percept): - """Execute the information gathering algorithm""" - self.observation = self.integrate_percept(percept) - vpis = self.vpi_cost_ratio(self.variables) - j = max(vpis) - variable = self.variables[j] - - if self.vpi(variable) > self.cost(variable): - return self.request(variable) - - return self.decnet.best_action() - - def request(self, variable): - """Return the value of the given random variable as the next percept""" - raise NotImplementedError - - def cost(self, var): - """Return the cost of obtaining evidence through tests, consultants or questions""" - raise NotImplementedError - - def vpi_cost_ratio(self, variables): - """Return the VPI to cost ratio for the given variables""" - v_by_c = [] - for var in variables: - v_by_c.append(self.vpi(var) / self.cost(var)) - return v_by_c - - def vpi(self, variable): - """Return VPI for a given variable""" - vpi = 0.0 - prob_dist = self.infer(variable, self.observation, self.decnet).prob - for item, _ in prob_dist.items(): - post_prob = prob_dist[item] - new_observation = list(self.observation) - new_observation.append(item) - expected_utility = self.decnet.get_expected_utility(variable, new_observation) - vpi += post_prob * expected_utility - - vpi -= self.decnet.get_expected_utility(variable, self.observation) - return vpi - - -# _________________________________________________________________________ -# chapter 25 Robotics -# TODO: Implement continuous map for MonteCarlo similar to Fig25.10 from the book - - -class MCLmap: - """Map which provides probability distributions and sensor readings. - Consists of discrete cells which are either an obstacle or empty""" - - def __init__(self, m): - self.m = m - self.nrows = len(m) - self.ncols = len(m[0]) - # list of empty spaces in the map - self.empty = [(i, j) for i in range(self.nrows) for j in range(self.ncols) if not m[i][j]] - - def sample(self): - """Returns a random kinematic state possible in the map""" - pos = random.choice(self.empty) - # 0N 1E 2S 3W - orient = random.choice(range(4)) - kin_state = pos + (orient,) - return kin_state - - def ray_cast(self, sensor_num, kin_state): - """Returns distace to nearest obstacle or map boundary in the direction of sensor""" - pos = kin_state[:2] - orient = kin_state[2] - # sensor layout when orientation is 0 (towards North) - # 0 - # 3R1 - # 2 - delta = ((sensor_num % 2 == 0) * (sensor_num - 1), (sensor_num % 2 == 1) * (2 - sensor_num)) - # sensor direction changes based on orientation - for _ in range(orient): - delta = (delta[1], -delta[0]) - range_count = 0 - while (0 <= pos[0] < self.nrows) and (0 <= pos[1] < self.nrows) and (not self.m[pos[0]][pos[1]]): - pos = vector_add(pos, delta) - range_count += 1 - return range_count - - -def monte_carlo_localization(a, z, N, P_motion_sample, P_sensor, m, S=None): - """Monte Carlo localization algorithm from Fig 25.9""" - - def ray_cast(sensor_num, kin_state, m): - return m.ray_cast(sensor_num, kin_state) - - M = len(z) - W = [0] * N - S_ = [0] * N - W_ = [0] * N - v = a['v'] - w = a['w'] - - if S is None: - S = [m.sample() for _ in range(N)] - - for i in range(N): - S_[i] = P_motion_sample(S[i], v, w) - W_[i] = 1 - for j in range(M): - z_ = ray_cast(j, S_[i], m) - W_[i] = W_[i] * P_sensor(z[j], z_) - - S = weighted_sample_with_replacement(N, S_, W_) - return S diff --git a/mdp4e.py b/mdp4e.py deleted file mode 100644 index f8871bdc9..000000000 --- a/mdp4e.py +++ /dev/null @@ -1,516 +0,0 @@ -""" -Markov Decision Processes (Chapter 16) - -First we define an MDP, and the special case of a GridMDP, in which -states are laid out in a 2-dimensional grid. We also represent a policy -as a dictionary of {state: action} pairs, and a Utility function as a -dictionary of {state: number} pairs. We then define the value_iteration -and policy_iteration algorithms. -""" - -import random -from collections import defaultdict - -import numpy as np - -from utils4e import vector_add, orientations, turn_right, turn_left - - -class MDP: - """A Markov Decision Process, defined by an initial state, transition model, - and reward function. We also keep track of a gamma value, for use by - algorithms. The transition model is represented somewhat differently from - the text. Instead of P(s' | s, a) being a probability number for each - state/state/action triplet, we instead have T(s, a) return a - list of (p, s') pairs. We also keep track of the possible states, - terminal states, and actions for each state. [Page 646]""" - - def __init__(self, init, actlist, terminals, transitions=None, reward=None, states=None, gamma=0.9): - if not (0 < gamma <= 1): - raise ValueError("An MDP must have 0 < gamma <= 1") - - # collect states from transitions table if not passed. - self.states = states or self.get_states_from_transitions(transitions) - - self.init = init - - if isinstance(actlist, list): - # if actlist is a list, all states have the same actions - self.actlist = actlist - - elif isinstance(actlist, dict): - # if actlist is a dict, different actions for each state - self.actlist = actlist - - self.terminals = terminals - self.transitions = transitions or {} - if not self.transitions: - print("Warning: Transition table is empty.") - - self.gamma = gamma - - self.reward = reward or {s: 0 for s in self.states} - - # self.check_consistency() - - def R(self, state): - """Return a numeric reward for this state.""" - - return self.reward[state] - - def T(self, state, action): - """Transition model. From a state and an action, return a list - of (probability, result-state) pairs.""" - - if not self.transitions: - raise ValueError("Transition model is missing") - else: - return self.transitions[state][action] - - def actions(self, state): - """Return a list of actions that can be performed in this state. By default, a - fixed list of actions, except for terminal states. Override this - method if you need to specialize by state.""" - - if state in self.terminals: - return [None] - else: - return self.actlist - - def get_states_from_transitions(self, transitions): - if isinstance(transitions, dict): - s1 = set(transitions.keys()) - s2 = set(tr[1] for actions in transitions.values() - for effects in actions.values() - for tr in effects) - return s1.union(s2) - else: - print('Could not retrieve states from transitions') - return None - - def check_consistency(self): - - # check that all states in transitions are valid - assert set(self.states) == self.get_states_from_transitions(self.transitions) - - # check that init is a valid state - assert self.init in self.states - - # check reward for each state - assert set(self.reward.keys()) == set(self.states) - - # check that all terminals are valid states - assert all(t in self.states for t in self.terminals) - - # check that probability distributions for all actions sum to 1 - for s1, actions in self.transitions.items(): - for a in actions.keys(): - s = 0 - for o in actions[a]: - s += o[0] - assert abs(s - 1) < 0.001 - - -class MDP2(MDP): - """ - Inherits from MDP. Handles terminal states, and transitions to and from terminal states better. - """ - - def __init__(self, init, actlist, terminals, transitions, reward=None, gamma=0.9): - MDP.__init__(self, init, actlist, terminals, transitions, reward, gamma=gamma) - - def T(self, state, action): - if action is None: - return [(0.0, state)] - else: - return self.transitions[state][action] - - -class GridMDP(MDP): - """A two-dimensional grid MDP, as in [Figure 16.1]. All you have to do is - specify the grid as a list of lists of rewards; use None for an obstacle - (unreachable state). Also, you should specify the terminal states. - An action is an (x, y) unit vector; e.g. (1, 0) means move east.""" - - def __init__(self, grid, terminals, init=(0, 0), gamma=.9): - grid.reverse() # because we want row 0 on bottom, not on top - reward = {} - states = set() - self.rows = len(grid) - self.cols = len(grid[0]) - self.grid = grid - for x in range(self.cols): - for y in range(self.rows): - if grid[y][x]: - states.add((x, y)) - reward[(x, y)] = grid[y][x] - self.states = states - actlist = orientations - transitions = {} - for s in states: - transitions[s] = {} - for a in actlist: - transitions[s][a] = self.calculate_T(s, a) - MDP.__init__(self, init, actlist=actlist, - terminals=terminals, transitions=transitions, - reward=reward, states=states, gamma=gamma) - - def calculate_T(self, state, action): - if action: - return [(0.8, self.go(state, action)), - (0.1, self.go(state, turn_right(action))), - (0.1, self.go(state, turn_left(action)))] - else: - return [(0.0, state)] - - def T(self, state, action): - return self.transitions[state][action] if action else [(0.0, state)] - - def go(self, state, direction): - """Return the state that results from going in this direction.""" - - state1 = tuple(vector_add(state, direction)) - return state1 if state1 in self.states else state - - def to_grid(self, mapping): - """Convert a mapping from (x, y) to v into a [[..., v, ...]] grid.""" - - return list(reversed([[mapping.get((x, y), None) - for x in range(self.cols)] - for y in range(self.rows)])) - - def to_arrows(self, policy): - chars = {(1, 0): '>', (0, 1): '^', (-1, 0): '<', (0, -1): 'v', None: '.'} - return self.to_grid({s: chars[a] for (s, a) in policy.items()}) - - -# ______________________________________________________________________________ - - -""" [Figure 16.1] -A 4x3 grid environment that presents the agent with a sequential decision problem. -""" - -sequential_decision_environment = GridMDP([[-0.04, -0.04, -0.04, +1], - [-0.04, None, -0.04, -1], - [-0.04, -0.04, -0.04, -0.04]], - terminals=[(3, 2), (3, 1)]) - - -# ______________________________________________________________________________ -# 16.1.3 The Bellman equation for utilities - - -def q_value(mdp, s, a, U): - if not a: - return mdp.R(s) - res = 0 - for p, s_prime in mdp.T(s, a): - res += p * (mdp.R(s) + mdp.gamma * U[s_prime]) - return res - - -# TODO: DDN in figure 16.4 and 16.5 - -# ______________________________________________________________________________ -# 16.2 Algorithms for MDPs -# 16.2.1 Value Iteration - - -def value_iteration(mdp, epsilon=0.001): - """Solving an MDP by value iteration. [Figure 16.6]""" - - U1 = {s: 0 for s in mdp.states} - R, T, gamma = mdp.R, mdp.T, mdp.gamma - while True: - U = U1.copy() - delta = 0 - for s in mdp.states: - # U1[s] = R(s) + gamma * max(sum(p * U[s1] for (p, s1) in T(s, a)) - # for a in mdp.actions(s)) - U1[s] = max(q_value(mdp, s, a, U) for a in mdp.actions(s)) - delta = max(delta, abs(U1[s] - U[s])) - if delta <= epsilon * (1 - gamma) / gamma: - return U - - -# ______________________________________________________________________________ -# 16.2.2 Policy Iteration - - -def best_policy(mdp, U): - """Given an MDP and a utility function U, determine the best policy, - as a mapping from state to action.""" - - pi = {} - for s in mdp.states: - pi[s] = max(mdp.actions(s), key=lambda a: q_value(mdp, s, a, U)) - return pi - - -def expected_utility(a, s, U, mdp): - """The expected utility of doing a in state s, according to the MDP and U.""" - - return sum(p * U[s1] for (p, s1) in mdp.T(s, a)) - - -def policy_iteration(mdp): - """Solve an MDP by policy iteration [Figure 17.7]""" - - U = {s: 0 for s in mdp.states} - pi = {s: random.choice(mdp.actions(s)) for s in mdp.states} - while True: - U = policy_evaluation(pi, U, mdp) - unchanged = True - for s in mdp.states: - a_star = max(mdp.actions(s), key=lambda a: q_value(mdp, s, a, U)) - # a = max(mdp.actions(s), key=lambda a: expected_utility(a, s, U, mdp)) - if q_value(mdp, s, a_star, U) > q_value(mdp, s, pi[s], U): - pi[s] = a_star - unchanged = False - if unchanged: - return pi - - -def policy_evaluation(pi, U, mdp, k=20): - """Return an updated utility mapping U from each state in the MDP to its - utility, using an approximation (modified policy iteration).""" - - R, T, gamma = mdp.R, mdp.T, mdp.gamma - for i in range(k): - for s in mdp.states: - U[s] = R(s) + gamma * sum(p * U[s1] for (p, s1) in T(s, pi[s])) - return U - - -# ___________________________________________________________________ -# 16.4 Partially Observed MDPs - - -class POMDP(MDP): - """A Partially Observable Markov Decision Process, defined by - a transition model P(s'|s,a), actions A(s), a reward function R(s), - and a sensor model P(e|s). We also keep track of a gamma value, - for use by algorithms. The transition and the sensor models - are defined as matrices. We also keep track of the possible states - and actions for each state. [Page 659].""" - - def __init__(self, actions, transitions=None, evidences=None, rewards=None, states=None, gamma=0.95): - """Initialize variables of the pomdp""" - - if not (0 < gamma <= 1): - raise ValueError('A POMDP must have 0 < gamma <= 1') - - self.states = states - self.actions = actions - - # transition model cannot be undefined - self.t_prob = transitions or {} - if not self.t_prob: - print('Warning: Transition model is undefined') - - # sensor model cannot be undefined - self.e_prob = evidences or {} - if not self.e_prob: - print('Warning: Sensor model is undefined') - - self.gamma = gamma - self.rewards = rewards - - def remove_dominated_plans(self, input_values): - """ - Remove dominated plans. - This method finds all the lines contributing to the - upper surface and removes those which don't. - """ - - values = [val for action in input_values for val in input_values[action]] - values.sort(key=lambda x: x[0], reverse=True) - - best = [values[0]] - y1_max = max(val[1] for val in values) - tgt = values[0] - prev_b = 0 - prev_ix = 0 - while tgt[1] != y1_max: - min_b = 1 - min_ix = 0 - for i in range(prev_ix + 1, len(values)): - if values[i][0] - tgt[0] + tgt[1] - values[i][1] != 0: - trans_b = (values[i][0] - tgt[0]) / (values[i][0] - tgt[0] + tgt[1] - values[i][1]) - if 0 <= trans_b <= 1 and trans_b > prev_b and trans_b < min_b: - min_b = trans_b - min_ix = i - prev_b = min_b - prev_ix = min_ix - tgt = values[min_ix] - best.append(tgt) - - return self.generate_mapping(best, input_values) - - def remove_dominated_plans_fast(self, input_values): - """ - Remove dominated plans using approximations. - Resamples the upper boundary at intervals of 100 and - finds the maximum values at these points. - """ - - values = [val for action in input_values for val in input_values[action]] - values.sort(key=lambda x: x[0], reverse=True) - - best = [] - sr = 100 - for i in range(sr + 1): - x = i / float(sr) - maximum = (values[0][1] - values[0][0]) * x + values[0][0] - tgt = values[0] - for value in values: - val = (value[1] - value[0]) * x + value[0] - if val > maximum: - maximum = val - tgt = value - - if all(any(tgt != v) for v in best): - best.append(np.array(tgt)) - - return self.generate_mapping(best, input_values) - - def generate_mapping(self, best, input_values): - """Generate mappings after removing dominated plans""" - - mapping = defaultdict(list) - for value in best: - for action in input_values: - if any(all(value == v) for v in input_values[action]): - mapping[action].append(value) - - return mapping - - def max_difference(self, U1, U2): - """Find maximum difference between two utility mappings""" - - for k, v in U1.items(): - sum1 = 0 - for element in U1[k]: - sum1 += sum(element) - sum2 = 0 - for element in U2[k]: - sum2 += sum(element) - return abs(sum1 - sum2) - - -class Matrix: - """Matrix operations class""" - - @staticmethod - def add(A, B): - """Add two matrices A and B""" - - res = [] - for i in range(len(A)): - row = [] - for j in range(len(A[0])): - row.append(A[i][j] + B[i][j]) - res.append(row) - return res - - @staticmethod - def scalar_multiply(a, B): - """Multiply scalar a to matrix B""" - - for i in range(len(B)): - for j in range(len(B[0])): - B[i][j] = a * B[i][j] - return B - - @staticmethod - def multiply(A, B): - """Multiply two matrices A and B element-wise""" - - matrix = [] - for i in range(len(B)): - row = [] - for j in range(len(B[0])): - row.append(B[i][j] * A[j][i]) - matrix.append(row) - - return matrix - - @staticmethod - def matmul(A, B): - """Inner-product of two matrices""" - - return [[sum(ele_a * ele_b for ele_a, ele_b in zip(row_a, col_b)) for col_b in list(zip(*B))] for row_a in A] - - @staticmethod - def transpose(A): - """Transpose a matrix""" - - return [list(i) for i in zip(*A)] - - -def pomdp_value_iteration(pomdp, epsilon=0.1): - """Solving a POMDP by value iteration.""" - - U = {'': [[0] * len(pomdp.states)]} - count = 0 - while True: - count += 1 - prev_U = U - values = [val for action in U for val in U[action]] - value_matxs = [] - for i in values: - for j in values: - value_matxs.append([i, j]) - - U1 = defaultdict(list) - for action in pomdp.actions: - for u in value_matxs: - u1 = Matrix.matmul(Matrix.matmul(pomdp.t_prob[int(action)], - Matrix.multiply(pomdp.e_prob[int(action)], Matrix.transpose(u))), - [[1], [1]]) - u1 = Matrix.add(Matrix.scalar_multiply(pomdp.gamma, Matrix.transpose(u1)), [pomdp.rewards[int(action)]]) - U1[action].append(u1[0]) - - U = pomdp.remove_dominated_plans_fast(U1) - # replace with U = pomdp.remove_dominated_plans(U1) for accurate calculations - - if count > 10: - if pomdp.max_difference(U, prev_U) < epsilon * (1 - pomdp.gamma) / pomdp.gamma: - return U - - -__doc__ += """ ->>> pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .01)) - ->>> sequential_decision_environment.to_arrows(pi) -[['>', '>', '>', '.'], ['^', None, '^', '.'], ['^', '>', '^', '<']] - ->>> from utils import print_table - ->>> print_table(sequential_decision_environment.to_arrows(pi)) -> > > . -^ None ^ . -^ > ^ < - ->>> print_table(sequential_decision_environment.to_arrows(policy_iteration(sequential_decision_environment))) -> > > . -^ None ^ . -^ > ^ < -""" # noqa - -""" -s = { 'a' : { 'plan1' : [(0.2, 'a'), (0.3, 'b'), (0.3, 'c'), (0.2, 'd')], - 'plan2' : [(0.4, 'a'), (0.15, 'b'), (0.45, 'c')], - 'plan3' : [(0.2, 'a'), (0.5, 'b'), (0.3, 'c')], - }, - 'b' : { 'plan1' : [(0.2, 'a'), (0.6, 'b'), (0.2, 'c'), (0.1, 'd')], - 'plan2' : [(0.6, 'a'), (0.2, 'b'), (0.1, 'c'), (0.1, 'd')], - 'plan3' : [(0.3, 'a'), (0.3, 'b'), (0.4, 'c')], - }, - 'c' : { 'plan1' : [(0.3, 'a'), (0.5, 'b'), (0.1, 'c'), (0.1, 'd')], - 'plan2' : [(0.5, 'a'), (0.3, 'b'), (0.1, 'c'), (0.1, 'd')], - 'plan3' : [(0.1, 'a'), (0.3, 'b'), (0.1, 'c'), (0.5, 'd')], - }, - } -""" diff --git a/mdp_apps.ipynb b/mdp_apps.ipynb deleted file mode 100644 index da3ae7b06..000000000 --- a/mdp_apps.ipynb +++ /dev/null @@ -1,1825 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# APPLICATIONS OF MARKOV DECISION PROCESSES\n", - "---\n", - "In this notebook we will take a look at some indicative applications of markov decision processes. \n", - "We will cover content from [`mdp.py`](https://github.com/aimacode/aima-python/blob/master/mdp.py), for **Chapter 17 Making Complex Decisions** of Stuart Russel's and Peter Norvig's book [*Artificial Intelligence: A Modern Approach*](http://aima.cs.berkeley.edu/).\n" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from mdp import *\n", - "from notebook import psource, pseudocode" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## CONTENTS\n", - "- Simple MDP\n", - " - State dependent reward function\n", - " - State and action dependent reward function\n", - " - State, action and next state dependent reward function\n", - "- Grid MDP\n", - " - Pathfinding problem\n", - "- POMDP\n", - " - Two state POMDP" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## SIMPLE MDP\n", - "---\n", - "### State dependent reward function\n", - "\n", - "Markov Decision Processes are formally described as processes that follow the Markov property which states that \"The future is independent of the past given the present\". \n", - "MDPs formally describe environments for reinforcement learning and we assume that the environment is *fully observable*. \n", - "Let us take a toy example MDP and solve it using the functions in `mdp.py`.\n", - "This is a simple example adapted from a [similar problem](http://www0.cs.ucl.ac.uk/staff/D.Silver/web/Teaching_files/MDP.pdf) by Dr. David Silver, tweaked to fit the limitations of the current functions.\n", - "![title](images/mdp-b.png)\n", - "\n", - "Let's say you're a student attending lectures in a university.\n", - "There are three lectures you need to attend on a given day.\n", - "
\n", - "Attending the first lecture gives you 4 points of reward.\n", - "After the first lecture, you have a 0.6 probability to continue into the second one, yielding 6 more points of reward.\n", - "
\n", - "But, with a probability of 0.4, you get distracted and start using Facebook instead and get a reward of -1.\n", - "From then onwards, you really can't let go of Facebook and there's just a 0.1 probability that you will concentrate back on the lecture.\n", - "
\n", - "After the second lecture, you have an equal chance of attending the next lecture or just falling asleep.\n", - "Falling asleep is the terminal state and yields you no reward, but continuing on to the final lecture gives you a big reward of 10 points.\n", - "
\n", - "From there on, you have a 40% chance of going to study and reach the terminal state, \n", - "but a 60% chance of going to the pub with your friends instead. \n", - "You end up drunk and don't know which lecture to attend, so you go to one of the lectures according to the probabilities given above.\n", - "
\n", - "We now have an outline of our stochastic environment and we need to maximize our reward by solving this MDP.\n", - "
\n", - "
\n", - "We first have to define our Transition Matrix as a nested dictionary to fit the requirements of the MDP class." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "t = {\n", - " 'leisure': {\n", - " 'facebook': {'leisure':0.9, 'class1':0.1},\n", - " 'quit': {'leisure':0.1, 'class1':0.9},\n", - " 'study': {},\n", - " 'sleep': {},\n", - " 'pub': {}\n", - " },\n", - " 'class1': {\n", - " 'study': {'class2':0.6, 'leisure':0.4},\n", - " 'facebook': {'class2':0.4, 'leisure':0.6},\n", - " 'quit': {},\n", - " 'sleep': {},\n", - " 'pub': {}\n", - " },\n", - " 'class2': {\n", - " 'study': {'class3':0.5, 'end':0.5},\n", - " 'sleep': {'end':0.5, 'class3':0.5},\n", - " 'facebook': {},\n", - " 'quit': {},\n", - " 'pub': {},\n", - " },\n", - " 'class3': {\n", - " 'study': {'end':0.6, 'class1':0.08, 'class2':0.16, 'class3':0.16},\n", - " 'pub': {'end':0.4, 'class1':0.12, 'class2':0.24, 'class3':0.24},\n", - " 'facebook': {},\n", - " 'quit': {},\n", - " 'sleep': {}\n", - " },\n", - " 'end': {}\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We now need to define the reward for each state." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "rewards = {\n", - " 'class1': 4,\n", - " 'class2': 6,\n", - " 'class3': 10,\n", - " 'leisure': -1,\n", - " 'end': 0\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This MDP has only one terminal state." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "terminals = ['end']" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's now set the initial state to Class 1." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "init = 'class1'" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We will write a CustomMDP class to extend the MDP class for the problem at hand. \n", - "This class will implement the `T` method to implement the transition model. This is the exact same class as given in [`mdp.ipynb`](https://github.com/aimacode/aima-python/blob/master/mdp.ipynb#MDP)." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "class CustomMDP(MDP):\n", - "\n", - " def __init__(self, transition_matrix, rewards, terminals, init, gamma=.9):\n", - " # All possible actions.\n", - " actlist = []\n", - " for state in transition_matrix.keys():\n", - " actlist.extend(transition_matrix[state])\n", - " actlist = list(set(actlist))\n", - " print(actlist)\n", - "\n", - " MDP.__init__(self, init, actlist, terminals=terminals, gamma=gamma)\n", - " self.t = transition_matrix\n", - " self.reward = rewards\n", - " for state in self.t:\n", - " self.states.add(state)\n", - "\n", - " def T(self, state, action):\n", - " if action is None:\n", - " return [(0.0, state)]\n", - " else: \n", - " return [(prob, new_state) for new_state, prob in self.t[state][action].items()]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We now need an instance of this class." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "['quit', 'sleep', 'study', 'pub', 'facebook']\n" - ] - } - ], - "source": [ - "mdp = CustomMDP(t, rewards, terminals, init, gamma=.9)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The utility of each state can be found by `value_iteration`." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'class1': 16.90340650279542,\n", - " 'class2': 14.597383430869879,\n", - " 'class3': 19.10533144728953,\n", - " 'end': 0.0,\n", - " 'leisure': 13.946891353066082}" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "value_iteration(mdp)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now that we can compute the utility values, we can find the best policy." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "pi = best_policy(mdp, value_iteration(mdp, .01))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "`pi` stores the best action for each state." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'class2': 'sleep', 'class3': 'pub', 'end': None, 'class1': 'study', 'leisure': 'quit'}\n" - ] - } - ], - "source": [ - "print(pi)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can confirm that this is the best policy by verifying this result against `policy_iteration`." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'class1': 'study',\n", - " 'class2': 'sleep',\n", - " 'class3': 'pub',\n", - " 'end': None,\n", - " 'leisure': 'quit'}" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "policy_iteration(mdp)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "collapsed": true - }, - "source": [ - "Everything looks perfect, but let us look at another possibility for an MDP.\n", - "
\n", - "Till now we have only dealt with rewards that the agent gets while it is **on** a particular state.\n", - "What if we want to have different rewards for a state depending on the action that the agent takes next. \n", - "The agent gets the reward _during its transition_ to the next state.\n", - "
\n", - "For the sake of clarity, we will call this the _transition reward_ and we will call this kind of MDP a _dynamic_ MDP. \n", - "This is not a conventional term, we just use it to minimize confusion between the two.\n", - "
\n", - "This next section deals with how to create and solve a dynamic MDP." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### State and action dependent reward function\n", - "Let us consider a very similar problem, but this time, we do not have rewards _on_ states, \n", - "instead, we have rewards on the transitions between states. \n", - "This state diagram will make it clearer.\n", - "![title](images/mdp-c.png)\n", - "\n", - "A very similar scenario as the previous problem, but we have different rewards for the same state depending on the action taken.\n", - "
\n", - "To deal with this, we just need to change the `R` method of the `MDP` class, but to prevent confusion, we will write a new similar class `DMDP`." - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "class DMDP:\n", - "\n", - " \"\"\"A Markov Decision Process, defined by an initial state, transition model,\n", - " and reward model. We also keep track of a gamma value, for use by\n", - " algorithms. The transition model is represented somewhat differently from\n", - " the text. Instead of P(s' | s, a) being a probability number for each\n", - " state/state/action triplet, we instead have T(s, a) return a\n", - " list of (p, s') pairs. The reward function is very similar.\n", - " We also keep track of the possible states,\n", - " terminal states, and actions for each state.\"\"\"\n", - "\n", - " def __init__(self, init, actlist, terminals, transitions={}, rewards={}, states=None, gamma=.9):\n", - " if not (0 < gamma <= 1):\n", - " raise ValueError(\"An MDP must have 0 < gamma <= 1\")\n", - "\n", - " if states:\n", - " self.states = states\n", - " else:\n", - " self.states = set()\n", - " self.init = init\n", - " self.actlist = actlist\n", - " self.terminals = terminals\n", - " self.transitions = transitions\n", - " self.rewards = rewards\n", - " self.gamma = gamma\n", - "\n", - " def R(self, state, action):\n", - " \"\"\"Return a numeric reward for this state and this action.\"\"\"\n", - " if (self.rewards == {}):\n", - " raise ValueError('Reward model is missing')\n", - " else:\n", - " return self.rewards[state][action]\n", - "\n", - " def T(self, state, action):\n", - " \"\"\"Transition model. From a state and an action, return a list\n", - " of (probability, result-state) pairs.\"\"\"\n", - " if(self.transitions == {}):\n", - " raise ValueError(\"Transition model is missing\")\n", - " else:\n", - " return self.transitions[state][action]\n", - "\n", - " def actions(self, state):\n", - " \"\"\"Set of actions that can be performed in this state. By default, a\n", - " fixed list of actions, except for terminal states. Override this\n", - " method if you need to specialize by state.\"\"\"\n", - " if state in self.terminals:\n", - " return [None]\n", - " else:\n", - " return self.actlist" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The transition model will be the same" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "t = {\n", - " 'leisure': {\n", - " 'facebook': {'leisure':0.9, 'class1':0.1},\n", - " 'quit': {'leisure':0.1, 'class1':0.9},\n", - " 'study': {},\n", - " 'sleep': {},\n", - " 'pub': {}\n", - " },\n", - " 'class1': {\n", - " 'study': {'class2':0.6, 'leisure':0.4},\n", - " 'facebook': {'class2':0.4, 'leisure':0.6},\n", - " 'quit': {},\n", - " 'sleep': {},\n", - " 'pub': {}\n", - " },\n", - " 'class2': {\n", - " 'study': {'class3':0.5, 'end':0.5},\n", - " 'sleep': {'end':0.5, 'class3':0.5},\n", - " 'facebook': {},\n", - " 'quit': {},\n", - " 'pub': {},\n", - " },\n", - " 'class3': {\n", - " 'study': {'end':0.6, 'class1':0.08, 'class2':0.16, 'class3':0.16},\n", - " 'pub': {'end':0.4, 'class1':0.12, 'class2':0.24, 'class3':0.24},\n", - " 'facebook': {},\n", - " 'quit': {},\n", - " 'sleep': {}\n", - " },\n", - " 'end': {}\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The reward model will be a dictionary very similar to the transition dictionary with a reward for every action for every state." - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "r = {\n", - " 'leisure': {\n", - " 'facebook':-1,\n", - " 'quit':0,\n", - " 'study':0,\n", - " 'sleep':0,\n", - " 'pub':0\n", - " },\n", - " 'class1': {\n", - " 'study':-2,\n", - " 'facebook':-1,\n", - " 'quit':0,\n", - " 'sleep':0,\n", - " 'pub':0\n", - " },\n", - " 'class2': {\n", - " 'study':-2,\n", - " 'sleep':0,\n", - " 'facebook':0,\n", - " 'quit':0,\n", - " 'pub':0\n", - " },\n", - " 'class3': {\n", - " 'study':10,\n", - " 'pub':1,\n", - " 'facebook':0,\n", - " 'quit':0,\n", - " 'sleep':0\n", - " },\n", - " 'end': {\n", - " 'study':0,\n", - " 'pub':0,\n", - " 'facebook':0,\n", - " 'quit':0,\n", - " 'sleep':0\n", - " }\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The MDP has only one terminal state" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "terminals = ['end']" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's now set the initial state to Class 1." - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "init = 'class1'" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We will write a CustomDMDP class to extend the DMDP class for the problem at hand.\n", - "This class will implement everything that the previous CustomMDP class implements along with a new reward model." - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "class CustomDMDP(DMDP):\n", - " \n", - " def __init__(self, transition_matrix, rewards, terminals, init, gamma=.9):\n", - " actlist = []\n", - " for state in transition_matrix.keys():\n", - " actlist.extend(transition_matrix[state])\n", - " actlist = list(set(actlist))\n", - " print(actlist)\n", - " \n", - " DMDP.__init__(self, init, actlist, terminals=terminals, gamma=gamma)\n", - " self.t = transition_matrix\n", - " self.rewards = rewards\n", - " for state in self.t:\n", - " self.states.add(state)\n", - " \n", - " \n", - " def T(self, state, action):\n", - " if action is None:\n", - " return [(0.0, state)]\n", - " else:\n", - " return [(prob, new_state) for new_state, prob in self.t[state][action].items()]\n", - " \n", - " def R(self, state, action):\n", - " if action is None:\n", - " return 0\n", - " else:\n", - " return self.rewards[state][action]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "One thing we haven't thought about yet is that the `value_iteration` algorithm won't work now that the reward model is changed.\n", - "It will be quite similar to the one we currently have nonetheless." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The Bellman update equation now is defined as follows\n", - "\n", - "$$U(s)=\\max_{a\\epsilon A(s)}\\bigg[R(s, a) + \\gamma\\sum_{s'}P(s'\\ |\\ s,a)U(s')\\bigg]$$\n", - "\n", - "It is not difficult to see that the update equation we have been using till now is just a special case of this more generalized equation. \n", - "We also need to max over the reward function now as the reward function is action dependent as well.\n", - "
\n", - "We will use this to write a function to carry out value iteration, very similar to the one we are familiar with." - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "def value_iteration_dmdp(dmdp, epsilon=0.001):\n", - " U1 = {s: 0 for s in dmdp.states}\n", - " R, T, gamma = dmdp.R, dmdp.T, dmdp.gamma\n", - " while True:\n", - " U = U1.copy()\n", - " delta = 0\n", - " for s in dmdp.states:\n", - " U1[s] = max([(R(s, a) + gamma*sum([(p*U[s1]) for (p, s1) in T(s, a)])) for a in dmdp.actions(s)])\n", - " delta = max(delta, abs(U1[s] - U[s]))\n", - " if delta < epsilon * (1 - gamma) / gamma:\n", - " return U" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We're all set.\n", - "Let's instantiate our class." - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "['quit', 'sleep', 'study', 'pub', 'facebook']\n" - ] - } - ], - "source": [ - "dmdp = CustomDMDP(t, r, terminals, init, gamma=.9)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Calculate utility values by calling `value_iteration_dmdp`." - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'class1': 2.0756895004431364,\n", - " 'class2': 5.772550326127298,\n", - " 'class3': 12.827904448229472,\n", - " 'end': 0.0,\n", - " 'leisure': 1.8474896554396596}" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "value_iteration_dmdp(dmdp)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "These are the expected utility values for our new MDP.\n", - "
\n", - "As you might have guessed, we cannot use the old `best_policy` function to find the best policy.\n", - "So we will write our own.\n", - "But, before that we need a helper function to calculate the expected utility value given a state and an action." - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "def expected_utility_dmdp(a, s, U, dmdp):\n", - " return dmdp.R(s, a) + dmdp.gamma*sum([(p*U[s1]) for (p, s1) in dmdp.T(s, a)])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now we write our modified `best_policy` function." - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "from utils import argmax\n", - "def best_policy_dmdp(dmdp, U):\n", - " pi = {}\n", - " for s in dmdp.states:\n", - " pi[s] = argmax(dmdp.actions(s), key=lambda a: expected_utility_dmdp(a, s, U, dmdp))\n", - " return pi" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Find the best policy." - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'class2': 'sleep', 'class3': 'study', 'end': None, 'class1': 'facebook', 'leisure': 'quit'}\n" - ] - } - ], - "source": [ - "pi = best_policy_dmdp(dmdp, value_iteration_dmdp(dmdp, .01))\n", - "print(pi)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "From this, we can infer that `value_iteration_dmdp` tries to minimize the negative reward. \n", - "Since we don't have rewards for states now, the algorithm takes the action that would try to avoid getting negative rewards and take the lesser of two evils if all rewards are negative.\n", - "You might also want to have state rewards alongside transition rewards. \n", - "Perhaps you can do that yourself now that the difficult part has been done.\n", - "
" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### State, action and next-state dependent reward function\n", - "\n", - "For truly stochastic environments, \n", - "we have noticed that taking an action from a particular state doesn't always do what we want it to. \n", - "Instead, for every action taken from a particular state, \n", - "it might be possible to reach a different state each time depending on the transition probabilities. \n", - "What if we want different rewards for each state, action and next-state triplet? \n", - "Mathematically, we now want a reward function of the form R(s, a, s') for our MDP. \n", - "This section shows how we can tweak the MDP class to achieve this.\n", - "
\n", - "\n", - "Let's now take a different problem statement. \n", - "The one we are working with is a bit too simple.\n", - "Consider a taxi that serves three adjacent towns A, B, and C.\n", - "Each time the taxi discharges a passenger, the driver must choose from three possible actions:\n", - "1. Cruise the streets looking for a passenger.\n", - "2. Go to the nearest taxi stand.\n", - "3. Wait for a radio call from the dispatcher with instructions.\n", - "
\n", - "Subject to the constraint that the taxi driver cannot do the third action in town B because of distance and poor reception.\n", - "\n", - "Let's model our MDP.\n", - "
\n", - "The MDP has three states, namely A, B and C.\n", - "
\n", - "It has three actions, namely 1, 2 and 3.\n", - "
\n", - "Action sets:\n", - "
\n", - "$K_{a}$ = {1, 2, 3}\n", - "
\n", - "$K_{b}$ = {1, 2}\n", - "
\n", - "$K_{c}$ = {1, 2, 3}\n", - "
\n", - "\n", - "We have the following transition probability matrices:\n", - "
\n", - "
\n", - "Action 1: Cruising streets \n", - "
\n", - "$\\\\\n", - " P^{1} = \n", - " \\left[ {\\begin{array}{ccc}\n", - " \\frac{1}{2} & \\frac{1}{4} & \\frac{1}{4} \\\\\n", - " \\frac{1}{2} & 0 & \\frac{1}{2} \\\\\n", - " \\frac{1}{4} & \\frac{1}{4} & \\frac{1}{2} \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - "
\n", - "
\n", - "Action 2: Waiting at the taxi stand \n", - "
\n", - "$\\\\\n", - " P^{2} = \n", - " \\left[ {\\begin{array}{ccc}\n", - " \\frac{1}{16} & \\frac{3}{4} & \\frac{3}{16} \\\\\n", - " \\frac{1}{16} & \\frac{7}{8} & \\frac{1}{16} \\\\\n", - " \\frac{1}{8} & \\frac{3}{4} & \\frac{1}{8} \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - "
\n", - "
\n", - "Action 3: Waiting for dispatch \n", - "
\n", - "$\\\\\n", - " P^{3} =\n", - " \\left[ {\\begin{array}{ccc}\n", - " \\frac{1}{4} & \\frac{1}{8} & \\frac{5}{8} \\\\\n", - " 0 & 1 & 0 \\\\\n", - " \\frac{3}{4} & \\frac{1}{16} & \\frac{3}{16} \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - "
\n", - "
\n", - "For the sake of readability, we will call the states A, B and C and the actions 'cruise', 'stand' and 'dispatch'.\n", - "We will now build the transition model as a dictionary using these matrices." - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "t = {\n", - " 'A': {\n", - " 'cruise': {'A':0.5, 'B':0.25, 'C':0.25},\n", - " 'stand': {'A':0.0625, 'B':0.75, 'C':0.1875},\n", - " 'dispatch': {'A':0.25, 'B':0.125, 'C':0.625}\n", - " },\n", - " 'B': {\n", - " 'cruise': {'A':0.5, 'B':0, 'C':0.5},\n", - " 'stand': {'A':0.0625, 'B':0.875, 'C':0.0625},\n", - " 'dispatch': {'A':0, 'B':1, 'C':0}\n", - " },\n", - " 'C': {\n", - " 'cruise': {'A':0.25, 'B':0.25, 'C':0.5},\n", - " 'stand': {'A':0.125, 'B':0.75, 'C':0.125},\n", - " 'dispatch': {'A':0.75, 'B':0.0625, 'C':0.1875}\n", - " }\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The reward matrices for the problem are as follows:\n", - "
\n", - "
\n", - "Action 1: Cruising streets \n", - "
\n", - "$\\\\\n", - " R^{1} = \n", - " \\left[ {\\begin{array}{ccc}\n", - " 10 & 4 & 8 \\\\\n", - " 14 & 0 & 18 \\\\\n", - " 10 & 2 & 8 \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - "
\n", - "
\n", - "Action 2: Waiting at the taxi stand \n", - "
\n", - "$\\\\\n", - " R^{2} = \n", - " \\left[ {\\begin{array}{ccc}\n", - " 8 & 2 & 4 \\\\\n", - " 8 & 16 & 8 \\\\\n", - " 6 & 4 & 2\\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - "
\n", - "
\n", - "Action 3: Waiting for dispatch \n", - "
\n", - "$\\\\\n", - " R^{3} = \n", - " \\left[ {\\begin{array}{ccc}\n", - " 4 & 6 & 4 \\\\\n", - " 0 & 0 & 0 \\\\\n", - " 4 & 0 & 8\\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - "
\n", - "
\n", - "We now build the reward model as a dictionary using these matrices." - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "r = {\n", - " 'A': {\n", - " 'cruise': {'A':10, 'B':4, 'C':8},\n", - " 'stand': {'A':8, 'B':2, 'C':4},\n", - " 'dispatch': {'A':4, 'B':6, 'C':4}\n", - " },\n", - " 'B': {\n", - " 'cruise': {'A':14, 'B':0, 'C':18},\n", - " 'stand': {'A':8, 'B':16, 'C':8},\n", - " 'dispatch': {'A':0, 'B':0, 'C':0}\n", - " },\n", - " 'C': {\n", - " 'cruise': {'A':10, 'B':2, 'C':18},\n", - " 'stand': {'A':6, 'B':4, 'C':2},\n", - " 'dispatch': {'A':4, 'B':0, 'C':8}\n", - " }\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "collapsed": true - }, - "source": [ - "The Bellman update equation now is defined as follows\n", - "\n", - "$$U(s)=\\max_{a\\epsilon A(s)}\\sum_{s'}P(s'\\ |\\ s,a)(R(s'\\ |\\ s,a) + \\gamma U(s'))$$\n", - "\n", - "It is not difficult to see that all the update equations we have used till now is just a special case of this more generalized equation. \n", - "If we did not have next-state-dependent rewards, the first term inside the summation exactly sums up to R(s, a) or the state-reward for a particular action and we would get the update equation used in the previous problem.\n", - "If we did not have action dependent rewards, the first term inside the summation sums up to R(s) or the state-reward and we would get the first update equation used in `mdp.ipynb`.\n", - "
\n", - "For example, as we have the same reward regardless of the action, let's consider a reward of **r** units for a particular state and let's assume the transition probabilities to be 0.1, 0.2, 0.3 and 0.4 for 4 possible actions for that state.\n", - "We will further assume that a particular action in a state leads to the same state every time we take that action.\n", - "The first term inside the summation for this case will evaluate to (0.1 + 0.2 + 0.3 + 0.4)r = r which is equal to R(s) in the first update equation.\n", - "
\n", - "There are many ways to write value iteration for this situation, but we will go with the most intuitive method.\n", - "One that can be implemented with minor alterations to the existing `value_iteration` algorithm.\n", - "
\n", - "Our `DMDP` class will be slightly different.\n", - "More specifically, the `R` method will have one more index to go through now that we have three levels of nesting in the reward model.\n", - "We will call the new class `DMDP2` as I have run out of creative names." - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "class DMDP2:\n", - "\n", - " \"\"\"A Markov Decision Process, defined by an initial state, transition model,\n", - " and reward model. We also keep track of a gamma value, for use by\n", - " algorithms. The transition model is represented somewhat differently from\n", - " the text. Instead of P(s' | s, a) being a probability number for each\n", - " state/state/action triplet, we instead have T(s, a) return a\n", - " list of (p, s') pairs. The reward function is very similar.\n", - " We also keep track of the possible states,\n", - " terminal states, and actions for each state.\"\"\"\n", - "\n", - " def __init__(self, init, actlist, terminals, transitions={}, rewards={}, states=None, gamma=.9):\n", - " if not (0 < gamma <= 1):\n", - " raise ValueError(\"An MDP must have 0 < gamma <= 1\")\n", - "\n", - " if states:\n", - " self.states = states\n", - " else:\n", - " self.states = set()\n", - " self.init = init\n", - " self.actlist = actlist\n", - " self.terminals = terminals\n", - " self.transitions = transitions\n", - " self.rewards = rewards\n", - " self.gamma = gamma\n", - "\n", - " def R(self, state, action, state_):\n", - " \"\"\"Return a numeric reward for this state, this action and the next state_\"\"\"\n", - " if (self.rewards == {}):\n", - " raise ValueError('Reward model is missing')\n", - " else:\n", - " return self.rewards[state][action][state_]\n", - "\n", - " def T(self, state, action):\n", - " \"\"\"Transition model. From a state and an action, return a list\n", - " of (probability, result-state) pairs.\"\"\"\n", - " if(self.transitions == {}):\n", - " raise ValueError(\"Transition model is missing\")\n", - " else:\n", - " return self.transitions[state][action]\n", - "\n", - " def actions(self, state):\n", - " \"\"\"Set of actions that can be performed in this state. By default, a\n", - " fixed list of actions, except for terminal states. Override this\n", - " method if you need to specialize by state.\"\"\"\n", - " if state in self.terminals:\n", - " return [None]\n", - " else:\n", - " return self.actlist\n", - " \n", - " def actions(self, state):\n", - " \"\"\"Set of actions that can be performed in this state. By default, a\n", - " fixed list of actions, except for terminal states. Override this\n", - " method if you need to specialize by state.\"\"\"\n", - " if state in self.terminals:\n", - " return [None]\n", - " else:\n", - " return self.actlist" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Only the `R` method is different from the previous `DMDP` class.\n", - "
\n", - "Our traditional custom class will be required to implement the transition model and the reward model.\n", - "
\n", - "We call this class `CustomDMDP2`." - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "class CustomDMDP2(DMDP2):\n", - " \n", - " def __init__(self, transition_matrix, rewards, terminals, init, gamma=.9):\n", - " actlist = []\n", - " for state in transition_matrix.keys():\n", - " actlist.extend(transition_matrix[state])\n", - " actlist = list(set(actlist))\n", - " print(actlist)\n", - " \n", - " DMDP2.__init__(self, init, actlist, terminals=terminals, gamma=gamma)\n", - " self.t = transition_matrix\n", - " self.rewards = rewards\n", - " for state in self.t:\n", - " self.states.add(state)\n", - " \n", - " def T(self, state, action):\n", - " if action is None:\n", - " return [(0.0, state)]\n", - " else:\n", - " return [(prob, new_state) for new_state, prob in self.t[state][action].items()]\n", - " \n", - " def R(self, state, action, state_):\n", - " if action is None:\n", - " return 0\n", - " else:\n", - " return self.rewards[state][action][state_]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can finally write value iteration for this problem.\n", - "The latest update equation will be used." - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "def value_iteration_taxi_mdp(dmdp2, epsilon=0.001):\n", - " U1 = {s: 0 for s in dmdp2.states}\n", - " R, T, gamma = dmdp2.R, dmdp2.T, dmdp2.gamma\n", - " while True:\n", - " U = U1.copy()\n", - " delta = 0\n", - " for s in dmdp2.states:\n", - " U1[s] = max([sum([(p*(R(s, a, s1) + gamma*U[s1])) for (p, s1) in T(s, a)]) for a in dmdp2.actions(s)])\n", - " delta = max(delta, abs(U1[s] - U[s]))\n", - " if delta < epsilon * (1 - gamma) / gamma:\n", - " return U" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "These algorithms can be made more pythonic by using cleverer list comprehensions.\n", - "We can also write the variants of value iteration in such a way that all problems are solved using the same base class, regardless of the reward function and the number of arguments it takes.\n", - "Quite a few things can be done to refactor the code and reduce repetition, but we have done it this way for the sake of clarity.\n", - "Perhaps you can try this as an exercise.\n", - "
\n", - "We now need to define terminals and initial state." - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "terminals = ['end']\n", - "init = 'A'" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's instantiate our class." - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "['stand', 'dispatch', 'cruise']\n" - ] - } - ], - "source": [ - "dmdp2 = CustomDMDP2(t, r, terminals, init, gamma=.9)" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'A': 124.4881543573768, 'B': 137.70885410461636, 'C': 129.08041190693115}" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "value_iteration_taxi_mdp(dmdp2)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "These are the expected utility values for the states of our MDP.\n", - "Let's proceed to write a helper function to find the expected utility and another to find the best policy." - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "def expected_utility_dmdp2(a, s, U, dmdp2):\n", - " return sum([(p*(dmdp2.R(s, a, s1) + dmdp2.gamma*U[s1])) for (p, s1) in dmdp2.T(s, a)])" - ] - }, - { - "cell_type": "code", - "execution_count": 33, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "from utils import argmax\n", - "def best_policy_dmdp2(dmdp2, U):\n", - " pi = {}\n", - " for s in dmdp2.states:\n", - " pi[s] = argmax(dmdp2.actions(s), key=lambda a: expected_utility_dmdp2(a, s, U, dmdp2))\n", - " return pi" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Find the best policy." - ] - }, - { - "cell_type": "code", - "execution_count": 34, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'C': 'cruise', 'A': 'stand', 'B': 'stand'}\n" - ] - } - ], - "source": [ - "pi = best_policy_dmdp2(dmdp2, value_iteration_taxi_mdp(dmdp2, .01))\n", - "print(pi)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We have successfully adapted the existing code to a different scenario yet again.\n", - "The takeaway from this section is that you can convert the vast majority of reinforcement learning problems into MDPs and solve for the best policy using simple yet efficient tools." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## GRID MDP\n", - "---\n", - "### Pathfinding Problem\n", - "Markov Decision Processes can be used to find the best path through a maze. Let us consider this simple maze.\n", - "![title](images/maze.png)\n", - "\n", - "This environment can be formulated as a GridMDP.\n", - "
\n", - "To make the grid matrix, we will consider the state-reward to be -0.1 for every state.\n", - "
\n", - "State (1, 1) will have a reward of -5 to signify that this state is to be prohibited.\n", - "
\n", - "State (9, 9) will have a reward of +5.\n", - "This will be the terminal state.\n", - "
\n", - "The matrix can be generated using the GridMDP editor or we can write it ourselves." - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "grid = [\n", - " [None, None, None, None, None, None, None, None, None, None, None], \n", - " [None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None, +5.0, None], \n", - " [None, -0.1, None, None, None, None, None, None, None, -0.1, None], \n", - " [None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None], \n", - " [None, -0.1, None, None, None, None, None, None, None, None, None], \n", - " [None, -0.1, None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None], \n", - " [None, -0.1, None, None, None, None, None, -0.1, None, -0.1, None], \n", - " [None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None, -0.1, None], \n", - " [None, None, None, None, None, -0.1, None, -0.1, None, -0.1, None], \n", - " [None, -5.0, -0.1, -0.1, -0.1, -0.1, None, -0.1, None, -0.1, None], \n", - " [None, None, None, None, None, None, None, None, None, None, None]\n", - "]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We have only one terminal state, (9, 9)" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "terminals = [(9, 9)]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We define our maze environment below" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "metadata": {}, - "outputs": [], - "source": [ - "maze = GridMDP(grid, terminals)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To solve the maze, we can use the `best_policy` function along with `value_iteration`." - ] - }, - { - "cell_type": "code", - "execution_count": 38, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "pi = best_policy(maze, value_iteration(maze))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This is the heatmap generated by the GridMDP editor using `value_iteration` on this environment\n", - "
\n", - "![title](images/mdp-d.png)\n", - "
\n", - "Let's print out the best policy" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "None None None None None None None None None None None\n", - "None v < < < < < < None . None\n", - "None v None None None None None None None ^ None\n", - "None > > > > > > > > ^ None\n", - "None ^ None None None None None None None None None\n", - "None ^ None > > > > v < < None\n", - "None ^ None None None None None v None ^ None\n", - "None ^ < < < < < < None ^ None\n", - "None None None None None ^ None ^ None ^ None\n", - "None > > > > ^ None ^ None ^ None\n", - "None None None None None None None None None None None\n" - ] - } - ], - "source": [ - "from utils import print_table\n", - "print_table(maze.to_arrows(pi))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "As you can infer, we can find the path to the terminal state starting from any given state using this policy.\n", - "All maze problems can be solved by formulating it as a MDP." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## POMDP\n", - "### Two state POMDP\n", - "Let's consider a problem where we have two doors, one to our left and one to our right.\n", - "One of these doors opens to a room with a tiger in it, and the other one opens to an empty hall.\n", - "
\n", - "We will call our two states `0` and `1` for `left` and `right` respectively.\n", - "
\n", - "The possible actions we can take are as follows:\n", - "
\n", - "1. __Open-left__: Open the left door.\n", - "Represented by `0`.\n", - "2. __Open-right__: Open the right door.\n", - "Represented by `1`.\n", - "3. __Listen__: Listen carefully to one side and possibly hear the tiger breathing.\n", - "Represented by `2`.\n", - "\n", - "
\n", - "The possible observations we can get are as follows:\n", - "
\n", - "1. __TL__: Tiger seems to be at the left door.\n", - "2. __TR__: Tiger seems to be at the right door.\n", - "\n", - "
\n", - "The reward function is as follows:\n", - "
\n", - "We get +10 reward for opening the door to the empty hall and we get -100 reward for opening the other door and setting the tiger free.\n", - "
\n", - "Listening costs us -1 reward.\n", - "
\n", - "We want to minimize our chances of setting the tiger free.\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Our transition probabilities can be defined as:\n", - "
\n", - "
\n", - "Action `0` (Open left door)\n", - "$\\\\\n", - " P(0) = \n", - " \\left[ {\\begin{array}{cc}\n", - " 0.5 & 0.5 \\\\\n", - " 0.5 & 0.5 \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - " \n", - "Action `1` (Open right door)\n", - "$\\\\\n", - " P(1) = \n", - " \\left[ {\\begin{array}{cc}\n", - " 0.5 & 0.5 \\\\\n", - " 0.5 & 0.5 \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - " \n", - "Action `2` (Listen)\n", - "$\\\\\n", - " P(2) = \n", - " \\left[ {\\begin{array}{cc}\n", - " 1.0 & 0.0 \\\\\n", - " 0.0 & 1.0 \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - " \n", - "
\n", - "
\n", - "Our observation probabilities can be defined as:\n", - "
\n", - "
\n", - "$\\\\\n", - " O(0) = \n", - " \\left[ {\\begin{array}{ccc}\n", - " Open left & TL & TR \\\\\n", - " Tiger: left & 0.5 & 0.5 \\\\\n", - " Tiger: right & 0.5 & 0.5 \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - "\n", - "$\\\\\n", - " O(1) = \n", - " \\left[ {\\begin{array}{ccc}\n", - " Open right & TL & TR \\\\\n", - " Tiger: left & 0.5 & 0.5 \\\\\n", - " Tiger: right & 0.5 & 0.5 \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - "\n", - "$\\\\\n", - " O(2) = \n", - " \\left[ {\\begin{array}{ccc}\n", - " Listen & TL & TR \\\\\n", - " Tiger: left & 0.85 & 0.15 \\\\\n", - " Tiger: right & 0.15 & 0.85 \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - "\n", - "
\n", - "
\n", - "The rewards of this POMDP are defined as:\n", - "
\n", - "
\n", - "$\\\\\n", - " R(0) = \n", - " \\left[ {\\begin{array}{cc}\n", - " Openleft & Reward \\\\\n", - " Tiger: left & -100 \\\\\n", - " Tiger: right & +10 \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - " \n", - "$\\\\\n", - " R(1) = \n", - " \\left[ {\\begin{array}{cc}\n", - " Openright & Reward \\\\\n", - " Tiger: left & +10 \\\\\n", - " Tiger: right & -100 \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - " \n", - "$\\\\\n", - " R(2) = \n", - " \\left[ {\\begin{array}{cc}\n", - " Listen & Reward \\\\\n", - " Tiger: left & -1 \\\\\n", - " Tiger: right & -1 \\\\\n", - " \\end{array}}\\right] \\\\\n", - " \\\\\n", - " $\n", - " \n", - "
\n", - "Based on these matrices, we will initialize our variables." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's first define our transition state." - ] - }, - { - "cell_type": "code", - "execution_count": 40, - "metadata": {}, - "outputs": [], - "source": [ - "t_prob = [[[0.5, 0.5], \n", - " [0.5, 0.5]], \n", - " \n", - " [[0.5, 0.5], \n", - " [0.5, 0.5]], \n", - " \n", - " [[1.0, 0.0], \n", - " [0.0, 1.0]]]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Followed by the observation model." - ] - }, - { - "cell_type": "code", - "execution_count": 41, - "metadata": {}, - "outputs": [], - "source": [ - "e_prob = [[[0.5, 0.5], \n", - " [0.5, 0.5]], \n", - " \n", - " [[0.5, 0.5], \n", - " [0.5, 0.5]], \n", - " \n", - " [[0.85, 0.15], \n", - " [0.15, 0.85]]]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "And the reward model." - ] - }, - { - "cell_type": "code", - "execution_count": 42, - "metadata": {}, - "outputs": [], - "source": [ - "rewards = [[-100, 10], \n", - " [10, -100], \n", - " [-1, -1]]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's now define our states, observations and actions.\n", - "
\n", - "We will use `gamma` = 0.95 for this example.\n", - "
" - ] - }, - { - "cell_type": "code", - "execution_count": 43, - "metadata": {}, - "outputs": [], - "source": [ - "# 0: open-left, 1: open-right, 2: listen\n", - "actions = ('0', '1', '2')\n", - "# 0: left, 1: right\n", - "states = ('0', '1')\n", - "\n", - "gamma = 0.95" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We have all the required variables to instantiate an object of the `POMDP` class." - ] - }, - { - "cell_type": "code", - "execution_count": 44, - "metadata": {}, - "outputs": [], - "source": [ - "pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can now find the utility function by running `pomdp_value_iteration` on our `pomdp` object." - ] - }, - { - "cell_type": "code", - "execution_count": 45, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "defaultdict(list,\n", - " {'0': [array([-83.05169196, 26.94830804])],\n", - " '1': [array([ 26.94830804, -83.05169196])],\n", - " '2': [array([23.55049363, -0.76359097]),\n", - " array([23.55049363, -0.76359097]),\n", - " array([23.55049363, -0.76359097]),\n", - " array([23.55049363, -0.76359097]),\n", - " array([23.24120177, 1.56028929]),\n", - " array([23.24120177, 1.56028929]),\n", - " array([23.24120177, 1.56028929]),\n", - " array([20.0874279 , 15.03900771]),\n", - " array([20.0874279 , 15.03900771]),\n", - " array([20.0874279 , 15.03900771]),\n", - " array([20.0874279 , 15.03900771]),\n", - " array([17.91696135, 17.91696135]),\n", - " array([17.91696135, 17.91696135]),\n", - " array([17.91696135, 17.91696135]),\n", - " array([17.91696135, 17.91696135]),\n", - " array([17.91696135, 17.91696135]),\n", - " array([15.03900771, 20.0874279 ]),\n", - " array([15.03900771, 20.0874279 ]),\n", - " array([15.03900771, 20.0874279 ]),\n", - " array([15.03900771, 20.0874279 ]),\n", - " array([ 1.56028929, 23.24120177]),\n", - " array([ 1.56028929, 23.24120177]),\n", - " array([ 1.56028929, 23.24120177]),\n", - " array([-0.76359097, 23.55049363]),\n", - " array([-0.76359097, 23.55049363]),\n", - " array([-0.76359097, 23.55049363]),\n", - " array([-0.76359097, 23.55049363])]})" - ] - }, - "execution_count": 45, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "utility = pomdp_value_iteration(pomdp, epsilon=3)\n", - "utility" - ] - }, - { - "cell_type": "code", - "execution_count": 46, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "%matplotlib inline\n", - "\n", - "def plot_utility(utility):\n", - " open_left = utility['0'][0]\n", - " open_right = utility['1'][0]\n", - " listen_left = utility['2'][0]\n", - " listen_right = utility['2'][-1]\n", - " left = (open_left[0] - listen_left[0]) / (open_left[0] - listen_left[0] + listen_left[1] - open_left[1])\n", - " right = (open_right[0] - listen_right[0]) / (open_right[0] - listen_right[0] + listen_right[1] - open_right[1])\n", - " \n", - " colors = ['g', 'b', 'k']\n", - " for action in utility:\n", - " for value in utility[action]:\n", - " plt.plot(value, color=colors[int(action)])\n", - " plt.vlines([left, right], -10, 35, linestyles='dashed', colors='c')\n", - " plt.ylim(-10, 35)\n", - " plt.xlim(0, 1)\n", - " plt.text(left/2 - 0.35, 30, 'open-left')\n", - " plt.text((right + left)/2 - 0.04, 30, 'listen')\n", - " plt.text((right + 1)/2 + 0.22, 30, 'open-right')\n", - " plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 47, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "plot_utility(utility)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Hence, we get a piecewise-continuous utility function consistent with the given POMDP." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.4" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/nlp4e.py b/nlp4e.py deleted file mode 100644 index 095f54357..000000000 --- a/nlp4e.py +++ /dev/null @@ -1,523 +0,0 @@ -"""Natural Language Processing (Chapter 22)""" - -from collections import defaultdict -from utils4e import weighted_choice -import copy -import operator -import heapq -from search import Problem - - -# ______________________________________________________________________________ -# 22.2 Grammars - - -def Rules(**rules): - """Create a dictionary mapping symbols to alternative sequences. - >>> Rules(A = "B C | D E") - {'A': [['B', 'C'], ['D', 'E']]} - """ - for (lhs, rhs) in rules.items(): - rules[lhs] = [alt.strip().split() for alt in rhs.split('|')] - return rules - - -def Lexicon(**rules): - """Create a dictionary mapping symbols to alternative words. - >>> Lexicon(Article = "the | a | an") - {'Article': ['the', 'a', 'an']} - """ - for (lhs, rhs) in rules.items(): - rules[lhs] = [word.strip() for word in rhs.split('|')] - return rules - - -class Grammar: - - def __init__(self, name, rules, lexicon): - """A grammar has a set of rules and a lexicon.""" - self.name = name - self.rules = rules - self.lexicon = lexicon - self.categories = defaultdict(list) - for lhs in lexicon: - for word in lexicon[lhs]: - self.categories[word].append(lhs) - - def rewrites_for(self, cat): - """Return a sequence of possible rhs's that cat can be rewritten as.""" - return self.rules.get(cat, ()) - - def isa(self, word, cat): - """Return True iff word is of category cat""" - return cat in self.categories[word] - - def cnf_rules(self): - """Returns the tuple (X, Y, Z) for rules in the form: - X -> Y Z""" - cnf = [] - for X, rules in self.rules.items(): - for (Y, Z) in rules: - cnf.append((X, Y, Z)) - - return cnf - - def generate_random(self, S='S'): - """Replace each token in S by a random entry in grammar (recursively).""" - import random - - def rewrite(tokens, into): - for token in tokens: - if token in self.rules: - rewrite(random.choice(self.rules[token]), into) - elif token in self.lexicon: - into.append(random.choice(self.lexicon[token])) - else: - into.append(token) - return into - - return ' '.join(rewrite(S.split(), [])) - - def __repr__(self): - return ''.format(self.name) - - -def ProbRules(**rules): - """Create a dictionary mapping symbols to alternative sequences, - with probabilities. - >>> ProbRules(A = "B C [0.3] | D E [0.7]") - {'A': [(['B', 'C'], 0.3), (['D', 'E'], 0.7)]} - """ - for (lhs, rhs) in rules.items(): - rules[lhs] = [] - rhs_separate = [alt.strip().split() for alt in rhs.split('|')] - for r in rhs_separate: - prob = float(r[-1][1:-1]) # remove brackets, convert to float - rhs_rule = (r[:-1], prob) - rules[lhs].append(rhs_rule) - - return rules - - -def ProbLexicon(**rules): - """Create a dictionary mapping symbols to alternative words, - with probabilities. - >>> ProbLexicon(Article = "the [0.5] | a [0.25] | an [0.25]") - {'Article': [('the', 0.5), ('a', 0.25), ('an', 0.25)]} - """ - for (lhs, rhs) in rules.items(): - rules[lhs] = [] - rhs_separate = [word.strip().split() for word in rhs.split('|')] - for r in rhs_separate: - prob = float(r[-1][1:-1]) # remove brackets, convert to float - word = r[:-1][0] - rhs_rule = (word, prob) - rules[lhs].append(rhs_rule) - - return rules - - -class ProbGrammar: - - def __init__(self, name, rules, lexicon): - """A grammar has a set of rules and a lexicon. - Each rule has a probability.""" - self.name = name - self.rules = rules - self.lexicon = lexicon - self.categories = defaultdict(list) - - for lhs in lexicon: - for word, prob in lexicon[lhs]: - self.categories[word].append((lhs, prob)) - - def rewrites_for(self, cat): - """Return a sequence of possible rhs's that cat can be rewritten as.""" - return self.rules.get(cat, ()) - - def isa(self, word, cat): - """Return True iff word is of category cat""" - return cat in [c for c, _ in self.categories[word]] - - def cnf_rules(self): - """Returns the tuple (X, Y, Z, p) for rules in the form: - X -> Y Z [p]""" - cnf = [] - for X, rules in self.rules.items(): - for (Y, Z), p in rules: - cnf.append((X, Y, Z, p)) - - return cnf - - def generate_random(self, S='S'): - """Replace each token in S by a random entry in grammar (recursively). - Returns a tuple of (sentence, probability).""" - - def rewrite(tokens, into): - for token in tokens: - if token in self.rules: - non_terminal, prob = weighted_choice(self.rules[token]) - into[1] *= prob - rewrite(non_terminal, into) - elif token in self.lexicon: - terminal, prob = weighted_choice(self.lexicon[token]) - into[0].append(terminal) - into[1] *= prob - else: - into[0].append(token) - return into - - rewritten_as, prob = rewrite(S.split(), [[], 1]) - return (' '.join(rewritten_as), prob) - - def __repr__(self): - return ''.format(self.name) - - -E0 = Grammar('E0', - Rules( # Grammar for E_0 [Figure 22.2] - S='NP VP | S Conjunction S', - NP='Pronoun | Name | Noun | Article Noun | Digit Digit | NP PP | NP RelClause', - VP='Verb | VP NP | VP Adjective | VP PP | VP Adverb', - PP='Preposition NP', - RelClause='That VP'), - - Lexicon( # Lexicon for E_0 [Figure 22.3] - Noun="stench | breeze | glitter | nothing | wumpus | pit | pits | gold | east", - Verb="is | see | smell | shoot | fell | stinks | go | grab | carry | kill | turn | feel", # noqa - Adjective="right | left | east | south | back | smelly | dead", - Adverb="here | there | nearby | ahead | right | left | east | south | back", - Pronoun="me | you | I | it", - Name="John | Mary | Boston | Aristotle", - Article="the | a | an", - Preposition="to | in | on | near", - Conjunction="and | or | but", - Digit="0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9", - That="that" - )) - -E_ = Grammar('E_', # Trivial Grammar and lexicon for testing - Rules( - S='NP VP', - NP='Art N | Pronoun', - VP='V NP'), - - Lexicon( - Art='the | a', - N='man | woman | table | shoelace | saw', - Pronoun='I | you | it', - V='saw | liked | feel' - )) - -E_NP_ = Grammar('E_NP_', # Another Trivial Grammar for testing - Rules(NP='Adj NP | N'), - Lexicon(Adj='happy | handsome | hairy', - N='man')) - -E_Prob = ProbGrammar('E_Prob', # The Probabilistic Grammar from the notebook - ProbRules( - S="NP VP [0.6] | S Conjunction S [0.4]", - NP="Pronoun [0.2] | Name [0.05] | Noun [0.2] | Article Noun [0.15] \ - | Article Adjs Noun [0.1] | Digit [0.05] | NP PP [0.15] | NP RelClause [0.1]", - VP="Verb [0.3] | VP NP [0.2] | VP Adjective [0.25] | VP PP [0.15] | VP Adverb [0.1]", - Adjs="Adjective [0.5] | Adjective Adjs [0.5]", - PP="Preposition NP [1]", - RelClause="RelPro VP [1]" - ), - ProbLexicon( - Verb="is [0.5] | say [0.3] | are [0.2]", - Noun="robot [0.4] | sheep [0.4] | fence [0.2]", - Adjective="good [0.5] | new [0.2] | sad [0.3]", - Adverb="here [0.6] | lightly [0.1] | now [0.3]", - Pronoun="me [0.3] | you [0.4] | he [0.3]", - RelPro="that [0.5] | who [0.3] | which [0.2]", - Name="john [0.4] | mary [0.4] | peter [0.2]", - Article="the [0.5] | a [0.25] | an [0.25]", - Preposition="to [0.4] | in [0.3] | at [0.3]", - Conjunction="and [0.5] | or [0.2] | but [0.3]", - Digit="0 [0.35] | 1 [0.35] | 2 [0.3]" - )) - -E_Chomsky = Grammar('E_Prob_Chomsky', # A Grammar in Chomsky Normal Form - Rules( - S='NP VP', - NP='Article Noun | Adjective Noun', - VP='Verb NP | Verb Adjective', - ), - Lexicon( - Article='the | a | an', - Noun='robot | sheep | fence', - Adjective='good | new | sad', - Verb='is | say | are' - )) - -E_Prob_Chomsky = ProbGrammar('E_Prob_Chomsky', # A Probabilistic Grammar in CNF - ProbRules( - S='NP VP [1]', - NP='Article Noun [0.6] | Adjective Noun [0.4]', - VP='Verb NP [0.5] | Verb Adjective [0.5]', - ), - ProbLexicon( - Article='the [0.5] | a [0.25] | an [0.25]', - Noun='robot [0.4] | sheep [0.4] | fence [0.2]', - Adjective='good [0.5] | new [0.2] | sad [0.3]', - Verb='is [0.5] | say [0.3] | are [0.2]' - )) -E_Prob_Chomsky_ = ProbGrammar('E_Prob_Chomsky_', - ProbRules( - S='NP VP [1]', - NP='NP PP [0.4] | Noun Verb [0.6]', - PP='Preposition NP [1]', - VP='Verb NP [0.7] | VP PP [0.3]', - ), - ProbLexicon( - Noun='astronomers [0.18] | eyes [0.32] | stars [0.32] | telescopes [0.18]', - Verb='saw [0.5] | \'\' [0.5]', - Preposition='with [1]' - )) - - -# ______________________________________________________________________________ -# 22.3 Parsing - - -class Chart: - """Class for parsing sentences using a chart data structure. - >>> chart = Chart(E0) - >>> len(chart.parses('the stench is in 2 2')) - 1 - """ - - def __init__(self, grammar, trace=False): - """A datastructure for parsing a string; and methods to do the parse. - self.chart[i] holds the edges that end just before the i'th word. - Edges are 5-element lists of [start, end, lhs, [found], [expects]].""" - self.grammar = grammar - self.trace = trace - - def parses(self, words, S='S'): - """Return a list of parses; words can be a list or string.""" - if isinstance(words, str): - words = words.split() - self.parse(words, S) - # Return all the parses that span the whole input - # 'span the whole input' => begin at 0, end at len(words) - return [[i, j, S, found, []] - for (i, j, lhs, found, expects) in self.chart[len(words)] - # assert j == len(words) - if i == 0 and lhs == S and expects == []] - - def parse(self, words, S='S'): - """Parse a list of words; according to the grammar. - Leave results in the chart.""" - self.chart = [[] for i in range(len(words) + 1)] - self.add_edge([0, 0, 'S_', [], [S]]) - for i in range(len(words)): - self.scanner(i, words[i]) - return self.chart - - def add_edge(self, edge): - """Add edge to chart, and see if it extends or predicts another edge.""" - start, end, lhs, found, expects = edge - if edge not in self.chart[end]: - self.chart[end].append(edge) - if self.trace: - print('Chart: added {}'.format(edge)) - if not expects: - self.extender(edge) - else: - self.predictor(edge) - - def scanner(self, j, word): - """For each edge expecting a word of this category here, extend the edge.""" - for (i, j, A, alpha, Bb) in self.chart[j]: - if Bb and self.grammar.isa(word, Bb[0]): - self.add_edge([i, j + 1, A, alpha + [(Bb[0], word)], Bb[1:]]) - - def predictor(self, edge): - """Add to chart any rules for B that could help extend this edge.""" - (i, j, A, alpha, Bb) = edge - B = Bb[0] - if B in self.grammar.rules: - for rhs in self.grammar.rewrites_for(B): - self.add_edge([j, j, B, [], rhs]) - - def extender(self, edge): - """See what edges can be extended by this edge.""" - (j, k, B, _, _) = edge - for (i, j, A, alpha, B1b) in self.chart[j]: - if B1b and B == B1b[0]: - self.add_edge([i, k, A, alpha + [edge], B1b[1:]]) - - -# ______________________________________________________________________________ -# CYK Parsing - - -class Tree: - def __init__(self, root, *args): - self.root = root - self.leaves = [leaf for leaf in args] - - -def CYK_parse(words, grammar): - """ [Figure 22.6] """ - # We use 0-based indexing instead of the book's 1-based. - P = defaultdict(float) - T = defaultdict(Tree) - - # Insert lexical categories for each word. - for (i, word) in enumerate(words): - for (X, p) in grammar.categories[word]: - P[X, i, i] = p - T[X, i, i] = Tree(X, word) - - # Construct X(i:k) from Y(i:j) and Z(j+1:k), shortest span first - for i, j, k in subspan(len(words)): - for (X, Y, Z, p) in grammar.cnf_rules(): - PYZ = P[Y, i, j] * P[Z, j + 1, k] * p - if PYZ > P[X, i, k]: - P[X, i, k] = PYZ - T[X, i, k] = Tree(X, T[Y, i, j], T[Z, j + 1, k]) - - return T - - -def subspan(N): - """returns all tuple(i, j, k) covering a span (i, k) with i <= j < k""" - for length in range(2, N + 1): - for i in range(1, N + 2 - length): - k = i + length - 1 - for j in range(i, k): - yield (i, j, k) - - -# using search algorithms in the searching part - - -class TextParsingProblem(Problem): - def __init__(self, initial, grammar, goal='S'): - """ - :param initial: the initial state of words in a list. - :param grammar: a grammar object - :param goal: the goal state, usually S - """ - super(TextParsingProblem, self).__init__(initial, goal) - self.grammar = grammar - self.combinations = defaultdict(list) # article combinations - # backward lookup of rules - for rule in grammar.rules: - for comb in grammar.rules[rule]: - self.combinations[' '.join(comb)].append(rule) - - def actions(self, state): - actions = [] - categories = self.grammar.categories - # first change each word to the article of its category - for i in range(len(state)): - word = state[i] - if word in categories: - for X in categories[word]: - state[i] = X - actions.append(copy.copy(state)) - state[i] = word - # if all words are replaced by articles, replace combinations of articles by inferring rules. - if not actions: - for start in range(len(state)): - for end in range(start, len(state) + 1): - # try combinations between (start, end) - articles = ' '.join(state[start:end]) - for c in self.combinations[articles]: - actions.append(state[:start] + [c] + state[end:]) - return actions - - def result(self, state, action): - return action - - def h(self, state): - # heuristic function - return len(state) - - -def astar_search_parsing(words, gramma): - """bottom-up parsing using A* search to find whether a list of words is a sentence""" - # init the problem - problem = TextParsingProblem(words, gramma, 'S') - state = problem.initial - # init the searching frontier - frontier = [(len(state) + problem.h(state), state)] - heapq.heapify(frontier) - - while frontier: - # search the frontier node with lowest cost first - cost, state = heapq.heappop(frontier) - actions = problem.actions(state) - for action in actions: - new_state = problem.result(state, action) - # update the new frontier node to the frontier - if new_state == [problem.goal]: - return problem.goal - if new_state != state: - heapq.heappush(frontier, (len(new_state) + problem.h(new_state), new_state)) - return False - - -def beam_search_parsing(words, gramma, b=3): - """bottom-up text parsing using beam search""" - # init problem - problem = TextParsingProblem(words, gramma, 'S') - # init frontier - frontier = [(len(problem.initial), problem.initial)] - heapq.heapify(frontier) - - # explore the current frontier and keep b new states with lowest cost - def explore(frontier): - new_frontier = [] - for cost, state in frontier: - # expand the possible children states of current state - if not problem.goal_test(' '.join(state)): - actions = problem.actions(state) - for action in actions: - new_state = problem.result(state, action) - if [len(new_state), new_state] not in new_frontier and new_state != state: - new_frontier.append([len(new_state), new_state]) - else: - return problem.goal - heapq.heapify(new_frontier) - # only keep b states - return heapq.nsmallest(b, new_frontier) - - while frontier: - frontier = explore(frontier) - if frontier == problem.goal: - return frontier - return False - - -# ______________________________________________________________________________ -# 22.4 Augmented Grammar - - -g = Grammar("arithmetic_expression", # A Grammar of Arithmetic Expression - rules={ - 'Number_0': 'Digit_0', 'Number_1': 'Digit_1', 'Number_2': 'Digit_2', - 'Number_10': 'Number_1 Digit_0', 'Number_11': 'Number_1 Digit_1', - 'Number_100': 'Number_10 Digit_0', - 'Exp_5': ['Number_5', '( Exp_5 )', 'Exp_1, Operator_+ Exp_4', 'Exp_2, Operator_+ Exp_3', - 'Exp_0, Operator_+ Exp_5', 'Exp_3, Operator_+ Exp_2', 'Exp_4, Operator_+ Exp_1', - 'Exp_5, Operator_+ Exp_0', 'Exp_1, Operator_* Exp_5'], # more possible combinations - 'Operator_+': operator.add, 'Operator_-': operator.sub, 'Operator_*': operator.mul, - 'Operator_/': operator.truediv, - 'Digit_0': 0, 'Digit_1': 1, 'Digit_2': 2, 'Digit_3': 3, 'Digit_4': 4 - }, - lexicon={}) - -g = Grammar("Ali loves Bob", # A example grammer of Ali loves Bob example - rules={ - "S_loves_ali_bob": "NP_ali, VP_x_loves_x_bob", "S_loves_bob_ali": "NP_bob, VP_x_loves_x_ali", - "VP_x_loves_x_bob": "Verb_xy_loves_xy NP_bob", "VP_x_loves_x_ali": "Verb_xy_loves_xy NP_ali", - "NP_bob": "Name_bob", "NP_ali": "Name_ali" - }, - lexicon={ - "Name_ali": "Ali", "Name_bob": "Bob", "Verb_xy_loves_xy": "loves" - }) diff --git a/notebook.py b/notebook.py deleted file mode 100644 index 7f0306335..000000000 --- a/notebook.py +++ /dev/null @@ -1,1122 +0,0 @@ -import time -from collections import defaultdict -from inspect import getsource - -import ipywidgets as widgets -import matplotlib.pyplot as plt -import networkx as nx -import numpy as np -from IPython.display import HTML -from IPython.display import display -from PIL import Image -from matplotlib import lines - -from games import TicTacToe, alpha_beta_player, random_player, Fig52Extended -from learning import DataSet -from logic import parse_definite_clause, standardize_variables, unify_mm, subst -from search import GraphProblem, romania_map - - -# ______________________________________________________________________________ -# Magic Words - - -def pseudocode(algorithm): - """Print the pseudocode for the given algorithm.""" - from urllib.request import urlopen - from IPython.display import Markdown - - algorithm = algorithm.replace(' ', '-') - url = "https://raw.githubusercontent.com/aimacode/aima-pseudocode/master/md/{}.md".format(algorithm) - f = urlopen(url) - md = f.read().decode('utf-8') - md = md.split('\n', 1)[-1].strip() - md = '#' + md - return Markdown(md) - - -def psource(*functions): - """Print the source code for the given function(s).""" - source_code = '\n\n'.join(getsource(fn) for fn in functions) - try: - from pygments.formatters import HtmlFormatter - from pygments.lexers import PythonLexer - from pygments import highlight - - display(HTML(highlight(source_code, PythonLexer(), HtmlFormatter(full=True)))) - - except ImportError: - print(source_code) - - -# ______________________________________________________________________________ -# Iris Visualization - - -def show_iris(i=0, j=1, k=2): - """Plots the iris dataset in a 3D plot. - The three axes are given by i, j and k, - which correspond to three of the four iris features.""" - - plt.rcParams.update(plt.rcParamsDefault) - - fig = plt.figure() - ax = fig.add_subplot(111, projection='3d') - - iris = DataSet(name="iris") - buckets = iris.split_values_by_classes() - - features = ["Sepal Length", "Sepal Width", "Petal Length", "Petal Width"] - f1, f2, f3 = features[i], features[j], features[k] - - a_setosa = [v[i] for v in buckets["setosa"]] - b_setosa = [v[j] for v in buckets["setosa"]] - c_setosa = [v[k] for v in buckets["setosa"]] - - a_virginica = [v[i] for v in buckets["virginica"]] - b_virginica = [v[j] for v in buckets["virginica"]] - c_virginica = [v[k] for v in buckets["virginica"]] - - a_versicolor = [v[i] for v in buckets["versicolor"]] - b_versicolor = [v[j] for v in buckets["versicolor"]] - c_versicolor = [v[k] for v in buckets["versicolor"]] - - for c, m, sl, sw, pl in [('b', 's', a_setosa, b_setosa, c_setosa), - ('g', '^', a_virginica, b_virginica, c_virginica), - ('r', 'o', a_versicolor, b_versicolor, c_versicolor)]: - ax.scatter(sl, sw, pl, c=c, marker=m) - - ax.set_xlabel(f1) - ax.set_ylabel(f2) - ax.set_zlabel(f3) - - plt.show() - - -# ______________________________________________________________________________ -# MNIST - - -def load_MNIST(path="aima-data/MNIST/Digits", fashion=False): - import os, struct - import array - import numpy as np - - if fashion: - path = "aima-data/MNIST/Fashion" - - plt.rcParams.update(plt.rcParamsDefault) - plt.rcParams['figure.figsize'] = (10.0, 8.0) - plt.rcParams['image.interpolation'] = 'nearest' - plt.rcParams['image.cmap'] = 'gray' - - train_img_file = open(os.path.join(path, "train-images-idx3-ubyte"), "rb") - train_lbl_file = open(os.path.join(path, "train-labels-idx1-ubyte"), "rb") - test_img_file = open(os.path.join(path, "t10k-images-idx3-ubyte"), "rb") - test_lbl_file = open(os.path.join(path, 't10k-labels-idx1-ubyte'), "rb") - - magic_nr, tr_size, tr_rows, tr_cols = struct.unpack(">IIII", train_img_file.read(16)) - tr_img = array.array("B", train_img_file.read()) - train_img_file.close() - magic_nr, tr_size = struct.unpack(">II", train_lbl_file.read(8)) - tr_lbl = array.array("b", train_lbl_file.read()) - train_lbl_file.close() - - magic_nr, te_size, te_rows, te_cols = struct.unpack(">IIII", test_img_file.read(16)) - te_img = array.array("B", test_img_file.read()) - test_img_file.close() - magic_nr, te_size = struct.unpack(">II", test_lbl_file.read(8)) - te_lbl = array.array("b", test_lbl_file.read()) - test_lbl_file.close() - - # print(len(tr_img), len(tr_lbl), tr_size) - # print(len(te_img), len(te_lbl), te_size) - - train_img = np.zeros((tr_size, tr_rows * tr_cols), dtype=np.int16) - train_lbl = np.zeros((tr_size,), dtype=np.int8) - for i in range(tr_size): - train_img[i] = np.array(tr_img[i * tr_rows * tr_cols: (i + 1) * tr_rows * tr_cols]).reshape((tr_rows * te_cols)) - train_lbl[i] = tr_lbl[i] - - test_img = np.zeros((te_size, te_rows * te_cols), dtype=np.int16) - test_lbl = np.zeros((te_size,), dtype=np.int8) - for i in range(te_size): - test_img[i] = np.array(te_img[i * te_rows * te_cols: (i + 1) * te_rows * te_cols]).reshape((te_rows * te_cols)) - test_lbl[i] = te_lbl[i] - - return (train_img, train_lbl, test_img, test_lbl) - - -digit_classes = [str(i) for i in range(10)] -fashion_classes = ["T-shirt/top", "Trouser", "Pullover", "Dress", "Coat", - "Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot"] - - -def show_MNIST(labels, images, samples=8, fashion=False): - if not fashion: - classes = digit_classes - else: - classes = fashion_classes - - num_classes = len(classes) - - for y, cls in enumerate(classes): - idxs = np.nonzero([i == y for i in labels]) - idxs = np.random.choice(idxs[0], samples, replace=False) - for i, idx in enumerate(idxs): - plt_idx = i * num_classes + y + 1 - plt.subplot(samples, num_classes, plt_idx) - plt.imshow(images[idx].reshape((28, 28))) - plt.axis("off") - if i == 0: - plt.title(cls) - - plt.show() - - -def show_ave_MNIST(labels, images, fashion=False): - if not fashion: - item_type = "Digit" - classes = digit_classes - else: - item_type = "Apparel" - classes = fashion_classes - - num_classes = len(classes) - - for y, cls in enumerate(classes): - idxs = np.nonzero([i == y for i in labels]) - print(item_type, y, ":", len(idxs[0]), "images.") - - ave_img = np.mean(np.vstack([images[i] for i in idxs[0]]), axis=0) - # print(ave_img.shape) - - plt.subplot(1, num_classes, y + 1) - plt.imshow(ave_img.reshape((28, 28))) - plt.axis("off") - plt.title(cls) - - plt.show() - - -# ______________________________________________________________________________ -# MDP - - -def make_plot_grid_step_function(columns, rows, U_over_time): - """ipywidgets interactive function supports single parameter as input. - This function creates and return such a function by taking as input - other parameters.""" - - def plot_grid_step(iteration): - data = U_over_time[iteration] - data = defaultdict(lambda: 0, data) - grid = [] - for row in range(rows): - current_row = [] - for column in range(columns): - current_row.append(data[(column, row)]) - grid.append(current_row) - grid.reverse() # output like book - fig = plt.imshow(grid, cmap=plt.cm.bwr, interpolation='nearest') - - plt.axis('off') - fig.axes.get_xaxis().set_visible(False) - fig.axes.get_yaxis().set_visible(False) - - for col in range(len(grid)): - for row in range(len(grid[0])): - magic = grid[col][row] - fig.axes.text(row, col, "{0:.2f}".format(magic), va='center', ha='center') - - plt.show() - - return plot_grid_step - - -def make_visualize(slider): - """Takes an input a sliderand returns callback function - for timer and animation.""" - - def visualize_callback(visualize, time_step): - if visualize is True: - for i in range(slider.min, slider.max + 1): - slider.value = i - time.sleep(float(time_step)) - - return visualize_callback - - -# ______________________________________________________________________________ - - -_canvas = """ - -
- -
- - -""" # noqa - - -class Canvas: - """Inherit from this class to manage the HTML canvas element in jupyter notebooks. - To create an object of this class any_name_xyz = Canvas("any_name_xyz") - The first argument given must be the name of the object being created. - IPython must be able to reference the variable name that is being passed.""" - - def __init__(self, varname, width=800, height=600, cid=None): - self.name = varname - self.cid = cid or varname - self.width = width - self.height = height - self.html = _canvas.format(self.cid, self.width, self.height, self.name) - self.exec_list = [] - display_html(self.html) - - def mouse_click(self, x, y): - """Override this method to handle mouse click at position (x, y)""" - raise NotImplementedError - - def mouse_move(self, x, y): - raise NotImplementedError - - def execute(self, exec_str): - """Stores the command to be executed to a list which is used later during update()""" - if not isinstance(exec_str, str): - print("Invalid execution argument:", exec_str) - self.alert("Received invalid execution command format") - prefix = "{0}_canvas_object.".format(self.cid) - self.exec_list.append(prefix + exec_str + ';') - - def fill(self, r, g, b): - """Changes the fill color to a color in rgb format""" - self.execute("fill({0}, {1}, {2})".format(r, g, b)) - - def stroke(self, r, g, b): - """Changes the colors of line/strokes to rgb""" - self.execute("stroke({0}, {1}, {2})".format(r, g, b)) - - def strokeWidth(self, w): - """Changes the width of lines/strokes to 'w' pixels""" - self.execute("strokeWidth({0})".format(w)) - - def rect(self, x, y, w, h): - """Draw a rectangle with 'w' width, 'h' height and (x, y) as the top-left corner""" - self.execute("rect({0}, {1}, {2}, {3})".format(x, y, w, h)) - - def rect_n(self, xn, yn, wn, hn): - """Similar to rect(), but the dimensions are normalized to fall between 0 and 1""" - x = round(xn * self.width) - y = round(yn * self.height) - w = round(wn * self.width) - h = round(hn * self.height) - self.rect(x, y, w, h) - - def line(self, x1, y1, x2, y2): - """Draw a line from (x1, y1) to (x2, y2)""" - self.execute("line({0}, {1}, {2}, {3})".format(x1, y1, x2, y2)) - - def line_n(self, x1n, y1n, x2n, y2n): - """Similar to line(), but the dimensions are normalized to fall between 0 and 1""" - x1 = round(x1n * self.width) - y1 = round(y1n * self.height) - x2 = round(x2n * self.width) - y2 = round(y2n * self.height) - self.line(x1, y1, x2, y2) - - def arc(self, x, y, r, start, stop): - """Draw an arc with (x, y) as centre, 'r' as radius from angles 'start' to 'stop'""" - self.execute("arc({0}, {1}, {2}, {3}, {4})".format(x, y, r, start, stop)) - - def arc_n(self, xn, yn, rn, start, stop): - """Similar to arc(), but the dimensions are normalized to fall between 0 and 1 - The normalizing factor for radius is selected between width and height by - seeing which is smaller.""" - x = round(xn * self.width) - y = round(yn * self.height) - r = round(rn * min(self.width, self.height)) - self.arc(x, y, r, start, stop) - - def clear(self): - """Clear the HTML canvas""" - self.execute("clear()") - - def font(self, font): - """Changes the font of text""" - self.execute('font("{0}")'.format(font)) - - def text(self, txt, x, y, fill=True): - """Display a text at (x, y)""" - if fill: - self.execute('fill_text("{0}", {1}, {2})'.format(txt, x, y)) - else: - self.execute('stroke_text("{0}", {1}, {2})'.format(txt, x, y)) - - def text_n(self, txt, xn, yn, fill=True): - """Similar to text(), but with normalized coordinates""" - x = round(xn * self.width) - y = round(yn * self.height) - self.text(txt, x, y, fill) - - def alert(self, message): - """Immediately display an alert""" - display_html(''.format(message)) - - def update(self): - """Execute the JS code to execute the commands queued by execute()""" - exec_code = "" - self.exec_list = [] - display_html(exec_code) - - -def display_html(html_string): - display(HTML(html_string)) - - -################################################################################ - - -class Canvas_TicTacToe(Canvas): - """Play a 3x3 TicTacToe game on HTML canvas""" - - def __init__(self, varname, player_1='human', player_2='random', - width=300, height=350, cid=None): - valid_players = ('human', 'random', 'alpha_beta') - if player_1 not in valid_players or player_2 not in valid_players: - raise TypeError("Players must be one of {}".format(valid_players)) - super().__init__(varname, width, height, cid) - self.ttt = TicTacToe() - self.state = self.ttt.initial - self.turn = 0 - self.strokeWidth(5) - self.players = (player_1, player_2) - self.font("20px Arial") - self.draw_board() - - def mouse_click(self, x, y): - player = self.players[self.turn] - if self.ttt.terminal_test(self.state): - if 0.55 <= x / self.width <= 0.95 and 6 / 7 <= y / self.height <= 6 / 7 + 1 / 8: - self.state = self.ttt.initial - self.turn = 0 - self.draw_board() - return - - if player == 'human': - x, y = int(3 * x / self.width) + 1, int(3 * y / (self.height * 6 / 7)) + 1 - if (x, y) not in self.ttt.actions(self.state): - # Invalid move - return - move = (x, y) - elif player == 'alpha_beta': - move = alpha_beta_player(self.ttt, self.state) - else: - move = random_player(self.ttt, self.state) - self.state = self.ttt.result(self.state, move) - self.turn ^= 1 - self.draw_board() - - def draw_board(self): - self.clear() - self.stroke(0, 0, 0) - offset = 1 / 20 - self.line_n(0 + offset, (1 / 3) * 6 / 7, 1 - offset, (1 / 3) * 6 / 7) - self.line_n(0 + offset, (2 / 3) * 6 / 7, 1 - offset, (2 / 3) * 6 / 7) - self.line_n(1 / 3, (0 + offset) * 6 / 7, 1 / 3, (1 - offset) * 6 / 7) - self.line_n(2 / 3, (0 + offset) * 6 / 7, 2 / 3, (1 - offset) * 6 / 7) - - board = self.state.board - for mark in board: - if board[mark] == 'X': - self.draw_x(mark) - elif board[mark] == 'O': - self.draw_o(mark) - if self.ttt.terminal_test(self.state): - # End game message - utility = self.ttt.utility(self.state, self.ttt.to_move(self.ttt.initial)) - if utility == 0: - self.text_n('Game Draw!', offset, 6 / 7 + offset) - else: - self.text_n('Player {} wins!'.format("XO"[utility < 0]), offset, 6 / 7 + offset) - # Find the 3 and draw a line - self.stroke([255, 0][self.turn], [0, 255][self.turn], 0) - for i in range(3): - if all([(i + 1, j + 1) in self.state.board for j in range(3)]) and \ - len({self.state.board[(i + 1, j + 1)] for j in range(3)}) == 1: - self.line_n(i / 3 + 1 / 6, offset * 6 / 7, i / 3 + 1 / 6, (1 - offset) * 6 / 7) - if all([(j + 1, i + 1) in self.state.board for j in range(3)]) and \ - len({self.state.board[(j + 1, i + 1)] for j in range(3)}) == 1: - self.line_n(offset, (i / 3 + 1 / 6) * 6 / 7, 1 - offset, (i / 3 + 1 / 6) * 6 / 7) - if all([(i + 1, i + 1) in self.state.board for i in range(3)]) and \ - len({self.state.board[(i + 1, i + 1)] for i in range(3)}) == 1: - self.line_n(offset, offset * 6 / 7, 1 - offset, (1 - offset) * 6 / 7) - if all([(i + 1, 3 - i) in self.state.board for i in range(3)]) and \ - len({self.state.board[(i + 1, 3 - i)] for i in range(3)}) == 1: - self.line_n(offset, (1 - offset) * 6 / 7, 1 - offset, offset * 6 / 7) - # restart button - self.fill(0, 0, 255) - self.rect_n(0.5 + offset, 6 / 7, 0.4, 1 / 8) - self.fill(0, 0, 0) - self.text_n('Restart', 0.5 + 2 * offset, 13 / 14) - else: # Print which player's turn it is - self.text_n("Player {}'s move({})".format("XO"[self.turn], self.players[self.turn]), - offset, 6 / 7 + offset) - - self.update() - - def draw_x(self, position): - self.stroke(0, 255, 0) - x, y = [i - 1 for i in position] - offset = 1 / 15 - self.line_n(x / 3 + offset, (y / 3 + offset) * 6 / 7, x / 3 + 1 / 3 - offset, (y / 3 + 1 / 3 - offset) * 6 / 7) - self.line_n(x / 3 + 1 / 3 - offset, (y / 3 + offset) * 6 / 7, x / 3 + offset, (y / 3 + 1 / 3 - offset) * 6 / 7) - - def draw_o(self, position): - self.stroke(255, 0, 0) - x, y = [i - 1 for i in position] - self.arc_n(x / 3 + 1 / 6, (y / 3 + 1 / 6) * 6 / 7, 1 / 9, 0, 360) - - -class Canvas_min_max(Canvas): - """MinMax for Fig52Extended on HTML canvas""" - - def __init__(self, varname, util_list, width=800, height=600, cid=None): - super.__init__(varname, width, height, cid) - self.utils = {node: util for node, util in zip(range(13, 40), util_list)} - self.game = Fig52Extended() - self.game.utils = self.utils - self.nodes = list(range(40)) - self.l = 1 / 40 - self.node_pos = {} - for i in range(4): - base = len(self.node_pos) - row_size = 3 ** i - for node in [base + j for j in range(row_size)]: - self.node_pos[node] = ((node - base) / row_size + 1 / (2 * row_size) - self.l / 2, - self.l / 2 + (self.l + (1 - 5 * self.l) / 3) * i) - self.font("12px Arial") - self.node_stack = [] - self.explored = {node for node in self.utils} - self.thick_lines = set() - self.change_list = [] - self.draw_graph() - self.stack_manager = self.stack_manager_gen() - - def min_max(self, node): - game = self.game - player = game.to_move(node) - - def max_value(node): - if game.terminal_test(node): - return game.utility(node, player) - self.change_list.append(('a', node)) - self.change_list.append(('h',)) - max_a = max(game.actions(node), key=lambda x: min_value(game.result(node, x))) - max_node = game.result(node, max_a) - self.utils[node] = self.utils[max_node] - x1, y1 = self.node_pos[node] - x2, y2 = self.node_pos[max_node] - self.change_list.append(('l', (node, max_node - 3 * node - 1))) - self.change_list.append(('e', node)) - self.change_list.append(('p',)) - self.change_list.append(('h',)) - return self.utils[node] - - def min_value(node): - if game.terminal_test(node): - return game.utility(node, player) - self.change_list.append(('a', node)) - self.change_list.append(('h',)) - min_a = min(game.actions(node), key=lambda x: max_value(game.result(node, x))) - min_node = game.result(node, min_a) - self.utils[node] = self.utils[min_node] - x1, y1 = self.node_pos[node] - x2, y2 = self.node_pos[min_node] - self.change_list.append(('l', (node, min_node - 3 * node - 1))) - self.change_list.append(('e', node)) - self.change_list.append(('p',)) - self.change_list.append(('h',)) - return self.utils[node] - - return max_value(node) - - def stack_manager_gen(self): - self.min_max(0) - for change in self.change_list: - if change[0] == 'a': - self.node_stack.append(change[1]) - elif change[0] == 'e': - self.explored.add(change[1]) - elif change[0] == 'h': - yield - elif change[0] == 'l': - self.thick_lines.add(change[1]) - elif change[0] == 'p': - self.node_stack.pop() - - def mouse_click(self, x, y): - try: - self.stack_manager.send(None) - except StopIteration: - pass - self.draw_graph() - - def draw_graph(self): - self.clear() - # draw nodes - self.stroke(0, 0, 0) - self.strokeWidth(1) - # highlight for nodes in stack - for node in self.node_stack: - x, y = self.node_pos[node] - self.fill(200, 200, 0) - self.rect_n(x - self.l / 5, y - self.l / 5, self.l * 7 / 5, self.l * 7 / 5) - for node in self.nodes: - x, y = self.node_pos[node] - if node in self.explored: - self.fill(255, 255, 255) - else: - self.fill(200, 200, 200) - self.rect_n(x, y, self.l, self.l) - self.line_n(x, y, x + self.l, y) - self.line_n(x, y, x, y + self.l) - self.line_n(x + self.l, y + self.l, x + self.l, y) - self.line_n(x + self.l, y + self.l, x, y + self.l) - self.fill(0, 0, 0) - if node in self.explored: - self.text_n(self.utils[node], x + self.l / 10, y + self.l * 9 / 10) - # draw edges - for i in range(13): - x1, y1 = self.node_pos[i][0] + self.l / 2, self.node_pos[i][1] + self.l - for j in range(3): - x2, y2 = self.node_pos[i * 3 + j + 1][0] + self.l / 2, self.node_pos[i * 3 + j + 1][1] - if i in [1, 2, 3]: - self.stroke(200, 0, 0) - else: - self.stroke(0, 200, 0) - if (i, j) in self.thick_lines: - self.strokeWidth(3) - else: - self.strokeWidth(1) - self.line_n(x1, y1, x2, y2) - self.update() - - -class Canvas_alpha_beta(Canvas): - """Alpha-beta pruning for Fig52Extended on HTML canvas""" - - def __init__(self, varname, util_list, width=800, height=600, cid=None): - super().__init__(varname, width, height, cid) - self.utils = {node: util for node, util in zip(range(13, 40), util_list)} - self.game = Fig52Extended() - self.game.utils = self.utils - self.nodes = list(range(40)) - self.l = 1 / 40 - self.node_pos = {} - for i in range(4): - base = len(self.node_pos) - row_size = 3 ** i - for node in [base + j for j in range(row_size)]: - self.node_pos[node] = ((node - base) / row_size + 1 / (2 * row_size) - self.l / 2, - 3 * self.l / 2 + (self.l + (1 - 6 * self.l) / 3) * i) - self.font("12px Arial") - self.node_stack = [] - self.explored = {node for node in self.utils} - self.pruned = set() - self.ab = {} - self.thick_lines = set() - self.change_list = [] - self.draw_graph() - self.stack_manager = self.stack_manager_gen() - - def alpha_beta_search(self, node): - game = self.game - player = game.to_move(node) - - # Functions used by alpha_beta - def max_value(node, alpha, beta): - if game.terminal_test(node): - self.change_list.append(('a', node)) - self.change_list.append(('h',)) - self.change_list.append(('p',)) - return game.utility(node, player) - v = -np.inf - self.change_list.append(('a', node)) - self.change_list.append(('ab', node, v, beta)) - self.change_list.append(('h',)) - for a in game.actions(node): - min_val = min_value(game.result(node, a), alpha, beta) - if v < min_val: - v = min_val - max_node = game.result(node, a) - self.change_list.append(('ab', node, v, beta)) - if v >= beta: - self.change_list.append(('h',)) - self.pruned.add(node) - break - alpha = max(alpha, v) - self.utils[node] = v - if node not in self.pruned: - self.change_list.append(('l', (node, max_node - 3 * node - 1))) - self.change_list.append(('e', node)) - self.change_list.append(('p',)) - self.change_list.append(('h',)) - return v - - def min_value(node, alpha, beta): - if game.terminal_test(node): - self.change_list.append(('a', node)) - self.change_list.append(('h',)) - self.change_list.append(('p',)) - return game.utility(node, player) - v = np.inf - self.change_list.append(('a', node)) - self.change_list.append(('ab', node, alpha, v)) - self.change_list.append(('h',)) - for a in game.actions(node): - max_val = max_value(game.result(node, a), alpha, beta) - if v > max_val: - v = max_val - min_node = game.result(node, a) - self.change_list.append(('ab', node, alpha, v)) - if v <= alpha: - self.change_list.append(('h',)) - self.pruned.add(node) - break - beta = min(beta, v) - self.utils[node] = v - if node not in self.pruned: - self.change_list.append(('l', (node, min_node - 3 * node - 1))) - self.change_list.append(('e', node)) - self.change_list.append(('p',)) - self.change_list.append(('h',)) - return v - - return max_value(node, -np.inf, np.inf) - - def stack_manager_gen(self): - self.alpha_beta_search(0) - for change in self.change_list: - if change[0] == 'a': - self.node_stack.append(change[1]) - elif change[0] == 'ab': - self.ab[change[1]] = change[2:] - elif change[0] == 'e': - self.explored.add(change[1]) - elif change[0] == 'h': - yield - elif change[0] == 'l': - self.thick_lines.add(change[1]) - elif change[0] == 'p': - self.node_stack.pop() - - def mouse_click(self, x, y): - try: - self.stack_manager.send(None) - except StopIteration: - pass - self.draw_graph() - - def draw_graph(self): - self.clear() - # draw nodes - self.stroke(0, 0, 0) - self.strokeWidth(1) - # highlight for nodes in stack - for node in self.node_stack: - x, y = self.node_pos[node] - # alpha > beta - if node not in self.explored and self.ab[node][0] > self.ab[node][1]: - self.fill(200, 100, 100) - else: - self.fill(200, 200, 0) - self.rect_n(x - self.l / 5, y - self.l / 5, self.l * 7 / 5, self.l * 7 / 5) - for node in self.nodes: - x, y = self.node_pos[node] - if node in self.explored: - if node in self.pruned: - self.fill(50, 50, 50) - else: - self.fill(255, 255, 255) - else: - self.fill(200, 200, 200) - self.rect_n(x, y, self.l, self.l) - self.line_n(x, y, x + self.l, y) - self.line_n(x, y, x, y + self.l) - self.line_n(x + self.l, y + self.l, x + self.l, y) - self.line_n(x + self.l, y + self.l, x, y + self.l) - self.fill(0, 0, 0) - if node in self.explored and node not in self.pruned: - self.text_n(self.utils[node], x + self.l / 10, y + self.l * 9 / 10) - # draw edges - for i in range(13): - x1, y1 = self.node_pos[i][0] + self.l / 2, self.node_pos[i][1] + self.l - for j in range(3): - x2, y2 = self.node_pos[i * 3 + j + 1][0] + self.l / 2, self.node_pos[i * 3 + j + 1][1] - if i in [1, 2, 3]: - self.stroke(200, 0, 0) - else: - self.stroke(0, 200, 0) - if (i, j) in self.thick_lines: - self.strokeWidth(3) - else: - self.strokeWidth(1) - self.line_n(x1, y1, x2, y2) - # display alpha and beta - for node in self.node_stack: - if node not in self.explored: - x, y = self.node_pos[node] - alpha, beta = self.ab[node] - self.text_n(alpha, x - self.l / 2, y - self.l / 10) - self.text_n(beta, x + self.l, y - self.l / 10) - self.update() - - -class Canvas_fol_bc_ask(Canvas): - """fol_bc_ask() on HTML canvas""" - - def __init__(self, varname, kb, query, width=800, height=600, cid=None): - super().__init__(varname, width, height, cid) - self.kb = kb - self.query = query - self.l = 1 / 20 - self.b = 3 * self.l - bc_out = list(self.fol_bc_ask()) - if len(bc_out) == 0: - self.valid = False - else: - self.valid = True - graph = bc_out[0][0][0] - s = bc_out[0][1] - while True: - new_graph = subst(s, graph) - if graph == new_graph: - break - graph = new_graph - self.make_table(graph) - self.context = None - self.draw_table() - - def fol_bc_ask(self): - KB = self.kb - query = self.query - - def fol_bc_or(KB, goal, theta): - for rule in KB.fetch_rules_for_goal(goal): - lhs, rhs = parse_definite_clause(standardize_variables(rule)) - for theta1 in fol_bc_and(KB, lhs, unify_mm(rhs, goal, theta)): - yield ([(goal, theta1[0])], theta1[1]) - - def fol_bc_and(KB, goals, theta): - if theta is None: - pass - elif not goals: - yield ([], theta) - else: - first, rest = goals[0], goals[1:] - for theta1 in fol_bc_or(KB, subst(theta, first), theta): - for theta2 in fol_bc_and(KB, rest, theta1[1]): - yield (theta1[0] + theta2[0], theta2[1]) - - return fol_bc_or(KB, query, {}) - - def make_table(self, graph): - table = [] - pos = {} - links = set() - edges = set() - - def dfs(node, depth): - if len(table) <= depth: - table.append([]) - pos = len(table[depth]) - table[depth].append(node[0]) - for child in node[1]: - child_id = dfs(child, depth + 1) - links.add(((depth, pos), child_id)) - return (depth, pos) - - dfs(graph, 0) - y_off = 0.85 / len(table) - for i, row in enumerate(table): - x_off = 0.95 / len(row) - for j, node in enumerate(row): - pos[(i, j)] = (0.025 + j * x_off + (x_off - self.b) / 2, 0.025 + i * y_off + (y_off - self.l) / 2) - for p, c in links: - x1, y1 = pos[p] - x2, y2 = pos[c] - edges.add((x1 + self.b / 2, y1 + self.l, x2 + self.b / 2, y2)) - - self.table = table - self.pos = pos - self.edges = edges - - def mouse_click(self, x, y): - x, y = x / self.width, y / self.height - for node in self.pos: - xs, ys = self.pos[node] - xe, ye = xs + self.b, ys + self.l - if xs <= x <= xe and ys <= y <= ye: - self.context = node - break - self.draw_table() - - def draw_table(self): - self.clear() - self.strokeWidth(3) - self.stroke(0, 0, 0) - self.font("12px Arial") - if self.valid: - # draw nodes - for i, j in self.pos: - x, y = self.pos[(i, j)] - self.fill(200, 200, 200) - self.rect_n(x, y, self.b, self.l) - self.line_n(x, y, x + self.b, y) - self.line_n(x, y, x, y + self.l) - self.line_n(x + self.b, y, x + self.b, y + self.l) - self.line_n(x, y + self.l, x + self.b, y + self.l) - self.fill(0, 0, 0) - self.text_n(self.table[i][j], x + 0.01, y + self.l - 0.01) - # draw edges - for x1, y1, x2, y2 in self.edges: - self.line_n(x1, y1, x2, y2) - else: - self.fill(255, 0, 0) - self.rect_n(0, 0, 1, 1) - # text area - self.fill(255, 255, 255) - self.rect_n(0, 0.9, 1, 0.1) - self.strokeWidth(5) - self.stroke(0, 0, 0) - self.line_n(0, 0.9, 1, 0.9) - self.font("22px Arial") - self.fill(0, 0, 0) - self.text_n(self.table[self.context[0]][self.context[1]] if self.context else "Click for text", 0.025, 0.975) - self.update() - - -############################################################################################################ - -##################### Functions to assist plotting in search.ipynb #################### - -############################################################################################################ - - -def show_map(graph_data, node_colors=None): - G = nx.Graph(graph_data['graph_dict']) - node_colors = node_colors or graph_data['node_colors'] - node_positions = graph_data['node_positions'] - node_label_pos = graph_data['node_label_positions'] - edge_weights = graph_data['edge_weights'] - - # set the size of the plot - plt.figure(figsize=(18, 13)) - # draw the graph (both nodes and edges) with locations from romania_locations - nx.draw(G, pos={k: node_positions[k] for k in G.nodes()}, - node_color=[node_colors[node] for node in G.nodes()], linewidths=0.3, edgecolors='k') - - # draw labels for nodes - node_label_handles = nx.draw_networkx_labels(G, pos=node_label_pos, font_size=14) - - # add a white bounding box behind the node labels - [label.set_bbox(dict(facecolor='white', edgecolor='none')) for label in node_label_handles.values()] - - # add edge lables to the graph - nx.draw_networkx_edge_labels(G, pos=node_positions, edge_labels=edge_weights, font_size=14) - - # add a legend - white_circle = lines.Line2D([], [], color="white", marker='o', markersize=15, markerfacecolor="white") - orange_circle = lines.Line2D([], [], color="orange", marker='o', markersize=15, markerfacecolor="orange") - red_circle = lines.Line2D([], [], color="red", marker='o', markersize=15, markerfacecolor="red") - gray_circle = lines.Line2D([], [], color="gray", marker='o', markersize=15, markerfacecolor="gray") - green_circle = lines.Line2D([], [], color="green", marker='o', markersize=15, markerfacecolor="green") - plt.legend((white_circle, orange_circle, red_circle, gray_circle, green_circle), - ('Un-explored', 'Frontier', 'Currently Exploring', 'Explored', 'Final Solution'), - numpoints=1, prop={'size': 16}, loc=(.8, .75)) - - # show the plot. No need to use in notebooks. nx.draw will show the graph itself. - plt.show() - - -# helper functions for visualisations - -def final_path_colors(initial_node_colors, problem, solution): - "Return a node_colors dict of the final path provided the problem and solution." - - # get initial node colors - final_colors = dict(initial_node_colors) - # color all the nodes in solution and starting node to green - final_colors[problem.initial] = "green" - for node in solution: - final_colors[node] = "green" - return final_colors - - -def display_visual(graph_data, user_input, algorithm=None, problem=None): - initial_node_colors = graph_data['node_colors'] - if user_input is False: - def slider_callback(iteration): - # don't show graph for the first time running the cell calling this function - try: - show_map(graph_data, node_colors=all_node_colors[iteration]) - except: - pass - - def visualize_callback(visualize): - if visualize is True: - button.value = False - - global all_node_colors - - iterations, all_node_colors, node = algorithm(problem) - solution = node.solution() - all_node_colors.append(final_path_colors(all_node_colors[0], problem, solution)) - - slider.max = len(all_node_colors) - 1 - - for i in range(slider.max + 1): - slider.value = i - # time.sleep(.5) - - slider = widgets.IntSlider(min=0, max=1, step=1, value=0) - slider_visual = widgets.interactive(slider_callback, iteration=slider) - display(slider_visual) - - button = widgets.ToggleButton(value=False) - button_visual = widgets.interactive(visualize_callback, visualize=button) - display(button_visual) - - if user_input is True: - node_colors = dict(initial_node_colors) - if isinstance(algorithm, dict): - assert set(algorithm.keys()).issubset({"Breadth First Tree Search", - "Depth First Tree Search", - "Breadth First Search", - "Depth First Graph Search", - "Best First Graph Search", - "Uniform Cost Search", - "Depth Limited Search", - "Iterative Deepening Search", - "Greedy Best First Search", - "A-star Search", - "Recursive Best First Search"}) - - algo_dropdown = widgets.Dropdown(description="Search algorithm: ", - options=sorted(list(algorithm.keys())), - value="Breadth First Tree Search") - display(algo_dropdown) - elif algorithm is None: - print("No algorithm to run.") - return 0 - - def slider_callback(iteration): - # don't show graph for the first time running the cell calling this function - try: - show_map(graph_data, node_colors=all_node_colors[iteration]) - except: - pass - - def visualize_callback(visualize): - if visualize is True: - button.value = False - - problem = GraphProblem(start_dropdown.value, end_dropdown.value, romania_map) - global all_node_colors - - user_algorithm = algorithm[algo_dropdown.value] - - iterations, all_node_colors, node = user_algorithm(problem) - solution = node.solution() - all_node_colors.append(final_path_colors(all_node_colors[0], problem, solution)) - - slider.max = len(all_node_colors) - 1 - - for i in range(slider.max + 1): - slider.value = i - # time.sleep(.5) - - start_dropdown = widgets.Dropdown(description="Start city: ", - options=sorted(list(node_colors.keys())), value="Arad") - display(start_dropdown) - - end_dropdown = widgets.Dropdown(description="Goal city: ", - options=sorted(list(node_colors.keys())), value="Fagaras") - display(end_dropdown) - - button = widgets.ToggleButton(value=False) - button_visual = widgets.interactive(visualize_callback, visualize=button) - display(button_visual) - - slider = widgets.IntSlider(min=0, max=1, step=1, value=0) - slider_visual = widgets.interactive(slider_callback, iteration=slider) - display(slider_visual) - - -# Function to plot NQueensCSP in csp.py and NQueensProblem in search.py -def plot_NQueens(solution): - n = len(solution) - board = np.array([2 * int((i + j) % 2) for j in range(n) for i in range(n)]).reshape((n, n)) - im = Image.open('images/queen_s.png') - height = im.size[1] - im = np.array(im).astype(np.float) / 255 - fig = plt.figure(figsize=(7, 7)) - ax = fig.add_subplot(111) - ax.set_title('{} Queens'.format(n)) - plt.imshow(board, cmap='binary', interpolation='nearest') - # NQueensCSP gives a solution as a dictionary - if isinstance(solution, dict): - for (k, v) in solution.items(): - newax = fig.add_axes([0.064 + (k * 0.112), 0.062 + ((7 - v) * 0.112), 0.1, 0.1], zorder=1) - newax.imshow(im) - newax.axis('off') - # NQueensProblem gives a solution as a list - elif isinstance(solution, list): - for (k, v) in enumerate(solution): - newax = fig.add_axes([0.064 + (k * 0.112), 0.062 + ((7 - v) * 0.112), 0.1, 0.1], zorder=1) - newax.imshow(im) - newax.axis('off') - fig.tight_layout() - plt.show() - - -# Function to plot a heatmap, given a grid -def heatmap(grid, cmap='binary', interpolation='nearest'): - fig = plt.figure(figsize=(7, 7)) - ax = fig.add_subplot(111) - ax.set_title('Heatmap') - plt.imshow(grid, cmap=cmap, interpolation=interpolation) - fig.tight_layout() - plt.show() - - -# Generates a gaussian kernel -def gaussian_kernel(l=5, sig=1.0): - ax = np.arange(-l // 2 + 1., l // 2 + 1.) - xx, yy = np.meshgrid(ax, ax) - kernel = np.exp(-(xx ** 2 + yy ** 2) / (2. * sig ** 2)) - return kernel - - -# Plots utility function for a POMDP -def plot_pomdp_utility(utility): - save = utility['0'][0] - delete = utility['1'][0] - ask_save = utility['2'][0] - ask_delete = utility['2'][-1] - left = (save[0] - ask_save[0]) / (save[0] - ask_save[0] + ask_save[1] - save[1]) - right = (delete[0] - ask_delete[0]) / (delete[0] - ask_delete[0] + ask_delete[1] - delete[1]) - - colors = ['g', 'b', 'k'] - for action in utility: - for value in utility[action]: - plt.plot(value, color=colors[int(action)]) - plt.vlines([left, right], -20, 10, linestyles='dashed', colors='c') - plt.ylim(-20, 13) - plt.xlim(0, 1) - plt.text(left / 2 - 0.05, 10, 'Save') - plt.text((right + left) / 2 - 0.02, 10, 'Ask') - plt.text((right + 1) / 2 - 0.07, 10, 'Delete') - plt.show() diff --git a/agents.ipynb b/notebooks/agents.ipynb similarity index 98% rename from agents.ipynb rename to notebooks/agents.ipynb index 636df75e3..6cff727ff 100644 --- a/agents.ipynb +++ b/notebooks/agents.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -15,8 +24,8 @@ "metadata": {}, "outputs": [], "source": [ - "from agents import *\n", - "from notebook import psource" + "from aima.agents import *\n", + "from aima.notebook_utils import psource" ] }, { @@ -96,7 +105,7 @@ "\n", "* `is_done(self)`: Returns true if the objective of the agent and the environment has been completed\n", "\n", - "The next two functions must be implemented by each subclasses of `Environment` for the agent to recieve percepts and execute actions \n", + "The next two functions must be implemented by each subclasses of `Environment` for the agent to receive percepts and execute actions \n", "\n", "* `percept(self, agent)`: Given an agent, this method returns a list of percepts that the agent sees at the current time\n", "\n", @@ -659,7 +668,7 @@ "outputs": [], "source": [ "from ipythonblocks import BlockGrid\n", - "from agents import *\n", + "from aima.agents import *\n", "\n", "color = {\"Breeze\": (225, 225, 225),\n", " \"Pit\": (0,0,0),\n", diff --git a/notebooks/agents.py b/notebooks/agents.py new file mode 100644 index 000000000..3e60e729a --- /dev/null +++ b/notebooks/agents.py @@ -0,0 +1,550 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Intelligent Agents # +# +# This notebook serves as supporting material for topics covered in **Chapter 2 - Intelligent Agents** from the book *Artificial Intelligence: A Modern Approach.* This notebook uses implementations from [agents.py](https://github.com/aimacode/aima-python/blob/master/agents.py) module. Let's start by importing everything from agents module. + +# %% +from aima.agents import * +from aima.notebook_utils import psource + +# %% [markdown] +# ## CONTENTS +# +# * Overview +# * Agent +# * Environment +# * Simple Agent and Environment +# * Agents in a 2-D Environment +# * Wumpus Environment +# +# ## OVERVIEW +# +# An agent, as defined in 2.1, is anything that can perceive its environment through sensors, and act upon that environment through actuators based on its agent program. This can be a dog, a robot, or even you. As long as you can perceive the environment and act on it, you are an agent. This notebook will explain how to implement a simple agent, create an environment, and implement a program that helps the agent act on the environment based on its percepts. +# +# ## AGENT +# +# Let us now see how we define an agent. Run the next cell to see how `Agent` is defined in agents module. + +# %% +psource(Agent) + +# %% [markdown] +# The `Agent` has two methods. +# * `__init__(self, program=None)`: The constructor defines various attributes of the Agent. These include +# +# * `alive`: which keeps track of whether the agent is alive or not +# +# * `bump`: which tracks if the agent collides with an edge of the environment (for eg, a wall in a park) +# +# * `holding`: which is a list containing the `Things` an agent is holding, +# +# * `performance`: which evaluates the performance metrics of the agent +# +# * `program`: which is the agent program and maps an agent's percepts to actions in the environment. If no implementation is provided, it defaults to asking the user to provide actions for each percept. +# +# * `can_grab(self, thing)`: Is used when an environment contains things that an agent can grab and carry. By default, an agent can carry nothing. +# +# ## ENVIRONMENT +# Now, let us see how environments are defined. Running the next cell will display an implementation of the abstract `Environment` class. + +# %% +psource(Environment) + + +# %% [markdown] +# `Environment` class has lot of methods! But most of them are incredibly simple, so let's see the ones we'll be using in this notebook. +# +# * `thing_classes(self)`: Returns a static array of `Thing` sub-classes that determine what things are allowed in the environment and what aren't +# +# * `add_thing(self, thing, location=None)`: Adds a thing to the environment at location +# +# * `run(self, steps)`: Runs an environment with the agent in it for a given number of steps. +# +# * `is_done(self)`: Returns true if the objective of the agent and the environment has been completed +# +# The next two functions must be implemented by each subclasses of `Environment` for the agent to receive percepts and execute actions +# +# * `percept(self, agent)`: Given an agent, this method returns a list of percepts that the agent sees at the current time +# +# * `execute_action(self, agent, action)`: The environment reacts to an action performed by a given agent. The changes may result in agent experiencing new percepts or other elements reacting to agent input. + +# %% [markdown] +# ## SIMPLE AGENT AND ENVIRONMENT +# +# Let's begin by using the `Agent` class to creating our first agent - a blind dog. + +# %% +class BlindDog(Agent): + def eat(self, thing): + print("Dog: Ate food at {}.".format(self.location)) + + def drink(self, thing): + print("Dog: Drank water at {}.".format( self.location)) + +dog = BlindDog() + +# %% [markdown] +# What we have just done is create a dog who can only feel what's in his location (since he's blind), and can eat or drink. Let's see if he's alive... + +# %% +print(dog.alive) + + +# %% [markdown] +# ![Cool dog](https://gifgun.files.wordpress.com/2015/07/wpid-wp-1435860392895.gif) +# This is our dog. How cool is he? Well, he's hungry and needs to go search for food. For him to do this, we need to give him a program. But before that, let's create a park for our dog to play in. + +# %% [markdown] +# ### ENVIRONMENT - Park +# +# A park is an example of an environment because our dog can perceive and act upon it. The Environment class is an abstract class, so we will have to create our own subclass from it before we can use it. + +# %% +class Food(Thing): + pass + +class Water(Thing): + pass + +class Park(Environment): + def percept(self, agent): + '''return a list of things that are in our agent's location''' + things = self.list_things_at(agent.location) + return things + + def execute_action(self, agent, action): + '''changes the state of the environment based on what the agent does.''' + if action == "move down": + print('{} decided to {} at location: {}'.format(str(agent)[1:-1], action, agent.location)) + agent.movedown() + elif action == "eat": + items = self.list_things_at(agent.location, tclass=Food) + if len(items) != 0: + if agent.eat(items[0]): #Have the dog eat the first item + print('{} ate {} at location: {}' + .format(str(agent)[1:-1], str(items[0])[1:-1], agent.location)) + self.delete_thing(items[0]) #Delete it from the Park after. + elif action == "drink": + items = self.list_things_at(agent.location, tclass=Water) + if len(items) != 0: + if agent.drink(items[0]): #Have the dog drink the first item + print('{} drank {} at location: {}' + .format(str(agent)[1:-1], str(items[0])[1:-1], agent.location)) + self.delete_thing(items[0]) #Delete it from the Park after. + + def is_done(self): + '''By default, we're done when we can't find a live agent, + but to prevent killing our cute dog, we will stop before itself - when there is no more food or water''' + no_edibles = not any(isinstance(thing, Food) or isinstance(thing, Water) for thing in self.things) + dead_agents = not any(agent.is_alive() for agent in self.agents) + return dead_agents or no_edibles + + + +# %% [markdown] +# ### PROGRAM - BlindDog +# Now that we have a Park Class, we re-implement our BlindDog to be able to move down and eat food or drink water only if it is present. +# + +# %% +class BlindDog(Agent): + location = 1 + + def movedown(self): + self.location += 1 + + def eat(self, thing): + '''returns True upon success or False otherwise''' + if isinstance(thing, Food): + return True + return False + + def drink(self, thing): + ''' returns True upon success or False otherwise''' + if isinstance(thing, Water): + return True + return False + + +# %% [markdown] +# Now its time to implement a program module for our dog. A program controls how the dog acts upon its environment. Our program will be very simple, and is shown in the table below. +# +# +# +# +# +# +# +# +# +# +# +# +# +# +#
Percept: Feel Food Feel WaterFeel Nothing
Action: eatdrinkmove down
+ +# %% +def program(percepts): + '''Returns an action based on the dog's percepts''' + for p in percepts: + if isinstance(p, Food): + return 'eat' + elif isinstance(p, Water): + return 'drink' + return 'move down' + + +# %% [markdown] +# Let's now run our simulation by creating a park with some food, water, and our dog. + +# %% +park = Park() +dog = BlindDog(program) +dogfood = Food() +water = Water() +park.add_thing(dog, 1) +park.add_thing(dogfood, 5) +park.add_thing(water, 7) + +park.run(5) + +# %% [markdown] +# Notice that the dog moved from location 1 to 4, over 4 steps, and ate food at location 5 in the 5th step. +# +# Let's continue this simulation for 5 more steps. + +# %% +park.run(5) + +# %% [markdown] +# Perfect! Note how the simulation stopped after the dog drank the water - exhausting all the food and water ends our simulation, as we had defined before. Let's add some more water and see if our dog can reach it. + +# %% +park.add_thing(water, 15) +park.run(10) + + +# %% [markdown] +# Above, we learnt to implement an agent, its program, and an environment on which it acts. However, this was a very simple case. Let's try to add complexity to it by creating a 2-Dimensional environment! +# +# +# ## AGENTS IN A 2D ENVIRONMENT +# +# For us to not read so many logs of what our dog did, we add a bit of graphics while making our Park 2D. To do so, we will need to make it a subclass of GraphicEnvironment instead of Environment. Parks implemented by subclassing GraphicEnvironment class adds these extra properties to it: +# +# - Our park is indexed in the 4th quadrant of the X-Y plane. +# - Every time we create a park subclassing GraphicEnvironment, we need to define the colors of all the things we plan to put into the park. The colors are defined in typical [RGB digital 8-bit format](https://en.wikipedia.org/wiki/RGB_color_model#Numeric_representations), common across the web. +# - Fences are added automatically to all parks so that our dog does not go outside the park's boundary - it just isn't safe for blind dogs to be outside the park by themselves! GraphicEnvironment provides `is_inbounds` function to check if our dog tries to leave the park. +# +# First let us try to upgrade our 1-dimensional `Park` environment by just replacing its superclass by `GraphicEnvironment`. + +# %% +class Park2D(GraphicEnvironment): + def percept(self, agent): + '''return a list of things that are in our agent's location''' + things = self.list_things_at(agent.location) + return things + + def execute_action(self, agent, action): + '''changes the state of the environment based on what the agent does.''' + if action == "move down": + print('{} decided to {} at location: {}'.format(str(agent)[1:-1], action, agent.location)) + agent.movedown() + elif action == "eat": + items = self.list_things_at(agent.location, tclass=Food) + if len(items) != 0: + if agent.eat(items[0]): #Have the dog eat the first item + print('{} ate {} at location: {}' + .format(str(agent)[1:-1], str(items[0])[1:-1], agent.location)) + self.delete_thing(items[0]) #Delete it from the Park after. + elif action == "drink": + items = self.list_things_at(agent.location, tclass=Water) + if len(items) != 0: + if agent.drink(items[0]): #Have the dog drink the first item + print('{} drank {} at location: {}' + .format(str(agent)[1:-1], str(items[0])[1:-1], agent.location)) + self.delete_thing(items[0]) #Delete it from the Park after. + + def is_done(self): + '''By default, we're done when we can't find a live agent, + but to prevent killing our cute dog, we will stop before itself - when there is no more food or water''' + no_edibles = not any(isinstance(thing, Food) or isinstance(thing, Water) for thing in self.things) + dead_agents = not any(agent.is_alive() for agent in self.agents) + return dead_agents or no_edibles + +class BlindDog(Agent): + location = [0,1] # change location to a 2d value + direction = Direction("down") # variable to store the direction our dog is facing + + def movedown(self): + self.location[1] += 1 + + def eat(self, thing): + '''returns True upon success or False otherwise''' + if isinstance(thing, Food): + return True + return False + + def drink(self, thing): + ''' returns True upon success or False otherwise''' + if isinstance(thing, Water): + return True + return False + + +# %% [markdown] +# Now let's test this new park with our same dog, food and water. We color our dog with a nice red and mark food and water with orange and blue respectively. + +# %% +park = Park2D(5,20, color={'BlindDog': (200,0,0), 'Water': (0, 200, 200), 'Food': (230, 115, 40)}) # park width is set to 5, and height to 20 +dog = BlindDog(program) +dogfood = Food() +water = Water() +park.add_thing(dog, [0,1]) +park.add_thing(dogfood, [0,5]) +park.add_thing(water, [0,7]) +morewater = Water() +park.add_thing(morewater, [0,15]) +print("BlindDog starts at (1,1) facing downwards, lets see if he can find any food!") +park.run(20) + +# %% [markdown] +# Adding some graphics was a good idea! We immediately see that the code works, but our blind dog doesn't make any use of the 2 dimensional space available to him. Let's make our dog more energetic so that he turns and moves forward, instead of always moving down. In doing so, we'll also need to make some changes to our environment to be able to handle this extra motion. +# +# ### PROGRAM - EnergeticBlindDog +# +# Let's make our dog turn or move forwards at random - except when he's at the edge of our park - in which case we make him change his direction explicitly by turning to avoid trying to leave the park. However, our dog is blind so he wouldn't know which way to turn - he'd just have to try arbitrarily. +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# +#
Percept: Feel Food Feel WaterFeel Nothing
Action: eatdrink +# +# +# +# +# +# +# +# +# +# +# +#
Remember being at Edge : At EdgeNot at Edge
Action : Turn Left / Turn Right
( 50% - 50% chance )
Turn Left / Turn Right / Move Forward
( 25% - 25% - 50% chance )
+#
+ +# %% +from random import choice + +class EnergeticBlindDog(Agent): + location = [0,1] + direction = Direction("down") + + def moveforward(self, success=True): + '''moveforward possible only if success (i.e. valid destination location)''' + if not success: + return + if self.direction.direction == Direction.R: + self.location[0] += 1 + elif self.direction.direction == Direction.L: + self.location[0] -= 1 + elif self.direction.direction == Direction.D: + self.location[1] += 1 + elif self.direction.direction == Direction.U: + self.location[1] -= 1 + + def turn(self, d): + self.direction = self.direction + d + + def eat(self, thing): + '''returns True upon success or False otherwise''' + if isinstance(thing, Food): + return True + return False + + def drink(self, thing): + ''' returns True upon success or False otherwise''' + if isinstance(thing, Water): + return True + return False + +def program(percepts): + '''Returns an action based on it's percepts''' + + for p in percepts: # first eat or drink - you're a dog! + if isinstance(p, Food): + return 'eat' + elif isinstance(p, Water): + return 'drink' + if isinstance(p,Bump): # then check if you are at an edge and have to turn + turn = False + choice = random.choice((1,2)); + else: + choice = random.choice((1,2,3,4)) # 1-right, 2-left, others-forward + if choice == 1: + return 'turnright' + elif choice == 2: + return 'turnleft' + else: + return 'moveforward' + + + +# %% [markdown] +# ### ENVIRONMENT - Park2D +# +# We also need to modify our park accordingly, in order to be able to handle all the new actions our dog wishes to execute. Additionally, we'll need to prevent our dog from moving to locations beyond our park boundary - it just isn't safe for blind dogs to be outside the park by themselves. + +# %% +class Park2D(GraphicEnvironment): + def percept(self, agent): + '''return a list of things that are in our agent's location''' + things = self.list_things_at(agent.location) + loc = copy.deepcopy(agent.location) # find out the target location + #Check if agent is about to bump into a wall + if agent.direction.direction == Direction.R: + loc[0] += 1 + elif agent.direction.direction == Direction.L: + loc[0] -= 1 + elif agent.direction.direction == Direction.D: + loc[1] += 1 + elif agent.direction.direction == Direction.U: + loc[1] -= 1 + if not self.is_inbounds(loc): + things.append(Bump()) + return things + + def execute_action(self, agent, action): + '''changes the state of the environment based on what the agent does.''' + if action == 'turnright': + print('{} decided to {} at location: {}'.format(str(agent)[1:-1], action, agent.location)) + agent.turn(Direction.R) + elif action == 'turnleft': + print('{} decided to {} at location: {}'.format(str(agent)[1:-1], action, agent.location)) + agent.turn(Direction.L) + elif action == 'moveforward': + print('{} decided to move {}wards at location: {}'.format(str(agent)[1:-1], agent.direction.direction, agent.location)) + agent.moveforward() + elif action == "eat": + items = self.list_things_at(agent.location, tclass=Food) + if len(items) != 0: + if agent.eat(items[0]): + print('{} ate {} at location: {}' + .format(str(agent)[1:-1], str(items[0])[1:-1], agent.location)) + self.delete_thing(items[0]) + elif action == "drink": + items = self.list_things_at(agent.location, tclass=Water) + if len(items) != 0: + if agent.drink(items[0]): + print('{} drank {} at location: {}' + .format(str(agent)[1:-1], str(items[0])[1:-1], agent.location)) + self.delete_thing(items[0]) + + def is_done(self): + '''By default, we're done when we can't find a live agent, + but to prevent killing our cute dog, we will stop before itself - when there is no more food or water''' + no_edibles = not any(isinstance(thing, Food) or isinstance(thing, Water) for thing in self.things) + dead_agents = not any(agent.is_alive() for agent in self.agents) + return dead_agents or no_edibles + + + +# %% [markdown] +# Now that our park is ready for the 2D motion of our energetic dog, lets test it! + +# %% +park = Park2D(5,5, color={'EnergeticBlindDog': (200,0,0), 'Water': (0, 200, 200), 'Food': (230, 115, 40)}) +dog = EnergeticBlindDog(program) +dogfood = Food() +water = Water() +park.add_thing(dog, [0,0]) +park.add_thing(dogfood, [1,2]) +park.add_thing(water, [0,1]) +morewater = Water() +morefood = Food() +park.add_thing(morewater, [2,4]) +park.add_thing(morefood, [4,3]) +print("dog started at [0,0], facing down. Let's see if he found any food or water!") +park.run(20) + +# %% [markdown] +# +# +# +# +# +# +# +# +# ## Wumpus Environment + +# %% +from ipythonblocks import BlockGrid +from aima.agents import * + +color = {"Breeze": (225, 225, 225), + "Pit": (0,0,0), + "Gold": (253, 208, 23), + "Glitter": (253, 208, 23), + "Wumpus": (43, 27, 23), + "Stench": (128, 128, 128), + "Explorer": (0, 0, 255), + "Wall": (44, 53, 57) + } + +def program(percepts): + '''Returns an action based on it's percepts''' + print(percepts) + return input() + +w = WumpusEnvironment(program, 7, 7) +grid = BlockGrid(w.width, w.height, fill=(123, 234, 123)) + +def draw_grid(world): + global grid + grid[:] = (123, 234, 123) + for x in range(0, len(world)): + for y in range(0, len(world[x])): + if len(world[x][y]): + grid[y, x] = color[world[x][y][-1].__class__.__name__] + +def step(): + global grid, w + draw_grid(w.get_world()) + grid.show() + w.step() + + +# %% +step() + +# %% diff --git a/arc_consistency_heuristics.ipynb b/notebooks/arc_consistency_heuristics.ipynb similarity index 99% rename from arc_consistency_heuristics.ipynb rename to notebooks/arc_consistency_heuristics.ipynb index fb2241819..68c22e51b 100644 --- a/arc_consistency_heuristics.ipynb +++ b/notebooks/arc_consistency_heuristics.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": { @@ -27,7 +36,7 @@ "metadata": {}, "outputs": [], "source": [ - "from csp import *" + "from aima.csp import *" ] }, { @@ -1860,7 +1869,7 @@ " 'O': set(range(0, 10)), 'R': set(range(0, 10)), 'Y': set(range(0, 10)),\n", " 'C1': set(range(0, 2)), 'C2': set(range(0, 2)), 'C3': set(range(0, 2)),\n", " 'C4': set(range(0, 2))},\n", - " [Constraint(('S', 'E', 'N', 'D', 'M', 'O', 'R', 'Y'), all_diff),\n", + " [Constraint(('S', 'E', 'N', 'D', 'M', 'O', 'R', 'Y'), all_diff_constraint),\n", " Constraint(('D', 'E', 'Y', 'C1'), lambda d, e, y, c1: d + e == y + 10 * c1),\n", " Constraint(('N', 'R', 'E', 'C1', 'C2'), lambda n, r, e, c1, c2: c1 + n + r == e + 10 * c2),\n", " Constraint(('E', 'O', 'N', 'C2', 'C3'), lambda e, o, n, c2, c3: c2 + e + o == n + 10 * c3),\n", diff --git a/notebooks/arc_consistency_heuristics.py b/notebooks/arc_consistency_heuristics.py new file mode 100644 index 000000000..726632fb2 --- /dev/null +++ b/notebooks/arc_consistency_heuristics.py @@ -0,0 +1,374 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] pycharm={} +# # Constraint Satisfaction Problems +# --- +# # Heuristics for Arc-Consistency Algorithms +# +# ## Introduction +# A ***Constraint Satisfaction Problem*** is a triple $(X,D,C)$ where: +# - $X$ is a set of variables $X_1, …, X_n$; +# - $D$ is a set of domains $D_1, …, D_n$, one for each variable and each of which consists of a set of allowable values $v_1, ..., v_k$; +# - $C$ is a set of constraints that specify allowable combinations of values. +# +# A CSP is called *arc-consistent* if every value in the domain of every variable is supported by all the neighbors of the variable while, is called *inconsistent*, if it has no solutions.
+# ***Arc-consistency algorithms*** remove all unsupported values from the domains of variables making the CSP *arc-consistent* or decide that a CSP is *inconsistent* by finding that some variable has no supported values in its domain.
+# Heuristics significantly enhance the efficiency of the *arc-consistency algorithms* improving their average performance in terms of *consistency-checks* which can be considered a standard measure of goodness for such algorithms. *Arc-heuristic* operate at arc-level and selects the constraint that will be used for the next check, while *domain-heuristics* operate at domain-level and selects which values will be used for the next support-check. + +# %% +from aima.csp import * + +# %% [markdown] +# ## Domain-Heuristics for Arc-Consistency Algorithms +# In [[1]](#cite-van2002domain) are investigated the effects of a *domain-heuristic* based on the notion of a *double-support check* by studying its average time-complexity. +# +# The objective of *arc-consistency algorithms* is to resolve some uncertainty; it has to be know, for each $v_i \in D_i$ and for each $v_j \in D_j$, whether it is supported. +# +# A *single-support check*, $(v_i, v_j) \in C_{ij}$, is one in which, before the check is done, it is already known that either $v_i$ or $v_j$ are supported. +# +# A *double-support check* $(v_i, v_j) \in C_{ij}$, is one in which there is still, before the check, uncertainty about the support-status of both $v_i$ and $v_j$. +# +# If a *double-support check* is successful, two uncertainties are resolved. If a *single-support check* is successful, only one uncertainty is resolved. A good *arc-consistency algorithm*, therefore, would always choose to do a *double-support check* in preference of a *single-support check*, because the cormer offers the potential higher payback. +# +# The improvement with *double-support check* is that, where possible, *consistency-checks* are used to find supports for two values, one value in the domain of each variable, which were previously known to be unsupported. It is motivated by the insight that *in order to minimize the number of consistency-checks it is necessary to maximize the number of uncertainties which are resolved per check*. + +# %% [markdown] pycharm={} +# ### AC-3b: an improved version of AC-3 with Double-Support Checks + +# %% [markdown] +# As shown in [[2]](#cite-van2000improving) the idea is to use *double-support checks* to improve the average performance of `AC3` which does not exploit the fact that relations are bidirectional and results in a new general purpose *arc-consistency algorithm* called `AC3b`. + +# %% pycharm={} +# %psource AC3 + +# %% pycharm={} +# %psource revise + +# %% [markdown] +# At any stage in the process of making 2-variable CSP *arc-consistent* in `AC3b`: +# - there is a set $S_i^+ \subseteq D_i$ whose values are all known to be supported by $X_j$; +# - there is a set $S_i^? = D_i \setminus S_i^+$ whose values are unknown, as yet, to be supported by $X_j$. +# +# The same holds if the roles for $X_i$ and $X_j$ are exchanged. +# +# In order to establish support for a value $v_i^? \in S_i^?$ it seems better to try to find a support among the values in $S_j^?$ first, because for each $v_j^? \in S_j^?$ the check $(v_i^?,v_j^?) \in C_{ij}$ is a *double-support check* and it is just as likely that any $v_j^? \in S_j^?$ supports $v_i^?$ than it is that any $v_j^+ \in S_j^+$ does. Only if no support can be found among the elements in $S_j^?$, should the elements $v_j^+$ in $S_j^+$ be used for *single-support checks* $(v_i^?,v_j^+) \in C_{ij}$. After it has been decided for each value in $D_i$ whether it is supported or not, either $S_x^+ = \emptyset$ and the 2-variable CSP is *inconsistent*, or $S_x^+ \neq \emptyset$ and the CSP is *satisfiable*. In the latter case, the elements from $D_i$ which are supported by $j$ are given by $S_x^+$. The elements in $D_j$ which are supported by $x$ are given by the union of $S_j^+$ with the set of those elements of $S_j^?$ which further processing will show to be supported by some $v_i^+ \in S_x^+$. + +# %% pycharm={} +# %psource AC3b + +# %% pycharm={} +# %psource partition + +# %% [markdown] pycharm={} +# `AC3b` is a refinement of the `AC3` algorithm which consists of the fact that if, when arc $(i,j)$ is being processed and the reverse arc $(j,i)$ is also in the queue, then consistency-checks can be saved because only support for the elements in $S_j^?$ has to be found (as opposed to support for all the elements in $D_j$ in the +# `AC3` algorithm).
+# `AC3b` inherits all its properties like $\mathcal{O}(ed^3)$ time-complexity and $\mathcal{O}(e + nd)$ space-complexity fron `AC3` and where $n$ denotes the number of variables in the CSP, $e$ denotes the number of binary constraints and $d$ denotes the maximum domain-size of the variables. + +# %% [markdown] pycharm={} +# ## Arc-Heuristics for Arc-Consistency Algorithms + +# %% [markdown] pycharm={} +# Many *arc-heuristics* can be devised, based on three major features of CSPs: +# - the number of acceptable pairs in each constraint (the *constraint size* or *satisfiability*); +# - the *domain size*; +# - the number of binary constraints that each variable participates in, equal to the *degree* of the node of that variable in the constraint graph. +# +# Simple examples of heuristics that might be expected to improve the efficiency of relaxation are: +# - ordering the list of variable pairs by *increasing* relative *satisfiability*; +# - ordering by *increasing size of the domain* of the variable $v_j$ relaxed against $v_i$; +# - ordering by *descending degree* of node of the variable relaxed. +# +# In
[[3]](#cite-wallace1992ordering) are investigated the effects of these *arc-heuristics* in an empirical way, experimenting the effects of them on random CSPs. Their results demonstrate that the first two, later called `sat up` and `dom j up` for n-ary and binary CSPs respectively, significantly reduce the number of *consistency-checks*. + +# %% pycharm={} +# %psource dom_j_up + +# %% pycharm={} +# %psource sat_up + +# %% [markdown] pycharm={} +# ## Experimental Results + +# %% [markdown] pycharm={} +# For the experiments below on binary CSPs, in addition to the two *arc-consistency algorithms* already cited above, `AC3` and `AC3b`, the `AC4` algorithm was used.
+# The `AC4` algorithm runs in $\mathcal{O}(ed^2)$ worst-case time but can be slower than `AC3` on average cases. + +# %% pycharm={} +# %psource AC4 + +# %% [markdown] +# ### Sudoku + +# %% [markdown] pycharm={} +# #### Easy Sudoku + +# %% pycharm={} +sudoku = Sudoku(easy1) +sudoku.display(sudoku.infer_assignment()) + +# %% pycharm={} +# %time _, checks = AC3(sudoku, arc_heuristic=no_arc_heuristic) +f'AC3 needs {checks} consistency-checks' + +# %% pycharm={} +sudoku = Sudoku(easy1) +# %time _, checks = AC3b(sudoku, arc_heuristic=no_arc_heuristic) +f'AC3b needs {checks} consistency-checks' + +# %% pycharm={} +sudoku = Sudoku(easy1) +# %time _, checks = AC4(sudoku, arc_heuristic=no_arc_heuristic) +f'AC4 needs {checks} consistency-checks' + +# %% pycharm={} +sudoku = Sudoku(easy1) +# %time _, checks = AC3(sudoku, arc_heuristic=dom_j_up) +f'AC3 with DOM J UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +sudoku = Sudoku(easy1) +# %time _, checks = AC3b(sudoku, arc_heuristic=dom_j_up) +f'AC3b with DOM J UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +sudoku = Sudoku(easy1) +# %time _, checks = AC4(sudoku, arc_heuristic=dom_j_up) +f'AC4 with DOM J UP arc heuristic needs {checks} consistency-checks' + +# %% +backtracking_search(sudoku, select_unassigned_variable=mrv, inference=forward_checking) +sudoku.display(sudoku.infer_assignment()) + +# %% [markdown] pycharm={} +# #### Harder Sudoku + +# %% pycharm={} +sudoku = Sudoku(harder1) +sudoku.display(sudoku.infer_assignment()) + +# %% pycharm={} +# %time _, checks = AC3(sudoku, arc_heuristic=no_arc_heuristic) +f'AC3 needs {checks} consistency-checks' + +# %% pycharm={} +sudoku = Sudoku(harder1) +# %time _, checks = AC3b(sudoku, arc_heuristic=no_arc_heuristic) +f'AC3b needs {checks} consistency-checks' + +# %% pycharm={} +sudoku = Sudoku(harder1) +# %time _, checks = AC4(sudoku, arc_heuristic=no_arc_heuristic) +f'AC4 needs {checks} consistency-checks' + +# %% pycharm={} +sudoku = Sudoku(harder1) +# %time _, checks = AC3(sudoku, arc_heuristic=dom_j_up) +f'AC3 with DOM J UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +sudoku = Sudoku(harder1) +# %time _, checks = AC3b(sudoku, arc_heuristic=dom_j_up) +f'AC3b with DOM J UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +sudoku = Sudoku(harder1) +# %time _, checks = AC4(sudoku, arc_heuristic=dom_j_up) +f'AC4 with DOM J UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +backtracking_search(sudoku, select_unassigned_variable=mrv, inference=forward_checking) +sudoku.display(sudoku.infer_assignment()) + +# %% [markdown] pycharm={} +# ### 8 Queens + +# %% pycharm={} +chess = NQueensCSP(8) +chess.display(chess.infer_assignment()) + +# %% pycharm={} +# %time _, checks = AC3(chess, arc_heuristic=no_arc_heuristic) +f'AC3 needs {checks} consistency-checks' + +# %% pycharm={} +chess = NQueensCSP(8) +# %time _, checks = AC3b(chess, arc_heuristic=no_arc_heuristic) +f'AC3b needs {checks} consistency-checks' + +# %% pycharm={} +chess = NQueensCSP(8) +# %time _, checks = AC4(chess, arc_heuristic=no_arc_heuristic) +f'AC4 needs {checks} consistency-checks' + +# %% pycharm={} +chess = NQueensCSP(8) +# %time _, checks = AC3(chess, arc_heuristic=dom_j_up) +f'AC3 with DOM J UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +chess = NQueensCSP(8) +# %time _, checks = AC3b(chess, arc_heuristic=dom_j_up) +f'AC3b with DOM J UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +chess = NQueensCSP(8) +# %time _, checks = AC4(chess, arc_heuristic=dom_j_up) +f'AC4 with DOM J UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +backtracking_search(chess, select_unassigned_variable=mrv, inference=forward_checking) +chess.display(chess.infer_assignment()) + +# %% [markdown] +# For the experiments below on n-ary CSPs, due to the n-ary constraints, the `GAC` algorithm was used.
+# The `GAC` algorithm has $\mathcal{O}(er^2d^t)$ time-complexity and $\mathcal{O}(erd)$ space-complexity where $e$ denotes the number of n-ary constraints, $r$ denotes the constraint arity and $d$ denotes the maximum domain-size of the variables. + +# %% pycharm={} +# %psource ACSolver.GAC + +# %% [markdown] pycharm={} +# ### Crossword + +# %% pycharm={} +crossword = Crossword(crossword1, words1) +crossword.display() +words1 + +# %% pycharm={} +# %time _, _, checks = ACSolver(crossword).GAC(arc_heuristic=no_heuristic) +f'GAC needs {checks} consistency-checks' + +# %% pycharm={} +crossword = Crossword(crossword1, words1) +# %time _, _, checks = ACSolver(crossword).GAC(arc_heuristic=sat_up) +f'GAC with SAT UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +crossword.display(ACSolver(crossword).domain_splitting()) + +# %% [markdown] pycharm={} +# ### Kakuro + +# %% [markdown] +# #### Easy Kakuro + +# %% pycharm={} +kakuro = Kakuro(kakuro2) +kakuro.display() + +# %% pycharm={} +# %time _, _, checks = ACSolver(kakuro).GAC(arc_heuristic=no_heuristic) +f'GAC needs {checks} consistency-checks' + +# %% pycharm={} +kakuro = Kakuro(kakuro2) +# %time _, _, checks = ACSolver(kakuro).GAC(arc_heuristic=sat_up) +f'GAC with SAT UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +kakuro.display(ACSolver(kakuro).domain_splitting()) + +# %% [markdown] pycharm={} +# #### Medium Kakuro + +# %% pycharm={} +kakuro = Kakuro(kakuro3) +kakuro.display() + +# %% pycharm={} +# %time _, _, checks = ACSolver(kakuro).GAC(arc_heuristic=no_heuristic) +f'GAC needs {checks} consistency-checks' + +# %% pycharm={} +kakuro = Kakuro(kakuro3) +# %time _, _, checks = ACSolver(kakuro).GAC(arc_heuristic=sat_up) +f'GAC with SAT UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +kakuro.display(ACSolver(kakuro).domain_splitting()) + +# %% [markdown] pycharm={} +# #### Harder Kakuro + +# %% pycharm={} +kakuro = Kakuro(kakuro4) +kakuro.display() + +# %% pycharm={} +# %time _, _, checks = ACSolver(kakuro).GAC() +f'GAC needs {checks} consistency-checks' + +# %% pycharm={} +kakuro = Kakuro(kakuro4) +# %time _, _, checks = ACSolver(kakuro).GAC(arc_heuristic=sat_up) +f'GAC with SAT UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +kakuro.display(ACSolver(kakuro).domain_splitting()) + +# %% [markdown] pycharm={} +# ### Cryptarithmetic Puzzle + +# %% [markdown] +# $$ +# \begin{array}{@{}r@{}} +# S E N D \\ +# {} + M O R E \\ +# \hline +# M O N E Y +# \end{array} +# $$ + +# %% pycharm={} +cryptarithmetic = NaryCSP( + {'S': set(range(1, 10)), 'M': set(range(1, 10)), + 'E': set(range(0, 10)), 'N': set(range(0, 10)), 'D': set(range(0, 10)), + 'O': set(range(0, 10)), 'R': set(range(0, 10)), 'Y': set(range(0, 10)), + 'C1': set(range(0, 2)), 'C2': set(range(0, 2)), 'C3': set(range(0, 2)), + 'C4': set(range(0, 2))}, + [Constraint(('S', 'E', 'N', 'D', 'M', 'O', 'R', 'Y'), all_diff_constraint), + Constraint(('D', 'E', 'Y', 'C1'), lambda d, e, y, c1: d + e == y + 10 * c1), + Constraint(('N', 'R', 'E', 'C1', 'C2'), lambda n, r, e, c1, c2: c1 + n + r == e + 10 * c2), + Constraint(('E', 'O', 'N', 'C2', 'C3'), lambda e, o, n, c2, c3: c2 + e + o == n + 10 * c3), + Constraint(('S', 'M', 'O', 'C3', 'C4'), lambda s, m, o, c3, c4: c3 + s + m == o + 10 * c4), + Constraint(('M', 'C4'), eq)]) + +# %% pycharm={} +# %time _, _, checks = ACSolver(cryptarithmetic).GAC(arc_heuristic=no_heuristic) +f'GAC needs {checks} consistency-checks' + +# %% pycharm={} +# %time _, _, checks = ACSolver(cryptarithmetic).GAC(arc_heuristic=sat_up) +f'GAC with SAT UP arc heuristic needs {checks} consistency-checks' + +# %% pycharm={} +assignment = ACSolver(cryptarithmetic).domain_splitting() + +from IPython.display import Latex +display(Latex(r'\begin{array}{@{}r@{}} ' + '{}{}{}{}'.format(assignment['S'], assignment['E'], assignment['N'], assignment['D']) + r' \\ + ' + + '{}{}{}{}'.format(assignment['M'], assignment['O'], assignment['R'], assignment['E']) + r' \\ \hline ' + + '{}{}{}{}{}'.format(assignment['M'], assignment['O'], assignment['N'], assignment['E'], assignment['Y']) + ' \end{array}')) + +# %% [markdown] pycharm={} +# ## References +# +#
[[1]](#ref-1) Van Dongen, Marc RC. 2002. _Domain-heuristics for arc-consistency algorithms_. +# +# [[2]](#ref-2) Van Dongen, MRC and Bowen, JA. 2000. _Improving arc-consistency algorithms with double-support checks_. +# +# [[3]](#ref-3) Wallace, Richard J and Freuder, Eugene Charles. 1992. _Ordering heuristics for arc consistency algorithms_. diff --git a/notebooks/bootstrap.ipynb b/notebooks/bootstrap.ipynb new file mode 100644 index 000000000..77d94d282 --- /dev/null +++ b/notebooks/bootstrap.ipynb @@ -0,0 +1,40 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Bootstrap\n", + "\n", + "Shared setup for the notebooks: put the repository root (the directory containing the `aima` package) on `sys.path` so `from aima import ...` works regardless of where the notebook is launched. Each notebook runs this with a single `%run bootstrap.ipynb` cell." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "# walk up from the current directory to the repo root (the one holding `aima/`)\n", + "_root = os.path.abspath(os.getcwd())\n", + "while _root != os.path.dirname(_root) and not os.path.isdir(os.path.join(_root, 'aima')):\n", + " _root = os.path.dirname(_root)\n", + "if _root not in sys.path:\n", + " sys.path.insert(0, _root)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/bootstrap.py b/notebooks/bootstrap.py new file mode 100644 index 000000000..2a30bbef3 --- /dev/null +++ b/notebooks/bootstrap.py @@ -0,0 +1,27 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Bootstrap +# +# Shared setup for the notebooks: put the repository root (the directory containing the `aima` package) on `sys.path` so `from aima import ...` works regardless of where the notebook is launched. Each notebook runs this with a single `%run bootstrap.ipynb` cell. + +# %% +import os, sys +# walk up from the current directory to the repo root (the one holding `aima/`) +_root = os.path.abspath(os.getcwd()) +while _root != os.path.dirname(_root) and not os.path.isdir(os.path.join(_root, 'aima')): + _root = os.path.dirname(_root) +if _root not in sys.path: + sys.path.insert(0, _root) diff --git a/notebooks/chapter16-17/Algorithms for MDPs.ipynb b/notebooks/chapter16-17/Algorithms for MDPs.ipynb new file mode 100644 index 000000000..bc4bbf807 --- /dev/null +++ b/notebooks/chapter16-17/Algorithms for MDPs.ipynb @@ -0,0 +1,355 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Algorithms for MDPs\n", + "\n", + "There are multiple different algorithms for solving MDPs. Some solutions such as value iteration, policy iteration, and linear programming are offline solutions that generate exact results. There are also online solutions computing the results by sampling possible features such as Monte Carlo planning." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Value Iteration\n", + "\n", + "When solving an MDP, our ultimate goal is to obtain an optimal policy. We start by looking at Value Iteration and a visualization that should help us understand it better.\n", + "\n", + "We start by calculating Value/Utility for each of the states. The Value of each state is the expected sum of discounted future rewards given we start in that state and follow a particular policy $\\pi$. The value or the utility of a state is given by\n", + "\n", + "$$U(s)=R(s)+\\gamma\\max_{a\\epsilon A(s)}\\sum_{s'} P(s'\\ |\\ s,a)U(s')$$\n", + "\n", + "This is called the Bellman equation. The algorithm Value Iteration (**Fig. 16.2** in the book) relies on finding solutions to this Equation. The intuition Value Iteration works are because values propagate through the state space utilizing local updates. This point will we more clear after we encounter the visualization. " + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", + "from aima.mdp import *\n", + "from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(value_iteration)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It takes as inputs two parameters, an MDP to solve and epsilon, the maximum error allowed in the utility of any state. It returns a dictionary containing utilities where the keys are the states and values represent utilities.
Value Iteration starts with arbitrary initial values for the utilities, calculates the right side of the Bellman equation and plugs it into the left-hand side, thereby updating the utility of each state from the utilities of its neighbors. \n", + "This is repeated until equilibrium is reached. \n", + "It works on the principle of _Dynamic Programming_ - using precomputed information to simplify the subsequent computation. \n", + "If $U_i(s)$ is the utility value for state $s$ at the $i$ th iteration, the iteration step, called Bellman update, looks like this:\n", + "\n", + "$$ U_{i+1}(s) \\leftarrow R(s) + \\gamma \\max_{a \\epsilon A(s)} \\sum_{s'} P(s'\\ |\\ s,a)U_{i}(s') $$\n", + "\n", + "As you might have noticed, `value_iteration` has an infinite loop. How do we decide when to stop iterating? \n", + "The concept of _contraction_ successfully explains the convergence of value iteration. \n", + "Refer to **Section 17.2.3** of the book for a detailed explanation. \n", + "In the algorithm, we calculate a value $delta$ that measures the difference in the utilities of the current time step and the previous time step. \n", + "\n", + "$$\\delta = \\max{(\\delta, \\begin{vmatrix}U_{i + 1}(s) - U_i(s)\\end{vmatrix})}$$\n", + "\n", + "This value of delta decreases as the values of $U_i$ converge.\n", + "We terminate the algorithm if the $\\delta$ value is less than a threshold value determined by the hyperparameter _epsilon_.\n", + "\n", + "$$\\delta \\lt \\epsilon \\frac{(1 - \\gamma)}{\\gamma}$$\n", + "\n", + "To summarize, the Bellman update is a _contraction_ by a factor of $gamma$ on the space of utility vectors. \n", + "Hence, from the properties of contractions in general, it follows that `value_iteration` always converges to a unique solution of the Bellman equations whenever $gamma$ is less than 1.\n", + "We then terminate the algorithm when a reasonable approximation is achieved.\n", + "In practice, it often occurs that the policy $pi$ becomes optimal long before the utility function converges. For the given 4 x 3 environment with $gamma = 0.9$, the policy $pi$ is optimal when $i = 4$ (at the 4th iteration), even though the maximum error in the utility function is still 0.46. This can be clarified from **figure 17.6** in the book. Hence, to increase computational efficiency, we often use another method to solve MDPs called Policy Iteration which we will see in the latter part of this notebook. \n", + "
For now, let us solve the **sequential_decision_environment** GridMDP using `value_iteration`." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{(0, 1): 0.3984432178350046,\n", + " (1, 2): 0.649585681261095,\n", + " (3, 2): 1,\n", + " (0, 0): 0.2962883154554812,\n", + " (3, 0): 0.12987274656746337,\n", + " (3, 1): -1,\n", + " (2, 1): 0.48644001739269643,\n", + " (2, 0): 0.34475423001241573,\n", + " (2, 2): 0.7953620878466678,\n", + " (1, 0): 0.253866998464795,\n", + " (0, 2): 0.5093943765842497}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "value_iteration(sequential_decision_environment)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To view the pseudocode for the algorithm:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pseudocode(\"Value-Iteration\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Visualization\n", + "\n", + "To illustrate that values propagate out of states let us create a simple visualization. We will be using a modified version of the value_iteration function which will store U over time. We will also remove the parameter epsilon and instead add the number of iterations we want." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "def value_iteration_instru(mdp, iterations=20):\n", + " U_over_time = []\n", + " U1 = {s: 0 for s in mdp.states}\n", + " R, T, gamma = mdp.R, mdp.T, mdp.gamma\n", + " for _ in range(iterations):\n", + " U = U1.copy()\n", + " for s in mdp.states:\n", + " U1[s] = max(q_value(mdp, s, a, U) for a in mdp.actions(s))\n", + " U_over_time.append(U)\n", + " return U_over_time" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, we define a function to create the visualization from the utilities returned by **value_iteration_instru**. The reader need not concern himself with the code that immediately follows as it is the usage of Matplotib with IPython Widgets. If you are interested in reading more about these visit [ipywidgets.readthedocs.io](http://ipywidgets.readthedocs.io)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "columns = 4\n", + "rows = 3\n", + "U_over_time = value_iteration_instru(sequential_decision_environment)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "from aima.notebook_utils import make_plot_grid_step_function\n", + "\n", + "plot_grid_step = make_plot_grid_step_function(columns, rows, U_over_time)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "d98fa86f0b764eef840e3d433f113579", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(IntSlider(value=1, description='iteration', max=15, min=1), Output()), _dom_classes=('wi…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "3aaadb0f71fd469d96f460a422bc48fa", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(ToggleButton(value=False, description='Visualize'), ToggleButtons(description='Extra Del…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import ipywidgets as widgets\n", + "from IPython.display import display\n", + "from aima.notebook_utils import make_visualize\n", + "\n", + "iteration_slider = widgets.IntSlider(min=1, max=15, step=1, value=0)\n", + "w=widgets.interactive(plot_grid_step,iteration=iteration_slider)\n", + "display(w)\n", + "\n", + "visualize_callback = make_visualize(iteration_slider)\n", + "\n", + "visualize_button = widgets.ToggleButton(description = \"Visualize\", value = False)\n", + "time_select = widgets.ToggleButtons(description='Extra Delay:',options=['0', '0.1', '0.2', '0.5', '0.7', '1.0'])\n", + "a = widgets.interactive(visualize_callback, visualize=visualize_button, time_step=time_select)\n", + "display(a)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Move the slider above to observe how the utility changes across iterations. It is also possible to move the slider using arrow keys or to jump to the value by directly editing the number with a double click. The **Visualize Button** will automatically animate the slider for you. The **Extra Delay Box** allows you to set time delay in seconds up to one second for each time step. There is also an interactive editor for grid-world problems `grid_mdp.py` in the GUI folder for you to play around with." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Policy Iteration\n", + "\n", + "We have already seen that value iteration converges to the optimal policy long before it accurately estimates the utility function. \n", + "If one action is clearly better than all the others, then the exact magnitude of the utilities in the states involved need not be precise. \n", + "The policy iteration algorithm works on this insight. \n", + "The algorithm executes two fundamental steps:\n", + "* **Policy evaluation**: Given a policy _πᵢ_, calculate _Uᵢ = U(πᵢ)_, the utility of each state if _πᵢ_ were to be executed.\n", + "* **Policy improvement**: Calculate a new policy _πᵢ₊₁_ using one-step look-ahead based on the utility values calculated.\n", + "\n", + "The algorithm terminates when the policy improvement step yields no change in the utilities. \n", + "We now have a simplified version of the Bellman equation\n", + "\n", + "$$U_i(s) = R(s) + \\gamma \\sum_{s'}P(s'\\ |\\ s, \\pi_i(s))U_i(s')$$\n", + "\n", + "An important observation in this equation is that this equation doesn't have the `max` operator, which makes it linear.\n", + "For _n_ states, we have _n_ linear equations with _n_ unknowns, which can be solved exactly in time _**O(n³)**_.\n", + "For more implementational details, have a look at **Section 16.2**.\n", + "Let us now look at how the expected utility is found and how `policy_iteration` is implemented." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(expected_utility)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(policy_iteration)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
Fortunately, it is not necessary to do _exact_ policy evaluation. \n", + "The utilities can instead be reasonably approximated by performing some number of simplified value iteration steps.\n", + "The simplified Bellman update equation for the process is\n", + "\n", + "$$U_{i+1}(s) \\leftarrow R(s) + \\gamma\\sum_{s'}P(s'\\ |\\ s,\\pi_i(s))U_{i}(s')$$\n", + "\n", + "and this is repeated _k_ times to produce the next utility estimate. This is called _modified policy iteration_." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(policy_evaluation)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{(0, 1): (0, 1),\n", + " (1, 2): (1, 0),\n", + " (3, 2): None,\n", + " (0, 0): (0, 1),\n", + " (3, 0): (-1, 0),\n", + " (3, 1): None,\n", + " (2, 1): (0, 1),\n", + " (2, 0): (0, 1),\n", + " (2, 2): (1, 0),\n", + " (1, 0): (1, 0),\n", + " (0, 2): (1, 0)}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "policy_iteration(sequential_decision_environment)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/chapter16-17/Algorithms for MDPs.py b/notebooks/chapter16-17/Algorithms for MDPs.py new file mode 100644 index 000000000..f92168bda --- /dev/null +++ b/notebooks/chapter16-17/Algorithms for MDPs.py @@ -0,0 +1,166 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Algorithms for MDPs +# +# There are multiple different algorithms for solving MDPs. Some solutions such as value iteration, policy iteration, and linear programming are offline solutions that generate exact results. There are also online solutions computing the results by sampling possible features such as Monte Carlo planning. + +# %% [markdown] +# ## Value Iteration +# +# When solving an MDP, our ultimate goal is to obtain an optimal policy. We start by looking at Value Iteration and a visualization that should help us understand it better. +# +# We start by calculating Value/Utility for each of the states. The Value of each state is the expected sum of discounted future rewards given we start in that state and follow a particular policy $\pi$. The value or the utility of a state is given by +# +# $$U(s)=R(s)+\gamma\max_{a\epsilon A(s)}\sum_{s'} P(s'\ |\ s,a)U(s')$$ +# +# This is called the Bellman equation. The algorithm Value Iteration (**Fig. 16.2** in the book) relies on finding solutions to this Equation. The intuition Value Iteration works are because values propagate through the state space utilizing local updates. This point will we more clear after we encounter the visualization. + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.mdp import * +from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility + +# %% +psource(value_iteration) + +# %% [markdown] +# It takes as inputs two parameters, an MDP to solve and epsilon, the maximum error allowed in the utility of any state. It returns a dictionary containing utilities where the keys are the states and values represent utilities.
Value Iteration starts with arbitrary initial values for the utilities, calculates the right side of the Bellman equation and plugs it into the left-hand side, thereby updating the utility of each state from the utilities of its neighbors. +# This is repeated until equilibrium is reached. +# It works on the principle of _Dynamic Programming_ - using precomputed information to simplify the subsequent computation. +# If $U_i(s)$ is the utility value for state $s$ at the $i$ th iteration, the iteration step, called Bellman update, looks like this: +# +# $$ U_{i+1}(s) \leftarrow R(s) + \gamma \max_{a \epsilon A(s)} \sum_{s'} P(s'\ |\ s,a)U_{i}(s') $$ +# +# As you might have noticed, `value_iteration` has an infinite loop. How do we decide when to stop iterating? +# The concept of _contraction_ successfully explains the convergence of value iteration. +# Refer to **Section 17.2.3** of the book for a detailed explanation. +# In the algorithm, we calculate a value $delta$ that measures the difference in the utilities of the current time step and the previous time step. +# +# $$\delta = \max{(\delta, \begin{vmatrix}U_{i + 1}(s) - U_i(s)\end{vmatrix})}$$ +# +# This value of delta decreases as the values of $U_i$ converge. +# We terminate the algorithm if the $\delta$ value is less than a threshold value determined by the hyperparameter _epsilon_. +# +# $$\delta \lt \epsilon \frac{(1 - \gamma)}{\gamma}$$ +# +# To summarize, the Bellman update is a _contraction_ by a factor of $gamma$ on the space of utility vectors. +# Hence, from the properties of contractions in general, it follows that `value_iteration` always converges to a unique solution of the Bellman equations whenever $gamma$ is less than 1. +# We then terminate the algorithm when a reasonable approximation is achieved. +# In practice, it often occurs that the policy $pi$ becomes optimal long before the utility function converges. For the given 4 x 3 environment with $gamma = 0.9$, the policy $pi$ is optimal when $i = 4$ (at the 4th iteration), even though the maximum error in the utility function is still 0.46. This can be clarified from **figure 17.6** in the book. Hence, to increase computational efficiency, we often use another method to solve MDPs called Policy Iteration which we will see in the latter part of this notebook. +#
For now, let us solve the **sequential_decision_environment** GridMDP using `value_iteration`. + +# %% +value_iteration(sequential_decision_environment) + +# %% [markdown] +# To view the pseudocode for the algorithm: + +# %% +pseudocode("Value-Iteration") + + +# %% [markdown] +# ### Visualization +# +# To illustrate that values propagate out of states let us create a simple visualization. We will be using a modified version of the value_iteration function which will store U over time. We will also remove the parameter epsilon and instead add the number of iterations we want. + +# %% +def value_iteration_instru(mdp, iterations=20): + U_over_time = [] + U1 = {s: 0 for s in mdp.states} + R, T, gamma = mdp.R, mdp.T, mdp.gamma + for _ in range(iterations): + U = U1.copy() + for s in mdp.states: + U1[s] = max(q_value(mdp, s, a, U) for a in mdp.actions(s)) + U_over_time.append(U) + return U_over_time + + +# %% [markdown] +# Next, we define a function to create the visualization from the utilities returned by **value_iteration_instru**. The reader need not concern himself with the code that immediately follows as it is the usage of Matplotib with IPython Widgets. If you are interested in reading more about these visit [ipywidgets.readthedocs.io](http://ipywidgets.readthedocs.io) + +# %% +columns = 4 +rows = 3 +U_over_time = value_iteration_instru(sequential_decision_environment) + +# %% +# %matplotlib inline +from aima.notebook_utils import make_plot_grid_step_function + +plot_grid_step = make_plot_grid_step_function(columns, rows, U_over_time) + +# %% +import ipywidgets as widgets +from IPython.display import display +from aima.notebook_utils import make_visualize + +iteration_slider = widgets.IntSlider(min=1, max=15, step=1, value=0) +w=widgets.interactive(plot_grid_step,iteration=iteration_slider) +display(w) + +visualize_callback = make_visualize(iteration_slider) + +visualize_button = widgets.ToggleButton(description = "Visualize", value = False) +time_select = widgets.ToggleButtons(description='Extra Delay:',options=['0', '0.1', '0.2', '0.5', '0.7', '1.0']) +a = widgets.interactive(visualize_callback, visualize=visualize_button, time_step=time_select) +display(a) + +# %% [markdown] +# Move the slider above to observe how the utility changes across iterations. It is also possible to move the slider using arrow keys or to jump to the value by directly editing the number with a double click. The **Visualize Button** will automatically animate the slider for you. The **Extra Delay Box** allows you to set time delay in seconds up to one second for each time step. There is also an interactive editor for grid-world problems `grid_mdp.py` in the GUI folder for you to play around with. + +# %% [markdown] +# ## Policy Iteration +# +# We have already seen that value iteration converges to the optimal policy long before it accurately estimates the utility function. +# If one action is clearly better than all the others, then the exact magnitude of the utilities in the states involved need not be precise. +# The policy iteration algorithm works on this insight. +# The algorithm executes two fundamental steps: +# * **Policy evaluation**: Given a policy _πᵢ_, calculate _Uᵢ = U(πᵢ)_, the utility of each state if _πᵢ_ were to be executed. +# * **Policy improvement**: Calculate a new policy _πᵢ₊₁_ using one-step look-ahead based on the utility values calculated. +# +# The algorithm terminates when the policy improvement step yields no change in the utilities. +# We now have a simplified version of the Bellman equation +# +# $$U_i(s) = R(s) + \gamma \sum_{s'}P(s'\ |\ s, \pi_i(s))U_i(s')$$ +# +# An important observation in this equation is that this equation doesn't have the `max` operator, which makes it linear. +# For _n_ states, we have _n_ linear equations with _n_ unknowns, which can be solved exactly in time _**O(n³)**_. +# For more implementational details, have a look at **Section 16.2**. +# Let us now look at how the expected utility is found and how `policy_iteration` is implemented. + +# %% +psource(expected_utility) + +# %% +psource(policy_iteration) + +# %% [markdown] +#
Fortunately, it is not necessary to do _exact_ policy evaluation. +# The utilities can instead be reasonably approximated by performing some number of simplified value iteration steps. +# The simplified Bellman update equation for the process is +# +# $$U_{i+1}(s) \leftarrow R(s) + \gamma\sum_{s'}P(s'\ |\ s,\pi_i(s))U_{i}(s')$$ +# +# and this is repeated _k_ times to produce the next utility estimate. This is called _modified policy iteration_. + +# %% +psource(policy_evaluation) + +# %% +policy_iteration(sequential_decision_environment) diff --git a/notebooks/chapter16-17/Introduction.ipynb b/notebooks/chapter16-17/Introduction.ipynb new file mode 100644 index 000000000..f8fa8c714 --- /dev/null +++ b/notebooks/chapter16-17/Introduction.ipynb @@ -0,0 +1,78 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Making Complex Decisions\n", + "---\n", + "\n", + "This Jupyter notebook acts as supporting material for topics covered in **Chapter 16 Making Complex Decisions** of the book *Artificial Intelligence: A Modern Approach*. We make use of the implementations in the mdp.py module. This notebook also includes a summary of the main topics as a review. The main content of this chapter is about Markov models and algorithms to solve it." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## CONTENTS\n", + "\n", + "* Introduction\n", + "* MDPs: the definition of Markov Decision Problems.\n", + "* Algorithms for MDPs: Algorithms to solve MDPs: value iteration and its visualization, and policy iteration.\n", + "* Sequential Decision Problems: demonstration case of the first example in chapter 16 of the book.\n", + "* POMDPs: Definition of partially observed MDPs and its visualization." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## OVERVIEW\n", + "\n", + "MDPs are meant to be a straightforward description of the real-world learning problem from interaction to achieve a goal. An agent and the environment interact continually. The agent selects actions and the environment responds to these actions and feeds new situations back to the agent.\n", + "\n", + "To use the implemented modules of Markov models, you need to import the packages by executing the following code in each notebook:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", + "from aima.mdp import *\n", + "from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/chapter16-17/Introduction.py b/notebooks/chapter16-17/Introduction.py new file mode 100644 index 000000000..335756dc3 --- /dev/null +++ b/notebooks/chapter16-17/Introduction.py @@ -0,0 +1,43 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Making Complex Decisions +# --- +# +# This Jupyter notebook acts as supporting material for topics covered in **Chapter 16 Making Complex Decisions** of the book *Artificial Intelligence: A Modern Approach*. We make use of the implementations in the mdp.py module. This notebook also includes a summary of the main topics as a review. The main content of this chapter is about Markov models and algorithms to solve it. + +# %% [markdown] +# ## CONTENTS +# +# * Introduction +# * MDPs: the definition of Markov Decision Problems. +# * Algorithms for MDPs: Algorithms to solve MDPs: value iteration and its visualization, and policy iteration. +# * Sequential Decision Problems: demonstration case of the first example in chapter 16 of the book. +# * POMDPs: Definition of partially observed MDPs and its visualization. + +# %% [markdown] +# ## OVERVIEW +# +# MDPs are meant to be a straightforward description of the real-world learning problem from interaction to achieve a goal. An agent and the environment interact continually. The agent selects actions and the environment responds to these actions and feeds new situations back to the agent. +# +# To use the implemented modules of Markov models, you need to import the packages by executing the following code in each notebook: + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.mdp import * +from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility + +# %% diff --git a/notebooks/chapter16-17/MDPs.ipynb b/notebooks/chapter16-17/MDPs.ipynb new file mode 100644 index 000000000..bf1a68651 --- /dev/null +++ b/notebooks/chapter16-17/MDPs.ipynb @@ -0,0 +1,609 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# MDP\n", + "\n", + "A sequential decision problem for a fully observable, stochastic environment with a Markovian transition model and additive rewards is called a Markov decision process. Markov model is very practical and can model many real-world decision-making processes. \n", + "\n", + "Before we start playing with the actual implementations let us review a couple of key points about MDPs.\n", + "\n", + "- A stochastic process has the **Markov property** if the conditional probability distribution of future states of the process (conditional on both past and present states) depends only upon the present state, not on the sequence of events that preceded it.\n", + "\n", + " -- Source: [Wikipedia](https://en.wikipedia.org/wiki/Markov_property)\n", + "\n", + "Often it is possible to model many different phenomena as a Markov process by being flexible with our definition of a state.\n", + " \n", + "\n", + "- MDPs help us deal with fully-observable and non-deterministic/stochastic environments. For dealing with partially-observable and stochastic cases we make use of a generalization of MDPs named POMDPs (partially observable Markov decision process).\n", + "\n", + "Our overall goal to solve an MDP is to come up with a policy that guides us to select the best action in each state so as to maximize the expected sum of future rewards." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", + "from aima.mdp import *\n", + "from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Implementation\n", + "\n", + "To begin with let us look at the implementation of the MDP class defined in mdp.py The docstring tells us what all is required to define an MDP namely - set of states, actions, initial state, transition model, and a reward function. Each of these is implemented as a method. Do not close the popup so that you can follow along with the description of the code below." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
class MDP:\n",
+       "    """A Markov Decision Process, defined by an initial state, transition model,\n",
+       "    and reward function. We also keep track of a gamma value, for use by\n",
+       "    algorithms. The transition model is represented somewhat differently from\n",
+       "    the text. Instead of P(s' | s, a) being a probability number for each\n",
+       "    state/state/action triplet, we instead have T(s, a) return a\n",
+       "    list of (p, s') pairs. We also keep track of the possible states,\n",
+       "    terminal states, and actions for each state. [page 646]"""\n",
+       "\n",
+       "    def __init__(self, init, actlist, terminals, transitions=None, reward=None, states=None, gamma=0.9):\n",
+       "        if not (0 < gamma <= 1):\n",
+       "            raise ValueError("An MDP must have 0 < gamma <= 1")\n",
+       "\n",
+       "        # collect states from transitions table if not passed.\n",
+       "        self.states = states or self.get_states_from_transitions(transitions)\n",
+       "\n",
+       "        self.init = init\n",
+       "\n",
+       "        if isinstance(actlist, list):\n",
+       "            # if actlist is a list, all states have the same actions\n",
+       "            self.actlist = actlist\n",
+       "\n",
+       "        elif isinstance(actlist, dict):\n",
+       "            # if actlist is a dict, different actions for each state\n",
+       "            self.actlist = actlist\n",
+       "\n",
+       "        self.terminals = terminals\n",
+       "        self.transitions = transitions or {}\n",
+       "        if not self.transitions:\n",
+       "            print("Warning: Transition table is empty.")\n",
+       "\n",
+       "        self.gamma = gamma\n",
+       "\n",
+       "        self.reward = reward or {s: 0 for s in self.states}\n",
+       "\n",
+       "        # self.check_consistency()\n",
+       "\n",
+       "    def R(self, state):\n",
+       "        """Return a numeric reward for this state."""\n",
+       "\n",
+       "        return self.reward[state]\n",
+       "\n",
+       "    def T(self, state, action):\n",
+       "        """Transition model. From a state and an action, return a list\n",
+       "        of (probability, result-state) pairs."""\n",
+       "\n",
+       "        if not self.transitions:\n",
+       "            raise ValueError("Transition model is missing")\n",
+       "        else:\n",
+       "            return self.transitions[state][action]\n",
+       "\n",
+       "    def actions(self, state):\n",
+       "        """Return a list of actions that can be performed in this state. By default, a\n",
+       "        fixed list of actions, except for terminal states. Override this\n",
+       "        method if you need to specialize by state."""\n",
+       "\n",
+       "        if state in self.terminals:\n",
+       "            return [None]\n",
+       "        else:\n",
+       "            return self.actlist\n",
+       "\n",
+       "    def get_states_from_transitions(self, transitions):\n",
+       "        if isinstance(transitions, dict):\n",
+       "            s1 = set(transitions.keys())\n",
+       "            s2 = set(tr[1] for actions in transitions.values()\n",
+       "                     for effects in actions.values()\n",
+       "                     for tr in effects)\n",
+       "            return s1.union(s2)\n",
+       "        else:\n",
+       "            print('Could not retrieve states from transitions')\n",
+       "            return None\n",
+       "\n",
+       "    def check_consistency(self):\n",
+       "\n",
+       "        # check that all states in transitions are valid\n",
+       "        assert set(self.states) == self.get_states_from_transitions(self.transitions)\n",
+       "\n",
+       "        # check that init is a valid state\n",
+       "        assert self.init in self.states\n",
+       "\n",
+       "        # check reward for each state\n",
+       "        assert set(self.reward.keys()) == set(self.states)\n",
+       "\n",
+       "        # check that all terminals are valid states\n",
+       "        assert all(t in self.states for t in self.terminals)\n",
+       "\n",
+       "        # check that probability distributions for all actions sum to 1\n",
+       "        for s1, actions in self.transitions.items():\n",
+       "            for a in actions.keys():\n",
+       "                s = 0\n",
+       "                for o in actions[a]:\n",
+       "                    s += o[0]\n",
+       "                assert abs(s - 1) < 0.001\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "psource(MDP)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The **_ _init_ _** method takes in the following parameters:\n", + "\n", + "- init: the initial state.\n", + "- actlist: List of actions possible in each state.\n", + "- terminals: List of terminal states where only possible action is exit\n", + "- gamma: Discounting factor. This makes sure that delayed rewards have less value compared to immediate ones.\n", + "\n", + "**R** method returns the reward for each state by using the self.reward dict.\n", + "\n", + "**T** method is not implemented and is somewhat different from the text. Here we return (probability, s') pairs which belong to list of possible state by taking action an in state s.\n", + "\n", + "**actions** method returns a list of actions possible in each state. By default, it returns all actions for states other than terminal states.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example\n", + "\n", + "Now let us implement the simple MDP in the image below. States A, B have actions X, Y available in them. Their probabilities are shown just above the arrows. We start by using MDP as the base class for our CustomMDP. Obviously, we need to make a few changes to suit our case. We make use of a transition matrix as our transitions are not very simple.\n", + "" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "# Transition Matrix as nested dict. State -> Actions in state -> List of (Probability, State) tuples\n", + "t = {\n", + " \"A\": {\n", + " \"X\": [(0.3, \"A\"), (0.7, \"B\")],\n", + " \"Y\": [(1.0, \"A\")]\n", + " },\n", + " \"B\": {\n", + " \"X\": {(0.8, \"End\"), (0.2, \"B\")},\n", + " \"Y\": {(1.0, \"A\")}\n", + " },\n", + " \"End\": {}\n", + "}\n", + "\n", + "init = \"A\"\n", + "\n", + "terminals = [\"End\"]\n", + "\n", + "rewards = {\n", + " \"A\": 5,\n", + " \"B\": -10,\n", + " \"End\": 100\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "class CustomMDP(MDP):\n", + " def __init__(self, init, terminals, transition_matrix, reward = None, gamma=.9):\n", + " # All possible actions.\n", + " actlist = []\n", + " for state in transition_matrix.keys():\n", + " actlist.extend(transition_matrix[state])\n", + " actlist = list(set(actlist))\n", + " MDP.__init__(self, init, actlist, terminals, transition_matrix, reward, gamma=gamma)\n", + "\n", + " def T(self, state, action):\n", + " if action is None:\n", + " return [(0.0, state)]\n", + " else: \n", + " return self.t[state][action]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Finally we instantize the class with the parameters for our MDP in the picture." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "our_mdp = CustomMDP(init, terminals, t, rewards, gamma=.9)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With this we have successfully represented our MDP. Later we will look at ways to solve this MDP." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# GRID MDP\n", + "\n", + "Now we look at a concrete implementation that makes use of the MDP as a base class. The GridMDP class in the MDP module is used to represent a grid world MDP like the one shown in **Fig 16.1** of the AIMA Book. We assume for now that the environment is _fully observable_ so that the agent always knows where it is. The code should be easy to understand if you have gone through the CustomMDP example." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
class GridMDP(MDP):\n",
+       "    """A two-dimensional grid MDP, as in [Figure 16.1]. All you have to do is\n",
+       "    specify the grid as a list of lists of rewards; use None for an obstacle\n",
+       "    (unreachable state). Also, you should specify the terminal states.\n",
+       "    An action is an (x, y) unit vector; e.g. (1, 0) means move east."""\n",
+       "\n",
+       "    def __init__(self, grid, terminals, init=(0, 0), gamma=.9):\n",
+       "        grid.reverse()  # because we want row 0 on bottom, not on top\n",
+       "        reward = {}\n",
+       "        states = set()\n",
+       "        self.rows = len(grid)\n",
+       "        self.cols = len(grid[0])\n",
+       "        self.grid = grid\n",
+       "        for x in range(self.cols):\n",
+       "            for y in range(self.rows):\n",
+       "                if grid[y][x]:\n",
+       "                    states.add((x, y))\n",
+       "                    reward[(x, y)] = grid[y][x]\n",
+       "        self.states = states\n",
+       "        actlist = orientations\n",
+       "        transitions = {}\n",
+       "        for s in states:\n",
+       "            transitions[s] = {}\n",
+       "            for a in actlist:\n",
+       "                transitions[s][a] = self.calculate_T(s, a)\n",
+       "        MDP.__init__(self, init, actlist=actlist,\n",
+       "                     terminals=terminals, transitions=transitions,\n",
+       "                     reward=reward, states=states, gamma=gamma)\n",
+       "\n",
+       "    def calculate_T(self, state, action):\n",
+       "        if action:\n",
+       "            return [(0.8, self.go(state, action)),\n",
+       "                    (0.1, self.go(state, turn_right(action))),\n",
+       "                    (0.1, self.go(state, turn_left(action)))]\n",
+       "        else:\n",
+       "            return [(0.0, state)]\n",
+       "\n",
+       "    def T(self, state, action):\n",
+       "        return self.transitions[state][action] if action else [(0.0, state)]\n",
+       "\n",
+       "    def go(self, state, direction):\n",
+       "        """Return the state that results from going in this direction."""\n",
+       "\n",
+       "        state1 = tuple(vector_add(state, direction))\n",
+       "        return state1 if state1 in self.states else state\n",
+       "\n",
+       "    def to_grid(self, mapping):\n",
+       "        """Convert a mapping from (x, y) to v into a [[..., v, ...]] grid."""\n",
+       "\n",
+       "        return list(reversed([[mapping.get((x, y), None)\n",
+       "                               for x in range(self.cols)]\n",
+       "                              for y in range(self.rows)]))\n",
+       "\n",
+       "    def to_arrows(self, policy):\n",
+       "        chars = {(1, 0): '>', (0, 1): '^', (-1, 0): '<', (0, -1): 'v', None: '.'}\n",
+       "        return self.to_grid({s: chars[a] for (s, a) in policy.items()})\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "psource(GridMDP)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The **_ _init_ _** method takes **grid** as an extra parameter compared to the MDP class. The grid is a nested list of rewards in states.\n", + "\n", + "**go** method returns the state by going in a particular direction by using vector_add.\n", + "\n", + "**T** method is not implemented and is somewhat different from the text. Here we return (probability, s') pairs belong to list of possible state by taking action an in state s.\n", + "\n", + "**actions** method returns a list of actions possible in each state. By default, it returns all actions for states other than terminal states.\n", + "\n", + "**to_arrows** are used for representing the policy in a grid-like format." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can create a GridMDP like the one in **Fig 17.1** as follows: \n", + "\n", + " GridMDP([[-0.04, -0.04, -0.04, +1],\n", + " [-0.04, None, -0.04, -1],\n", + " [-0.04, -0.04, -0.04, -0.04]],\n", + " terminals=[(3, 2), (3, 1)])\n", + " \n", + "In fact, the **sequential_decision_environment** in the MDP module has been instantiated using the exact same code." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "sequential_decision_environment" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/chapter16-17/MDPs.py b/notebooks/chapter16-17/MDPs.py new file mode 100644 index 000000000..9e3e7897a --- /dev/null +++ b/notebooks/chapter16-17/MDPs.py @@ -0,0 +1,151 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # MDP +# +# A sequential decision problem for a fully observable, stochastic environment with a Markovian transition model and additive rewards is called a Markov decision process. Markov model is very practical and can model many real-world decision-making processes. +# +# Before we start playing with the actual implementations let us review a couple of key points about MDPs. +# +# - A stochastic process has the **Markov property** if the conditional probability distribution of future states of the process (conditional on both past and present states) depends only upon the present state, not on the sequence of events that preceded it. +# +# -- Source: [Wikipedia](https://en.wikipedia.org/wiki/Markov_property) +# +# Often it is possible to model many different phenomena as a Markov process by being flexible with our definition of a state. +# +# +# - MDPs help us deal with fully-observable and non-deterministic/stochastic environments. For dealing with partially-observable and stochastic cases we make use of a generalization of MDPs named POMDPs (partially observable Markov decision process). +# +# Our overall goal to solve an MDP is to come up with a policy that guides us to select the best action in each state so as to maximize the expected sum of future rewards. + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.mdp import * +from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility + +# %% [markdown] +# ## Implementation +# +# To begin with let us look at the implementation of the MDP class defined in mdp.py The docstring tells us what all is required to define an MDP namely - set of states, actions, initial state, transition model, and a reward function. Each of these is implemented as a method. Do not close the popup so that you can follow along with the description of the code below. + +# %% +psource(MDP) + +# %% [markdown] +# The **_ _init_ _** method takes in the following parameters: +# +# - init: the initial state. +# - actlist: List of actions possible in each state. +# - terminals: List of terminal states where only possible action is exit +# - gamma: Discounting factor. This makes sure that delayed rewards have less value compared to immediate ones. +# +# **R** method returns the reward for each state by using the self.reward dict. +# +# **T** method is not implemented and is somewhat different from the text. Here we return (probability, s') pairs which belong to list of possible state by taking action an in state s. +# +# **actions** method returns a list of actions possible in each state. By default, it returns all actions for states other than terminal states. +# + +# %% [markdown] +# ## Example +# +# Now let us implement the simple MDP in the image below. States A, B have actions X, Y available in them. Their probabilities are shown just above the arrows. We start by using MDP as the base class for our CustomMDP. Obviously, we need to make a few changes to suit our case. We make use of a transition matrix as our transitions are not very simple. +# + +# %% +# Transition Matrix as nested dict. State -> Actions in state -> List of (Probability, State) tuples +t = { + "A": { + "X": [(0.3, "A"), (0.7, "B")], + "Y": [(1.0, "A")] + }, + "B": { + "X": {(0.8, "End"), (0.2, "B")}, + "Y": {(1.0, "A")} + }, + "End": {} +} + +init = "A" + +terminals = ["End"] + +rewards = { + "A": 5, + "B": -10, + "End": 100 +} + + +# %% +class CustomMDP(MDP): + def __init__(self, init, terminals, transition_matrix, reward = None, gamma=.9): + # All possible actions. + actlist = [] + for state in transition_matrix.keys(): + actlist.extend(transition_matrix[state]) + actlist = list(set(actlist)) + MDP.__init__(self, init, actlist, terminals, transition_matrix, reward, gamma=gamma) + + def T(self, state, action): + if action is None: + return [(0.0, state)] + else: + return self.t[state][action] + + +# %% [markdown] +# Finally we instantize the class with the parameters for our MDP in the picture. + +# %% +our_mdp = CustomMDP(init, terminals, t, rewards, gamma=.9) + +# %% [markdown] +# With this we have successfully represented our MDP. Later we will look at ways to solve this MDP. + +# %% [markdown] +# # GRID MDP +# +# Now we look at a concrete implementation that makes use of the MDP as a base class. The GridMDP class in the MDP module is used to represent a grid world MDP like the one shown in **Fig 16.1** of the AIMA Book. We assume for now that the environment is _fully observable_ so that the agent always knows where it is. The code should be easy to understand if you have gone through the CustomMDP example. + +# %% +psource(GridMDP) + +# %% [markdown] +# The **_ _init_ _** method takes **grid** as an extra parameter compared to the MDP class. The grid is a nested list of rewards in states. +# +# **go** method returns the state by going in a particular direction by using vector_add. +# +# **T** method is not implemented and is somewhat different from the text. Here we return (probability, s') pairs belong to list of possible state by taking action an in state s. +# +# **actions** method returns a list of actions possible in each state. By default, it returns all actions for states other than terminal states. +# +# **to_arrows** are used for representing the policy in a grid-like format. + +# %% [markdown] +# We can create a GridMDP like the one in **Fig 17.1** as follows: +# +# GridMDP([[-0.04, -0.04, -0.04, +1], +# [-0.04, None, -0.04, -1], +# [-0.04, -0.04, -0.04, -0.04]], +# terminals=[(3, 2), (3, 1)]) +# +# In fact, the **sequential_decision_environment** in the MDP module has been instantiated using the exact same code. + +# %% +sequential_decision_environment + +# %% diff --git a/notebooks/chapter16-17/Partially Observable MDP.ipynb b/notebooks/chapter16-17/Partially Observable MDP.ipynb new file mode 100644 index 000000000..4c1939793 --- /dev/null +++ b/notebooks/chapter16-17/Partially Observable MDP.ipynb @@ -0,0 +1,627 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# POMDP\n", + "---\n", + "POMDP is short for Partially Observable Markov Decision Problems.\n", + "\n", + "In retrospect, a Markov decision process or MDP is defined as:\n", + "- a sequential decision problem for a fully observable, stochastic environment with a Markovian transition model and additive rewards.\n", + "\n", + "An MDP consists of a set of states (with an initial state $s_0$); a set $A(s)$ of actions\n", + "in each state; a transition model $P(s' | s, a)$; and a reward function $R(s)$.\n", + "\n", + "The MDP seeks to make sequential decisions to occupy states to maximize some combination of the reward function $R(s)$.\n", + "\n", + "The characteristic problem of the MDP is hence to identify the optimal policy function $\\pi^*(s)$ that provides the _utility-maximising_ action $a$ to be taken when the current state is $s$.\n", + "\n", + "## Belief vector\n", + "\n", + "**Note**: The book refers to the _belief vector_ as the _belief state_. We use the latter terminology here to retain our ability to refer to the belief vector as a _probability distribution over states_.\n", + "\n", + "The solution of an MDP is subject to certain properties of the problem which are assumed and justified in [Section 17.1]. One critical assumption is that the agent is **fully aware of its current state at all times**.\n", + "\n", + "A tedious (but rewarding, as we will see) way of expressing this is in terms of the **belief vector** $b$ of the agent. The belief vector is a function mapping states to probabilities or certainties of being in those states.\n", + "\n", + "Consider an agent that is fully aware that it is in state $s_i$ in the state-space $(s_1, s_2, ... s_n)$ at the current time.\n", + "\n", + "Its belief vector is the vector $(b(s_1), b(s_2), ... b(s_n))$ given by the function $b(s)$:\n", + "\\begin{align*}\n", + "b(s) &= 0 \\quad \\text{if }s \\neq s_i \\\\ &= 1 \\quad \\text{if } s = s_i\n", + "\\end{align*}\n", + "\n", + "Note that $b(s)$ is a probability distribution that necessarily sums to $1$ over all $s$.\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## POMDPs - a conceptual outline\n", + "\n", + "The POMDP really has only two modifications to the **problem formulation** compared to the MDP.\n", + "\n", + "- **Belief state** - In the real world, the current state of an agent is often not known with complete certainty. This makes the concept of a belief vector extremely relevant. It allows the agent to represent different degrees of certainty with which it _believes_ it is in each state.\n", + "\n", + "- **Evidence percepts** - In the real world, agents often have certain kinds of evidence, collected from sensors. They can use the probability distribution of observed evidence, conditional on state, to consolidate their information. This is a known distribution $P(e\\ |\\ s)$ - $e$ being an evidence, and $s$ being the state it is conditional on.\n", + "\n", + "Consider the world we used for the MDP. \n", + "\n", + "![title](images/grid_mdp.jpg)\n", + "\n", + "### Using the belief vector\n", + "An agent beginning at $(1, 1)$ may not be certain that it is indeed in $(1, 1)$. Consider a belief vector $b$ such that:\n", + "\\begin{align*}\n", + " b((1,1)) &= 0.8 \\\\\n", + " b((2,1)) &= 0.1 \\\\\n", + " b((1,2)) &= 0.1 \\\\\n", + " b(s) &= 0 \\quad \\quad \\forall \\text{ other } s\n", + "\\end{align*}\n", + "\n", + "By horizontally catenating each row, we can represent this as an 11-dimensional vector (omitting $(2, 2)$).\n", + "\n", + "Thus, taking $s_1 = (1, 1)$, $s_2 = (1, 2)$, ... $s_{11} = (4,3)$, we have $b$:\n", + "\n", + "$b = (0.8, 0.1, 0, 0, 0.1, 0, 0, 0, 0, 0, 0)$ \n", + "\n", + "This fully represents the certainty to which the agent is aware of its state.\n", + "\n", + "### Using evidence\n", + "The evidence observed here could be the number of adjacent 'walls' or 'dead ends' observed by the agent. We assume that the agent cannot 'orient' the walls - only count them.\n", + "\n", + "In this case, $e$ can take only two values, 1 and 2. This gives $P(e\\ |\\ s)$ as:\n", + "\\begin{align*}\n", + " P(e=2\\ |\\ s) &= \\frac{1}{7} \\quad \\forall \\quad s \\in \\{s_1, s_2, s_4, s_5, s_8, s_9, s_{11}\\}\\\\\n", + " P(e=1\\ |\\ s) &= \\frac{1}{4} \\quad \\forall \\quad s \\in \\{s_3, s_6, s_7, s_{10}\\} \\\\\n", + " P(e\\ |\\ s) &= 0 \\quad \\forall \\quad \\text{ other } s, e\n", + "\\end{align*}\n", + "\n", + "Note that the implications of the evidence on the state must be known **a priori** to the agent. Ways of reliably learning this distribution from percepts are beyond the scope of this notebook." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## POMDPs - a rigorous outline\n", + "\n", + "A POMDP is thus a sequential decision problem for a *partially* observable, stochastic environment with a Markovian transition model, a known 'sensor model' for inferring state from observation, and additive rewards. \n", + "\n", + "Practically, a POMDP has the following, which an MDP also has:\n", + "- a set of states, each denoted by $s$\n", + "- a set of actions available in each state, $A(s)$\n", + "- a reward accrued on attaining some state, $R(s)$\n", + "- a transition probability $P(s'\\ |\\ s, a)$ of action $a$ changing the state from $s$ to $s'$\n", + "\n", + "And the following, which an MDP does not:\n", + "- a sensor model $P(e\\ |\\ s)$ on evidence conditional on states\n", + "\n", + "Additionally, the POMDP is now uncertain of its current state hence has:\n", + "- a belief vector $b$ representing the certainty of being in each state (as a probability distribution)\n", + "\n", + "\n", + "### New uncertainties\n", + "\n", + "It is useful to intuitively appreciate the new uncertainties that have arisen in the agent's awareness of its own state.\n", + "\n", + "- At any point, the agent has a belief vector $b$, the distribution of its believed likelihood of being in each state $s$.\n", + "- For each of these states $s$ that the agent may **actually** be in, it has some set of actions given by $A(s)$.\n", + "- Each of these actions may transport it to some other state $s'$, assuming an initial state $s$, with probability $P(s'\\ |\\ s, a)$\n", + "- Once the action is performed, the agent receives a percept $e$. $P(e\\ |\\ s)$ now tells it the chances of having perceived $e$ for each state $s$. The agent must use this information to update its new belief state appropriately.\n", + "\n", + "### Evolution of the belief vector - the `FORWARD` function\n", + "\n", + "The new belief vector $b'(s')$ after an action $a$ on the belief vector $b(s)$ and the noting of evidence $e$ is:\n", + "$$ b'(s') = \\alpha P(e\\ |\\ s') \\sum_s P(s'\\ | s, a) b(s)$$ \n", + "\n", + "where $\\alpha$ is a normalizing constant (to retain the interpretation of $b$ as a probability distribution.\n", + "\n", + "This equation is just counting the sum of likelihoods of going to a state $s'$ from every possible state $s$, times the initial likelihood of being in each $s$. This is multiplied by the likelihood that the known evidence actually implies the new state $s'$. \n", + "\n", + "This function is represented as `b' = FORWARD(b, a, e)`\n", + "\n", + "### Probability distribution of the evolving belief vector\n", + "\n", + "The goal here is to find $P(b'\\ |\\ b, a)$ - the probability that action $a$ transforms belief vector $b$ into belief vector $b'$. The following steps illustrate this -\n", + "\n", + "The probability of observing evidence $e$ when action $a$ is enacted on belief vector $b$ can be distributed over each possible new state $s'$ resulting from it:\n", + "\\begin{align*}\n", + " P(e\\ |\\ b, a) &= \\sum_{s'} P(e\\ |\\ b, a, s') P(s'\\ |\\ b, a) \\\\\n", + " &= \\sum_{s'} P(e\\ |\\ s') P(s'\\ |\\ b, a) \\\\\n", + " &= \\sum_{s'} P(e\\ |\\ s') \\sum_s P(s'\\ |\\ s, a) b(s)\n", + "\\end{align*}\n", + "\n", + "The probability of getting belief vector $b'$ from $b$ by application of action $a$ can thus be summed over all possible evidence $e$:\n", + "\\begin{align*}\n", + " P(b'\\ |\\ b, a) &= \\sum_{e} P(b'\\ |\\ b, a, e) P(e\\ |\\ b, a) \\\\\n", + " &= \\sum_{e} P(b'\\ |\\ b, a, e) \\sum_{s'} P(e\\ |\\ s') \\sum_s P(s'\\ |\\ s, a) b(s)\n", + "\\end{align*}\n", + "\n", + "where $P(b'\\ |\\ b, a, e) = 1$ if $b' = $ `FORWARD(b, a, e)` and $= 0$ otherwise.\n", + "\n", + "Given initial and final belief states $b$ and $b'$, the transition probabilities still depend on the action $a$ and observed evidence $e$. Some belief states may be achievable by certain actions, but have non-zero probabilities for states prohibited by the evidence $e$. Thus, the above condition thus ensures that only valid combinations of $(b', b, a, e)$ are considered.\n", + "\n", + "### A modified reward space\n", + "\n", + "For MDPs, the reward space was simple - one reward per available state. However, for a belief vector $b(s)$, the expected reward is now:\n", + "$$\\rho(b) = \\sum_s b(s) R(s)$$\n", + "\n", + "Thus, as the belief vector can take infinite values of the distribution over states, so can the reward for each belief vector vary over a hyperplane in the belief space, or space of states (planes in an $N$-dimensional space are formed by a linear combination of the axes)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that we know the basics, let's have a look at the `POMDP` class." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
class POMDP(MDP):\n",
+       "    """A Partially Observable Markov Decision Process, defined by\n",
+       "    a transition model P(s'|s,a), actions A(s), a reward function R(s),\n",
+       "    and a sensor model P(e|s). We also keep track of a gamma value,\n",
+       "    for use by algorithms. The transition and the sensor models\n",
+       "    are defined as matrices. We also keep track of the possible states\n",
+       "    and actions for each state. [page 659]."""\n",
+       "\n",
+       "    def __init__(self, actions, transitions=None, evidences=None, rewards=None, states=None, gamma=0.95):\n",
+       "        """Initialize variables of the pomdp"""\n",
+       "\n",
+       "        if not (0 < gamma <= 1):\n",
+       "            raise ValueError('A POMDP must have 0 < gamma <= 1')\n",
+       "\n",
+       "        self.states = states\n",
+       "        self.actions = actions\n",
+       "\n",
+       "        # transition model cannot be undefined\n",
+       "        self.t_prob = transitions or {}\n",
+       "        if not self.t_prob:\n",
+       "            print('Warning: Transition model is undefined')\n",
+       "\n",
+       "        # sensor model cannot be undefined\n",
+       "        self.e_prob = evidences or {}\n",
+       "        if not self.e_prob:\n",
+       "            print('Warning: Sensor model is undefined')\n",
+       "\n",
+       "        self.gamma = gamma\n",
+       "        self.rewards = rewards\n",
+       "\n",
+       "    def remove_dominated_plans(self, input_values):\n",
+       "        """\n",
+       "        Remove dominated plans.\n",
+       "        This method finds all the lines contributing to the\n",
+       "        upper surface and removes those which don't.\n",
+       "        """\n",
+       "\n",
+       "        values = [val for action in input_values for val in input_values[action]]\n",
+       "        values.sort(key=lambda x: x[0], reverse=True)\n",
+       "\n",
+       "        best = [values[0]]\n",
+       "        y1_max = max(val[1] for val in values)\n",
+       "        tgt = values[0]\n",
+       "        prev_b = 0\n",
+       "        prev_ix = 0\n",
+       "        while tgt[1] != y1_max:\n",
+       "            min_b = 1\n",
+       "            min_ix = 0\n",
+       "            for i in range(prev_ix + 1, len(values)):\n",
+       "                if values[i][0] - tgt[0] + tgt[1] - values[i][1] != 0:\n",
+       "                    trans_b = (values[i][0] - tgt[0]) / (values[i][0] - tgt[0] + tgt[1] - values[i][1])\n",
+       "                    if 0 <= trans_b <= 1 and trans_b > prev_b and trans_b < min_b:\n",
+       "                        min_b = trans_b\n",
+       "                        min_ix = i\n",
+       "            prev_b = min_b\n",
+       "            prev_ix = min_ix\n",
+       "            tgt = values[min_ix]\n",
+       "            best.append(tgt)\n",
+       "\n",
+       "        return self.generate_mapping(best, input_values)\n",
+       "\n",
+       "    def remove_dominated_plans_fast(self, input_values):\n",
+       "        """\n",
+       "        Remove dominated plans using approximations.\n",
+       "        Resamples the upper boundary at intervals of 100 and\n",
+       "        finds the maximum values at these points.\n",
+       "        """\n",
+       "\n",
+       "        values = [val for action in input_values for val in input_values[action]]\n",
+       "        values.sort(key=lambda x: x[0], reverse=True)\n",
+       "\n",
+       "        best = []\n",
+       "        sr = 100\n",
+       "        for i in range(sr + 1):\n",
+       "            x = i / float(sr)\n",
+       "            maximum = (values[0][1] - values[0][0]) * x + values[0][0]\n",
+       "            tgt = values[0]\n",
+       "            for value in values:\n",
+       "                val = (value[1] - value[0]) * x + value[0]\n",
+       "                if val > maximum:\n",
+       "                    maximum = val\n",
+       "                    tgt = value\n",
+       "\n",
+       "            if all(any(tgt != v) for v in best):\n",
+       "                best.append(np.array(tgt))\n",
+       "\n",
+       "        return self.generate_mapping(best, input_values)\n",
+       "\n",
+       "    def generate_mapping(self, best, input_values):\n",
+       "        """Generate mappings after removing dominated plans"""\n",
+       "\n",
+       "        mapping = defaultdict(list)\n",
+       "        for value in best:\n",
+       "            for action in input_values:\n",
+       "                if any(all(value == v) for v in input_values[action]):\n",
+       "                    mapping[action].append(value)\n",
+       "\n",
+       "        return mapping\n",
+       "\n",
+       "    def max_difference(self, U1, U2):\n",
+       "        """Find maximum difference between two utility mappings"""\n",
+       "\n",
+       "        for k, v in U1.items():\n",
+       "            sum1 = 0\n",
+       "            for element in U1[k]:\n",
+       "                sum1 += sum(element)\n",
+       "            sum2 = 0\n",
+       "            for element in U2[k]:\n",
+       "                sum2 += sum(element)\n",
+       "        return abs(sum1 - sum2)\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", + "from aima.mdp import *\n", + "from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility\n", + "psource(POMDP)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `POMDP` class includes all variables of the `MDP` class and additionally also stores the sensor model in `e_prob`.\n", + "
\n", + "
\n", + "`remove_dominated_plans`, `remove_dominated_plans_fast`, `generate_mapping` and `max_difference` are helper methods for `pomdp_value_iteration` which will be explained shortly." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To understand how we can model a partially observable MDP, let's take a simple example.\n", + "Let's consider a simple two state world.\n", + "The states are labeled 0 and 1, with the reward at state 0 being 0 and at state 1 being 1.\n", + "
\n", + "There are two actions:\n", + "
\n", + "`Stay`: stays put with probability 0.9 and\n", + "`Go`: switches to the other state with probability 0.9.\n", + "
\n", + "For now, let's assume the discount factor `gamma` to be 1.\n", + "
\n", + "The sensor reports the correct state with probability 0.6.\n", + "
\n", + "This is a simple problem with a trivial solution.\n", + "Obviously, the agent should `Stay` when it thinks it is in state 1 and `Go` when it thinks it is in state 0.\n", + "
\n", + "The belief space can be viewed as one-dimensional because the two probabilities must sum to 1.\n", + "\n", + "Let's model this POMDP using the `POMDP` class." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# transition probability P(s'|s,a)\n", + "t_prob = [[[0.9, 0.1], [0.1, 0.9]], [[0.1, 0.9], [0.9, 0.1]]]\n", + "# evidence function P(e|s)\n", + "e_prob = [[[0.6, 0.4], [0.4, 0.6]], [[0.6, 0.4], [0.4, 0.6]]]\n", + "# reward function\n", + "rewards = [[0.0, 0.0], [1.0, 1.0]]\n", + "# discount factor\n", + "gamma = 0.95\n", + "# actions\n", + "actions = ('0', '1')\n", + "# states\n", + "states = ('0', '1')" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we have defined our `POMDP` object." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## POMDP VALUE ITERATION\n", + "Defining a POMDP is useless unless we can find a way to solve it. As POMDPs can have infinitely many belief states, we cannot calculate one utility value for each state as we did in `value_iteration` for MDPs.\n", + "
\n", + "Instead of thinking about policies, we should think about conditional plans and how the expected utility of executing a fixed conditional plan varies with the initial belief state.\n", + "
\n", + "If we bound the depth of the conditional plans, then there are only finitely many such plans and the continuous space of belief states will generally be divided inte _regions_, each corresponding to a particular conditional plan that is optimal in that region. The utility function, being the maximum of a collection of hyperplanes, will be piecewise linear and convex.\n", + "
\n", + "For the one-step plans `Stay` and `Go`, the utility values are as follows\n", + "
\n", + "
\n", + "$$\\alpha_{|Stay|}(0) = R(0) + \\gamma(0.9R(0) + 0.1R(1)) = 0.1$$\n", + "$$\\alpha_{|Stay|}(1) = R(1) + \\gamma(0.9R(1) + 0.1R(0)) = 1.9$$\n", + "$$\\alpha_{|Go|}(0) = R(0) + \\gamma(0.9R(1) + 0.1R(0)) = 0.9$$\n", + "$$\\alpha_{|Go|}(1) = R(1) + \\gamma(0.9R(0) + 0.1R(1)) = 1.1$$" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The utility function can be found by `pomdp_value_iteration`.\n", + "
\n", + "To summarize, it generates a set of all plans consisting of an action and, for each possible next percept, a plan in U with computed utility vectors.\n", + "The dominated plans are then removed from this set and the process is repeated until the maximum difference between the utility functions of two consecutive iterations reaches a value less than a threshold value." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pseudocode('POMDP-Value-Iteration')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's have a look at the `pomdp_value_iteration` function." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(pomdp_value_iteration)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's try solving a simple one-dimensional POMDP using value-iteration.\n", + "
\n", + "Consider the problem of a user listening to voicemails.\n", + "At the end of each message, they can either _save_ or _delete_ a message.\n", + "This forms the unobservable state _S = {save, delete}_.\n", + "It is the task of the POMDP solver to guess which goal the user has.\n", + "
\n", + "The belief space has two elements, _b(s = save)_ and _b(s = delete)_.\n", + "For example, for the belief state _b = (1, 0)_, the left end of the line segment indicates _b(s = save) = 1_ and _b(s = delete) = 0_.\n", + "The intermediate points represent varying degrees of certainty in the user's goal.\n", + "
\n", + "The machine has three available actions: it can _ask_ what the user wishes to do in order to infer his or her current goal, or it can _doSave_ or _doDelete_ and move to the next message.\n", + "If the user says _save_, then an error may occur with probability 0.2, whereas if the user says _delete_, an error may occur with a probability 0.3.\n", + "
\n", + "The machine receives a large positive reward (+5) for getting the user's goal correct, a very large negative reward (-20) for taking the action _doDelete_ when the user wanted _save_, and a smaller but still significant negative reward (-10) for taking the action _doSave_ when the user wanted _delete_. \n", + "There is also a small negative reward for taking the _ask_ action (-1).\n", + "The discount factor is set to 0.95 for this example.\n", + "
\n", + "Let's define the POMDP." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "# transition function P(s'|s,a)\n", + "t_prob = [[[0.65, 0.35], [0.65, 0.35]], [[0.65, 0.35], [0.65, 0.35]], [[1.0, 0.0], [0.0, 1.0]]]\n", + "# evidence function P(e|s)\n", + "e_prob = [[[0.5, 0.5], [0.5, 0.5]], [[0.5, 0.5], [0.5, 0.5]], [[0.8, 0.2], [0.3, 0.7]]]\n", + "# reward function\n", + "rewards = [[5, -10], [-20, 5], [-1, -1]]\n", + "\n", + "gamma = 0.95\n", + "actions = ('0', '1', '2')\n", + "states = ('0', '1')\n", + "\n", + "pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We have defined the `POMDP` object.\n", + "Let's run `pomdp_value_iteration` to find the utility function." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "utility = pomdp_value_iteration(pomdp, epsilon=0.1)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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X//6VyIjI7sdl8RyWbduHQ8//PAZP7viiiu4+eabcXJywsbGBmdnZz766CPt762srLQlIxcXF+66667eDntA09jYSFRUFK2trVhbW3PjjTfyt7/9DYDbbruN7OxsJkyYgBACHx8fNm7ceMbrO7pm7NixWFlZMW7cOG6++Wbuu+++i96rp2RnwzPPwPvvq3++5RZ45BEIC/vfz7qDYqyna0VRQoE48b89gSohhPtpf18phPC40D0mTZokztdjuLiumM3pm4lPi2dL+hZqW2qxs7JjTugclkYsJTY8ljAP06+99UW2VagTtAWenmaORCLpPgN5HGdkwL//DR99BDod3HYbPPwwhIT87xpFURKFEJO6c39TikAKMEcIUagoSgDwkxBi+IXu0ZEInE5Lewu7c3cTlxpHXGocaRXqks4on1HEhseyNGIp04OnY60z5SSn7zDn0CEAfho/3syRSCTdZyCO45QUNfl/8gnY2MDtt8Pf/w5BQede2xMRMGWm3AT8EXj21H+/McZNba1smRc2j3lh83gp5iVSy1OJT40nPi2el399med/eR53e3cuHXYpS8OXcumwS/Fy9DLGW0skEonJOX4cnn4aPvsM7OzgvvvgwQfBVIWHxioRXQfMAbwVRckDnkRN/p8rivInIAe42hjvdTYRXhFEREdwf/T91DTXkJCRQFxaHN+lfcdnRz9Dp+iIHhStzRJG+45GURRThCKRSCTd5rff4Kmn4IsvwNFRTfwPPAC+vqZ9X6MtBxmDziwHdRa90HOg4IC2uXywUK3YCHEL0QRhbuhcHGwcjPJ+lspAnEZL+h/9eRwfPgwrV8LXX4OLC9xzD9x/P3h7d/4elroc1GUuZuzUFXSKjilBU5gSNIUVc1dQUFvAd2nfEZcax4dHPuTtA2/jYO3A/CHzWRq+lNiIWAa5DjLa+0skEsmFOHBATf6bNoGbGzzxhLr009t73xY1E1AURQDodDr8/f2JiYnh0UcfZejQoUZ9n6a2JnZk71A3l9PiyK7KBmCc3zhtljAlaApWOiujvq85SGloAGC4o6OZI5FIuk9/Gse//gorVsDmzeDhoT7133MPuLtf/LUdYRHVQcbAIAId4erqSlRUFA888ADLli0zytq+EIITZSe0ZaOfc3+mXbTj7ejN4mGLWRqxlEVDF+Fu34P/QxKJZMCze7ea/BMSwMtL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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "%matplotlib inline\n", + "plot_pomdp_utility(utility)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/chapter16-17/Partially Observable MDP.py b/notebooks/chapter16-17/Partially Observable MDP.py new file mode 100644 index 000000000..27200b1d7 --- /dev/null +++ b/notebooks/chapter16-17/Partially Observable MDP.py @@ -0,0 +1,291 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # POMDP +# --- +# POMDP is short for Partially Observable Markov Decision Problems. +# +# In retrospect, a Markov decision process or MDP is defined as: +# - a sequential decision problem for a fully observable, stochastic environment with a Markovian transition model and additive rewards. +# +# An MDP consists of a set of states (with an initial state $s_0$); a set $A(s)$ of actions +# in each state; a transition model $P(s' | s, a)$; and a reward function $R(s)$. +# +# The MDP seeks to make sequential decisions to occupy states to maximize some combination of the reward function $R(s)$. +# +# The characteristic problem of the MDP is hence to identify the optimal policy function $\pi^*(s)$ that provides the _utility-maximising_ action $a$ to be taken when the current state is $s$. +# +# ## Belief vector +# +# **Note**: The book refers to the _belief vector_ as the _belief state_. We use the latter terminology here to retain our ability to refer to the belief vector as a _probability distribution over states_. +# +# The solution of an MDP is subject to certain properties of the problem which are assumed and justified in [Section 17.1]. One critical assumption is that the agent is **fully aware of its current state at all times**. +# +# A tedious (but rewarding, as we will see) way of expressing this is in terms of the **belief vector** $b$ of the agent. The belief vector is a function mapping states to probabilities or certainties of being in those states. +# +# Consider an agent that is fully aware that it is in state $s_i$ in the state-space $(s_1, s_2, ... s_n)$ at the current time. +# +# Its belief vector is the vector $(b(s_1), b(s_2), ... b(s_n))$ given by the function $b(s)$: +# \begin{align*} +# b(s) &= 0 \quad \text{if }s \neq s_i \\ &= 1 \quad \text{if } s = s_i +# \end{align*} +# +# Note that $b(s)$ is a probability distribution that necessarily sums to $1$ over all $s$. +# +# + +# %% [markdown] +# ## POMDPs - a conceptual outline +# +# The POMDP really has only two modifications to the **problem formulation** compared to the MDP. +# +# - **Belief state** - In the real world, the current state of an agent is often not known with complete certainty. This makes the concept of a belief vector extremely relevant. It allows the agent to represent different degrees of certainty with which it _believes_ it is in each state. +# +# - **Evidence percepts** - In the real world, agents often have certain kinds of evidence, collected from sensors. They can use the probability distribution of observed evidence, conditional on state, to consolidate their information. This is a known distribution $P(e\ |\ s)$ - $e$ being an evidence, and $s$ being the state it is conditional on. +# +# Consider the world we used for the MDP. +# +# ![title](images/grid_mdp.jpg) +# +# ### Using the belief vector +# An agent beginning at $(1, 1)$ may not be certain that it is indeed in $(1, 1)$. Consider a belief vector $b$ such that: +# \begin{align*} +# b((1,1)) &= 0.8 \\ +# b((2,1)) &= 0.1 \\ +# b((1,2)) &= 0.1 \\ +# b(s) &= 0 \quad \quad \forall \text{ other } s +# \end{align*} +# +# By horizontally catenating each row, we can represent this as an 11-dimensional vector (omitting $(2, 2)$). +# +# Thus, taking $s_1 = (1, 1)$, $s_2 = (1, 2)$, ... $s_{11} = (4,3)$, we have $b$: +# +# $b = (0.8, 0.1, 0, 0, 0.1, 0, 0, 0, 0, 0, 0)$ +# +# This fully represents the certainty to which the agent is aware of its state. +# +# ### Using evidence +# The evidence observed here could be the number of adjacent 'walls' or 'dead ends' observed by the agent. We assume that the agent cannot 'orient' the walls - only count them. +# +# In this case, $e$ can take only two values, 1 and 2. This gives $P(e\ |\ s)$ as: +# \begin{align*} +# P(e=2\ |\ s) &= \frac{1}{7} \quad \forall \quad s \in \{s_1, s_2, s_4, s_5, s_8, s_9, s_{11}\}\\ +# P(e=1\ |\ s) &= \frac{1}{4} \quad \forall \quad s \in \{s_3, s_6, s_7, s_{10}\} \\ +# P(e\ |\ s) &= 0 \quad \forall \quad \text{ other } s, e +# \end{align*} +# +# Note that the implications of the evidence on the state must be known **a priori** to the agent. Ways of reliably learning this distribution from percepts are beyond the scope of this notebook. + +# %% [markdown] +# ## POMDPs - a rigorous outline +# +# A POMDP is thus a sequential decision problem for a *partially* observable, stochastic environment with a Markovian transition model, a known 'sensor model' for inferring state from observation, and additive rewards. +# +# Practically, a POMDP has the following, which an MDP also has: +# - a set of states, each denoted by $s$ +# - a set of actions available in each state, $A(s)$ +# - a reward accrued on attaining some state, $R(s)$ +# - a transition probability $P(s'\ |\ s, a)$ of action $a$ changing the state from $s$ to $s'$ +# +# And the following, which an MDP does not: +# - a sensor model $P(e\ |\ s)$ on evidence conditional on states +# +# Additionally, the POMDP is now uncertain of its current state hence has: +# - a belief vector $b$ representing the certainty of being in each state (as a probability distribution) +# +# +# ### New uncertainties +# +# It is useful to intuitively appreciate the new uncertainties that have arisen in the agent's awareness of its own state. +# +# - At any point, the agent has a belief vector $b$, the distribution of its believed likelihood of being in each state $s$. +# - For each of these states $s$ that the agent may **actually** be in, it has some set of actions given by $A(s)$. +# - Each of these actions may transport it to some other state $s'$, assuming an initial state $s$, with probability $P(s'\ |\ s, a)$ +# - Once the action is performed, the agent receives a percept $e$. $P(e\ |\ s)$ now tells it the chances of having perceived $e$ for each state $s$. The agent must use this information to update its new belief state appropriately. +# +# ### Evolution of the belief vector - the `FORWARD` function +# +# The new belief vector $b'(s')$ after an action $a$ on the belief vector $b(s)$ and the noting of evidence $e$ is: +# $$ b'(s') = \alpha P(e\ |\ s') \sum_s P(s'\ | s, a) b(s)$$ +# +# where $\alpha$ is a normalizing constant (to retain the interpretation of $b$ as a probability distribution. +# +# This equation is just counting the sum of likelihoods of going to a state $s'$ from every possible state $s$, times the initial likelihood of being in each $s$. This is multiplied by the likelihood that the known evidence actually implies the new state $s'$. +# +# This function is represented as `b' = FORWARD(b, a, e)` +# +# ### Probability distribution of the evolving belief vector +# +# The goal here is to find $P(b'\ |\ b, a)$ - the probability that action $a$ transforms belief vector $b$ into belief vector $b'$. The following steps illustrate this - +# +# The probability of observing evidence $e$ when action $a$ is enacted on belief vector $b$ can be distributed over each possible new state $s'$ resulting from it: +# \begin{align*} +# P(e\ |\ b, a) &= \sum_{s'} P(e\ |\ b, a, s') P(s'\ |\ b, a) \\ +# &= \sum_{s'} P(e\ |\ s') P(s'\ |\ b, a) \\ +# &= \sum_{s'} P(e\ |\ s') \sum_s P(s'\ |\ s, a) b(s) +# \end{align*} +# +# The probability of getting belief vector $b'$ from $b$ by application of action $a$ can thus be summed over all possible evidence $e$: +# \begin{align*} +# P(b'\ |\ b, a) &= \sum_{e} P(b'\ |\ b, a, e) P(e\ |\ b, a) \\ +# &= \sum_{e} P(b'\ |\ b, a, e) \sum_{s'} P(e\ |\ s') \sum_s P(s'\ |\ s, a) b(s) +# \end{align*} +# +# where $P(b'\ |\ b, a, e) = 1$ if $b' = $ `FORWARD(b, a, e)` and $= 0$ otherwise. +# +# Given initial and final belief states $b$ and $b'$, the transition probabilities still depend on the action $a$ and observed evidence $e$. Some belief states may be achievable by certain actions, but have non-zero probabilities for states prohibited by the evidence $e$. Thus, the above condition thus ensures that only valid combinations of $(b', b, a, e)$ are considered. +# +# ### A modified reward space +# +# For MDPs, the reward space was simple - one reward per available state. However, for a belief vector $b(s)$, the expected reward is now: +# $$\rho(b) = \sum_s b(s) R(s)$$ +# +# Thus, as the belief vector can take infinite values of the distribution over states, so can the reward for each belief vector vary over a hyperplane in the belief space, or space of states (planes in an $N$-dimensional space are formed by a linear combination of the axes). + +# %% [markdown] +# Now that we know the basics, let's have a look at the `POMDP` class. + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.mdp import * +from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility +psource(POMDP) + +# %% [markdown] +# The `POMDP` class includes all variables of the `MDP` class and additionally also stores the sensor model in `e_prob`. +#
+#
+# `remove_dominated_plans`, `remove_dominated_plans_fast`, `generate_mapping` and `max_difference` are helper methods for `pomdp_value_iteration` which will be explained shortly. + +# %% [markdown] +# To understand how we can model a partially observable MDP, let's take a simple example. +# Let's consider a simple two state world. +# The states are labeled 0 and 1, with the reward at state 0 being 0 and at state 1 being 1. +#
+# There are two actions: +#
+# `Stay`: stays put with probability 0.9 and +# `Go`: switches to the other state with probability 0.9. +#
+# For now, let's assume the discount factor `gamma` to be 1. +#
+# The sensor reports the correct state with probability 0.6. +#
+# This is a simple problem with a trivial solution. +# Obviously, the agent should `Stay` when it thinks it is in state 1 and `Go` when it thinks it is in state 0. +#
+# The belief space can be viewed as one-dimensional because the two probabilities must sum to 1. +# +# Let's model this POMDP using the `POMDP` class. + +# %% +# transition probability P(s'|s,a) +t_prob = [[[0.9, 0.1], [0.1, 0.9]], [[0.1, 0.9], [0.9, 0.1]]] +# evidence function P(e|s) +e_prob = [[[0.6, 0.4], [0.4, 0.6]], [[0.6, 0.4], [0.4, 0.6]]] +# reward function +rewards = [[0.0, 0.0], [1.0, 1.0]] +# discount factor +gamma = 0.95 +# actions +actions = ('0', '1') +# states +states = ('0', '1') + +# %% +pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma) + +# %% [markdown] +# Now we have defined our `POMDP` object. + +# %% [markdown] +# ## POMDP VALUE ITERATION +# Defining a POMDP is useless unless we can find a way to solve it. As POMDPs can have infinitely many belief states, we cannot calculate one utility value for each state as we did in `value_iteration` for MDPs. +#
+# Instead of thinking about policies, we should think about conditional plans and how the expected utility of executing a fixed conditional plan varies with the initial belief state. +#
+# If we bound the depth of the conditional plans, then there are only finitely many such plans and the continuous space of belief states will generally be divided inte _regions_, each corresponding to a particular conditional plan that is optimal in that region. The utility function, being the maximum of a collection of hyperplanes, will be piecewise linear and convex. +#
+# For the one-step plans `Stay` and `Go`, the utility values are as follows +#
+#
+# $$\alpha_{|Stay|}(0) = R(0) + \gamma(0.9R(0) + 0.1R(1)) = 0.1$$ +# $$\alpha_{|Stay|}(1) = R(1) + \gamma(0.9R(1) + 0.1R(0)) = 1.9$$ +# $$\alpha_{|Go|}(0) = R(0) + \gamma(0.9R(1) + 0.1R(0)) = 0.9$$ +# $$\alpha_{|Go|}(1) = R(1) + \gamma(0.9R(0) + 0.1R(1)) = 1.1$$ + +# %% [markdown] +# The utility function can be found by `pomdp_value_iteration`. +#
+# To summarize, it generates a set of all plans consisting of an action and, for each possible next percept, a plan in U with computed utility vectors. +# The dominated plans are then removed from this set and the process is repeated until the maximum difference between the utility functions of two consecutive iterations reaches a value less than a threshold value. + +# %% +pseudocode('POMDP-Value-Iteration') + +# %% [markdown] +# Let's have a look at the `pomdp_value_iteration` function. + +# %% +psource(pomdp_value_iteration) + +# %% [markdown] +# Let's try solving a simple one-dimensional POMDP using value-iteration. +#
+# Consider the problem of a user listening to voicemails. +# At the end of each message, they can either _save_ or _delete_ a message. +# This forms the unobservable state _S = {save, delete}_. +# It is the task of the POMDP solver to guess which goal the user has. +#
+# The belief space has two elements, _b(s = save)_ and _b(s = delete)_. +# For example, for the belief state _b = (1, 0)_, the left end of the line segment indicates _b(s = save) = 1_ and _b(s = delete) = 0_. +# The intermediate points represent varying degrees of certainty in the user's goal. +#
+# The machine has three available actions: it can _ask_ what the user wishes to do in order to infer his or her current goal, or it can _doSave_ or _doDelete_ and move to the next message. +# If the user says _save_, then an error may occur with probability 0.2, whereas if the user says _delete_, an error may occur with a probability 0.3. +#
+# The machine receives a large positive reward (+5) for getting the user's goal correct, a very large negative reward (-20) for taking the action _doDelete_ when the user wanted _save_, and a smaller but still significant negative reward (-10) for taking the action _doSave_ when the user wanted _delete_. +# There is also a small negative reward for taking the _ask_ action (-1). +# The discount factor is set to 0.95 for this example. +#
+# Let's define the POMDP. + +# %% +# transition function P(s'|s,a) +t_prob = [[[0.65, 0.35], [0.65, 0.35]], [[0.65, 0.35], [0.65, 0.35]], [[1.0, 0.0], [0.0, 1.0]]] +# evidence function P(e|s) +e_prob = [[[0.5, 0.5], [0.5, 0.5]], [[0.5, 0.5], [0.5, 0.5]], [[0.8, 0.2], [0.3, 0.7]]] +# reward function +rewards = [[5, -10], [-20, 5], [-1, -1]] + +gamma = 0.95 +actions = ('0', '1', '2') +states = ('0', '1') + +pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma) + +# %% [markdown] +# We have defined the `POMDP` object. +# Let's run `pomdp_value_iteration` to find the utility function. + +# %% +utility = pomdp_value_iteration(pomdp, epsilon=0.1) + +# %% +# %matplotlib inline +plot_pomdp_utility(utility) + +# %% diff --git a/notebooks/chapter16-17/Sequential Decision Problems.ipynb b/notebooks/chapter16-17/Sequential Decision Problems.ipynb new file mode 100644 index 000000000..f3ccb3d93 --- /dev/null +++ b/notebooks/chapter16-17/Sequential Decision Problems.ipynb @@ -0,0 +1,706 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sequential Decision Problems\n", + "\n", + "Now that we have defined MDPs in `MPDs.ipynb` and the tools required to solve MDPs in `Algorithms for MDPs.ipynb`, let us see how Sequential Decision Problems can be solved step by step and how a few built-in tools in the GridMDP class help us better analyze the problem at hand. \n", + "As always, we will work with the grid world from **Figure 16.1** from the book.\n", + "\n", + "
This is the environment for our agent.\n", + "We assume for now that the environment is _fully observable_, so that the agent always knows where it is.\n", + "We also assume that the transitions are **Markovian**, that is, the probability of reaching state $s'$ from state $s$ depends only on $s$ and not on the history of earlier states.\n", + "Almost all stochastic decision problems can be reframed as a Markov Decision Process just by tweaking the definition of a _state_ for that particular problem.\n", + "
\n", + "However, the actions of our agents in this environment are unreliable. In other words, the motion of our agent is stochastic. \n", + "

\n", + "More specifically, the agent may - \n", + "* move correctly in the intended direction with a probability of _0.8_, \n", + "* move $90^\\circ$ to the right of the intended direction with a probability 0.1\n", + "* move $90^\\circ$ to the left of the intended direction with a probability 0.1\n", + "

\n", + "The agent stays put if it bumps into a wall.\n", + "" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "These properties of the agent are called the transition properties and are hardcoded into the GridMDP class as you can see below." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", + "from aima.mdp import *\n", + "from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
    def T(self, state, action):\n",
+       "        return self.transitions[state][action] if action else [(0.0, state)]\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "psource(GridMDP.T)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To completely define our task environment, we need to specify the utility function for the agent. \n", + "This is the function that gives the agent a rough estimate of how good being in a particular state is, or how much _reward_ an agent receives by being in that state.\n", + "The agent then tries to maximize the reward it gets.\n", + "As the decision problem is sequential, the utility function will depend on a sequence of states rather than on a single state.\n", + "For now, we simply stipulate that in each state $s$, the agent receives a finite reward $R(s)$.\n", + "\n", + "For any given state, the actions the agent can take are encoded as given below:\n", + "- Move Up: (0, 1)\n", + "- Move Down: (0, -1)\n", + "- Move Left: (-1, 0)\n", + "- Move Right: (1, 0)\n", + "- Do nothing: `None`\n", + "\n", + "We now wonder what a valid solution to the problem might look like. \n", + "We cannot have fixed action sequences as the environment is stochastic and we can eventually end up in an undesirable state.\n", + "Therefore, a solution must specify what the agent should do for _any_ state the agent might reach.\n", + "
\n", + "Such a solution is known as a **policy** and is usually denoted by $\\pi$.\n", + "
\n", + "The **optimal policy** is the policy that yields the highest expected utility an is usually denoted by $\\pi^*$.\n", + "
\n", + "The `GridMDP` class has a useful method `to_arrows` that outputs a grid showing the direction the agent should move, given a policy.\n", + "We will use this later to better understand the properties of the environment." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
    def to_arrows(self, policy):\n",
+       "        chars = {(1, 0): '>', (0, 1): '^', (-1, 0): '<', (0, -1): 'v', None: '.'}\n",
+       "        return self.to_grid({s: chars[a] for (s, a) in policy.items()})\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "psource(GridMDP.to_arrows)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This method directly encodes the actions that the agent can take (described above) to characters representing arrows and shows it in a grid format for human visalization purposes. \n", + "It converts the received policy from a `dictionary` to a grid using the `to_grid` method." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
    def to_grid(self, mapping):\n",
+       "        """Convert a mapping from (x, y) to v into a [[..., v, ...]] grid."""\n",
+       "\n",
+       "        return list(reversed([[mapping.get((x, y), None)\n",
+       "                               for x in range(self.cols)]\n",
+       "                              for y in range(self.rows)]))\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "psource(GridMDP.to_grid)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that we have all the tools required and a good understanding of the agent and the environment, we consider some cases and see how the agent should behave for each case." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Case 1\n", + "---\n", + "R(s) = -0.04 in all states except terminal states" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "# Note that this environment is also initialized in mdp.py by default\n", + "sequential_decision_environment = GridMDP([[-0.04, -0.04, -0.04, +1],\n", + " [-0.04, None, -0.04, -1],\n", + " [-0.04, -0.04, -0.04, -0.04]],\n", + " terminals=[(3, 2), (3, 1)])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will use the `best_policy` function to find the best policy for this environment.\n", + "But, as you can see, `best_policy` requires a utility function as well.\n", + "We already know that the utility function can be found by `value_iteration`.\n", + "Hence, our best policy is:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can now use the `to_arrows` method to see how our agent should pick its actions in the environment." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "> > > .\n", + "^ None ^ .\n", + "^ > ^ <\n" + ] + } + ], + "source": [ + "from aima.utils import print_table\n", + "print_table(sequential_decision_environment.to_arrows(pi))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is exactly the output we expected\n", + "
\n", + "\n", + "
\n", + "\n", + "Notice that, because the cost of taking a step is fairly small compared with the penalty for ending up in `(4, 2)` by accident, the optimal policy is conservative. \n", + "In state `(3, 1)` it recommends taking the long way round, rather than taking the shorter way and risking getting a large negative reward of -1 in `(4, 2)`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Case 2\n", + "---\n", + "R(s) = -0.4 in all states except in terminal states" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "sequential_decision_environment = GridMDP([[-0.4, -0.4, -0.4, +1],\n", + " [-0.4, None, -0.4, -1],\n", + " [-0.4, -0.4, -0.4, -0.4]],\n", + " terminals=[(3, 2), (3, 1)])" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "> > > .\n", + "^ None ^ .\n", + "^ > ^ <\n" + ] + } + ], + "source": [ + "pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001))\n", + "from aima.utils import print_table\n", + "print_table(sequential_decision_environment.to_arrows(pi))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is exactly the output we expected\n", + "\n", + "\n", + "As the reward for each state is now more negative, life is certainly more unpleasant.\n", + "The agent takes the shortest route to the +1 state and is willing to risk falling into the -1 state by accident." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Case 3\n", + "---\n", + "R(s) = -4 in all states except terminal states" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "sequential_decision_environment = GridMDP([[-4, -4, -4, +1],\n", + " [-4, None, -4, -1],\n", + " [-4, -4, -4, -4]],\n", + " terminals=[(3, 2), (3, 1)])" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "> > > .\n", + "^ None > .\n", + "> > > ^\n" + ] + } + ], + "source": [ + "pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001))\n", + "from aima.utils import print_table\n", + "print_table(sequential_decision_environment.to_arrows(pi))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is exactly the output we expected\n", + "\n", + "\n", + "The living reward for each state is now lower than the least rewarding terminal. Life is so _painful_ that the agent heads for the nearest exit as even the worst exit is less painful than any living state." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Case 4\n", + "---\n", + "R(s) = 4 in all states except terminal states" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "sequential_decision_environment = GridMDP([[4, 4, 4, +1],\n", + " [4, None, 4, -1],\n", + " [4, 4, 4, 4]],\n", + " terminals=[(3, 2), (3, 1)])" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "> > < .\n", + "> None < .\n", + "> > > v\n" + ] + } + ], + "source": [ + "pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001))\n", + "from aima.utils import print_table\n", + "print_table(sequential_decision_environment.to_arrows(pi))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this case, the output we expect is\n", + "\n", + "
\n", + "As life is positively enjoyable and the agent avoids _both_ exits.\n", + "Even though the output we get is not exactly what we want, it is definitely not wrong.\n", + "The scenario here requires the agent to anything but reach a terminal state, as this is the only way the agent can maximize its reward (total reward tends to infinity), and the program does just that.\n", + "
\n", + "Currently, the GridMDP class doesn't support an explicit marker for a \"do whatever you like\" action or a \"don't care\" condition.\n", + "You can, however, extend the class to do so.\n", + "
\n", + "For in-depth knowledge about sequential decision problems, refer **Section 17.1** in the AIMA book." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/chapter16-17/Sequential Decision Problems.py b/notebooks/chapter16-17/Sequential Decision Problems.py new file mode 100644 index 000000000..4c390b492 --- /dev/null +++ b/notebooks/chapter16-17/Sequential Decision Problems.py @@ -0,0 +1,197 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Sequential Decision Problems +# +# Now that we have defined MDPs in `MPDs.ipynb` and the tools required to solve MDPs in `Algorithms for MDPs.ipynb`, let us see how Sequential Decision Problems can be solved step by step and how a few built-in tools in the GridMDP class help us better analyze the problem at hand. +# As always, we will work with the grid world from **Figure 16.1** from the book. +# +#
This is the environment for our agent. +# We assume for now that the environment is _fully observable_, so that the agent always knows where it is. +# We also assume that the transitions are **Markovian**, that is, the probability of reaching state $s'$ from state $s$ depends only on $s$ and not on the history of earlier states. +# Almost all stochastic decision problems can be reframed as a Markov Decision Process just by tweaking the definition of a _state_ for that particular problem. +#
+# However, the actions of our agents in this environment are unreliable. In other words, the motion of our agent is stochastic. +#

+# More specifically, the agent may - +# * move correctly in the intended direction with a probability of _0.8_, +# * move $90^\circ$ to the right of the intended direction with a probability 0.1 +# * move $90^\circ$ to the left of the intended direction with a probability 0.1 +#

+# The agent stays put if it bumps into a wall. +# + +# %% [markdown] +# These properties of the agent are called the transition properties and are hardcoded into the GridMDP class as you can see below. + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.mdp import * +from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility + +# %% +psource(GridMDP.T) + +# %% [markdown] +# To completely define our task environment, we need to specify the utility function for the agent. +# This is the function that gives the agent a rough estimate of how good being in a particular state is, or how much _reward_ an agent receives by being in that state. +# The agent then tries to maximize the reward it gets. +# As the decision problem is sequential, the utility function will depend on a sequence of states rather than on a single state. +# For now, we simply stipulate that in each state $s$, the agent receives a finite reward $R(s)$. +# +# For any given state, the actions the agent can take are encoded as given below: +# - Move Up: (0, 1) +# - Move Down: (0, -1) +# - Move Left: (-1, 0) +# - Move Right: (1, 0) +# - Do nothing: `None` +# +# We now wonder what a valid solution to the problem might look like. +# We cannot have fixed action sequences as the environment is stochastic and we can eventually end up in an undesirable state. +# Therefore, a solution must specify what the agent should do for _any_ state the agent might reach. +#
+# Such a solution is known as a **policy** and is usually denoted by $\pi$. +#
+# The **optimal policy** is the policy that yields the highest expected utility an is usually denoted by $\pi^*$. +#
+# The `GridMDP` class has a useful method `to_arrows` that outputs a grid showing the direction the agent should move, given a policy. +# We will use this later to better understand the properties of the environment. + +# %% +psource(GridMDP.to_arrows) + +# %% [markdown] +# This method directly encodes the actions that the agent can take (described above) to characters representing arrows and shows it in a grid format for human visalization purposes. +# It converts the received policy from a `dictionary` to a grid using the `to_grid` method. + +# %% +psource(GridMDP.to_grid) + +# %% [markdown] +# Now that we have all the tools required and a good understanding of the agent and the environment, we consider some cases and see how the agent should behave for each case. + +# %% [markdown] +# ## Case 1 +# --- +# R(s) = -0.04 in all states except terminal states + +# %% +# Note that this environment is also initialized in mdp.py by default +sequential_decision_environment = GridMDP([[-0.04, -0.04, -0.04, +1], + [-0.04, None, -0.04, -1], + [-0.04, -0.04, -0.04, -0.04]], + terminals=[(3, 2), (3, 1)]) + +# %% [markdown] +# We will use the `best_policy` function to find the best policy for this environment. +# But, as you can see, `best_policy` requires a utility function as well. +# We already know that the utility function can be found by `value_iteration`. +# Hence, our best policy is: + +# %% +pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001)) + +# %% [markdown] +# We can now use the `to_arrows` method to see how our agent should pick its actions in the environment. + +# %% +from aima.utils import print_table +print_table(sequential_decision_environment.to_arrows(pi)) + +# %% [markdown] +# This is exactly the output we expected +#
+# +#
+# +# Notice that, because the cost of taking a step is fairly small compared with the penalty for ending up in `(4, 2)` by accident, the optimal policy is conservative. +# In state `(3, 1)` it recommends taking the long way round, rather than taking the shorter way and risking getting a large negative reward of -1 in `(4, 2)`. + +# %% [markdown] +# ## Case 2 +# --- +# R(s) = -0.4 in all states except in terminal states + +# %% +sequential_decision_environment = GridMDP([[-0.4, -0.4, -0.4, +1], + [-0.4, None, -0.4, -1], + [-0.4, -0.4, -0.4, -0.4]], + terminals=[(3, 2), (3, 1)]) + +# %% +pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001)) +from aima.utils import print_table +print_table(sequential_decision_environment.to_arrows(pi)) + +# %% [markdown] +# This is exactly the output we expected +# +# +# As the reward for each state is now more negative, life is certainly more unpleasant. +# The agent takes the shortest route to the +1 state and is willing to risk falling into the -1 state by accident. + +# %% [markdown] +# ## Case 3 +# --- +# R(s) = -4 in all states except terminal states + +# %% +sequential_decision_environment = GridMDP([[-4, -4, -4, +1], + [-4, None, -4, -1], + [-4, -4, -4, -4]], + terminals=[(3, 2), (3, 1)]) + +# %% +pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001)) +from aima.utils import print_table +print_table(sequential_decision_environment.to_arrows(pi)) + +# %% [markdown] +# This is exactly the output we expected +# +# +# The living reward for each state is now lower than the least rewarding terminal. Life is so _painful_ that the agent heads for the nearest exit as even the worst exit is less painful than any living state. + +# %% [markdown] +# ## Case 4 +# --- +# R(s) = 4 in all states except terminal states + +# %% +sequential_decision_environment = GridMDP([[4, 4, 4, +1], + [4, None, 4, -1], + [4, 4, 4, 4]], + terminals=[(3, 2), (3, 1)]) + +# %% +pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001)) +from aima.utils import print_table +print_table(sequential_decision_environment.to_arrows(pi)) + +# %% [markdown] +# In this case, the output we expect is +# +#
+# As life is positively enjoyable and the agent avoids _both_ exits. +# Even though the output we get is not exactly what we want, it is definitely not wrong. +# The scenario here requires the agent to anything but reach a terminal state, as this is the only way the agent can maximize its reward (total reward tends to infinity), and the program does just that. +#
+# Currently, the GridMDP class doesn't support an explicit marker for a "do whatever you like" action or a "don't care" condition. +# You can, however, extend the class to do so. +#
+# For in-depth knowledge about sequential decision problems, refer **Section 17.1** in the AIMA book. + +# %% diff --git a/notebooks/chapter16-17/images/-0.04.jpg b/notebooks/chapter16-17/images/-0.04.jpg new file mode 100644 index 000000000..3cf276421 Binary files /dev/null and b/notebooks/chapter16-17/images/-0.04.jpg differ diff --git a/notebooks/chapter16-17/images/-0.4.jpg b/notebooks/chapter16-17/images/-0.4.jpg new file mode 100644 index 000000000..b274d2ce3 Binary files /dev/null and b/notebooks/chapter16-17/images/-0.4.jpg differ diff --git a/notebooks/chapter16-17/images/-4.jpg b/notebooks/chapter16-17/images/-4.jpg new file mode 100644 index 000000000..79eefb0cd Binary files /dev/null and b/notebooks/chapter16-17/images/-4.jpg differ diff --git a/notebooks/chapter16-17/images/4.jpg b/notebooks/chapter16-17/images/4.jpg new file mode 100644 index 000000000..55e75001d Binary files /dev/null and b/notebooks/chapter16-17/images/4.jpg differ diff --git a/notebooks/chapter16-17/images/grid_mdp.jpg b/notebooks/chapter16-17/images/grid_mdp.jpg new file mode 100644 index 000000000..fa77fa276 Binary files /dev/null and b/notebooks/chapter16-17/images/grid_mdp.jpg differ diff --git a/notebooks/chapter16-17/images/grid_mdp_agent.jpg b/notebooks/chapter16-17/images/grid_mdp_agent.jpg new file mode 100644 index 000000000..3f247b6f2 Binary files /dev/null and b/notebooks/chapter16-17/images/grid_mdp_agent.jpg differ diff --git a/notebooks/chapter16-17/images/maze.png b/notebooks/chapter16-17/images/maze.png new file mode 100644 index 000000000..f3fcd1990 Binary files /dev/null and b/notebooks/chapter16-17/images/maze.png differ diff --git a/notebooks/chapter16-17/images/mdp-a.png b/notebooks/chapter16-17/images/mdp-a.png new file mode 100644 index 000000000..2f3774891 Binary files /dev/null and b/notebooks/chapter16-17/images/mdp-a.png differ diff --git a/notebooks/chapter16-17/images/mdp-b.png b/notebooks/chapter16-17/images/mdp-b.png new file mode 100644 index 000000000..f21a3760c Binary files /dev/null and b/notebooks/chapter16-17/images/mdp-b.png differ diff --git a/notebooks/chapter16-17/images/mdp-c.png b/notebooks/chapter16-17/images/mdp-c.png new file mode 100644 index 000000000..1034079a2 Binary files /dev/null and b/notebooks/chapter16-17/images/mdp-c.png differ diff --git a/notebooks/chapter16-17/images/mdp-d.png b/notebooks/chapter16-17/images/mdp-d.png new file mode 100644 index 000000000..8ba7cf073 Binary files /dev/null and b/notebooks/chapter16-17/images/mdp-d.png differ diff --git a/notebooks/chapter16-17/images/mdp.png b/notebooks/chapter16-17/images/mdp.png new file mode 100644 index 000000000..e874130ee Binary files /dev/null and b/notebooks/chapter16-17/images/mdp.png differ diff --git a/notebooks/chapter18/Datasets.ipynb b/notebooks/chapter18/Datasets.ipynb new file mode 100644 index 000000000..303873122 --- /dev/null +++ b/notebooks/chapter18/Datasets.ipynb @@ -0,0 +1,646 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# DATASETS" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The following tutorial is a demonstration of the `DataSet` data structure which is frequently used in the following sections. `DataSet` plays the role of organizing data in different forms to make them able to be used by machine learning algorithms. Here we make the following datasets as examples:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- Fisher's Iris: Each item represents a flower, with four measurements: the length and the width of the sepals and petals. Each item/flower is categorized into one of three species: Setosa, Versicolor and Virginica.\n", + "\n", + "- Zoo: The dataset holds different animals and their classification as \"mammal\", \"fish\", etc. The new animal we want to classify has the following measurements: 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 4, 1, 0, 1 (don't concern yourself with what the measurements mean).\n", + "\n", + "- Restaurant: The restaurant example in Fig XX of the book. Each item in the dataset represents a condition of customers to make decisions. The target class of each item can be \"yes\" or \"no\", meaning whether to dine in this restaurant.\n", + "\n", + "- Orings: The dataset holds different conditions of the night before each launch of the space shuttle. It is to predict the number of O-rings that will experience thermal distress for a given flight when the launch temperature is below freezing. The target class can be 0,1 or 2 meaning the number of oring failures." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To make use the datasets easier, we have written a class, DataSet, in learning.py. The tutorials found here make use of this class. Now let's have a look at how it works." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Intro" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A lot of the datasets we will work with are .csv files (although other formats are supported too). We have a collection of sample datasets ready to use on [aima-data](https://github.com/aimacode/aima-data/tree/a21fc108f52ad551344e947b0eb97df82f8d2b2b). Four examples are the datasets mentioned above (iris.csv, zoo.csv, orings.csv, and restaurant.csv). You can find plenty of datasets online, and a good repository of such datasets is [UCI Machine Learning Repository](https://archive.ics.uci.edu/ml/datasets.php)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In such files, each line corresponds to one item/measurement. Each individual value in a line represents a feature and usually there is a value denoting the class of the item." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can find the code for the dataset in `learning.py` or use the following code:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%psource DataSet" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Importing a Dataset" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are multiple ways to import a dataset from the `learning` module. But first the necessary modules need to be imported:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", + "from aima.learning import *\n", + "from aima.notebook_utils import *" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Importing from aima-data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Dataset uploaded to aima-data can be imported as the following:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "iris = DataSet(name=\"iris\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To check that we imported the correct dataset, we can do the following:" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[5.1, 3.5, 1.4, 0.2, 'setosa']\n", + "[0, 1, 2, 3]\n" + ] + } + ], + "source": [ + "print(iris.examples[0])\n", + "print(iris.inputs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Which correctly prints the first line in the csv file and the list of attribute indexes.\n", + "\n", + "When importing a dataset, we can specify to exclude an attribute (for example, at index 1) by setting the parameter exclude to the attribute index or name" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[0, 2, 3]\n" + ] + } + ], + "source": [ + "iris2 = DataSet(name=\"iris\",exclude=[1])\n", + "print(iris2.inputs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Constructing your own dataset" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In order to use self-defined datasets, you need to prepare the csv files for the datasets in the following format of the [iris example](https://github.com/aimacode/aima-data/blob/a21fc108f52ad551344e947b0eb97df82f8d2b2b/iris.csv). Then you can create your own dataset by specifying the correct dataset name, attributes, targets and exclusive attributes." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here is how we create restaurant dataset in Figure 18.3 from restaurant.csv:" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [], + "source": [ + "def RestaurantDataSet(examples=None):\n", + " \"\"\"Build a DataSet of Restaurant waiting examples. [Figure 18.3]\"\"\"\n", + " return DataSet(name='restaurant', target='Wait', examples=examples,\n", + " attr_names='Alternate Bar Fri/Sat Hungry Patrons Price ' +\n", + " 'Raining Reservation Type WaitEstimate Wait')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Please note that the dataset name should be the same to the csv file name in order to assist the program finding the correct file." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "restaurant = RestaurantDataSet()\n", + "restaurant.inputs" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Class Attributes" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here we will demonstrate the attributes of a `DataSet` object and how they can be utilized. All the attributes can be specified when defining a dataset." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- examples: Holds the items of the dataset. Each item is a list of values. Could be indexed or sliced." + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[[5.1, 3.5, 1.4, 0.2, 'setosa'],\n", + " [4.9, 3.0, 1.4, 0.2, 'setosa'],\n", + " [4.7, 3.2, 1.3, 0.2, 'setosa']]" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "iris.examples[:3]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- **attrs**: The indexes of the features (by default in the range of [0,f), where f is the number of features). For example, item[i] returns the feature at index i of item.\n", + "\n", + "- **attrnames**: An optional list with attribute names. For example, item[s], where s is a feature name, returns the feature of name s in item.\n", + "\n", + "- **target**: The attribute a learning algorithm will try to predict. By default the last attribute.\n", + "\n", + "- **inputs**: This is the indexes of attributes without the target." + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "attrs: [0, 1, 2, 3, 4]\n", + "attrnames (by default same as attrs): ['sepal-len', 'sepal-width', 'petal-len', 'petal-width', 'class']\n", + "target: 4\n", + "inputs: [0, 1, 2, 3]\n" + ] + } + ], + "source": [ + "print(\"attrs:\", iris.attrs)\n", + "print(\"attr_names (by default same as attrs):\", iris.attr_names)\n", + "print(\"target:\", iris.target)\n", + "print(\"inputs:\", iris.inputs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- **values**: A list of lists which holds the set of possible values for the corresponding attribute/feature. If initially None, it gets computed (by the function setproblem) from the examples." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For instance if we want to show the possible values of the first attribute:" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[4.7, 5.5, 5.0, 4.9, 5.1, 4.6, 5.4, 4.4, 4.8, 4.3, 5.8, 7.0, 7.1, 4.5, 5.9, 5.6, 6.9, 6.5, 6.4, 6.6, 6.0, 6.1, 7.6, 7.4, 7.9, 5.7, 5.3, 5.2, 6.3, 6.7, 6.2, 6.8, 7.3, 7.2, 7.7]\n" + ] + } + ], + "source": [ + "print(iris.values[0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- **name**: Name of the dataset." + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "name: iris\n" + ] + } + ], + "source": [ + "print(\"name:\", iris.name)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- **source**: The source of the dataset (url or other). Not used in the code.\n", + "\n", + "- **exclude**: A list of indexes to exclude from inputs. The list can include either attribute indexes (attrs) or names (attrnames)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Helper Functions" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will now take a look at the auxiliary functions found in the class. These functions help modify a DataSet object to your needs." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- **sanitize**: Takes as input an example and returns it with non-input (target) attributes replaced by None. Useful for testing. Keep in mind that the example given is not itself sanitized, but instead a sanitized copy is returned." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the function doesn't actually change the given example; it returns a sanitized copy of it." + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Sanitized: [5.1, 3.5, 1.4, 0.2, None]\n", + "Original: [5.1, 3.5, 1.4, 0.2, 'setosa']\n" + ] + } + ], + "source": [ + "print(\"Sanitized:\",iris.sanitize(iris.examples[0]))\n", + "print(\"Original:\",iris.examples[0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- **classes_to_numbers**: Maps the class names of a dataset to numbers. If the class names are not given, they are computed from the dataset values. Useful for classifiers that return a numerical value instead of a string." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For a lot of the classifiers in the book, classes should have numerical values. With this function we are able to map string class names to numbers." + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Class of first example: setosa\n", + "Class of first example: 0\n" + ] + } + ], + "source": [ + "print(\"Class of first example:\",iris2.examples[0][iris2.target])\n", + "iris2.classes_to_numbers()\n", + "print(\"Class of first example:\",iris2.examples[0][iris2.target])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- **remove_examples**: Removes examples containing a given value. Useful for removing examples with missing values, or for removing classes (needed for binary classifiers)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Currently the iris dataset has three classes, setosa, virginica and versicolor. We want though to convert it to a binary class dataset (a dataset with two classes). The class we want to remove is \"virginica\". To accomplish that we will utilize the helper function remove_examples." + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['setosa', 'versicolor']\n" + ] + } + ], + "source": [ + "iris2 = DataSet(name=\"iris\")\n", + "\n", + "iris2.remove_examples(\"virginica\")\n", + "print(iris2.values[iris2.target])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- **find_means_and_deviations**: find the mean values and deviations of each class in the dataset." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In the iris example we have three classes, thus both means and deviations have the length of 3." + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "3 3\n" + ] + } + ], + "source": [ + "means, deviations = iris.find_means_and_deviations()\n", + "print(len(means), len(deviations))" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Setosa feature means: [5.006, 3.418, 1.464, 0.244]\n", + "Versicolor mean for first feature: 5.936\n", + "Setosa feature deviations: [0.3524896872134513, 0.38102439795469095, 0.17351115943644546, 0.10720950308167838]\n", + "Virginica deviation for second feature: 0.32249663817263746\n" + ] + } + ], + "source": [ + "print(\"Setosa feature means:\", means[\"setosa\"])\n", + "print(\"Versicolor mean for first feature:\", means[\"versicolor\"][0])\n", + "\n", + "print(\"Setosa feature deviations:\", deviations[\"setosa\"])\n", + "print(\"Virginica deviation for second feature:\",deviations[\"virginica\"][1])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Dataset Visualization" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Since the example datasets are used extensively in the code of the book, below we show the common ways to provide a visualized tool that helps in comprehending the dataset and thus how the algorithms work." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Iris Visualization" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We plot the dataset in a 3D space using matplotlib and the function show_iris from notebook.py. The function takes as input three parameters, i, j and k, which are indicises to the iris features, \"Sepal Length\", \"Sepal Width\", \"Petal Length\" and \"Petal Width\" (0 to 3). By default we show the first three features." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "iris = DataSet(name=\"iris\")\n", + "\n", + "show_iris()\n", + "show_iris(0, 1, 3)\n", + "show_iris(1, 2, 3)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/chapter18/Datasets.py b/notebooks/chapter18/Datasets.py new file mode 100644 index 000000000..63a7c855e --- /dev/null +++ b/notebooks/chapter18/Datasets.py @@ -0,0 +1,230 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # DATASETS + +# %% [markdown] +# The following tutorial is a demonstration of the `DataSet` data structure which is frequently used in the following sections. `DataSet` plays the role of organizing data in different forms to make them able to be used by machine learning algorithms. Here we make the following datasets as examples: + +# %% [markdown] +# - Fisher's Iris: Each item represents a flower, with four measurements: the length and the width of the sepals and petals. Each item/flower is categorized into one of three species: Setosa, Versicolor and Virginica. +# +# - Zoo: The dataset holds different animals and their classification as "mammal", "fish", etc. The new animal we want to classify has the following measurements: 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 4, 1, 0, 1 (don't concern yourself with what the measurements mean). +# +# - Restaurant: The restaurant example in Fig XX of the book. Each item in the dataset represents a condition of customers to make decisions. The target class of each item can be "yes" or "no", meaning whether to dine in this restaurant. +# +# - Orings: The dataset holds different conditions of the night before each launch of the space shuttle. It is to predict the number of O-rings that will experience thermal distress for a given flight when the launch temperature is below freezing. The target class can be 0,1 or 2 meaning the number of oring failures. + +# %% [markdown] +# To make use the datasets easier, we have written a class, DataSet, in learning.py. The tutorials found here make use of this class. Now let's have a look at how it works. + +# %% [markdown] +# ## Intro + +# %% [markdown] +# A lot of the datasets we will work with are .csv files (although other formats are supported too). We have a collection of sample datasets ready to use on [aima-data](https://github.com/aimacode/aima-data/tree/a21fc108f52ad551344e947b0eb97df82f8d2b2b). Four examples are the datasets mentioned above (iris.csv, zoo.csv, orings.csv, and restaurant.csv). You can find plenty of datasets online, and a good repository of such datasets is [UCI Machine Learning Repository](https://archive.ics.uci.edu/ml/datasets.php). + +# %% [markdown] +# In such files, each line corresponds to one item/measurement. Each individual value in a line represents a feature and usually there is a value denoting the class of the item. + +# %% [markdown] +# You can find the code for the dataset in `learning.py` or use the following code: + +# %% +# %psource DataSet + +# %% [markdown] +# ## Importing a Dataset + +# %% [markdown] +# There are multiple ways to import a dataset from the `learning` module. But first the necessary modules need to be imported: + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.learning import * +from aima.notebook_utils import * + +# %% [markdown] +# ### Importing from aima-data + +# %% [markdown] +# Dataset uploaded to aima-data can be imported as the following: + +# %% +iris = DataSet(name="iris") + +# %% [markdown] +# To check that we imported the correct dataset, we can do the following: + +# %% +print(iris.examples[0]) +print(iris.inputs) + +# %% [markdown] +# Which correctly prints the first line in the csv file and the list of attribute indexes. +# +# When importing a dataset, we can specify to exclude an attribute (for example, at index 1) by setting the parameter exclude to the attribute index or name + +# %% +iris2 = DataSet(name="iris",exclude=[1]) +print(iris2.inputs) + + +# %% [markdown] +# ### Constructing your own dataset + +# %% [markdown] +# In order to use self-defined datasets, you need to prepare the csv files for the datasets in the following format of the [iris example](https://github.com/aimacode/aima-data/blob/a21fc108f52ad551344e947b0eb97df82f8d2b2b/iris.csv). Then you can create your own dataset by specifying the correct dataset name, attributes, targets and exclusive attributes. + +# %% [markdown] +# Here is how we create restaurant dataset in Figure 18.3 from restaurant.csv: + +# %% +def RestaurantDataSet(examples=None): + """Build a DataSet of Restaurant waiting examples. [Figure 18.3]""" + return DataSet(name='restaurant', target='Wait', examples=examples, + attr_names='Alternate Bar Fri/Sat Hungry Patrons Price ' + + 'Raining Reservation Type WaitEstimate Wait') + + +# %% [markdown] +# Please note that the dataset name should be the same to the csv file name in order to assist the program finding the correct file. + +# %% +restaurant = RestaurantDataSet() +restaurant.inputs + +# %% [markdown] +# ## Class Attributes + +# %% [markdown] +# Here we will demonstrate the attributes of a `DataSet` object and how they can be utilized. All the attributes can be specified when defining a dataset. + +# %% [markdown] +# - examples: Holds the items of the dataset. Each item is a list of values. Could be indexed or sliced. + +# %% +iris.examples[:3] + +# %% [markdown] +# - **attrs**: The indexes of the features (by default in the range of [0,f), where f is the number of features). For example, item[i] returns the feature at index i of item. +# +# - **attrnames**: An optional list with attribute names. For example, item[s], where s is a feature name, returns the feature of name s in item. +# +# - **target**: The attribute a learning algorithm will try to predict. By default the last attribute. +# +# - **inputs**: This is the indexes of attributes without the target. + +# %% +print("attrs:", iris.attrs) +print("attr_names (by default same as attrs):", iris.attr_names) +print("target:", iris.target) +print("inputs:", iris.inputs) + +# %% [markdown] +# - **values**: A list of lists which holds the set of possible values for the corresponding attribute/feature. If initially None, it gets computed (by the function setproblem) from the examples. + +# %% [markdown] +# For instance if we want to show the possible values of the first attribute: + +# %% +print(iris.values[0]) + +# %% [markdown] +# - **name**: Name of the dataset. + +# %% +print("name:", iris.name) + +# %% [markdown] +# - **source**: The source of the dataset (url or other). Not used in the code. +# +# - **exclude**: A list of indexes to exclude from inputs. The list can include either attribute indexes (attrs) or names (attrnames). + +# %% [markdown] +# ## Helper Functions + +# %% [markdown] +# We will now take a look at the auxiliary functions found in the class. These functions help modify a DataSet object to your needs. + +# %% [markdown] +# - **sanitize**: Takes as input an example and returns it with non-input (target) attributes replaced by None. Useful for testing. Keep in mind that the example given is not itself sanitized, but instead a sanitized copy is returned. + +# %% [markdown] +# Note that the function doesn't actually change the given example; it returns a sanitized copy of it. + +# %% +print("Sanitized:",iris.sanitize(iris.examples[0])) +print("Original:",iris.examples[0]) + +# %% [markdown] +# - **classes_to_numbers**: Maps the class names of a dataset to numbers. If the class names are not given, they are computed from the dataset values. Useful for classifiers that return a numerical value instead of a string. + +# %% [markdown] +# For a lot of the classifiers in the book, classes should have numerical values. With this function we are able to map string class names to numbers. + +# %% +print("Class of first example:",iris2.examples[0][iris2.target]) +iris2.classes_to_numbers() +print("Class of first example:",iris2.examples[0][iris2.target]) + +# %% [markdown] +# - **remove_examples**: Removes examples containing a given value. Useful for removing examples with missing values, or for removing classes (needed for binary classifiers). + +# %% [markdown] +# Currently the iris dataset has three classes, setosa, virginica and versicolor. We want though to convert it to a binary class dataset (a dataset with two classes). The class we want to remove is "virginica". To accomplish that we will utilize the helper function remove_examples. + +# %% +iris2 = DataSet(name="iris") + +iris2.remove_examples("virginica") +print(iris2.values[iris2.target]) + +# %% [markdown] +# - **find_means_and_deviations**: find the mean values and deviations of each class in the dataset. + +# %% [markdown] +# In the iris example we have three classes, thus both means and deviations have the length of 3. + +# %% +means, deviations = iris.find_means_and_deviations() +print(len(means), len(deviations)) + +# %% +print("Setosa feature means:", means["setosa"]) +print("Versicolor mean for first feature:", means["versicolor"][0]) + +print("Setosa feature deviations:", deviations["setosa"]) +print("Virginica deviation for second feature:",deviations["virginica"][1]) + +# %% [markdown] +# ## Dataset Visualization + +# %% [markdown] +# Since the example datasets are used extensively in the code of the book, below we show the common ways to provide a visualized tool that helps in comprehending the dataset and thus how the algorithms work. + +# %% [markdown] +# ### Iris Visualization + +# %% [markdown] +# We plot the dataset in a 3D space using matplotlib and the function show_iris from notebook.py. The function takes as input three parameters, i, j and k, which are indicises to the iris features, "Sepal Length", "Sepal Width", "Petal Length" and "Petal Width" (0 to 3). By default we show the first three features. + +# %% +iris = DataSet(name="iris") + +show_iris() +show_iris(0, 1, 3) +show_iris(1, 2, 3) diff --git a/notebooks/chapter18/Decision Tree.ipynb b/notebooks/chapter18/Decision Tree.ipynb new file mode 100644 index 000000000..7e808df3c --- /dev/null +++ b/notebooks/chapter18/Decision Tree.ipynb @@ -0,0 +1,511 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", + "from aima.learning import *\n", + "from aima.notebook_utils import *" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## DECISION TREE LEARNER\n", + "\n", + "### Overview\n", + "\n", + "#### Decision Trees\n", + "A decision tree is a flowchart that uses a tree of decisions and their possible consequences for classification. At each non-leaf node of the tree an attribute of the input is tested, based on which corresponding branch leading to a child-node is selected. At the leaf node the input is classified based on the class label of this leaf node. The paths from root to leaves represent classification rules based on which leaf nodes are assigned class labels.\n", + "![perceptron](images/decisiontree_fruit.jpg)\n", + "#### Decision Tree Learning\n", + "Decision tree learning is the construction of a decision tree from class-labeled training data. The data is expected to be a tuple in which each record of the tuple is an attribute used for classification. The decision tree is built top-down, by choosing a variable at each step that best splits the set of items. There are different metrics for measuring the \"best split\". These generally measure the homogeneity of the target variable within the subsets.\n", + "\n", + "#### Gini Impurity\n", + "Gini impurity of a set is the probability of a randomly chosen element to be incorrectly labeled if it was randomly labeled according to the distribution of labels in the set.\n", + "\n", + "$$I_G(p) = \\sum{p_i(1 - p_i)} = 1 - \\sum{p_i^2}$$\n", + "\n", + "We select a split which minimizes the Gini impurity in child nodes.\n", + "\n", + "#### Information Gain\n", + "Information gain is based on the concept of entropy from information theory. Entropy is defined as:\n", + "\n", + "$$H(p) = -\\sum{p_i \\log_2{p_i}}$$\n", + "\n", + "Information Gain is difference between entropy of the parent and weighted sum of entropy of children. The feature used for splitting is the one which provides the most information gain.\n", + "\n", + "#### Pseudocode\n", + "\n", + "You can view the pseudocode by running the cell below:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pseudocode(\"Decision Tree Learning\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Implementation\n", + "The nodes of the tree constructed by our learning algorithm are stored using either `DecisionFork` or `DecisionLeaf` based on whether they are a parent node or a leaf node respectively." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(DecisionFork)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "DecisionFork holds the attribute, which is tested at that node, and a dict of branches. The branches store the child nodes, one for each of the attribute's values. Calling an object of this class as a function with input tuple as an argument returns the next node in the classification path based on the result of the attribute test." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(DecisionTreeLearner)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The implementation of DecisionTreeLearner provided in learning.py uses information gain as the metric for selecting which attribute to test for splitting. The function builds the tree top-down in a recursive manner. Based on the input it makes one of the four choices:\n", + "\n", + "
    \n", + "
  1. If the input at the current step has no training data we return the mode of classes of input data received in the parent step (previous level of recursion).
  2. \n", + "
  3. If all values in training data belong to the same class it returns a `DecisionLeaf` whose class label is the class which all the data belongs to.
  4. \n", + "
  5. If the data has no attributes that can be tested we return the class with highest plurality value in the training data.
  6. \n", + "
  7. We choose the attribute which gives the highest amount of entropy gain and return a `DecisionFork` which splits based on this attribute. Each branch recursively calls `decision_tree_learning` to construct the sub-tree.
  8. \n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example\n", + "\n", + "Here we will apply the Decision Tree Learner to classify our previous example datasets. `DecisionTreeLearner` only takes a `DataSet` object as input and will automatically learn the attributes and targets of data. The learner function will return a `DecisionFork` object which can be use to predict further data example.\n", + "\n", + "Decision Tree can be used to simulate the process of human making a decision. It can be applied to either binary or multiple classification. For many complex problems like digit image classification, decision tree can yeild very good results. Thus it can be the first algorithm to in order to investigate a dataset at the beginning of a project." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Iris Example" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's try on the algorithm on iris example as it has less attributes which makes it clearer to demonstrate. \n", + "The first step is to load dataset to decision tree learner" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "iris = DataSet(name=\"iris\")\n", + "\n", + "DTL = DecisionTreeLearner(iris)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Then you can try a new example on the trained learner:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "setosa\n" + ] + } + ], + "source": [ + "print(DTL([5.1, 3.0, 1.1, 0.1]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As expected, the Decision Tree learner classifies the sample as \"setosa\" as seen in the previous section." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Restaurant Example" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now let's try the decision tree algorithm on Figure 18.3 example" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "restaurant = DataSet(name=\"restaurant\", attr_names='Alternate Bar Fri/Sat Hungry Patrons Price ' +\n", + " 'Raining Reservation Type WaitEstimate Wait')\n", + "RT = DecisionTreeLearner(restaurant)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If we try to print the object, we can see the trained decision tree in the from of nested decision forks:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DecisionFork(4, 'Patrons', {'Full': DecisionFork(9, 'WaitEstimate', {'10-30': DecisionFork(8, 'Type', {'Italian': 'No', 'French': 'No', 'Burger': 'Yes', 'Thai': 'Yes'}), '>60': 'No', '0-10': 'No', '30-60': DecisionFork(8, 'Type', {'Italian': 'No', 'French': 'Yes', 'Burger': 'Yes', 'Thai': 'No'})}), 'None': 'No', 'Some': 'Yes'})\n" + ] + } + ], + "source": [ + "print(RT)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now let's try a new example according to the attribute names:" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['Alternate', 'Bar', 'Fri/Sat', 'Hungry', 'Patrons', 'Price', 'Raining', 'Reservation', 'Type', 'WaitEstimate', 'Wait']\n" + ] + } + ], + "source": [ + "print(restaurant.attr_names)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's try some Thai food with full patron and waiting time between 10 and 30 minites:" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Yes\n" + ] + } + ], + "source": [ + "example = ['Yes', 'No', 'Yes', 'Yes', 'Full', '$', 'No', 'No', 'Thai', '10-30', 'Yes']\n", + "print(RT(example))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If we want to try some else like French:" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "No\n" + ] + } + ], + "source": [ + "example = ['Yes', 'No', 'Yes', 'Yes', 'Full', '$', 'No', 'No', 'French', '10-30', 'Yes']\n", + "print(RT(example))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Random Forest" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "### Overview\n", + "\n", + "![random_forest.png](images/random_forest.png) \n", + "Image via [src](https://cdn-images-1.medium.com/max/800/0*tG-IWcxL1jg7RkT0.png)\n", + "\n", + "#### Random Forest\n", + "\n", + "As the name of the algorithm and image above suggest, this algorithm creates the forest with a number of trees. The more number of trees makes the forest robust. In the same way in random forest algorithm, the higher the number of trees in the forest, the higher is the accuray result. The main difference between Random Forest and Decision trees is that, finding the root node and splitting the feature nodes will be random. \n", + "\n", + "Let's see how Rnadom Forest Algorithm work : \n", + "Random Forest Algorithm works in two steps, first is the creation of random forest and then the prediction. Let's first see the creation : \n", + "\n", + "The first step in creation is to randomly select 'm' features out of total 'n' features. From these 'm' features calculate the node d using the best split point and then split the node into further nodes using best split. Repeat these steps until 'i' number of nodes are reached. Repeat the entire whole process to build the forest. \n", + "\n", + "Now, let's see how the prediction works\n", + "Take the test features and predict the outcome for each randomly created decision tree. Calculate the votes for each prediction and the prediction which gets the highest votes would be the final prediction.\n", + "\n", + "\n", + "### Implementation\n", + "\n", + "Below mentioned is the implementation of Random Forest Algorithm." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(RandomForest)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This algorithm creates an ensemble of decision trees using bagging and feature bagging. It takes 'm' examples randomly from the total number of examples and then perform feature bagging with probability p to retain an attribute. All the predictors are predicted from the DecisionTreeLearner and then a final prediction is made." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### MNIST Example" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First let's load MNIST data to a `DataSet` object with predefined load function:" + ] + }, + { + "cell_type": "code", + "execution_count": 68, + "metadata": {}, + "outputs": [], + "source": [ + "train_img, train_lbl, test_img, test_lbl = load_MNIST(path=\"aima-data/MNIST/Digits\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In order to use `load_MNIST` correctly, you need to input the correct data directory of MNIST data. Then we compose the training data and labels to a full example." + ] + }, + { + "cell_type": "code", + "execution_count": 93, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "60000\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "examples = [np.append(train_img[i], train_lbl[i]) for i in range(len(train_img))]\n", + "print(len(examples))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We have 60000 examples in total, we use the first 100 examples to roughly train the model:" + ] + }, + { + "cell_type": "code", + "execution_count": 80, + "metadata": {}, + "outputs": [], + "source": [ + "MNIST_dataset = DataSet(examples=examples[:100])\n", + "MNIST_RF = RandomForest(MNIST_dataset)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's try a example in the dataset:" + ] + }, + { + "cell_type": "code", + "execution_count": 100, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAK4AAACuCAYAAACvDDbuAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4xLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy8QZhcZAAAFGklEQVR4nO3dvyt9cRzH8c+Vya/k94SFQTIYWKQrKaPJoO5qkPwoK5mky2Qw2JSMBoPFoEQoE4v8KBmQMokkut8/4PM+Ou659369judjfHfuuZ/h6VPnHI5EJpPJOEBM0f9eAJANwoUkwoUkwoUkwoUkwoUkwoUkwoUkwoWk4rAHJhKJfK4DcM45F/ZBLjsuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJBEuJIV+sfNfU19fb8739/e9WUlJiXlsMpn0Zjc3N5HWFWR6etqcz8/Pe7P19XXz2ImJiVwuKa/YcSGJcCGJcCGJcCGJizPnXFGR//ObSqXMY1taWrzZ9fW1eezX11e0hQXo6uryZrOzs+ax5eXl3mxhYSHnayo0dlxIIlxIIlxIIlxIIlxI4q6Cc25sbMybpdPp0J+fnJw057e3t9kuyTnnXEdHhznf2dnxZpWVleaxx8fH3uzl5SXSun4DdlxIIlxIIlxIIlxI+lMXZ01NTeZ8fHw89Dk+Pz+92cfHR9Zr+k7QuqqqqkKfw7o4e319zXpNvwU7LiQRLiQRLiQRLiQRLiQlMplMJtSBiUS+15JTxcX+DZONjQ3z2OHh4dDn3dvb82b9/f3hFxagt7fXm21tbZnH/uSuQnNzsze7u7sL/flCC5kjOy40ES4kES4kES4kxfaRb21trTf7yUVY0GPcxcXFrNfknHOlpaXmfGVlxZv95CJse3vbnD8/P4c+hxJ2XEgiXEgiXEgiXEgiXEiK7V2Furq6SJ+/uroy57u7u6HPYd1BGBkZMY8N+ovesGZmZsx5HH5p3MKOC0mEC0mEC0mEC0mxvTgbHR2N9PnGxkZzHvQ7spaamhpv1tPTk/WavtPe3m7Ord+9zddfJRcSOy4kES4kES4kES4kES4kxfauwv39faTPW/9myTnnhoaGIp03X4Ludqj9lW9Y7LiQRLiQRLiQRLiQFNtXMFVXV3uz8/Nz89iGhoZ8L+e/Ubs44xVMiDXChSTChSTChSTChaTYPvK13pmVTCbNY9va2ryZ9f99nbMfBXd3d/9scSGdnJyYc+t/8a6urprHPj095XRNvwU7LiQRLiQRLiQRLiTF9pFvvnR2dnqz09PTyOc9PDz0ZoODg+axcX2tknM88kXMES4kES4kES4kES4kxfaRb1QVFRXmfG5uLi/ft7y87M3ifPcgKnZcSCJcSCJcSCJcSOKRb4CgFyWfnZ1FOu/R0ZE5HxgY8GZvb2+RvksRj3wRa4QLSYQLSYQLSYQLSTzydfbj3XQ6nZfvWlpaMud/8Q5CFOy4kES4kES4kES4kMQjX+dca2urN7u4uIh8XusVSn19feax7+/vkb8vDnjki1gjXEgiXEgiXEgiXEjika9zbmpqKi/ntd4Hxt2D3GDHhSTChSTChSTChSQuzpxzZWVlkT7/+PhoztfW1iKdF8HYcSGJcCGJcCGJcCGJcCGJuwo5sLm5ac4vLy8LvJK/gx0XkggXkggXkggXkrg4c84dHBx4s1QqZR778PDgzXi0W3jsuJBEuJBEuJBEuJBEuJDEu8Pwq/DuMMQa4UIS4UIS4UIS4UIS4UIS4UIS4UIS4UIS4UJS6F8kD/soDigEdlxIIlxIIlxIIlxIIlxIIlxIIlxIIlxIIlxI+gegNfU4U/K/dQAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plt.figure(figsize=(2,2))\n", + "plt.imshow(test_img[-3].reshape((28, 28)))\n", + "plt.axis(\"off\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The prediction of our random forest model is as following:" + ] + }, + { + "cell_type": "code", + "execution_count": 101, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[4, 4, 4, 1, 9]\n", + "4\n" + ] + } + ], + "source": [ + "print(MNIST_RF(test_img[-3]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Which is the same as our own perception of the figure." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Decision List Learner" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Decision list is an algorithm that applys PAC learning to a new hypothesis space. PAC is short for probably approximately correct which means any hypothesis that is consistent with a sufficiently large set of training examples is unlikely to be seriously wrong. " + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/chapter18/Decision Tree.py b/notebooks/chapter18/Decision Tree.py new file mode 100644 index 000000000..b8528f767 --- /dev/null +++ b/notebooks/chapter18/Decision Tree.py @@ -0,0 +1,221 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.learning import * +from aima.notebook_utils import * + +# %% [markdown] +# ## DECISION TREE LEARNER +# +# ### Overview +# +# #### Decision Trees +# A decision tree is a flowchart that uses a tree of decisions and their possible consequences for classification. At each non-leaf node of the tree an attribute of the input is tested, based on which corresponding branch leading to a child-node is selected. At the leaf node the input is classified based on the class label of this leaf node. The paths from root to leaves represent classification rules based on which leaf nodes are assigned class labels. +# ![perceptron](images/decisiontree_fruit.jpg) +# #### Decision Tree Learning +# Decision tree learning is the construction of a decision tree from class-labeled training data. The data is expected to be a tuple in which each record of the tuple is an attribute used for classification. The decision tree is built top-down, by choosing a variable at each step that best splits the set of items. There are different metrics for measuring the "best split". These generally measure the homogeneity of the target variable within the subsets. +# +# #### Gini Impurity +# Gini impurity of a set is the probability of a randomly chosen element to be incorrectly labeled if it was randomly labeled according to the distribution of labels in the set. +# +# $$I_G(p) = \sum{p_i(1 - p_i)} = 1 - \sum{p_i^2}$$ +# +# We select a split which minimizes the Gini impurity in child nodes. +# +# #### Information Gain +# Information gain is based on the concept of entropy from information theory. Entropy is defined as: +# +# $$H(p) = -\sum{p_i \log_2{p_i}}$$ +# +# Information Gain is difference between entropy of the parent and weighted sum of entropy of children. The feature used for splitting is the one which provides the most information gain. +# +# #### Pseudocode +# +# You can view the pseudocode by running the cell below: + +# %% +pseudocode("Decision Tree Learning") + +# %% [markdown] +# ### Implementation +# The nodes of the tree constructed by our learning algorithm are stored using either `DecisionFork` or `DecisionLeaf` based on whether they are a parent node or a leaf node respectively. + +# %% +psource(DecisionFork) + +# %% [markdown] +# DecisionFork holds the attribute, which is tested at that node, and a dict of branches. The branches store the child nodes, one for each of the attribute's values. Calling an object of this class as a function with input tuple as an argument returns the next node in the classification path based on the result of the attribute test. + +# %% +psource(DecisionTreeLearner) + +# %% [markdown] +# The implementation of DecisionTreeLearner provided in learning.py uses information gain as the metric for selecting which attribute to test for splitting. The function builds the tree top-down in a recursive manner. Based on the input it makes one of the four choices: +# +#
    +#
  1. If the input at the current step has no training data we return the mode of classes of input data received in the parent step (previous level of recursion).
  2. +#
  3. If all values in training data belong to the same class it returns a `DecisionLeaf` whose class label is the class which all the data belongs to.
  4. +#
  5. If the data has no attributes that can be tested we return the class with highest plurality value in the training data.
  6. +#
  7. We choose the attribute which gives the highest amount of entropy gain and return a `DecisionFork` which splits based on this attribute. Each branch recursively calls `decision_tree_learning` to construct the sub-tree.
  8. +#
+ +# %% [markdown] +# ### Example +# +# Here we will apply the Decision Tree Learner to classify our previous example datasets. `DecisionTreeLearner` only takes a `DataSet` object as input and will automatically learn the attributes and targets of data. The learner function will return a `DecisionFork` object which can be use to predict further data example. +# +# Decision Tree can be used to simulate the process of human making a decision. It can be applied to either binary or multiple classification. For many complex problems like digit image classification, decision tree can yeild very good results. Thus it can be the first algorithm to in order to investigate a dataset at the beginning of a project. + +# %% [markdown] +# #### Iris Example + +# %% [markdown] +# Let's try on the algorithm on iris example as it has less attributes which makes it clearer to demonstrate. +# The first step is to load dataset to decision tree learner + +# %% +iris = DataSet(name="iris") + +DTL = DecisionTreeLearner(iris) + +# %% [markdown] +# Then you can try a new example on the trained learner: + +# %% +print(DTL([5.1, 3.0, 1.1, 0.1])) + +# %% [markdown] +# As expected, the Decision Tree learner classifies the sample as "setosa" as seen in the previous section. + +# %% [markdown] +# #### Restaurant Example + +# %% [markdown] +# Now let's try the decision tree algorithm on Figure 18.3 example + +# %% +restaurant = DataSet(name="restaurant", attr_names='Alternate Bar Fri/Sat Hungry Patrons Price ' + + 'Raining Reservation Type WaitEstimate Wait') +RT = DecisionTreeLearner(restaurant) + +# %% [markdown] +# If we try to print the object, we can see the trained decision tree in the from of nested decision forks: + +# %% +print(RT) + +# %% [markdown] +# Now let's try a new example according to the attribute names: + +# %% +print(restaurant.attr_names) + +# %% [markdown] +# Let's try some Thai food with full patron and waiting time between 10 and 30 minites: + +# %% +example = ['Yes', 'No', 'Yes', 'Yes', 'Full', '$', 'No', 'No', 'Thai', '10-30', 'Yes'] +print(RT(example)) + +# %% [markdown] +# If we want to try some else like French: + +# %% +example = ['Yes', 'No', 'Yes', 'Yes', 'Full', '$', 'No', 'No', 'French', '10-30', 'Yes'] +print(RT(example)) + +# %% [markdown] +# ## Random Forest + +# %% [markdown] +# +# ### Overview +# +# ![random_forest.png](images/random_forest.png) +# Image via [src](https://cdn-images-1.medium.com/max/800/0*tG-IWcxL1jg7RkT0.png) +# +# #### Random Forest +# +# As the name of the algorithm and image above suggest, this algorithm creates the forest with a number of trees. The more number of trees makes the forest robust. In the same way in random forest algorithm, the higher the number of trees in the forest, the higher is the accuray result. The main difference between Random Forest and Decision trees is that, finding the root node and splitting the feature nodes will be random. +# +# Let's see how Rnadom Forest Algorithm work : +# Random Forest Algorithm works in two steps, first is the creation of random forest and then the prediction. Let's first see the creation : +# +# The first step in creation is to randomly select 'm' features out of total 'n' features. From these 'm' features calculate the node d using the best split point and then split the node into further nodes using best split. Repeat these steps until 'i' number of nodes are reached. Repeat the entire whole process to build the forest. +# +# Now, let's see how the prediction works +# Take the test features and predict the outcome for each randomly created decision tree. Calculate the votes for each prediction and the prediction which gets the highest votes would be the final prediction. +# +# +# ### Implementation +# +# Below mentioned is the implementation of Random Forest Algorithm. + +# %% +psource(RandomForest) + +# %% [markdown] +# This algorithm creates an ensemble of decision trees using bagging and feature bagging. It takes 'm' examples randomly from the total number of examples and then perform feature bagging with probability p to retain an attribute. All the predictors are predicted from the DecisionTreeLearner and then a final prediction is made. + +# %% [markdown] +# ### MNIST Example + +# %% [markdown] +# First let's load MNIST data to a `DataSet` object with predefined load function: + +# %% +train_img, train_lbl, test_img, test_lbl = load_MNIST(path="aima-data/MNIST/Digits") + +# %% [markdown] +# In order to use `load_MNIST` correctly, you need to input the correct data directory of MNIST data. Then we compose the training data and labels to a full example. + +# %% +import numpy as np +import matplotlib.pyplot as plt +examples = [np.append(train_img[i], train_lbl[i]) for i in range(len(train_img))] +print(len(examples)) + +# %% [markdown] +# We have 60000 examples in total, we use the first 100 examples to roughly train the model: + +# %% +MNIST_dataset = DataSet(examples=examples[:100]) +MNIST_RF = RandomForest(MNIST_dataset) + +# %% [markdown] +# Let's try a example in the dataset: + +# %% +plt.figure(figsize=(2,2)) +plt.imshow(test_img[-3].reshape((28, 28))) +plt.axis("off") +plt.show() + +# %% [markdown] +# The prediction of our random forest model is as following: + +# %% +print(MNIST_RF(test_img[-3])) + +# %% [markdown] +# Which is the same as our own perception of the figure. + +# %% [markdown] +# # Decision List Learner + +# %% [markdown] +# Decision list is an algorithm that applys PAC learning to a new hypothesis space. PAC is short for probably approximately correct which means any hypothesis that is consistent with a sufficiently large set of training examples is unlikely to be seriously wrong. diff --git a/notebooks/chapter18/Ensemble Learning.ipynb b/notebooks/chapter18/Ensemble Learning.ipynb new file mode 100644 index 000000000..0c4ef96d8 --- /dev/null +++ b/notebooks/chapter18/Ensemble Learning.ipynb @@ -0,0 +1,389 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ENSEMBLE LEARNER\n", + "\n", + "### Overview\n", + "\n", + "Ensemble Learning improves the performance of our model by combining several learners. It improvise the stability and predictive power of the model. Ensemble methods are meta-algorithms that combine several machine learning techniques into one predictive model in order to decrease variance, bias, or improve predictions. \n", + "\n", + "\n", + "\n", + "![ensemble_learner.jpg](images/ensemble_learner.jpg)\n", + "\n", + "\n", + "Some commonly used Ensemble Learning techniques are : \n", + "\n", + "1. Bagging : Bagging tries to implement similar learners on small sample populations and then takes a mean of all the predictions. It helps us to reduce variance error.\n", + "\n", + "2. Boosting : Boosting is an iterative technique which adjust the weight of an observation based on the last classification. If an observation was classified incorrectly, it tries to increase the weight of this observation and vice versa. It helps us to reduce bias error.\n", + "\n", + "3. Stacking : This is a very interesting way of combining models. Here we use a learner to combine output from different learners. It can either decrease bias or variance error depending on the learners we use.\n", + "\n", + "### Implementation\n", + "\n", + "Below mentioned is the implementation of Ensemble Learner." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(EnsembleLearner)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This algorithm takes input as a list of learning algorithms, have them vote and then finally returns the predicted result." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## AdaBoost\n", + "\n", + "### Overview\n", + "\n", + "**AdaBoost** is an algorithm which uses **ensemble learning**. In ensemble learning the hypotheses in the collection, or ensemble, vote for what the output should be and the output with the majority votes is selected as the final answer.\n", + "\n", + "AdaBoost algorithm, as mentioned in the book, works with a **weighted training set** and **weak learners** (classifiers that have about 50%+epsilon accuracy i.e slightly better than random guessing). It manipulates the weights attached to the the examples that are showed to it. Importance is given to the examples with higher weights.\n", + "\n", + "All the examples start with equal weights and a hypothesis is generated using these examples. Examples which are incorrectly classified, their weights are increased so that they can be classified correctly by the next hypothesis. The examples that are correctly classified, their weights are reduced. This process is repeated *K* times (here *K* is an input to the algorithm) and hence, *K* hypotheses are generated.\n", + "\n", + "These *K* hypotheses are also assigned weights according to their performance on the weighted training set. The final ensemble hypothesis is the weighted-majority combination of these *K* hypotheses.\n", + "\n", + "The speciality of AdaBoost is that by using weak learners and a sufficiently large *K*, a highly accurate classifier can be learned irrespective of the complexity of the function being learned or the dullness of the hypothesis space." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Implementation\n", + "\n", + "To view the source code of `AdaBoost`, you need to import the necessities first:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", + "from aima.learning import *\n", + "from aima.notebook_utils import *\n", + "from aima.utils import *" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Then use the following command:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def AdaBoost(L, K):\n",
+       "    """[Figure 18.34]"""\n",
+       "\n",
+       "    def train(dataset):\n",
+       "        examples, target = dataset.examples, dataset.target\n",
+       "        N = len(examples)\n",
+       "        epsilon = 1/(2*N)\n",
+       "        w = [1/N]*N\n",
+       "        h, z = [], []\n",
+       "        for k in range(K):\n",
+       "            h_k = L(dataset, w)\n",
+       "            h.append(h_k)\n",
+       "            error = sum(weight for example, weight in zip(examples, w)\n",
+       "                        if example[target] != h_k(example))\n",
+       "\n",
+       "            # Avoid divide-by-0 from either 0% or 100% error rates:\n",
+       "            error = clip(error, epsilon, 1 - epsilon)\n",
+       "            for j, example in enumerate(examples):\n",
+       "                if example[target] == h_k(example):\n",
+       "                    w[j] *= error/(1 - error)\n",
+       "            w = normalize(w)\n",
+       "            z.append(math.log((1 - error)/error))\n",
+       "        return WeightedMajority(h, z)\n",
+       "    return train\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "psource(ada_boost)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "AdaBoost takes as inputs: **L** and *K* where **L** is the learner and *K* is the number of hypotheses to be generated. The learner **L** takes in as inputs: a dataset and the weights associated with the examples in the dataset. But the input learner like `DecisionTreeLearner` doesnot handle weights and only takes a dataset as its input. \n", + "To remedy that we will give as input to the `DecisionTreeLearner` a modified dataset in which the examples will be repeated according to the weights associated to them. Intuitively, what this will do is force the learner to repeatedly learn the same example again and again until it can classify it correctly. \n", + "\n", + "To convert `DecisionTreeLearner` so that it can take weights as input too, we will have to pass it through the **`WeightedLearner`** function." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(WeightedLearner)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `WeightedLearner` function will then call the `PerceptronLearner`, during each iteration, with the modified dataset which contains the examples according to the weights associated with them." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example\n", + "\n", + "We will pass the `DecisionTreeLearner` through `WeightedLearner` function. Then we will create an `AdaboostLearner` classifier with number of hypotheses or *K* equal to 5." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "weighted_tree = WeightedLearner(DecisionTreeLearner)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'virginica'" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "iris2 = DataSet(name=\"iris\")\n", + "iris2.classes_to_numbers()\n", + "\n", + "adaboost = ada_boost(iris2, weighted_tree, 5)\n", + "\n", + "adaboost([5, 3, 1, 0.1])" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error ratio for adaboost: 0.0\n" + ] + } + ], + "source": [ + "print(\"Error ratio for adaboost: \", err_ratio(adaboost, iris2))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Generally using ensemble learning will increase the accuracy of final result as the weight voting of different learners will average the random error." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Evaluate Learners" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We also offer an algorithm evaluating util function: `compare` in the source code. With this function user can compare different algorithms on multiple datasets in order to choose from them.\n", + "\n", + "The default algorithms to compare are `NearestNeighborLearner` and `DecisionTreeLearner`, and the datasets are iris, orings, zoo, restaurant and several other auto-generated test cases.\n", + "\n", + "To use the `compare` function with default settings:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " iris orings zoo restaur restaur majorit parity xor\n", + "NearestNeighbor 0.00 0.27 0.00 0.00 0.00 0.00 0.00 0.00\n", + "DecisionTree 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00\n" + ] + } + ], + "source": [ + "compare([DecisionTreeLearner, NearestNeighborLearner],\n", + " [DataSet(name='iris'), DataSet(name='orings')])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As the datasets used here are very simple, there is no significant difference between the error rate of two algorithms except `NearestNeighborLearner` are not doing well on `orings` dataset. You can try self-defined datasets by specifying the `dataset` attribute as the list of datasets of interests such as MNIST." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/chapter18/Ensemble Learning.py b/notebooks/chapter18/Ensemble Learning.py new file mode 100644 index 000000000..4c806ce7f --- /dev/null +++ b/notebooks/chapter18/Ensemble Learning.py @@ -0,0 +1,127 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# ## ENSEMBLE LEARNER +# +# ### Overview +# +# Ensemble Learning improves the performance of our model by combining several learners. It improvise the stability and predictive power of the model. Ensemble methods are meta-algorithms that combine several machine learning techniques into one predictive model in order to decrease variance, bias, or improve predictions. +# +# +# +# ![ensemble_learner.jpg](images/ensemble_learner.jpg) +# +# +# Some commonly used Ensemble Learning techniques are : +# +# 1. Bagging : Bagging tries to implement similar learners on small sample populations and then takes a mean of all the predictions. It helps us to reduce variance error. +# +# 2. Boosting : Boosting is an iterative technique which adjust the weight of an observation based on the last classification. If an observation was classified incorrectly, it tries to increase the weight of this observation and vice versa. It helps us to reduce bias error. +# +# 3. Stacking : This is a very interesting way of combining models. Here we use a learner to combine output from different learners. It can either decrease bias or variance error depending on the learners we use. +# +# ### Implementation +# +# Below mentioned is the implementation of Ensemble Learner. + +# %% +psource(EnsembleLearner) + +# %% [markdown] +# This algorithm takes input as a list of learning algorithms, have them vote and then finally returns the predicted result. + +# %% [markdown] +# ## AdaBoost +# +# ### Overview +# +# **AdaBoost** is an algorithm which uses **ensemble learning**. In ensemble learning the hypotheses in the collection, or ensemble, vote for what the output should be and the output with the majority votes is selected as the final answer. +# +# AdaBoost algorithm, as mentioned in the book, works with a **weighted training set** and **weak learners** (classifiers that have about 50%+epsilon accuracy i.e slightly better than random guessing). It manipulates the weights attached to the the examples that are showed to it. Importance is given to the examples with higher weights. +# +# All the examples start with equal weights and a hypothesis is generated using these examples. Examples which are incorrectly classified, their weights are increased so that they can be classified correctly by the next hypothesis. The examples that are correctly classified, their weights are reduced. This process is repeated *K* times (here *K* is an input to the algorithm) and hence, *K* hypotheses are generated. +# +# These *K* hypotheses are also assigned weights according to their performance on the weighted training set. The final ensemble hypothesis is the weighted-majority combination of these *K* hypotheses. +# +# The speciality of AdaBoost is that by using weak learners and a sufficiently large *K*, a highly accurate classifier can be learned irrespective of the complexity of the function being learned or the dullness of the hypothesis space. + +# %% [markdown] +# ### Implementation +# +# To view the source code of `AdaBoost`, you need to import the necessities first: + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.learning import * +from aima.notebook_utils import * +from aima.utils import * + +# %% [markdown] +# Then use the following command: + +# %% +psource(ada_boost) + +# %% [markdown] +# AdaBoost takes as inputs: **L** and *K* where **L** is the learner and *K* is the number of hypotheses to be generated. The learner **L** takes in as inputs: a dataset and the weights associated with the examples in the dataset. But the input learner like `DecisionTreeLearner` doesnot handle weights and only takes a dataset as its input. +# To remedy that we will give as input to the `DecisionTreeLearner` a modified dataset in which the examples will be repeated according to the weights associated to them. Intuitively, what this will do is force the learner to repeatedly learn the same example again and again until it can classify it correctly. +# +# To convert `DecisionTreeLearner` so that it can take weights as input too, we will have to pass it through the **`WeightedLearner`** function. + +# %% +psource(WeightedLearner) + +# %% [markdown] +# The `WeightedLearner` function will then call the `PerceptronLearner`, during each iteration, with the modified dataset which contains the examples according to the weights associated with them. + +# %% [markdown] +# ### Example +# +# We will pass the `DecisionTreeLearner` through `WeightedLearner` function. Then we will create an `AdaboostLearner` classifier with number of hypotheses or *K* equal to 5. + +# %% +weighted_tree = WeightedLearner(DecisionTreeLearner) + +# %% +iris2 = DataSet(name="iris") +iris2.classes_to_numbers() + +adaboost = ada_boost(iris2, weighted_tree, 5) + +adaboost([5, 3, 1, 0.1]) + +# %% +print("Error ratio for adaboost: ", err_ratio(adaboost, iris2)) + +# %% [markdown] +# Generally using ensemble learning will increase the accuracy of final result as the weight voting of different learners will average the random error. + +# %% [markdown] +# ## Evaluate Learners + +# %% [markdown] +# We also offer an algorithm evaluating util function: `compare` in the source code. With this function user can compare different algorithms on multiple datasets in order to choose from them. +# +# The default algorithms to compare are `NearestNeighborLearner` and `DecisionTreeLearner`, and the datasets are iris, orings, zoo, restaurant and several other auto-generated test cases. +# +# To use the `compare` function with default settings: + +# %% +compare([DecisionTreeLearner, NearestNeighborLearner], + [DataSet(name='iris'), DataSet(name='orings')]) + +# %% [markdown] +# As the datasets used here are very simple, there is no significant difference between the error rate of two algorithms except `NearestNeighborLearner` are not doing well on `orings` dataset. You can try self-defined datasets by specifying the `dataset` attribute as the list of datasets of interests such as MNIST. diff --git a/notebooks/chapter18/Linear and Nonparametric Models.ipynb b/notebooks/chapter18/Linear and Nonparametric Models.ipynb new file mode 100644 index 000000000..a0bc2cedc --- /dev/null +++ b/notebooks/chapter18/Linear and Nonparametric Models.ipynb @@ -0,0 +1,435 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Linear Models\n", + "Linear models are already used for hundred of years. It is a class of linear functions of continuous-valued inputs." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Linear Learner\n", + "### Overview\n", + "\n", + "Linear Learner is a model that assumes a linear relationship between the input variables x and the single output variable y. More specifically, that y can be calculated from a linear combination of the input variables x. Linear learner is a quite simple model as the representation of this model is a linear equation. \n", + "\n", + "The linear equation assigns one scaler factor to each input value or column, called a coefficients or weights. One additional coefficient is also added, giving additional degree of freedom and is often called the intercept or the bias coefficient. \n", + "For example : y = ax1 + bx2 + c . \n", + "\n", + "### Implementation\n", + "\n", + "Below mentioned is the implementation of Linear Learner." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(LinearLearner)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This algorithm first assigns some random weights to the input variables and then based on the error calculated updates the weight for each variable. Finally the prediction is made with the updated weights. \n", + "\n", + "### Example\n", + "\n", + "We will now use the Linear Learner to classify a sample with values: 5.1, 3.0, 1.1, 0.1." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", + "from aima.learning import *\n", + "from aima.notebook_utils import *\n", + "from aima.utils import *" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "One of the key point when applying a dataset to the linear learner is to convert the class names to numbers before feeding it into the learner:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.0863712261312969\n" + ] + } + ], + "source": [ + "iris = DataSet(name=\"iris\")\n", + "iris.classes_to_numbers()\n", + "\n", + "linear_learner = LinearLearner(iris)\n", + "print(linear_learner([5, 3, 1, 0.1]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The result is closer to 0 than 1 and 2, thus we can class it as class 0 which represents 'setosa'." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Logistic Linear Learner" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Logistic linear learner is different from common linear learner only in the updating rule of weights. Logistic function is continuous and derivable. Using `LogisticLinearLearner` is similar to using `LinearLearner`:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.16279158793332174\n" + ] + } + ], + "source": [ + "logistic_learner = LogisticLinearLeaner(iris)\n", + "print(logistic_learner([5, 3, 1, 0.1]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The prediction can also be treated as class 0." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Nonparametric Models" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A nonparametric model is one that cannot be characterized by a bounded set of parameters. This usually happens when the dataset is huge and let the data speak for their own property is ensenstially good. The simplest approach is to put all examples into a lookup table. \n", + "\n", + "Here we will demonstrate a improved version of lookup table: k-nearest neighbors lookup, which finds the k examples that are nearest to the given query.\n", + "\n", + "## K-NEAREST NEIGHBOURS CLASSIFIER\n", + "\n", + "### Overview\n", + "\n", + "In this section we are going to use this to classify Iris flowers. More about kNN on [Scholarpedia](http://www.scholarpedia.org/article/K-nearest_neighbor).\n", + "\n", + "![kNN plot](images/knn_plot.png)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's see how kNN works with a simple plot shown in the above picture.\n", + "\n", + "We have co-ordinates (we call them **features** in Machine Learning) of this red star and we need to predict its class using the kNN algorithm. In this algorithm, the value of **k** is arbitrary. **k** is one of the **hyper parameters** for kNN algorithm. We choose this number based on our dataset and choosing a particular number is known as **hyper parameter tuning/optimising**. We learn more about this in coming topics.\n", + "\n", + "Let's put **k = 3**. It means you need to find 3-Nearest Neighbors of this red star and classify this new point into the majority class. Observe that smaller circle which contains three points other than **test point** (red star). As there are two violet points, which form the majority, we predict the class of red star as **violet- Class B**.\n", + "\n", + "Similarly if we put **k = 5**, you can observe that there are three yellow points, which form the majority. So, we classify our test point as **yellow- Class A**.\n", + "\n", + "In practical tasks, we iterate through a bunch of values for k (like [1, 3, 5, 10, 20, 50, 100]), see how it performs and select the best one. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Implementation\n", + "\n", + "Below follows the implementation of the kNN algorithm:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "psource(NearestNeighborLearner)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It takes as input a dataset and k (default value is 1) and it returns a function, which we can later use to classify a new item.\n", + "\n", + "To accomplish that, the function uses a heap-queue, where the items of the dataset are sorted according to their distance from *example* (the item to classify). We then take the k smallest elements from the heap-queue and we find the majority class. We classify the item to this class." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example\n", + "\n", + "We measured a new flower with the following values: 5.1, 3.0, 1.1, 0.1. We want to classify that item/flower in a class. To do that, we write the following:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "setosa\n" + ] + } + ], + "source": [ + "iris = DataSet(name=\"iris\")\n", + "\n", + "knn_model = NearestNeighborLearner(iris,k=3)\n", + "print(knn_model([5.1,3.0,1.1,0.1]))\n" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0.06000000000000005" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "err_ratio(knn_model, iris)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Which is the same as expected. By altering k, you can change the number of neighbors considering in the lookup procedure. Thus the classification accuracy is directly affected by k.\n", + "\n", + "In order to show the influence of k, we need to fake some data that easier for visualization first. We will use only two dimensions among the attributes of iris dataset.\n", + "\n", + "First, we load the dataset and compose a new dataset with the first two among all the attributes together with the target dimension." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "iris = DataSet(name=\"iris\")\n", + "iris.classes_to_numbers()\n", + "examples = np.asarray(iris.examples)\n", + "reduced_iris = DataSet(examples=examples[:,[0,1,4]].tolist(), distance=euclidean_distance)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Then we build models with different k and plot the model's decision boundaries with util function `plot_model_boundary`.\n", + "\n", + "Let's try with k=1 and assign the first attribute to x-axis and the second to y-axis." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "model = NearestNeighborLearner(reduced_iris, k=1)\n", + "plot_model_boundary(reduced_iris,0,1, model=model)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see there are zigzag and rectangle shapes. The class of a point heavily relies on the nearest point type.\n", + "\n", + "Then we increase k to 3:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "model = NearestNeighborLearner(reduced_iris, k=3)\n", + "plot_model_boundary(reduced_iris,0,1, model=model)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "When k=3 we can see there are some areas near blue points are classified as green and the shape of each color chunk is no longer shuttered.\n", + "\n", + "Then we set k=5." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXQAAAD4CAYAAAD8Zh1EAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4xLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy8QZhcZAAAgAElEQVR4nO3deXyU5bXA8d+ZLQshJJCw74uoiCAiiwgFsaAWQetau2i1ly7a1mpba++tXrWte6utel1btVRxVxQ3VMS6ALJvCrLJLmuAJCSTmXnuH8+EJDPvJDPJJJNMzjef+Th55837nkE48+RZziPGGJRSSrV8rlQHoJRSKjk0oSulVJrQhK6UUmlCE7pSSqUJTehKKZUmPKm6cUFuruldWJiq2yvV6i0mP9UhqPrYuHivMcYxeaYsofcuLGTR7ben6vZKtXrChakOQdXHRfJVrJe0y0UppdKEJnSllEoTmtCVUipNaEJXSqk0kbJBUaVU09DBz9ZDW+hKKZUmNKErpVSa0ISulFJpQhO6UkqlCU3oSimVJuJK6CKyWURWisgyEVnk8LqIyN9EZL2IrBCRYckPVSmlVG0SmbY4wRizN8ZrZwEDwo+RwP+F/6uUUqqJJKvLZRrwlLHmA3ki0iVJ11ZKKRWHeBO6Ad4RkcUiMt3h9W7A1mrfbwsfU0op1UTi7XI5zRizXUQ6AnNE5AtjzIeJ3iz8YTAdoGdBQaI/rpRSqhZxtdCNMdvD/90NvAyMiDhlO9Cj2vfdw8cir/OIMWa4MWZ4YW5u/SJWSinlqM6ELiJtRKRt5XNgErAq4rRZwA/Cs11GAQeNMTuTHq1SSqmY4uly6QS8LCKV5z9tjHlLRH4CYIx5CHgDOBtYD5QCP2yccJVSSsVSZ0I3xmwEhjgcf6jacwNcldzQlFJKJUJXiiqlVJrQhK6UUmlCE7pSSqUJTehKKZUmNKErpVSa0ISulFJpQhO6UkqlCU3oSimVJjShK6VUmtCErpRSaSKRHYuUUs2YcGGqQ1Appi10pZRKE5rQlVIqTWhCV0qpNKEJXSml0oQOiirVwujgp4pFW+hKKZUm4k7oIuIWkaUi8rrDa5eLyB4RWRZ+/Ci5YSqllKpLIl0uvwQ+B3JjvP6sMebqhoeklFKqPuJqoYtId+BbwGONG45SSqn6ireFfi/wW6BtLeecLyLjgHXAr4wxWyNPEJHpwHSAngUFCYaqVOuig58qUXW20EVkCrDbGLO4ltNeA3obY04E5gBPOp1kjHnEGDPcGDO8MDdWz41Scdi1C2bMgHvvhblzwe9PdURKpVw8LfQxwFQRORvIBHJFZIYx5nuVJxhj9lU7/zHgzuSGqVQ1S5fCPfdAMGgfixfDrFnw5z9DVlaqo1MqZepsoRtjbjDGdDfG9AYuAd6vnswBRKRLtW+nYgdPlUq+UAjuv9+2yINBe6y8HHbvhtmzUxubUilW73noInKLiEwNf/sLEVktIsuBXwCXJyM4paJs2wYVFdHHKyrg00+bPh6lmpGEVooaYz4APgg/v7Ha8RuAG5IZmFKOfD7bSneSmdm0sSjVzOhKUdWydO5sHyI1j2dkwOTJqYlJqWZCE7pqeX7zG+jQwbbIMzPB64WxY+1DqVZMi3OplqdTJzswumYNHDgAAwdCx46pjkqplNOErlomlwtOOCHVUSjVrGiXi1JKpQltoavmYd8++OQTO6f85JOhT59UR6RUi6MJXaXeRx/BQw/Z6YihELzyCowfD1deGT2bRSkVk3a5qNQqKbHJ3O+HQMAmdL8f5s2zg55KqbhpQleptXy5HeCMVF5uW+5Kqbhpl4tKLZHY3SqtoLtFS+SqZNKErhpmxw5Ytswu8BkxAnJyEvv5IUOqimxVl5GhC4WUSpAmdFV///oXvP02GGO7Tf75T7juOhg6NP5rZGfDL34Bf/ub/T4YBLcbvvlNOO64xolbqTSlCV3Vz+rV8M470RtL/OUv8OijtoUdrxEj4IEHYP5823c+bBh0757ceJVqBTShq/r54AObfCOJwMqVMHx4Ytdr106LaynVQDrLRdVPrBK2db2mlGo02kJX9XPaabBwYXQrPRhMbY2VUAhWrYI9e6BfP+jdO3WxVKOzWVRTiDuhi4gbWARsN8ZMiXgtA3gKOBnYB1xsjNmcxDhVczN0KIwcCQsW2KTu8diB0Z/8xA50psL+/XDTTXDwoB2oBTj+eFtu16NtF5X+Evlb/kvsXqG5Dq9dCRwwxvQXkUuAO4CLkxCfaq5E4Kqr7GyUJUvs5sxjxkBhYepi+vvfbcu8epfP6tXw2mtw3nmpi0upJhJXH7qIdAe+BTwW45RpwJPh5y8AE0VawaqQ1k7E1iL/znfg3HNTm8xLSmDt2uj+e78f3n03NTEp1cTiHRS9F/gtEGu0qxuwFcAYEwAOAh0iTxKR6SKySEQW7Tl0qB7hKhVDIFC/15RKI3V2uYjIFGC3MWaxiIxvyM2MMY8AjwAM79fPNORaqoUqLoY33rCDp2edBXl5ybluu3Z2J6Pt22se93jsPPcmooOfKpXiaaGPAaaKyGZgJnC6iMyIOGc70ANARDxAO+zgqFJVXnoJrrgCXngBXn4Zpk+Hp55K3vWvvtr25Xu99vvMTGjfHi66KHn3UKoZq7OFboy5AbgBINxC/7Ux5nsRp80CLgM+BS4A3jfGaAtcVdm1C2bOjD7++uswahQcc0zD79GvH9x3H8ydCzt32tIBY8aAz9fwayvVAtR7LpeI3AIsMsbMAh4H/iUi64H9wCVJik+lixdeiP3aiy/CDTck5z55eTqjRbVaCSV0Y8wHwAfh5zdWO14G2nmoalFaWr/XlFJx09UWKj5bt9rukY4d7RRFtzuxnz/9dFi0yPm1b3yjfjF99RUUFUHfvtC2bf2uUU86+KmaI03oqm6/+lXN2SPPPgvXXAOnnhr/NYYPh169bBKurrDQJvtEFBXBn/5k++XdbqiogGnTdPBTtXpanEvV7tFHo6cCAtx7r/PGFLW54w649FKbxAsK4IIL7OpOpy3oanPXXfY3hvJy211TUWFXgy5cmNh1lEoz2kJXtfvgg9ivzZ4NU6fGfy2Xy3bXnHtu/ePZswc2b45eEVpebuNpwjnnSjU32kJXtautFb5nT9PFUamkJHb//eHDTRuLUs2MttBV7bp0ce5yATjnnNg/FwrZlrTHAz16VG34bAxs2WJf79Ur8e6W7t2df8bjSXxTjTjo4KdqSTShq9r95jd2ADRSz552xouTVatsH7vfbxN427bw29/aJH7XXXb5v4jdpu7aaxPbO9TjgR/9CB56yPadG2NXhubm1v4Bo1QroAld1e7wYZtEIwtcxWpZFxXZwc/qG1+Ul8PNN9uEfuRI1fGyMrjtNrj/fpuQ43XaafY3hzfegL17bW32SZOgTZv4r6FUGtKErmo3e7ZztcKdO2HbtujNnOfNc96CLnIz6UqhEHz0EZx9dmJx9esHP/95Yj+jVJrTQVFVu/37nY+73bY1HqmoyHaFRAoGnT8Y/H67w5BSqsG0hd7SHD5sE2CnTlVVBZMpELALdnJz7WPYMNi0KTpJV1TYFZqRBg+G996z3SnVVc5MibxOZqbdJq6JPJ/oGOfzjRKGUo1CE3pLUVYGDz5ol897PHZQ8dJLYfLk5N3j3XfhX/+yA42BgE3mP/yhPX7wYFUyzsiACy903jt06FDo0wc2bKjqZsnIsDNQAgFYtqyqfz0jA/r3tx8CSqkG04TeUjz4ICxebJNiZdfFjBl21eWwYQ2//rJl8MQTNfu6ly61yf3OO+0A5KJFdiOJb33LJm4nLhf8z//YVvq8efbDZ+JEGDfOvj5vnn0tFILx4+2y/0SnLiqlHGlCbwmKi20yj+yuKC+3G0UkI6G/8kr0wGVFhU30oZCtkxJvrRSvF8480z4iTZhgH0qppNOmUUtw6FDs1ZGxBi0TtS/GBlNutw5aKtVCaEJvCTp2dO6WcLkSW5RTm0GDnO8hAp07V638jPwAMcbObElGTfOSEnst3exKqXqJZ5PoTOBDICN8/gvGmJsizrkcuAu7tyjA/caYx5Ibaivm8cD3vgdPPlk1oOhy2RkiFyZpafr55zsX4jr/fJg1C557rirR5ufDn/9sa7k8+KBd3AN2tsrPf2772RNRVAR/+xt88YX9ACkosPuDDhjQoLekVGsTTx96OXC6MaZYRLzARyLypjFmfsR5zxpjrk5+iAqAM86wie7ll233yPHH22TbqVNyrr9wofOCoFmzbJdPdQcO2CX7oVDNFaGrV8Mtt8Ddd1fVbqlLKAT/+792qmTl/XfuhFtvteUD2rev19tRqjWKZ5NoAxSHv/WGH/o7cSoMHRp7dklDxdrzMzKZVzpyJLpfPxi0rfYvv4x/0+cvvrDdOJEfJoGAnQ2TrN9AlGoF4upDFxG3iCwDdgNzjDELHE47X0RWiMgLItIjxnWmi8giEVm0J1aiUKkRuRAoHrFK6yZSVjfWuYEA7NiReExKtWJxJXRjTNAYMxToDowQkRMiTnkN6G2MORGYAzwZ4zqPGGOGG2OGFyZSjElVCYVs8o134DAUsi1gp2X31dWna8NppWoo5LyCtLrSUjsVE2xNFqeunoyM5A341sFgOMIRQjjEoVQLktA8dGNMkYjMBc4EVlU7Xn3O22PAnckJTx0VDMLTT8M779j54e3bwxVX1F4D/K9/hU8/rfp+xAjb9+00m2X6dLtPZ6QBA2wXSqTu3W13TPW58SIwZIithOhkwwY7mFq5EUV2ti3PO2QILF9eNQ/e7bYldysXIzWiuczlaZ6mmGIyyOBczmUa0xDiHANQqhmps4UuIoUikhd+ngV8E/gi4pzq/4KnAp8nM0gF/POf8PbbdhAyFLIzS+69Fz6P8Uf9wAM1kznYgc+//c35/FhdH0VF0S1xlwvy8qIXIonYAVun3x7KyuC//7vmrkKlpXYQ9YorbF95x472uhMnwu2321k8jegTPuFxHucgBwkSpJRSXuRFZjGrUe+rVGOJp8ulCzBXRFYAn2H70F8XkVtEpHJDyV+IyGoRWQ78Ari8ccJtpUpLYe7c6ATq98OLLzr/zIcfOh+PTPKVnnnG+fiePdF95aEQrFkT3Y0TCtndjTZujL7Oiy86d60YY6dETptm66I/8ojdwKIJuuSe4zn81PwzLaecl3lZu19UixTPLJcVwEkOx2+s9vwG4IbkhqaOKiqy3RBOZWljDRzG6mM3xibWyG6X2hYGOSViEed+eZfLTkHs16/m8a1bY19/27bYrzWivex1PF5GGX78ZNK4vyEolWxay6UlKChwTtAitrKhE7fbeRaKy+Xch56XV/vy/8hrVW79FvkhEwzavUIjDRwIS5Y4X79/f+fjDRBPmdxudGMTm6KO55JLBhkAPHdhVf3chxedzNxNvQnpAmvVTOnfzJbA54OpU+3Mj8jjsQpmTZ3qfDzWzkBXXOF8vE8fu1K1OpcLRo+GrKyaHw4+ny0hELmLEdj9Pp1mxbjd8J3vON+7kX2P7+HDV+NYBhlcyqWOg6LTjv0CnyeI8zKMINTopgkCdcwsUirJNKG3FBdcAJddZgcOMzLsStGbbnJuDYNNkhdeWJVEvV67svQHP3A+v0MH58Tdo4fzbwfBoB24HDnSJvZ27WDKFPj1r52v7/HYAdmePauOdekCf/lLow9+xjKYwVzP9fSlLxlk0I1uXMVVjGe84/mdc0r44+nvM6TT1zVf8O0EdynU+BAIQd6yxgpdKUdiUlQIaXi/fmbR7ben5N7KwZ132nrnkVwum9Aj/554vTZBd+jQNPElKOGdiRJ0UfUbFMyF/aMhFPHB5CqFzK+hNEa3mFL1cZEsNsY4zlfWFrqytmxxPu6UzMEm9J07GzemliLQNjqZA7gqIGN308ejWi0dFFVWr152imJk8haxj8iZLhUV0LVrvW/X2C3oJuU5DK6y6KQe8kJ5x9TEpFolTeipEgjA88/bAlTl5XDiiVV95E6MgTlz7M5Chw7ZwcrLLrMzRB5/3O77GQzagcmLL7aDkIm44AK7O1H1WStuN4wdC/Pn16z14vPBKae0qEqIC1jATGayhz10pjPf5bucFD0bt34OHwsuP4R8HP2l13UE2q2C0p7Q/mM4dCJIBbRbAftHQMhhP9am0uMZGHQTZG2Dw8fBijth98TUxaOSRrtcUuUvf4HZs21yLi+3/de/+13s6obPP283cN671y4oWrsWbr7ZLqV/++2qaYV+vz1v9uzE4om1I1Jenr3PwIG2Pz07G846C666KrHrp9BHfMTf+Tvb2Y4fP1vYwj3cwxJiTKNMVHkX27WSt9wmbc8haL8ADoVr2uwfYbtlKtrD/pGQ8yUpK1ja9SUY/iNo+yV4jkD+EhhzDhTOTU08Kqk0oafCjh2wYkXNlZ/G2O/ffTf6fL8fXnutZu3xyuPLYsykmDkzsZheeCF6oVAwaDeH7tLF1iefOdNuJP3d70bPiGnGZjAjakWoHz8zmJG8m5T0h6KTwHghkAt7x0PeagjkYCtOh4WyoHggpGQlqoFgFngiFpF5jsDg36UgHpVsmtBTYcsW5xax3+9cCGvvXucNI2qboRSZ/OuyaZPz9dxu2N1yB/YCBNiP876rO2nkQV1xQTAn+rhxkZp/egKHBjm/lLumaUNRjaLlNLPSSeUenZE8nprztCvl58euPR6L0yKe2nTtCl9/HX08EGi2UxPj4cZNW9pymMNRr3Wg/u+r+grS6mpMZyRopy5G9pdLCEyQuv75xbpHpf1HMvn5G2dTEYrRXeakzQbn46W947+GarY0oadC7972sWFDzW4OjwcmT44+PysLJkyA99+veb7Xaxf+OBXDmjLFbgk3c6YtmNW1K1xyCZxwAjz7rN3KrvJDZdQou+ho9eqa3UA+ny1h26ZNMt51SgjCBVzA0zxNOVW/tfjwcTEXN+7Ni4bYqYshQ9WiowBkboPSfrX9ZBS/CfK7dTvZtmEaBHLI6vw+vxz8FSd33cHiHV2oCDn9Uw5R8zeBEGRvg0B2zW6XQDasujWx96aaJe1ySZUbbrDL5z0eO9jYt6/dWzPWzJGCAudW/c9+ZuuJVxKBSZPs5hC33WYHT4uLYd06u7Lz3nujKx/Onw8zZthVnp062XgyMuyHy5VXJvVtp8KZnMklXEIOObhw0Y52XMEVjGVs4944kA/BbGoOgAoc6QkJ1lu/cqGHbaunQ0lfKO/IkS3nc/ucC5gydA6n99mMzx0x/pG9PuIe4Q+V3eNgzU3gz4eQG450hUUPw45z6/UWVfOiK0VTLRismm4Yi99vS8pGbhMnYlvXv/qVTdClpXYWistlj23fnlgsM2bYOPz+qg+aRpKKeegGgx8/PnyNtoHFRUl8Y5VdLh+X7OC+t6bbAdXqXKV0GfQI9x3bjZCBS16ovHc54CP6Q8OAdx9UFNjnR+fO62YeLYquFG3G3O7akznYQVEnxsD69fa5ywU5OVVJuD6rODeFKw/6fI2azFNFEDLIaHG7EX18oNTOc48Uymb3nmMBcFV/S5mx9nQVqMireh7KQpN5etE+9JYgLy/2oGishUjt2sGBA4ndp1u3xM6vQ0taDRqrZV3XwGRt57/y+UCeX3N8jP7tBGLq9AYYh4FPKadN7lYgr8a9t5Uarp0d4w/fXeY880alBU3ojS0YhLfeqlrJOWZMVSncuXPta0eO2JWX3/623UszUna2/blPPoketDz/fOf7nncePPlkzQ8Ct9vOKXfaUCIvz3bZPPmk3Y0oPx/OPbf2PUsTFCDAG7zBXOYSIsRYxnIO5xytPR4pRIjHeZx5zCNAgO505xf8gp70ZCELeZVXKaKIQQziQi6kkMKY9353XwnPrB5AyeGetG23kR8M2sTY/DZsLmrH86uPj/6B7M2QtZ1LZo8iI3sX045bxbc7J5YIJ/TZzMtfHEdFIlPOJQD9HoS+D4OrHLZ+B774NeSsg4ODqTGnXSq4vF8pn249gVlrj+FQeSZDOu3igkFryMrZypHiHkT1o2fuhJIBCb0P1XLU2YcuIpnAh0AG9gPgBWPMTRHnZABPAScD+4CLjTGba7tuq+lDv/NOWLmyal6412uTav/+8PHHVcc9HptU77nHzmqJVFFhF/V88IH9PjvbLv0/7TTn+86ebZNzpLPPtouFIn33u3bmS1lZ1YBpRgZceqldGVoP1Ru9BsMf+SNrWXt0kY8PHz3owZ/4Ey6H3r/f8Ts2UnMGjyBMZSpv8dbRWSsuXGSRxV3cRQEFUdd5eVcJz3xyEQQzsb2MIXAfYdrQuby17Ez8QRem+v0zdkGwDQSyONrmcZcw/qQX+Vkfh/83tdh4II8HFp7C1kN5dZ8MMPI86PpO1SyUYAYU94fFf4f9Y8FUtsFCkLGHKb13MWf9IMqD9rhbQmR7K7h54lv84eMhlByqLK9soM2XUDIwofhVM9TAPvRy4HRjzBBgKHCmiIyKOOdK4IAxpj/wV+COhsSbNjZutCtCqy/yqaiw873nzat5PBCwy/7nxliC7fXCf/2X3Sz6gQfg4YdjJ3OIvUfom286H3/uuZrJHGx8zzwTvZdpPaxlLetYV2PFph8/29nOUpZGnb+VrVHJHOwHwyxm1ZiCGCJEGWW8wiuO935u6djwbJPKv+4uCLZh1vLxlAc9NZM52H7m6skcINiGD5ZPwe8006gWffOLuGfynDjPNvD1mTWnFLrLAT8cGFUtmYffQ0VbZq+tSuYAQePiSMDDBxsH8s/Jn/HwtOfsQChoMm8F6kzoxioOf+sNPyKb9dOAyubgC8BEEaelja3M+vXOqy9jreL0+21rvjY+n23J1zVoGSsJx/qNrKLCeVokOC84StCXfEnAYQefMspYx7qo45/xWcxrGYc6KEGCrGZ19PVDQYLFzvXITSDG/PpQ5S+j0cc3HElwBW6inIpkFQ+KOShqJPrPIhBys/JrO7aS73OFZ7Wk3yC3ihZXH7qIuIHFQH/gAWPMgohTugFbAYwxARE5CHSAmrvwish0YDpAz4LoX43TTn6+7UqJ3HfT7XZeyu9223ngySBSe2mAeM8PBu0AawPlk48XL0FqDu768Dmu2OxG4gO0Tt0tPnGBp9jWV4kSa7VmCHAYhDRuOvns+cmcnlidZO+I+rhyZe2KsY9pBRindpOhY5taNv1WaSuuj21jTNAYMxToDowQkRPqczNjzCPGmOHGmOGFuU7/wNLMSSfZ7dUik7fHYxcKRR53u51XitZH9cVG1XXv7rw36bBh0dMnvV77HpLw/2oEI/ASXY7AjZsxjIk6PpKRMQdL+9Anqs/di5dpTIs61yXCsQPeAndJxI1Lwsvga/5W4nX76d9tdfT5rlI69vgAt7fUFvX65okwdjJ0escxxqr7lMKAu+GMYTBhDPT8V9Q9LZvG255yddS0Sk/+UjzZO2wlxxrX9tMzfw8eV8SHpDvIOQPX1h6XSksJ/R5mjCkC5gJnRry0HegBICIeoB12cLR183hs6dlevWxy9PnsNMM//MEuwY+Uk5O8GuO//a0deK2ub1+44w47MyYz0z68XtsXf911cPnldrC18vjJJ8PVVyclHB8+buZmetITL158+OhMZ27iJtrg3PXhtLdnFlmMZnTUcQ+emK36GwdBrz5zbI1yz2GbrPM/s98f+2f7vecQuI4Q7P04k0f9A9fg39tjlZtXdH+Js05+ld/wG97gDchbCZ3fgVPPgz4PxXjXQRg/Fk64EfKXQsEncPJPbfnaowyVyfyyIcs4Jif6Q6xQCrh13HyyOiyzsbhLkMxdXHLqc9w8bgGDO36NxxUkwx0gx1fOT4d/xjEdnAuSqfQWzyyXQqDCGFMkIlnAO8AdxpjXq51zFTDYGPMTEbkE+LYxJsZ29FarmeVSaf9+O/BZWGjnh//859FdMRkZ8P3v26X7yXLwoB2c7dPH9r1X8vvtgqW8PJvEKwUCtrpibq79gGmAWL0Se9lLiBCFFMZc5FNMMT/mx1RQ88/IixeDieqPd+NmMpO5nMtjxlMUCDD97ZFQ1tkWzBp3BnR8z66WLO0JmTvAW0wWWZRTTijkhpLetta572DMe/PFdbDyLqIX6QThzAHQdlPNw4EsmLMUigdy7ehPcEmQEd12sZWt3MANUaV+M8nkZ/yMUYxiS1k5BwMhjmuTgUeq2mOHyn2U+H10bFOC21Xz33RjdQ+pFKlllks8fehdgCfD/egu4DljzOsicguwyBgzC3gc+JeIrAf2A5ckKfT0Ub3lvW6dc996eTksXZrchN6une02ieTzOW8h5/E0aGu5eDj1dUdaz3q8eKMSegUVjlMcgwRZzvJar5nn8UBp36oDHT61OdhdBm2rBmaPcMQ+cYXsRhDV7u3o68k4rrh0lcPhQdEJHYGCj6B4IKO6V5Vn+IIvHC9fRhkrWckoRtEz07kbKjfDT25Gw2cjqZatzoRujFkB0Xt1GWNurPa8DNBmQLzy8pwHIF0u27feTDVlQy+PvKgB1Lrkk1/nOdVXcv6ULPYRPXgoiONMmljHXW22gIQImYgPGuMGr0PXh3FDuR38rtF67uqFETPBWzMxe/DQnpaz3Z9KHV0pmgrHHGO7NMrLayb2WOVzW6Fe9KIjHdm2uz9m/c+gvBC6vYK375P09LRnIxtrJFcvXqYylS0Hc3l93THsPNyW4wt3c9aA9eRlOk81nMIUZjIzqqzucRzHGtbUaJG7cXMMx7ChYi/+9VfArm9B1nZcA+6nZ//X2P7VZdEJHZdd4bnwCdhxji2l2+cx6Pc32HUG9Hgaej8BCGz+IWw71y5+8hRDtemIbtxMYELMP6u1rOUN3uAABxjGMCYxiWxSuGepShlN6KngcsGNN8Jdd9kiWi6XneHyk5/YWSgKQRi5bibbVg2tWuF54BRyNl5PtzOuYIOn5kYNFVSwb9dw7v5kIoGQi5BxseFAPnM29uOOM96l0GEa31mcxV728g7v4MFDgAAjGMFkJvMH/lDj3CBBevtPYuuc+/CXtQkXtgphdkzjtCGLeTOjnH1H3NToejFemL0jvCAofPyL38Paa2HUd6Hz2+AJz6Yp+Bi6vgwfzIUx50LWDjI9IXz4+CW/jNlCf5d3eQzTShYAABauSURBVIInjva7b2Qjc5jDndwZc7BZpS9N6KnSsWNVQj9yxO5U1IL26WxspRUeXl05AlO9sFUwm5JSLx9u6g+R5UgMPL5oJIFqqyYDITelfuHZ1YO4ekT0QiUXLi7jMs7nfHaxi0IKaUc7fskvHWN6c90xeMvCdcTDVzDBLGYuHY3LVX0Ti+pxRU7VFDCZ4Zk11aZGekqg6+uw7jp4ax20/YJbznydnvR0HDMAu9L2SZ6MWn1bRBFv8RbnE6POj0pbunws1bp0sdMJNZnX8OW+Dnhc0fO1/UEvbHfYjKGsM4Hy6D70EC6W7+pc671yyKE//WmHXUC1i13OJ+6Y6rjdWwjBH0zw/99Wh92SXH7o9B4gcPg4etM7ZjIH2MQmx9crqKh1pa1KX5pFWrHmPJstx+cn5LgKMgSZDgnXU0ys9snB8syjg4/xlMP14HGe0ZK5Gw46RBTVdx4Hn0ON+5APyqtWzdZV0rctbWMOHFd+OKnWRVvoqt4CBPiIj7iLu3iAB1hL8lYn9s0/QH5WGRKxqtLnDuHr/1j0D3iL6dJlCa6oejG1F9M6xCFe5EVu53b+zb/Zy17HlasAWQMeISNyq7f6OuYeh4MC2+L/lO0a/opspWeQwdmc3cAAVUukCV3VS5Agf+SPPMzDfMZnfMiH3MqtzGJWUq4vAhPG3Y7JWQ/uw+ApAncpbU+8mZsLJuGjZpmCQQyiS/sthHBTVTsu/N9s5/ndu9nNNVzDS7zEEpYwm9lcy7VMYhJ9qFnQK5NM7uwygW8ftwavK7HplA7vDhY+A+XtoSIXKtralvl/ZkNF3VMvq7ue6+lGNzLIIJtsvHi5kAsZQozSDyqtaZeLqpcFLGADG45O+avcr/NZnmU848mlYfVfyijjxTZ3w5l/hoNDwN8e2i/ksKeCDfyAGczgMz5jJzsZyUg6hAq5dNW3qTkwWfnceYu/p3iKEkqOTn8MhL8e5VHu4A62s53FLKYnPRnKUADOO24tk/pt5IevNnBT5T0T4LVd0H6BjXP/yIjyuPHpQAfu5m62spVDHKIvfXXKYiumCV3Vy0IW1pi/XcmNm9Wsdqy3koh1rMODB7/4Ia9qBagfmM98JjGJUzjl6PFPDxXHSIgCpb0cjsMKVjguFPqKr/Djp1v4K1IbX4wVo4kyXthXS037OAlCT3omISDV0mlCTyNNOcjZhjaOKycFIYvEdvVxkkUWoRj9307zqwt9DuVujwYV5LkLXoo67MNHGWVRx124cDuVz1WqmdOEruplIhOZx7yoQlIePJxA4tWVN7OZD/iAMsoYyUgGM5gccqISbgYZTGYye9jDe7zHPvYxmMGMzh6N+PZi/AVE7qOZ1+Ujx3t+k2/yGq/VeA8ePIxmNMGgl3lberJmTyGdc4o5vc8m2mdFJ3+lmhNN6Kpe+tKX7/N9nuIpPOG/Rl68/J7fH/0+Xm/yJv/m31RQgcHwMR8zhCHcwA38iT9RSimCECDAt/k2IUJcy7UECBAkyHzmM4tZfGfClzw959e2kmKl7I38cfQGIHpPz/M5ny1sYRnL8OAhRIg+9OFi/0+57t1JFJVlUR704HEFefWLgfzPuP8wsECrQqvmSxO6qrfJTOY0TmMNa8gii+M4LuGuikMcYgYzasz7Lqec5SzndE7nQR7kC76gmGKO5VhyyGE602v035dTzk528lruPXDe/8LWS6HoROj6Gu7C+bzGGVzJlVH39uDhN/yGnexkC1voTGd60Ysn15zIvtJsAsa+l0DITQA3f184gr+f9abjZlNKNQea0FWDtKFNjcHJRK1gBW7cUQt5yilnPvMZxjCO5/ijx7ewJaqbB+zqyAoq7ETcXk/bB3aTuYUsdEzolbqEvyot2N79aDKv7sCRLPYfyaJD9pEE36VSTUMTukopHz7HTS4EcdyCzocv5mBpLE5b39Uak9t5nrkBvDFea85irY7VjS/Sjyb0VigQEhZs6876/fl0aVvMaT23kO2NvQIyRIiVrGQFK8gll7GMTVp97sr53ZG8eJnABPaxjw/5kGKKGcIQBjOYTnRia1k5fHUplHWDwnn4urxHobRnJztrJHwfPiYyMaGYzui7gZmrBteoz+KSEP3yD9SyiYSBdsvAe9A+P3wMlCe+0XU8ZqwYzEmdd3F84R7t/lE11JnQRaQH8BTQCdtIecQYc1/EOeOBV4HKrVleMsbcktxQVTIU+73893sTOVCWSVnAS4Y7wDMrB3Pr6e/TPfdw1PlBgtzGbaxjHWWU4cXLC7zAdVwXMxknwoePSUziVV6tcbw73TnAAW7kRkKECBBgDnM4nuM5d+9d/P0/37JVD0NZsPHHZOZu4bfjP+Y2900UUYQJfw1mMOdwTkIxndV/A2v3FrJ0V2cEgwjkZpRzzaj5MX4iBPkL4dAJEMy2RbYkBPnz4cCoev7JxDZr7UDeXt+fk7rs5JpR83FpUldh8bTQA8B1xpglItIWWCwic4wxayLO+48xZkryQ1TJ9OyqQewpqRrwKw96KA8a7l84gtvPeC/q/A/5kLWsPToIWdnXfR/38SiPJjyjJVIxxXbT5Qjb2c5f+WuN/vIyylhl1rD208kQqLbfaaAtZQcHsmh9BfcOvJc1rGEPe+hHv3otuHG7DNed+ilbD+ay4UA+HbKOMKjj7tiJs/0CODgYguGYQpn2v4dOAFdpzVk3SSGUBz0s3dmZRTu6MqLbjiRfX7VUddZyMcbsNMYsCT8/DHwOMbZXV83ep9t6OAz4CV8V5VFaEZ2cP+RDxxWhIUKsZ32D46kcFI1UTrljJUH/ob4cqYheyu8PevjwK1tu9gROYAITGrx6ske7Q4zv/RWDO9WSzAGMqyqZVydByI1s9yRPedDLf77SFaKqSkLNKxHpjd1fdIHDy6NFZDmwA/i1MWa1w89PB6YD9GzGe2c2dw0Zy3KLw16mYU45K9Y0RINJympKF66Yg6LOPxDAOVJwO9RPj1RXSdp4VT//x5+058ABp7OE7524iqmdIjeJrjue+BjHmvGq9Yq72qKI5AAvAtcYYw5FvLwE6GWMGQL8HXjF6RrGmEeMMcONMcMLcxtWvEnVzzd6b46qFuiSEMcW7CXLYWB0IhMdZ5tkkEE/+jU4nqEMdZy14sXrPMsl5ytys4ohouRAhjvAxD4bGxxPfXyzz1ZwF0e/IEEmFTa8DEIsGe4gE/psbrTrq5YnroQuIl5sMv+3MSaqKIYx5pAxpjj8/A3AKyLaBG+Gzj5uCa78pTYBucrAcwiTtZ2LR0T3YwOMYhSjGY0PH168ZJJJNtlcz/W17qYTr0wyHUu9ZpHF9VxPFllkkokXLz58jJOx/Pepi8nx+cn0VOB1BfG5AwzpvIuJfWO3hBvTtztn07PPe+AuBdcR8BwGz2EuH/MKma7kV6j2ugL43AHO6LuBwR13J/36quWKZ5aLAI8Dnxtj/hLjnM7A18YYIyIjsB8Uuka6GZrlmUlwwtuwdyQUDYM2mzCd3+Tfrn7cyq1R5wvCz/gZU5jCKlbRlracwilkkpmUePaxj4UsjDp+kINsYhMP8zALWUgxxZzIiXSnO7Q7zENTXmfRjq4UlWVybMFe+uYXJSWe+nCJcPdJfpb0e565X3vI8Qa5sJuH9l6HfvUk+N6JKxnSeRdd2zr8VqBatXj60McA3wdWisiy8LHfgx1xMsY8BFwA/FREAsAR4BJjTOzOWpUyH/ERAamAwo/sI2w96ymlNGYt7Z7hr2R7nddjvjaHOZzN2YxjXNRrPneIU3tsS3o8DTEsN5thTdCTeNaAhg9Gq/RUZ0I3xnxErFGoqnPuB+5PVlCq8TjV/06l2uJpbrEq1dzpStHmyO+HRYtY0v8Qe8YP4tCgHkm79Gmcxtu8TaDa3puC0I9+ZJPNRjayjnXkk8/JnNzgeeZ1mcIUx3noAJOYlPT7JTqbJRE72MFKVpJNdtzdUvWJJ1kzdVT60YTe3GzcCLfeCsEgJxICga0XjWbRP34KSRhgu5ALWclKdrObMsrIJBMfPn7KT7mTO4/u4uPGjQ8ft3ALXemahDfmrIACTuEUPuOzGsfb0Y4zObPR7ptMBsOTPMm7vAvYqZiP8ii/5/ccy7Epjk61JrpJdHMSCsGdd0JJCZSV4Snz4znip8cL8+nx3KdJuUU22dzJnfySX3IRF/EjfsSDPHi0VosfPxVUUEYZhznM3dydlPvGUkYZK1jheDwZC5eawnKW8x7v4Q9/lYW/7uCOGr8JKdXYNKE3J5s3Q2lp1GFPSTl9Hn03abdx4eJkTuYCLmAc4/Dh413ejSpLazDsDn81lmUsc5z+6MfPPOY12n2T6X3ej7ma9nM+T0FEqrXShN6cBIPEKp/nLm/clp7TMnvg6E5BTX1fg2kxrdva4oz1/pRqDNqH3gxUjnFJoC/n3OMiI2L/hEB2Bpu/Hz11L5nGMpaXeCmqld6WtjU2f0i2IQxxTIgZZHAqpzbafav7pKiUlUVC35wQEztk46qjJu3XfM1a1pJLLoMZzGmcxkpWRrXSQ4Q4juOSHq8OfqpYNKE3I8bjZsEz13DqeXcjwRDu8goqcjI5cHJfNv9wQqPe+1t8iwUsYCc7j5bJdePmGq6JXVclCXLI4Uqu5B/8g2D4K4MMRjKSEzmx0e4LUBwM8POPelOy70QgxHsiPJHzFfd8YxmdfdEFwAyGx3mcuczFjRtByCSTP/AHBjOYVayijDI8eHDh4iqucixfoFRj0YTezHw9aQhvfnkfvZ6cR+bXB/n6myey66yhSZnhUpsMMvgzf2YRi1jDGgooYBzjaEe7Rr0vwOmcznEcx3/4D2WUcQqncCzHNuoHCcCfVvso2TvU1lQPqzjUn/9dvIuHRu+POv9TPmUe86q2u8MO3t7FXfyVv7Ka1SxhCTnkMJaxdKRjo8avVCRN6M1QWdf2rL3hvCa/rxs3I8NfTa0LXbiIi5r0nhs2faNGMgcglMH+7WMpC71EpqtmNcm3eTuqW8Vg2M9+drCDweEvpVJFB0VV6xWK1R3iIuBQucJpJos92xXzNaWakrbQm5Duydu8dOi8gH3bxlPzn0GIjPxV5Lij/2mMYQzb2BY1cOzCRW96N2aojUI3j04/2kJXrdZ1Q3YiGfvAXWIPuErBe5irhy93PH8Sk+hCl6MDnZWraa/m6qRs9qFUQ2kLXbVa/bMzeeCs93his2HTvkK6tNvPZX0C9Mh0rjiZQQa3cRuf8AnLWU572jORiXSmcxNHrpQzTeiqVSvwevn1AGDAQcAdfsTmwcO48JdSzY12uSilVJrQFnqStfTxpGKK+YqvyCe/UassKqWSL54t6HoATwGdsDvzPmKMuS/iHAHuA84GSoHLjTFLkh+uaiwGw/M8z6u8ihcvAQL0ohfXcz256IbeSrUE8XS5BIDrjDHHA6OAq0Tk+IhzzgIGhB/Tgf9LapSq0S1gAa/xGhVUUEopfvxsZCP3cm+qQ1NKxanOhG6M2VnZ2jbGHAY+B7pFnDYNeMpY84E8EWm8ik4q6V7jtajFMUGCrGUtBziQoqiUUolIaFBURHoDJwELIl7qBmyt9v02opM+IjJdRBaJyKI9hw4lFqlqVIc57HjcjZsSSpo4GqVUfcQ9KCoiOcCLwDXGmHplY2PMI8AjAMP79WsROwC39EHOeA1jGG/zdlT9bjfuRi2fq5ofXUHacsXVQhcRLzaZ/9sY85LDKduB6jsZdw8fUy3EuZxLW9rixQvYjS18+Pgv/ktXQSrVQsQzy0WAx4HPjTF/iXHaLOBqEZkJjAQOGmN2Ji9M1djyyONu7uZN3mQlKymggClMoT/9Ux2aUipO8XS5jAG+D6wUkWXhY78HegIYYx4C3sBOWVyPnbb4w+SHqhpbLrlcHP5SSrU8dSZ0Y8xHUPtOA8YYA1yVrKCUUkolTleKhul4j1KqpdNaLkoplSY0oSulVJrQhK6UUmlCE7pSSqWJtB0U1UFOpVRroy10pZRKE5rQlVIqTWhCV0qpNKEJXSml0oQmdKWUShOa0JVSKk1oQldKqTShCV0ppdKEJnSllEoTLX6lqK4IVUopq84Wuoj8Q0R2i8iqGK+PF5GDIrIs/Lgx+WEqpZSqSzwt9CeA+4GnajnnP8aYKUmJSCmlVL3U2UI3xnwI7G+CWJRSSjVAsgZFR4vIchF5U0QGJemaSimlEpCMQdElQC9jTLGInA28AgxwOlFEpgPTAXoWFCR0Ex38VEqp2jW4hW6MOWSMKQ4/fwPwiohjtjbGPGKMGW6MGV6Ym9vQWyullKqmwQldRDqLiISfjwhfc19Dr6uUUioxdXa5iMgzwHigQES2ATcBXgBjzEPABcBPRSQAHAEuMcaYRotYKaWUozoTujHmO3W8fj92WqNSSqkUanYrRXXwU6nm6bkLn3c8fpH+o202tJaLUkqlCU3oSimVJjShK6VUmtCErpRSaSJlg6IH8nUAVCmlkklb6EoplSY0oSulVJrQhK6UUmlCE7pSSqWJZrdSVCnVsugK0uZDW+hKKZUmNKErpVSa0ISulFJpQhO6UkqlCU3oSimVJjShK6VUmqgzoYvIP0Rkt4isivG6iMjfRGS9iKwQkWHJD1MppVRd4mmhPwGcWcvrZwEDwo/pwP81PCyllFKJqjOhG2M+BPbXcso04CljzQfyRKRLsgJUSikVn2SsFO0GbK32/bbwsZ2RJ4rIdGwrHqD4IrlobRLu3xQKgL2pDqKJ6XtOf63t/UJ6vOdesV5o0qX/xphHgEea8p7JICKLjDHDUx1HU9L3nP5a2/uF9H/PyZjlsh3oUe377uFjSimlmlAyEvos4Afh2S6jgIPGmKjuFqWUUo2rzi4XEXkGGA8UiMg24CbAC2CMeQh4AzgbWA+UAj9srGBTqMV1EyWBvuf019reL6T5exZjTKpjUEoplQS6UlQppdKEJnSllEoTmtDrICJuEVkqIq+nOpamICKbRWSliCwTkUWpjqcpiEieiLwgIl+IyOciMjrVMTUmERkY/v9b+TgkItekOq7GJiK/EpHVIrJKRJ4RkcxUx5Rs2odeBxG5FhgO5BpjpqQ6nsYmIpuB4caYlr74Im4i8iTwH2PMYyLiA7KNMUWpjqspiIgbO814pDHmq1TH01hEpBvwEXC8MeaIiDwHvGGMeSK1kSWXttBrISLdgW8Bj6U6FtU4RKQdMA54HMAY428tyTxsIrAhnZN5NR4gS0Q8QDawI8XxJJ0m9NrdC/wWCKU6kCZkgHdEZHG4VEO66wPsAf4Z7lp7TETapDqoJnQJ8Eyqg2hsxpjtwN3AFmxZkoPGmHdSG1XyaUKPQUSmALuNMYtTHUsTO80YMwxbRfMqERmX6oAamQcYBvyfMeYkoAT4XWpDahrh7qWpwPOpjqWxiUg+tpBgH6Ar0EZEvpfaqJJPE3psY4Cp4T7lmcDpIjIjtSE1vnBLBmPMbuBlYERqI2p024BtxpgF4e9fwCb41uAsYIkx5utUB9IEzgA2GWP2GGMqgJeAU1McU9JpQo/BGHODMaa7MaY39tfS940xafeJXp2ItBGRtpXPgUmA48Ym6cIYswvYKiIDw4cmAmtSGFJT+g6toLslbAswSkSyRUSw/58/T3FMSdek1RZVs9cJeNn+fccDPG2MeSu1ITWJnwP/DndBbCQ9y1fUEP7A/ibw41TH0hSMMQtE5AVgCRAAlpKGZQB02qJSSqUJ7XJRSqk0oQldKaXShCZ0pZRKE5rQlVIqTWhCV0qpNKEJXSml0oQmdKWUShP/D4I/cvsGu/bSAAAAAElFTkSuQmCC\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "model = NearestNeighborLearner(reduced_iris, k=5)\n", + "plot_model_boundary(reduced_iris,0,1, model=model)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The class boundary seems more regular. Then let's double k, making k =14:" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "model = NearestNeighborLearner(reduced_iris, k=14)\n", + "plot_model_boundary(reduced_iris,0,1, model=model)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The decision boundary is smoother with larger k generally." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/chapter18/Linear and Nonparametric Models.py b/notebooks/chapter18/Linear and Nonparametric Models.py new file mode 100644 index 000000000..2862877b9 --- /dev/null +++ b/notebooks/chapter18/Linear and Nonparametric Models.py @@ -0,0 +1,180 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Linear Models +# Linear models are already used for hundred of years. It is a class of linear functions of continuous-valued inputs. + +# %% [markdown] +# ## Linear Learner +# ### Overview +# +# Linear Learner is a model that assumes a linear relationship between the input variables x and the single output variable y. More specifically, that y can be calculated from a linear combination of the input variables x. Linear learner is a quite simple model as the representation of this model is a linear equation. +# +# The linear equation assigns one scaler factor to each input value or column, called a coefficients or weights. One additional coefficient is also added, giving additional degree of freedom and is often called the intercept or the bias coefficient. +# For example : y = ax1 + bx2 + c . +# +# ### Implementation +# +# Below mentioned is the implementation of Linear Learner. + +# %% +psource(LinearLearner) + +# %% [markdown] +# This algorithm first assigns some random weights to the input variables and then based on the error calculated updates the weight for each variable. Finally the prediction is made with the updated weights. +# +# ### Example +# +# We will now use the Linear Learner to classify a sample with values: 5.1, 3.0, 1.1, 0.1. + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.learning import * +from aima.notebook_utils import * +from aima.utils import * + +# %% [markdown] +# One of the key point when applying a dataset to the linear learner is to convert the class names to numbers before feeding it into the learner: + +# %% +iris = DataSet(name="iris") +iris.classes_to_numbers() + +linear_learner = LinearLearner(iris) +print(linear_learner([5, 3, 1, 0.1])) + +# %% [markdown] +# The result is closer to 0 than 1 and 2, thus we can class it as class 0 which represents 'setosa'. + +# %% [markdown] +# ## Logistic Linear Learner + +# %% [markdown] +# Logistic linear learner is different from common linear learner only in the updating rule of weights. Logistic function is continuous and derivable. Using `LogisticLinearLearner` is similar to using `LinearLearner`: + +# %% +logistic_learner = LogisticLinearLeaner(iris) +print(logistic_learner([5, 3, 1, 0.1])) + +# %% [markdown] +# The prediction can also be treated as class 0. + +# %% [markdown] +# # Nonparametric Models + +# %% [markdown] +# A nonparametric model is one that cannot be characterized by a bounded set of parameters. This usually happens when the dataset is huge and let the data speak for their own property is ensenstially good. The simplest approach is to put all examples into a lookup table. +# +# Here we will demonstrate a improved version of lookup table: k-nearest neighbors lookup, which finds the k examples that are nearest to the given query. +# +# ## K-NEAREST NEIGHBOURS CLASSIFIER +# +# ### Overview +# +# In this section we are going to use this to classify Iris flowers. More about kNN on [Scholarpedia](http://www.scholarpedia.org/article/K-nearest_neighbor). +# +# ![kNN plot](images/knn_plot.png) + +# %% [markdown] +# Let's see how kNN works with a simple plot shown in the above picture. +# +# We have co-ordinates (we call them **features** in Machine Learning) of this red star and we need to predict its class using the kNN algorithm. In this algorithm, the value of **k** is arbitrary. **k** is one of the **hyper parameters** for kNN algorithm. We choose this number based on our dataset and choosing a particular number is known as **hyper parameter tuning/optimising**. We learn more about this in coming topics. +# +# Let's put **k = 3**. It means you need to find 3-Nearest Neighbors of this red star and classify this new point into the majority class. Observe that smaller circle which contains three points other than **test point** (red star). As there are two violet points, which form the majority, we predict the class of red star as **violet- Class B**. +# +# Similarly if we put **k = 5**, you can observe that there are three yellow points, which form the majority. So, we classify our test point as **yellow- Class A**. +# +# In practical tasks, we iterate through a bunch of values for k (like [1, 3, 5, 10, 20, 50, 100]), see how it performs and select the best one. + +# %% [markdown] +# ### Implementation +# +# Below follows the implementation of the kNN algorithm: + +# %% +psource(NearestNeighborLearner) + +# %% [markdown] +# It takes as input a dataset and k (default value is 1) and it returns a function, which we can later use to classify a new item. +# +# To accomplish that, the function uses a heap-queue, where the items of the dataset are sorted according to their distance from *example* (the item to classify). We then take the k smallest elements from the heap-queue and we find the majority class. We classify the item to this class. + +# %% [markdown] +# ### Example +# +# We measured a new flower with the following values: 5.1, 3.0, 1.1, 0.1. We want to classify that item/flower in a class. To do that, we write the following: + +# %% +iris = DataSet(name="iris") + +knn_model = NearestNeighborLearner(iris,k=3) +print(knn_model([5.1,3.0,1.1,0.1])) + + +# %% +err_ratio(knn_model, iris) + +# %% [markdown] +# Which is the same as expected. By altering k, you can change the number of neighbors considering in the lookup procedure. Thus the classification accuracy is directly affected by k. +# +# In order to show the influence of k, we need to fake some data that easier for visualization first. We will use only two dimensions among the attributes of iris dataset. +# +# First, we load the dataset and compose a new dataset with the first two among all the attributes together with the target dimension. + +# %% +iris = DataSet(name="iris") +iris.classes_to_numbers() +examples = np.asarray(iris.examples) +reduced_iris = DataSet(examples=examples[:,[0,1,4]].tolist(), distance=euclidean_distance) + +# %% [markdown] +# Then we build models with different k and plot the model's decision boundaries with util function `plot_model_boundary`. +# +# Let's try with k=1 and assign the first attribute to x-axis and the second to y-axis. + +# %% +model = NearestNeighborLearner(reduced_iris, k=1) +plot_model_boundary(reduced_iris,0,1, model=model) + +# %% [markdown] +# We can see there are zigzag and rectangle shapes. The class of a point heavily relies on the nearest point type. +# +# Then we increase k to 3: + +# %% +model = NearestNeighborLearner(reduced_iris, k=3) +plot_model_boundary(reduced_iris,0,1, model=model) + +# %% [markdown] +# When k=3 we can see there are some areas near blue points are classified as green and the shape of each color chunk is no longer shuttered. +# +# Then we set k=5. + +# %% +model = NearestNeighborLearner(reduced_iris, k=5) +plot_model_boundary(reduced_iris,0,1, model=model) + +# %% [markdown] +# The class boundary seems more regular. Then let's double k, making k =14: + +# %% +model = NearestNeighborLearner(reduced_iris, k=14) +plot_model_boundary(reduced_iris,0,1, model=model) + +# %% [markdown] +# The decision boundary is smoother with larger k generally. + +# %% diff --git a/notebooks/chapter18/images/decisiontree_fruit.jpg b/notebooks/chapter18/images/decisiontree_fruit.jpg new file mode 100644 index 000000000..41ac4d606 Binary files /dev/null and b/notebooks/chapter18/images/decisiontree_fruit.jpg differ diff --git a/notebooks/chapter18/images/ensemble_learner.jpg b/notebooks/chapter18/images/ensemble_learner.jpg new file mode 100644 index 000000000..b1edd1ec5 Binary files /dev/null and b/notebooks/chapter18/images/ensemble_learner.jpg differ diff --git a/notebooks/chapter18/images/knn_plot.png b/notebooks/chapter18/images/knn_plot.png new file mode 100644 index 000000000..58b316fdd Binary files /dev/null and b/notebooks/chapter18/images/knn_plot.png differ diff --git a/notebooks/chapter18/images/random_forest.png b/notebooks/chapter18/images/random_forest.png new file mode 100644 index 000000000..e0ab1d658 Binary files /dev/null and b/notebooks/chapter18/images/random_forest.png differ diff --git a/notebooks/chapter19/Learners.ipynb b/notebooks/chapter19/Learners.ipynb index c6f3d1e4f..01e012774 100644 --- a/notebooks/chapter19/Learners.ipynb +++ b/notebooks/chapter19/Learners.ipynb @@ -22,22 +22,40 @@ { "cell_type": "code", "execution_count": 1, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:59:58.324794Z", + "iopub.status.busy": "2026-06-27T00:59:58.324523Z", + "iopub.status.idle": "2026-06-27T01:00:03.130370Z", + "shell.execute_reply": "2026-06-27T01:00:03.129078Z" + } + }, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "Using TensorFlow backend.\n" + "WARNING: All log messages before absl::InitializeLog() is called are written to STDERR\n", + "I0000 00:00:1782521998.836520 1650251 cudart_stub.cc:31] Could not find cuda drivers on your machine, GPU will not be used.\n", + "I0000 00:00:1782521998.935525 1650251 cpu_feature_guard.cc:227] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.\n", + "To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING: All log messages before absl::InitializeLog() is called are written to STDERR\n", + "I0000 00:00:1782522001.478528 1650251 cudart_stub.cc:31] Could not find cuda drivers on your machine, GPU will not be used.\n" ] } ], "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from deep_learning4e import *\n", - "from notebook4e import *\n", - "from learning4e import *" + "from aima.learning import *\n", + "from aima.notebook_utils import *\n", + "from aima.deep_learning import *" ] }, { @@ -75,10 +93,20 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, + "execution_count": 2, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:00:03.133548Z", + "iopub.status.busy": "2026-06-27T01:00:03.132937Z", + "iopub.status.idle": "2026-06-27T01:00:03.137628Z", + "shell.execute_reply": "2026-06-27T01:00:03.136609Z" + } + }, "outputs": [], "source": [ + "# input_size and output_size are derived from the dataset\n", + "# (here, the iris dataset: 4 features, 3 classes)\n", + "input_size, output_size = 4, 3\n", "raw_net = [InputLayer(input_size), DenseLayer(input_size, output_size)]" ] }, @@ -101,38 +129,648 @@ { "cell_type": "code", "execution_count": 3, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:00:03.141039Z", + "iopub.status.busy": "2026-06-27T01:00:03.140793Z", + "iopub.status.idle": "2026-06-27T01:00:03.147671Z", + "shell.execute_reply": "2026-06-27T01:00:03.146437Z" + } + }, "outputs": [], "source": [ + "import numpy as np\n", + "\n", "iris = DataSet(name=\"iris\")\n", "classes = [\"setosa\", \"versicolor\", \"virginica\"]\n", - "iris.classes_to_numbers(classes)" + "iris.classes_to_numbers(classes)\n", + "X_iris = np.array([x[:iris.target] for x in iris.examples])\n", + "y_iris = np.array([x[iris.target] for x in iris.examples])" ] }, { "cell_type": "code", - "execution_count": 7, - "metadata": {}, + "execution_count": 4, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:00:03.150050Z", + "iopub.status.busy": "2026-06-27T01:00:03.149689Z", + "iopub.status.idle": "2026-06-27T01:00:06.822817Z", + "shell.execute_reply": "2026-06-27T01:00:06.821999Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "epoch:50, total_loss:14.089098023560856\n", - "epoch:100, total_loss:12.439240091345326\n", - "epoch:150, total_loss:11.848151059704785\n", - "epoch:200, total_loss:11.283665595671044\n", - "epoch:250, total_loss:11.153290841913241\n", - "epoch:300, total_loss:11.00747536734494\n", - "epoch:350, total_loss:10.871093050365419\n", - "epoch:400, total_loss:10.838400319844233\n", - "epoch:450, total_loss:10.687417928867456\n", - "epoch:500, total_loss:10.650371951865573\n" + "epoch:1, total_loss:59.31476216472319\n", + "epoch:2, total_loss:33.461028303165996\n", + "epoch:3, total_loss:29.07359698509647\n", + "epoch:4, total_loss:26.09747495394851\n", + "epoch:5, total_loss:23.105593480138605\n", + "epoch:6, total_loss:21.445983801235528\n", + "epoch:7, total_loss:20.449595850278975\n", + "epoch:8, total_loss:19.373627669877457\n", + "epoch:9, total_loss:18.727359766065582\n", + "epoch:10, total_loss:18.740420615546125\n", + "epoch:11, total_loss:18.154319844273658\n", + "epoch:12, total_loss:17.71277720323123\n", + "epoch:13, total_loss:17.683700544303374\n", + "epoch:14, total_loss:17.415592373854228\n", + "epoch:15, total_loss:16.793151405001282\n", + "epoch:16, total_loss:16.330676889009748\n", + "epoch:17, total_loss:16.49432189367278\n", + "epoch:18, total_loss:16.348972672607356\n", + "epoch:19, total_loss:16.39012532443877\n", + "epoch:20, total_loss:15.926673386115379\n", + "epoch:21, total_loss:16.321431079837943\n", + "epoch:22, total_loss:15.488413775412445\n", + "epoch:23, total_loss:15.755159235596516\n", + "epoch:24, total_loss:15.840125504914784\n", + "epoch:25, total_loss:15.529250244634857\n", + "epoch:26, total_loss:15.633864082657587\n", + "epoch:27, total_loss:15.08692652613317\n", + "epoch:28, total_loss:15.280564389867369\n", + "epoch:29, total_loss:14.910021735822003\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:30, total_loss:15.326149776828503\n", + "epoch:31, total_loss:15.06272489216177\n", + "epoch:32, total_loss:14.94213693442384\n", + "epoch:33, total_loss:15.030613484467018\n", + "epoch:34, total_loss:14.934027341519089\n", + "epoch:35, total_loss:14.963658137233594\n", + "epoch:36, total_loss:14.786870372904607\n", + "epoch:37, total_loss:14.691905985967471\n", + "epoch:38, total_loss:14.92251082845551\n", + "epoch:39, total_loss:14.505893059986327\n", + "epoch:40, total_loss:14.385189252110546\n", + "epoch:41, total_loss:14.235979796518652\n", + "epoch:42, total_loss:14.327186490005815\n", + "epoch:43, total_loss:14.17949729832847\n", + "epoch:44, total_loss:14.381041948120252\n", + "epoch:45, total_loss:14.066475479509641\n", + "epoch:46, total_loss:14.421088420803787\n", + "epoch:47, total_loss:14.076476200037776\n", + "epoch:48, total_loss:13.49505447054567\n", + "epoch:49, total_loss:13.962366607835115\n", + "epoch:50, total_loss:14.132859856533951\n", + "epoch:51, total_loss:14.211254138227915\n", + "epoch:52, total_loss:13.783430685286982\n", + "epoch:53, total_loss:13.822881691925584\n", + "epoch:54, total_loss:13.874411694740841\n", + "epoch:55, total_loss:13.689458840764601\n", + "epoch:56, total_loss:13.549748796839697\n", + "epoch:57, total_loss:13.794046328692554\n", + "epoch:58, total_loss:13.627430047802486\n", + "epoch:59, total_loss:13.566341070391756\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:60, total_loss:13.607640703294347\n", + "epoch:61, total_loss:13.765936907137627\n", + "epoch:62, total_loss:13.427898654697174\n", + "epoch:63, total_loss:13.16438282240882\n", + "epoch:64, total_loss:13.699717505863067\n", + "epoch:65, total_loss:13.517151451699508\n", + "epoch:66, total_loss:13.176597420714746\n", + "epoch:67, total_loss:13.24646814613201\n", + "epoch:68, total_loss:13.244917374131173\n", + "epoch:69, total_loss:13.215854259795039\n", + "epoch:70, total_loss:13.361632303713026\n", + "epoch:71, total_loss:13.036742330577628\n", + "epoch:72, total_loss:13.304198910127244\n", + "epoch:73, total_loss:13.081586979276803\n", + "epoch:74, total_loss:12.909194473776125\n", + "epoch:75, total_loss:13.16158078431335\n", + "epoch:76, total_loss:13.091040510323086\n", + "epoch:77, total_loss:13.102056665885813\n", + "epoch:78, total_loss:13.056174157091682\n", + "epoch:79, total_loss:12.878650349945584\n", + "epoch:80, total_loss:12.915746609328925\n", + "epoch:81, total_loss:12.837916075878645\n", + "epoch:82, total_loss:12.955636403256928\n", + "epoch:83, total_loss:12.957573670383816\n", + "epoch:84, total_loss:12.847427321058367\n", + "epoch:85, total_loss:12.964006319616256\n", + "epoch:86, total_loss:12.84767541513131\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:87, total_loss:12.718307453929764\n", + "epoch:88, total_loss:12.778235374570496\n", + "epoch:89, total_loss:12.814078968217807\n", + "epoch:90, total_loss:13.031645834417173\n", + "epoch:91, total_loss:12.727466523814076\n", + "epoch:92, total_loss:12.638851384556933\n", + "epoch:93, total_loss:12.733171742007476\n", + "epoch:94, total_loss:12.279789633590688\n", + "epoch:95, total_loss:12.637884240833793\n", + "epoch:96, total_loss:12.209017659269005\n", + "epoch:97, total_loss:12.497042351941795\n", + "epoch:98, total_loss:12.716724411988926\n", + "epoch:99, total_loss:12.73128953153456\n", + "epoch:100, total_loss:12.372433012352953\n", + "epoch:101, total_loss:12.694675488168658\n", + "epoch:102, total_loss:12.511400414457075\n", + "epoch:103, total_loss:12.335057397559682\n", + "epoch:104, total_loss:12.47197415139572\n", + "epoch:105, total_loss:12.410238905268818\n", + "epoch:106, total_loss:12.50400526153365\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:107, total_loss:12.260620810613265\n", + "epoch:108, total_loss:12.384989645992645\n", + "epoch:109, total_loss:12.169719464561028\n", + "epoch:110, total_loss:12.282819567375439\n", + "epoch:111, total_loss:12.44722168123286\n", + "epoch:112, total_loss:12.296862962858754\n", + "epoch:113, total_loss:12.595632436628286\n", + "epoch:114, total_loss:12.318763730670716\n", + "epoch:115, total_loss:12.388617752169296\n", + "epoch:116, total_loss:12.130779151806331\n", + "epoch:117, total_loss:12.151072037432037\n", + "epoch:118, total_loss:12.206420660523472\n", + "epoch:119, total_loss:12.284020291663786\n", + "epoch:120, total_loss:12.299575823334571\n", + "epoch:121, total_loss:12.064111371837056\n", + "epoch:122, total_loss:12.466999100769304\n", + "epoch:123, total_loss:12.219042299862862\n", + "epoch:124, total_loss:12.112483619291446\n", + "epoch:125, total_loss:12.216083217610361\n", + "epoch:126, total_loss:12.113049691471737\n", + "epoch:127, total_loss:12.038687327822823\n", + "epoch:128, total_loss:11.89015755142935\n", + "epoch:129, total_loss:12.082701522201729\n", + "epoch:130, total_loss:12.071966267261239\n", + "epoch:131, total_loss:12.051358877606608\n", + "epoch:132, total_loss:12.08898139774195\n", + "epoch:133, total_loss:11.934586460084915\n", + "epoch:134, total_loss:11.885709946710701\n", + "epoch:135, total_loss:11.785474905954723\n", + "epoch:136, total_loss:12.032143808040379\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:137, total_loss:11.795580769502434\n", + "epoch:138, total_loss:11.996297963040147\n", + "epoch:139, total_loss:12.074923042611077\n", + "epoch:140, total_loss:11.9567557564889\n", + "epoch:141, total_loss:11.951781322622685\n", + "epoch:142, total_loss:12.280298174146248\n", + "epoch:143, total_loss:11.797433083661497\n", + "epoch:144, total_loss:11.986423110368772\n", + "epoch:145, total_loss:12.00307375198648\n", + "epoch:146, total_loss:11.906785155746888\n", + "epoch:147, total_loss:11.907037764738789\n", + "epoch:148, total_loss:11.705841962734006\n", + "epoch:149, total_loss:12.035653424548613\n", + "epoch:150, total_loss:11.605169112666514\n", + "epoch:151, total_loss:11.806263149635944\n", + "epoch:152, total_loss:11.849579320163057\n", + "epoch:153, total_loss:11.787135277614043\n", + "epoch:154, total_loss:11.757319453581685\n", + "epoch:155, total_loss:11.827193922538715\n", + "epoch:156, total_loss:11.79314044525262\n", + "epoch:157, total_loss:11.667900558325982\n", + "epoch:158, total_loss:11.697665187408786\n", + "epoch:159, total_loss:11.867174108617622\n", + "epoch:160, total_loss:11.859606031782409\n", + "epoch:161, total_loss:11.542011500917392\n", + "epoch:162, total_loss:11.78412706240579\n", + "epoch:163, total_loss:11.67732009454037\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:164, total_loss:11.863381214916538\n", + "epoch:165, total_loss:11.647855897524444\n", + "epoch:166, total_loss:11.727139024545231\n", + "epoch:167, total_loss:11.510185761614483\n", + "epoch:168, total_loss:11.559588295523524\n", + "epoch:169, total_loss:11.847765500464757\n", + "epoch:170, total_loss:11.813258614572618\n", + "epoch:171, total_loss:11.88579048659853\n", + "epoch:172, total_loss:11.546217607901818\n", + "epoch:173, total_loss:12.081776763488348\n", + "epoch:174, total_loss:11.684912022940589\n", + "epoch:175, total_loss:11.64133030918811\n", + "epoch:176, total_loss:11.94564429832782\n", + "epoch:177, total_loss:11.394889502157216\n", + "epoch:178, total_loss:11.39319325212946\n", + "epoch:179, total_loss:11.517631773301865\n", + "epoch:180, total_loss:11.689110205956284\n", + "epoch:181, total_loss:11.522651649036852\n", + "epoch:182, total_loss:11.573823030727956\n", + "epoch:183, total_loss:11.531603974201625\n", + "epoch:184, total_loss:11.574193712070949\n", + "epoch:185, total_loss:11.628423068438831\n", + "epoch:186, total_loss:11.541855185156964\n", + "epoch:187, total_loss:11.315308847778619\n", + "epoch:188, total_loss:11.582892737794857\n", + "epoch:189, total_loss:11.466293796924703\n", + "epoch:190, total_loss:11.490824135761025\n", + "epoch:191, total_loss:11.502023559395\n", + "epoch:192, total_loss:11.719433164577346\n", + "epoch:193, total_loss:11.51070260484475\n", + "epoch:194, total_loss:11.383943788976348\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:195, total_loss:11.524551182546933\n", + "epoch:196, total_loss:11.642204143224934\n", + "epoch:197, total_loss:11.409284403875658\n", + "epoch:198, total_loss:11.706139185389647\n", + "epoch:199, total_loss:11.504022320086387\n", + "epoch:200, total_loss:11.560474765720476\n", + "epoch:201, total_loss:11.360181554950765\n", + "epoch:202, total_loss:11.487462935335515\n", + "epoch:203, total_loss:11.576651766373796\n", + "epoch:204, total_loss:11.27824836056429\n", + "epoch:205, total_loss:11.302452707972014\n", + "epoch:206, total_loss:11.622801422094083\n", + "epoch:207, total_loss:11.417505218977006\n", + "epoch:208, total_loss:11.293848073422906\n", + "epoch:209, total_loss:11.78249743759448\n", + "epoch:210, total_loss:11.481425902977152\n", + "epoch:211, total_loss:11.307112782165412\n", + "epoch:212, total_loss:11.355353736327775\n", + "epoch:213, total_loss:11.565010066946343\n", + "epoch:214, total_loss:11.513935488842517\n", + "epoch:215, total_loss:11.54360589809292\n", + "epoch:216, total_loss:11.457524366453812\n", + "epoch:217, total_loss:11.293108892063186\n", + "epoch:218, total_loss:11.54969331052015\n", + "epoch:219, total_loss:11.326586703349342\n", + "epoch:220, total_loss:11.533131356614327\n", + "epoch:221, total_loss:11.580343129942644\n", + "epoch:222, total_loss:11.481365790896467\n", + "epoch:223, total_loss:11.221858335813195\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:224, total_loss:11.281770455761087\n", + "epoch:225, total_loss:11.303411528748592\n", + "epoch:226, total_loss:11.2676201914405\n", + "epoch:227, total_loss:11.31898342534404\n", + "epoch:228, total_loss:11.380369211367775\n", + "epoch:229, total_loss:11.1546412115287\n", + "epoch:230, total_loss:11.36847476573549\n", + "epoch:231, total_loss:11.286208889192357\n", + "epoch:232, total_loss:11.344102204364019\n", + "epoch:233, total_loss:11.65988374985428\n", + "epoch:234, total_loss:11.180990735039373\n", + "epoch:235, total_loss:11.129537255906111\n", + "epoch:236, total_loss:11.093160206428102\n", + "epoch:237, total_loss:11.106413209661298\n", + "epoch:238, total_loss:10.996692076820825\n", + "epoch:239, total_loss:11.347482189042562\n", + "epoch:240, total_loss:11.09532463981298\n", + "epoch:241, total_loss:11.47818807193955\n", + "epoch:242, total_loss:11.12880423187597\n", + "epoch:243, total_loss:11.224552619402548\n", + "epoch:244, total_loss:11.299794414161184\n", + "epoch:245, total_loss:11.336155458588804\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:246, total_loss:11.354954999814138\n", + "epoch:247, total_loss:11.456953191261539\n", + "epoch:248, total_loss:11.364118278556017\n", + "epoch:249, total_loss:11.279778626821273\n", + "epoch:250, total_loss:11.346586478563408\n", + "epoch:251, total_loss:11.063300792807688\n", + "epoch:252, total_loss:11.201740193439761\n", + "epoch:253, total_loss:11.476829310505105\n", + "epoch:254, total_loss:11.403246832947032\n", + "epoch:255, total_loss:11.172686961882235\n", + "epoch:256, total_loss:11.062242933043267\n", + "epoch:257, total_loss:11.022420068253043\n", + "epoch:258, total_loss:11.10249234977285\n", + "epoch:259, total_loss:11.439362977191958\n", + "epoch:260, total_loss:11.124393760427047\n", + "epoch:261, total_loss:11.37105989603374\n", + "epoch:262, total_loss:11.2406397203475\n", + "epoch:263, total_loss:11.140800561107014\n", + "epoch:264, total_loss:11.18743171626298\n", + "epoch:265, total_loss:11.32249721549863\n", + "epoch:266, total_loss:11.0676485725417\n", + "epoch:267, total_loss:11.180333037241088\n", + "epoch:268, total_loss:11.232196095259935\n", + "epoch:269, total_loss:11.095116301564493\n", + "epoch:270, total_loss:11.192408078327949\n", + "epoch:271, total_loss:11.003941362144944\n", + "epoch:272, total_loss:11.112141942237\n", + "epoch:273, total_loss:11.029736301850322\n", + "epoch:274, total_loss:11.035907415946966\n", + "epoch:275, total_loss:11.254639119889644\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:276, total_loss:11.46740945209864\n", + "epoch:277, total_loss:10.833389266449094\n", + "epoch:278, total_loss:11.350113592780405\n", + "epoch:279, total_loss:11.01886068000809\n", + "epoch:280, total_loss:11.192982815677562\n", + "epoch:281, total_loss:11.165208439862777\n", + "epoch:282, total_loss:10.917720563866286\n", + "epoch:283, total_loss:11.130295869013851\n", + "epoch:284, total_loss:11.080966579810218\n", + "epoch:285, total_loss:11.057585849396453\n", + "epoch:286, total_loss:10.92447792249546\n", + "epoch:287, total_loss:11.044421613069836\n", + "epoch:288, total_loss:11.171595363632646\n", + "epoch:289, total_loss:11.09647470193617\n", + "epoch:290, total_loss:11.320909355885094\n", + "epoch:291, total_loss:11.1054462613746\n", + "epoch:292, total_loss:10.973193065033461\n", + "epoch:293, total_loss:10.897445297526941\n", + "epoch:294, total_loss:11.238417607896032\n", + "epoch:295, total_loss:10.824814312353759\n", + "epoch:296, total_loss:10.768831781511192\n", + "epoch:297, total_loss:10.999032418713188\n", + "epoch:298, total_loss:11.110465377462571\n", + "epoch:299, total_loss:11.12439451948392\n", + "epoch:300, total_loss:10.930532889824507\n", + "epoch:301, total_loss:11.016776874252606\n", + "epoch:302, total_loss:11.019214393266923\n", + "epoch:303, total_loss:10.915475058914934\n", + "epoch:304, total_loss:11.343665096239242\n", + "epoch:305, total_loss:11.130973661232154\n", + "epoch:306, total_loss:10.905500238059203\n", + "epoch:307, total_loss:11.065665105983\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:308, total_loss:11.031335211775001\n", + "epoch:309, total_loss:11.077036492040401\n", + "epoch:310, total_loss:10.919804376802261\n", + "epoch:311, total_loss:10.76634088045004\n", + "epoch:312, total_loss:10.8348889619673\n", + "epoch:313, total_loss:10.933223871991022\n", + "epoch:314, total_loss:11.151303538897166\n", + "epoch:315, total_loss:11.201897532177862\n", + "epoch:316, total_loss:11.011673892608803\n", + "epoch:317, total_loss:10.698641451021468\n", + "epoch:318, total_loss:10.988519352949979\n", + "epoch:319, total_loss:11.301556518822666\n", + "epoch:320, total_loss:11.084015651697072\n", + "epoch:321, total_loss:10.867371496783472\n", + "epoch:322, total_loss:10.858838115829101\n", + "epoch:323, total_loss:10.950696538623607\n", + "epoch:324, total_loss:10.997155901391247\n", + "epoch:325, total_loss:10.900610228484165\n", + "epoch:326, total_loss:11.188364285602843\n", + "epoch:327, total_loss:10.882019635750474\n", + "epoch:328, total_loss:10.908442014318686\n", + "epoch:329, total_loss:10.835518950449968\n", + "epoch:330, total_loss:10.91576593679381\n", + "epoch:331, total_loss:10.93761039758479\n", + "epoch:332, total_loss:10.830106678748368\n", + "epoch:333, total_loss:10.785336099089456\n", + "epoch:334, total_loss:10.75093892442817\n", + "epoch:335, total_loss:11.032461189788457\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:336, total_loss:10.86943278814915\n", + "epoch:337, total_loss:10.965342127850716\n", + "epoch:338, total_loss:11.039704781539015\n", + "epoch:339, total_loss:11.125682928361973\n", + "epoch:340, total_loss:10.758390986264905\n", + "epoch:341, total_loss:10.878341155998331\n", + "epoch:342, total_loss:10.985317355889885\n", + "epoch:343, total_loss:10.85598207037099\n", + "epoch:344, total_loss:10.910206833871698\n", + "epoch:345, total_loss:10.78836617842957\n", + "epoch:346, total_loss:10.964373645538434\n", + "epoch:347, total_loss:10.940719935168014\n", + "epoch:348, total_loss:10.90990894755275\n", + "epoch:349, total_loss:10.904814117851853\n", + "epoch:350, total_loss:10.88105686070788\n", + "epoch:351, total_loss:10.857800034632843\n", + "epoch:352, total_loss:10.919814811629564\n", + "epoch:353, total_loss:10.776056511633088\n", + "epoch:354, total_loss:10.960987004137044\n", + "epoch:355, total_loss:10.747432630077974\n", + "epoch:356, total_loss:10.713817367504737\n", + "epoch:357, total_loss:10.700945411649801\n", + "epoch:358, total_loss:10.937710615519688\n", + "epoch:359, total_loss:10.95164560715185\n", + "epoch:360, total_loss:10.960831084321525\n", + "epoch:361, total_loss:10.856448219545621\n", + "epoch:362, total_loss:10.912902000784864\n", + "epoch:363, total_loss:10.869689353067901\n", + "epoch:364, total_loss:10.853617490471263\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:365, total_loss:10.878445914312435\n", + "epoch:366, total_loss:11.040722715447732\n", + "epoch:367, total_loss:10.810414396512455\n", + "epoch:368, total_loss:10.890142136457598\n", + "epoch:369, total_loss:10.990201031531997\n", + "epoch:370, total_loss:10.864666783567436\n", + "epoch:371, total_loss:11.075167523199474\n", + "epoch:372, total_loss:10.786739046832038\n", + "epoch:373, total_loss:11.015469986040483\n", + "epoch:374, total_loss:10.852746400245353\n", + "epoch:375, total_loss:10.879740350592819\n", + "epoch:376, total_loss:10.910220760560222\n", + "epoch:377, total_loss:10.959983351661958\n", + "epoch:378, total_loss:10.803247613628743\n", + "epoch:379, total_loss:10.931548933739277\n", + "epoch:380, total_loss:10.929219738044553\n", + "epoch:381, total_loss:10.890783471739597\n", + "epoch:382, total_loss:10.858173872968596\n", + "epoch:383, total_loss:11.053393577895225\n", + "epoch:384, total_loss:10.735623320286434\n", + "epoch:385, total_loss:10.726258510920053\n", + "epoch:386, total_loss:10.8838288872744\n", + "epoch:387, total_loss:10.979763554283393\n", + "epoch:388, total_loss:10.721301986260325\n", + "epoch:389, total_loss:10.79876115558831\n", + "epoch:390, total_loss:10.846049959048143\n", + "epoch:391, total_loss:10.786829376891331\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:392, total_loss:10.771352249171576\n", + "epoch:393, total_loss:10.709860777934558\n", + "epoch:394, total_loss:10.988322755766282\n", + "epoch:395, total_loss:10.656531000549002\n", + "epoch:396, total_loss:10.850510032950105\n", + "epoch:397, total_loss:10.582153043820533\n", + "epoch:398, total_loss:10.78349215769655\n", + "epoch:399, total_loss:10.770876438679727\n", + "epoch:400, total_loss:10.730870421924148\n", + "epoch:401, total_loss:10.703968802508832\n", + "epoch:402, total_loss:10.879751579328474\n", + "epoch:403, total_loss:10.644552082878697\n", + "epoch:404, total_loss:10.670986112049825\n", + "epoch:405, total_loss:10.91947772514622\n", + "epoch:406, total_loss:10.805348392200983\n", + "epoch:407, total_loss:10.795900912514343\n", + "epoch:408, total_loss:10.602210478623993\n", + "epoch:409, total_loss:10.815956721895347\n", + "epoch:410, total_loss:10.957507947954578\n", + "epoch:411, total_loss:10.6533734644956\n", + "epoch:412, total_loss:10.878293827624173\n", + "epoch:413, total_loss:10.751859574819848\n", + "epoch:414, total_loss:10.78021582980708\n", + "epoch:415, total_loss:11.339907239284344\n", + "epoch:416, total_loss:10.766439642267878\n", + "epoch:417, total_loss:10.691408724076881\n", + "epoch:418, total_loss:10.652920377241356\n", + "epoch:419, total_loss:10.487145161707089\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:420, total_loss:11.027019236286609\n", + "epoch:421, total_loss:10.927645620440293\n", + "epoch:422, total_loss:10.679213200522405\n", + "epoch:423, total_loss:10.671764797943158\n", + "epoch:424, total_loss:10.750383104266978\n", + "epoch:425, total_loss:10.875817154853705\n", + "epoch:426, total_loss:10.944030101690748\n", + "epoch:427, total_loss:10.928036382429092\n", + "epoch:428, total_loss:10.947655353208413\n", + "epoch:429, total_loss:10.944172934797923\n", + "epoch:430, total_loss:10.578319084900338\n", + "epoch:431, total_loss:10.942926483988833\n", + "epoch:432, total_loss:10.807559054638537\n", + "epoch:433, total_loss:11.002922518311912\n", + "epoch:434, total_loss:10.572906521251266\n", + "epoch:435, total_loss:10.829493353579078\n", + "epoch:436, total_loss:10.873412826520925\n", + "epoch:437, total_loss:10.696661324989169\n", + "epoch:438, total_loss:10.848434602131727\n", + "epoch:439, total_loss:10.65723267295385\n", + "epoch:440, total_loss:10.729141649791057\n", + "epoch:441, total_loss:10.656043621219418\n", + "epoch:442, total_loss:10.608765215007072\n", + "epoch:443, total_loss:10.787346056459132\n", + "epoch:444, total_loss:10.761017284947256\n", + "epoch:445, total_loss:10.639615319726955\n", + "epoch:446, total_loss:10.933273477669523\n", + "epoch:447, total_loss:10.832242057404045\n", + "epoch:448, total_loss:10.745324574329555\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:449, total_loss:10.702190166961817\n", + "epoch:450, total_loss:10.665893940873417\n", + "epoch:451, total_loss:10.86400034835733\n", + "epoch:452, total_loss:10.806813525935313\n", + "epoch:453, total_loss:10.549991913389794\n", + "epoch:454, total_loss:10.701336272589131\n", + "epoch:455, total_loss:10.600325133411253\n", + "epoch:456, total_loss:10.880046803245936\n", + "epoch:457, total_loss:10.752522193188385\n", + "epoch:458, total_loss:10.78560497471056\n", + "epoch:459, total_loss:10.858960271909979\n", + "epoch:460, total_loss:10.490636789321773\n", + "epoch:461, total_loss:10.862508226960598\n", + "epoch:462, total_loss:10.543107312573792\n", + "epoch:463, total_loss:10.96903897710602\n", + "epoch:464, total_loss:10.563424782030056\n", + "epoch:465, total_loss:10.903264868619248\n", + "epoch:466, total_loss:10.613614667912822\n", + "epoch:467, total_loss:10.670085565824722\n", + "epoch:468, total_loss:10.572973580645177\n", + "epoch:469, total_loss:10.71196778750004\n", + "epoch:470, total_loss:10.765127592682404\n", + "epoch:471, total_loss:10.63995668507757\n", + "epoch:472, total_loss:10.808078282914975\n", + "epoch:473, total_loss:10.577580374002107\n", + "epoch:474, total_loss:10.762890756782571\n", + "epoch:475, total_loss:10.621447225565642\n", + "epoch:476, total_loss:10.78466964847079\n", + "epoch:477, total_loss:10.642169148047282\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:478, total_loss:10.686759833856536\n", + "epoch:479, total_loss:10.640348268575325\n", + "epoch:480, total_loss:10.52722100241036\n", + "epoch:481, total_loss:10.69556899593067\n", + "epoch:482, total_loss:10.731314064217294\n", + "epoch:483, total_loss:10.72755843090813\n", + "epoch:484, total_loss:10.822232402102639\n", + "epoch:485, total_loss:10.629897681805321\n", + "epoch:486, total_loss:10.601721287135755\n", + "epoch:487, total_loss:10.654589400939356\n", + "epoch:488, total_loss:10.619103688256695\n", + "epoch:489, total_loss:10.624210849595018\n", + "epoch:490, total_loss:10.790483664903162\n", + "epoch:491, total_loss:10.74746751802616\n", + "epoch:492, total_loss:10.461786404373873\n", + "epoch:493, total_loss:10.839570241136071\n", + "epoch:494, total_loss:10.642483451747152\n", + "epoch:495, total_loss:10.551401919289328\n", + "epoch:496, total_loss:10.577710949900766\n", + "epoch:497, total_loss:10.672594252141312\n", + "epoch:498, total_loss:10.579594645969713\n", + "epoch:499, total_loss:10.753440751518946\n", + "epoch:500, total_loss:10.653607190000272\n" ] } ], "source": [ - "pl = perceptron_learner(iris, epochs=500, learning_rate=0.01, verbose=50)" + "pl = PerceptronLearner(iris, l_rate=0.01, epochs=500, verbose=50).fit(X_iris, y_iris)" ] }, { @@ -144,8 +782,15 @@ }, { "cell_type": "code", - "execution_count": 8, - "metadata": {}, + "execution_count": 5, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:00:06.826148Z", + "iopub.status.busy": "2026-06-27T01:00:06.825740Z", + "iopub.status.idle": "2026-06-27T01:00:06.844611Z", + "shell.execute_reply": "2026-06-27T01:00:06.841655Z" + } + }, "outputs": [ { "name": "stdout", @@ -168,8 +813,15 @@ }, { "cell_type": "code", - "execution_count": 14, - "metadata": {}, + "execution_count": 6, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:00:06.847928Z", + "iopub.status.busy": "2026-06-27T01:00:06.847699Z", + "iopub.status.idle": "2026-06-27T01:00:06.853938Z", + "shell.execute_reply": "2026-06-27T01:00:06.852755Z" + } + }, "outputs": [ { "name": "stdout", @@ -203,8 +855,15 @@ }, { "cell_type": "code", - "execution_count": 11, - "metadata": {}, + "execution_count": 7, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:00:06.856675Z", + "iopub.status.busy": "2026-06-27T01:00:06.856403Z", + "iopub.status.idle": "2026-06-27T01:00:07.661136Z", + "shell.execute_reply": "2026-06-27T01:00:07.659564Z" + } + }, "outputs": [ { "name": "stdout", @@ -234,41 +893,111 @@ }, { "cell_type": "code", - "execution_count": 12, - "metadata": {}, + "execution_count": 8, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:00:07.663853Z", + "iopub.status.busy": "2026-06-27T01:00:07.663561Z", + "iopub.status.idle": "2026-06-27T01:01:11.238146Z", + "shell.execute_reply": "2026-06-27T01:01:11.237210Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "epoch:1, total_loss:423.8627535296463\n", - "epoch:2, total_loss:341.31697581698995\n", - "epoch:3, total_loss:328.98647291325443\n", - "epoch:4, total_loss:327.8999700915627\n", - "epoch:5, total_loss:310.081065570072\n", - "epoch:6, total_loss:268.5474616202945\n", - "epoch:7, total_loss:259.0999998773958\n", - "epoch:8, total_loss:259.09999987481393\n", - "epoch:9, total_loss:259.09999987211944\n", - "epoch:10, total_loss:259.0999998693056\n" + "epoch:1, total_loss:284.997237059612\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:2, total_loss:188.79815976164036\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:3, total_loss:183.90105502608466\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:4, total_loss:182.7\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:5, total_loss:182.70000000000002\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:6, total_loss:182.70000000000002\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:7, total_loss:182.7000000000001\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:8, total_loss:182.69999999999993\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:9, total_loss:182.6999999999999\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:10, total_loss:182.70000000000007\n" ] } ], "source": [ "mnist = DataSet(examples=train_examples[:1000])\n", - "pl = perceptron_learner(mnist, epochs=10, verbose=1)" + "X_mnist = np.array([x[:mnist.target] for x in mnist.examples])\n", + "y_mnist = np.array([x[mnist.target] for x in mnist.examples])\n", + "pl = PerceptronLearner(mnist, l_rate=0.01, epochs=10, verbose=1).fit(X_mnist, y_mnist)" ] }, { "cell_type": "code", - "execution_count": 21, - "metadata": {}, + "execution_count": 9, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:01:11.240438Z", + "iopub.status.busy": "2026-06-27T01:01:11.240205Z", + "iopub.status.idle": "2026-06-27T01:01:15.781007Z", + "shell.execute_reply": "2026-06-27T01:01:15.779675Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "0.893\n" + "0.9\n" ] } ], @@ -285,8 +1014,15 @@ }, { "cell_type": "code", - "execution_count": 33, - "metadata": {}, + "execution_count": 10, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:01:15.786800Z", + "iopub.status.busy": "2026-06-27T01:01:15.786408Z", + "iopub.status.idle": "2026-06-27T01:01:16.280406Z", + "shell.execute_reply": "2026-06-27T01:01:16.279294Z" + } + }, "outputs": [ { "name": "stdout", @@ -325,10 +1061,20 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, + "execution_count": 11, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:01:16.283290Z", + "iopub.status.busy": "2026-06-27T01:01:16.282920Z", + "iopub.status.idle": "2026-06-27T01:01:16.288734Z", + "shell.execute_reply": "2026-06-27T01:01:16.287603Z" + } + }, "outputs": [], "source": [ + "# input_size and output_size are derived from the dataset and\n", + "# hidden_layer_sizes is the list of hidden layer sizes (here, iris with one hidden layer)\n", + "input_size, output_size, hidden_layer_sizes = 4, 3, [4]\n", "# initialize the network\n", "raw_net = [InputLayer(input_size)]\n", "# add hidden layers\n", @@ -352,123 +1098,1016 @@ }, { "cell_type": "code", - "execution_count": 4, - "metadata": {}, + "execution_count": 12, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:01:16.290969Z", + "iopub.status.busy": "2026-06-27T01:01:16.290672Z", + "iopub.status.idle": "2026-06-27T01:01:17.889654Z", + "shell.execute_reply": "2026-06-27T01:01:17.888667Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "epoch:10, total_loss:15.931817841643683\n", - "epoch:20, total_loss:8.248422285412149\n", - "epoch:30, total_loss:6.102968668275\n", - "epoch:40, total_loss:5.463915043272969\n", - "epoch:50, total_loss:5.298986288420822\n", - "epoch:60, total_loss:4.032928400456889\n", - "epoch:70, total_loss:3.2628899927346855\n", - "epoch:80, total_loss:6.01336701367312\n", - "epoch:90, total_loss:5.412020420311795\n", - "epoch:100, total_loss:3.1044027319850773\n" + "epoch:1, total_loss:32.912795298488625\n", + "epoch:2, total_loss:27.516426370563963\n", + "epoch:3, total_loss:22.888486273511155\n", + "epoch:4, total_loss:20.34304116675352\n", + "epoch:5, total_loss:18.995505492622872\n", + "epoch:6, total_loss:18.037974436127158\n", + "epoch:7, total_loss:17.432439555704953\n", + "epoch:8, total_loss:16.705715872125694\n", + "epoch:9, total_loss:16.33334026639665\n", + "epoch:10, total_loss:16.527876198404634\n", + "epoch:11, total_loss:15.30644525236212\n", + "epoch:12, total_loss:16.016784884200007\n", + "epoch:13, total_loss:15.942120933521043\n" ] - } - ], - "source": [ - "nn = neural_net_learner(iris, epochs=100, learning_rate=0.15, optimizer=gradient_descent, verbose=10)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Similarly we check the model's accuracy on both training and test dataset:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ + }, { "name": "stdout", "output_type": "stream", "text": [ - "error ration on training set: 0.033333333333333326\n" + "epoch:14, total_loss:15.37374822603961\n", + "epoch:15, total_loss:15.137675519389836\n", + "epoch:16, total_loss:14.49887197267364\n" ] - } - ], - "source": [ - "print(\"error ration on training set:\",err_ratio(nn, iris))" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ + }, { "name": "stdout", "output_type": "stream", "text": [ - "accuracy on test set: 1\n" + "epoch:17, total_loss:14.16305801363231\n", + "epoch:18, total_loss:15.48701807473735\n", + "epoch:19, total_loss:14.660274353053168\n", + "epoch:20, total_loss:12.78907703027487\n", + "epoch:21, total_loss:13.436848379822264\n", + "epoch:22, total_loss:13.552838418240952\n", + "epoch:23, total_loss:9.790057285211436\n", + "epoch:24, total_loss:11.958259472644617\n", + "epoch:25, total_loss:8.523598412025178\n", + "epoch:26, total_loss:7.494516285379954\n" ] - } - ], - "source": [ - "tests = [([5.0, 3.1, 0.9, 0.1], 0),\n", - " ([5.1, 3.5, 1.0, 0.0], 0),\n", - " ([4.9, 3.3, 1.1, 0.1], 0),\n", - " ([6.0, 3.0, 4.0, 1.1], 1),\n", - " ([6.1, 2.2, 3.5, 1.0], 1),\n", - " ([5.9, 2.5, 3.3, 1.1], 1),\n", - " ([7.5, 4.1, 6.2, 2.3], 2),\n", - " ([7.3, 4.0, 6.1, 2.4], 2),\n", - " ([7.0, 3.3, 6.1, 2.5], 2)]\n", - "print(\"accuracy on test set:\",grade_learner(nn, tests))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can see that the error ratio on the training set is smaller than the perceptron learner. As the error ratio is relatively small, let's try the model on the MNIST dataset to see whether there will be a larger difference. " - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [ + }, { "name": "stdout", "output_type": "stream", "text": [ - "epoch:10, total_loss:89.0002153455983\n", - "epoch:20, total_loss:87.29675663038348\n", - "epoch:30, total_loss:86.29591779319225\n", - "epoch:40, total_loss:83.78091780128402\n", - "epoch:50, total_loss:82.17091581738829\n", - "epoch:60, total_loss:83.8434277386084\n", - "epoch:70, total_loss:83.55209905561495\n", - "epoch:80, total_loss:83.106898191118\n", - "epoch:90, total_loss:83.37041170165992\n", - "epoch:100, total_loss:82.57013813500876\n" + "epoch:27, total_loss:17.3181739806647\n", + "epoch:28, total_loss:11.307454180226836\n", + "epoch:29, total_loss:8.17711187267412\n" ] - } - ], - "source": [ - "nn = neural_net_learner(mnist, epochs=100, verbose=10)" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:30, total_loss:12.556339269456796\n", + "epoch:31, total_loss:7.212416746125583\n", + "epoch:32, total_loss:7.1898027254553885\n", + "epoch:33, total_loss:9.87739964645205\n", + "epoch:34, total_loss:6.041383985576961\n", + "epoch:35, total_loss:8.354390190811536\n", + "epoch:36, total_loss:5.864359327218853\n", + "epoch:37, total_loss:6.346876600785102\n", + "epoch:38, total_loss:7.678281535334033\n", + "epoch:39, total_loss:6.787107736893334\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:40, total_loss:6.7717688791189925\n", + "epoch:41, total_loss:5.3145745831067375\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:42, total_loss:8.435624037861784\n", + "epoch:43, total_loss:6.802409339189361\n", + "epoch:44, total_loss:6.161610149058332\n", + "epoch:45, total_loss:6.649262144175456\n", + "epoch:46, total_loss:4.82588099591665\n", + "epoch:47, total_loss:4.765359547839223\n", + "epoch:48, total_loss:5.88233472352701\n", + "epoch:49, total_loss:7.833472139567658\n", + "epoch:50, total_loss:4.044576153637201\n", + "epoch:51, total_loss:5.065926151227105\n", + "epoch:52, total_loss:4.875004864981223\n", + "epoch:53, total_loss:5.182787483656709\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:54, total_loss:8.137643713343785\n", + "epoch:55, total_loss:9.441096315408045\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:56, total_loss:5.322962109475348\n", + "epoch:57, total_loss:6.314929949935136\n", + "epoch:58, total_loss:3.4806002194195345\n", + "epoch:59, total_loss:3.661207020253595\n", + "epoch:60, total_loss:4.295308603569921\n", + "epoch:61, total_loss:5.059960240900545\n", + "epoch:62, total_loss:5.21355355106433\n", + "epoch:63, total_loss:4.38691079942881\n", + "epoch:64, total_loss:6.0250148812710504\n", + "epoch:65, total_loss:4.006162753999467\n", + "epoch:66, total_loss:5.720074610151459\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:67, total_loss:4.712761400808384\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:68, total_loss:5.168520872318532\n", + "epoch:69, total_loss:3.648191296042262\n", + "epoch:70, total_loss:6.325452372098929\n", + "epoch:71, total_loss:4.758284120607314\n", + "epoch:72, total_loss:5.306882309216254\n", + "epoch:73, total_loss:4.4938666672692005\n", + "epoch:74, total_loss:4.625061385703857\n", + "epoch:75, total_loss:5.051469055003512\n", + "epoch:76, total_loss:3.6898974460022584\n", + "epoch:77, total_loss:5.7374056092134635\n", + "epoch:78, total_loss:3.7144674846812897\n", + "epoch:79, total_loss:4.946209187164789\n", + "epoch:80, total_loss:4.847912548421224\n", + "epoch:81, total_loss:5.647376863066678\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:82, total_loss:4.569249377587947\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:83, total_loss:5.419922272125784\n", + "epoch:84, total_loss:4.261300987031312\n", + "epoch:85, total_loss:3.5630389278467107\n", + "epoch:86, total_loss:3.774046889581062\n", + "epoch:87, total_loss:4.382902546649984\n", + "epoch:88, total_loss:4.961165782207284\n", + "epoch:89, total_loss:3.888827902043524\n", + "epoch:90, total_loss:4.12949520432408\n", + "epoch:91, total_loss:4.366357513205353\n", + "epoch:92, total_loss:3.874538264402984\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:93, total_loss:3.636996826374803\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:94, total_loss:7.673077214679743\n", + "epoch:95, total_loss:3.7280600425229014\n", + "epoch:96, total_loss:2.1422780252097837\n", + "epoch:97, total_loss:3.278394539370065\n", + "epoch:98, total_loss:3.5470320758547094\n", + "epoch:99, total_loss:4.5049273286466365\n", + "epoch:100, total_loss:5.804618584310183\n" + ] + } + ], + "source": [ + "nn = NeuralNetworkLearner(iris, [4], l_rate=0.15, epochs=100, optimizer=stochastic_gradient_descent, verbose=10).fit(X_iris, y_iris)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Similarly we check the model's accuracy on both training and test dataset:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:01:17.893527Z", + "iopub.status.busy": "2026-06-27T01:01:17.893242Z", + "iopub.status.idle": "2026-06-27T01:01:17.919862Z", + "shell.execute_reply": "2026-06-27T01:01:17.918489Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "error ration on training set: 0.040000000000000036\n" + ] + } + ], + "source": [ + "print(\"error ration on training set:\",err_ratio(nn, iris))" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:01:17.923882Z", + "iopub.status.busy": "2026-06-27T01:01:17.923588Z", + "iopub.status.idle": "2026-06-27T01:01:17.931023Z", + "shell.execute_reply": "2026-06-27T01:01:17.929962Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "accuracy on test set: 1\n" + ] + } + ], + "source": [ + "tests = [([5.0, 3.1, 0.9, 0.1], 0),\n", + " ([5.1, 3.5, 1.0, 0.0], 0),\n", + " ([4.9, 3.3, 1.1, 0.1], 0),\n", + " ([6.0, 3.0, 4.0, 1.1], 1),\n", + " ([6.1, 2.2, 3.5, 1.0], 1),\n", + " ([5.9, 2.5, 3.3, 1.1], 1),\n", + " ([7.5, 4.1, 6.2, 2.3], 2),\n", + " ([7.3, 4.0, 6.1, 2.4], 2),\n", + " ([7.0, 3.3, 6.1, 2.5], 2)]\n", + "print(\"accuracy on test set:\",grade_learner(nn, tests))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that the error ratio on the training set is smaller than the perceptron learner. As the error ratio is relatively small, let's try the model on the MNIST dataset to see whether there will be a larger difference. " + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:01:17.933389Z", + "iopub.status.busy": "2026-06-27T01:01:17.933090Z", + "iopub.status.idle": "2026-06-27T01:12:56.770082Z", + "shell.execute_reply": "2026-06-27T01:12:56.768454Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:1, total_loss:148.21290821886535\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:2, total_loss:91.92809023468917\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:3, total_loss:89.95472297531978\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:4, total_loss:89.01306311955564\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:5, total_loss:88.05471405726098\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:6, total_loss:87.72804575175529\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:7, total_loss:86.93619125795307\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:8, total_loss:85.79501208136118\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:9, total_loss:84.46697804765577\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:10, total_loss:83.85651467508092\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:11, total_loss:82.47548333944026\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:12, total_loss:81.50464955455206\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:13, total_loss:80.28551533481775\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:14, total_loss:79.56566441280448\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:15, total_loss:79.14206994302415\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:16, total_loss:78.0226233284735\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:17, total_loss:78.48454959293564\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:18, total_loss:77.91337107695324\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:19, total_loss:78.01688160983814\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:20, total_loss:78.35875028102286\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:21, total_loss:78.13756583399673\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:22, total_loss:77.96990409511119\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:23, total_loss:77.72333707508524\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:24, total_loss:78.62328635085098\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:25, total_loss:76.03958224710722\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:26, total_loss:75.439568997961\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:27, total_loss:74.80600093509969\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:28, total_loss:74.33014493281406\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:29, total_loss:73.94069005124106\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:30, total_loss:73.50839483827598\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:31, total_loss:73.20268087722567\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:32, total_loss:73.17248451715453\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:33, total_loss:72.52351520907712\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:34, total_loss:72.03636143501343\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:35, total_loss:71.84364042784816\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:36, total_loss:71.7311755900969\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:37, total_loss:72.6794639438994\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:38, total_loss:71.23310672741088\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:39, total_loss:72.46079863331452\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:40, total_loss:72.5948415129244\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:41, total_loss:71.27253303685123\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:42, total_loss:71.71953618085173\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:43, total_loss:70.52417447936269\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:44, total_loss:70.04233582990267\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:45, total_loss:69.46778932137015\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:46, total_loss:70.51921597411045\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:47, total_loss:70.38918343891932\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:48, total_loss:70.17580937065325\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:49, total_loss:69.95630575841054\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:50, total_loss:68.31685036843999\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:51, total_loss:67.95322320864001\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:52, total_loss:68.63812315512547\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:53, total_loss:68.17642274460644\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:54, total_loss:67.79825218819803\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:55, total_loss:67.44328354647436\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:56, total_loss:68.29031221365366\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:57, total_loss:67.44218848763964\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:58, total_loss:68.0210946752627\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:59, total_loss:68.50769384976209\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:60, total_loss:67.39327202490935\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:61, total_loss:66.37803712456447\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:62, total_loss:66.09921909346444\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:63, total_loss:66.14559504770372\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:64, total_loss:65.89682009932491\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:65, total_loss:65.78850533037902\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:66, total_loss:65.55503142694319\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:67, total_loss:65.54569799301048\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:68, total_loss:65.40188440972726\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:69, total_loss:65.2586554981169\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:70, total_loss:65.33544330314929\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:71, total_loss:66.77675821085754\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:72, total_loss:68.82115427304635\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:73, total_loss:68.43423906261155\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:74, total_loss:68.61844525818006\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:75, total_loss:72.08001372338893\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:76, total_loss:68.72255117172097\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:77, total_loss:65.19385152324037\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:78, total_loss:65.1369807653386\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:79, total_loss:64.69856251099463\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:80, total_loss:64.55543828498355\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:81, total_loss:64.43800743044909\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:82, total_loss:64.34235293125388\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:83, total_loss:64.2370080057394\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:84, total_loss:67.40044224735277\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:85, total_loss:75.72516719133378\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:86, total_loss:74.5356819943464\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:87, total_loss:73.90731174885096\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:88, total_loss:73.21067955866441\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:89, total_loss:73.09614224050888\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:90, total_loss:72.92346573797603\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:91, total_loss:72.8129758560969\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:92, total_loss:72.72914016712502\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:93, total_loss:72.43638857830153\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:94, total_loss:72.198933158585\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:95, total_loss:72.05339051462525\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:96, total_loss:71.98473114802397\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:97, total_loss:71.91683278452426\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:98, total_loss:71.86745778657574\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:99, total_loss:71.79430556250283\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "epoch:100, total_loss:71.7582000717878\n" + ] + } + ], + "source": [ + "nn = NeuralNetworkLearner(mnist, [10], l_rate=0.01, epochs=100, optimizer=stochastic_gradient_descent, verbose=10).fit(X_mnist, y_mnist)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T01:12:56.772812Z", + "iopub.status.busy": "2026-06-27T01:12:56.772558Z", + "iopub.status.idle": "2026-06-27T01:13:02.530469Z", + "shell.execute_reply": "2026-06-27T01:13:02.529341Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "0.784\n" + "0.629\n" ] } ], @@ -500,7 +2139,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.9" + "version": "3.12.13" } }, "nbformat": 4, diff --git a/notebooks/chapter19/Learners.py b/notebooks/chapter19/Learners.py new file mode 100644 index 000000000..f8a7cf16c --- /dev/null +++ b/notebooks/chapter19/Learners.py @@ -0,0 +1,203 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Learners +# +# In this section, we will introduce several pre-defined learners to learning the datasets by updating their weights to minimize the loss function. when using a learner to deal with machine learning problems, there are several standard steps: +# +# - **Learner initialization**: Before training the network, it usually should be initialized first. There are several choices when initializing the weights: random initialization, initializing weights are zeros or use Gaussian distribution to init the weights. +# +# - **Optimizer specification**: Which means specifying the updating rules of learnable parameters of the network. Usually, we can choose Adam optimizer as default. +# +# - **Applying back-propagation**: In neural networks, we commonly use back-propagation to pass and calculate gradient information of each layer. Back-propagation needs to be integrated with the chosen optimizer in order to update the weights of NN properly in each epoch. +# +# - **Iterations**: Iterating over the forward and back-propagation process of given epochs. Sometimes the iterating process will have to be stopped by triggering early access in case of overfitting. +# +# We will introduce several learners with different structures. We will import all necessary packages before that: + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.learning import * +from aima.notebook_utils import * +from aima.deep_learning import * + +# %% [markdown] +# ## Perceptron Learner +# +# ### Overview +# +# The Perceptron is a linear classifier. It works the same way as a neural network with no hidden layers (just input and output). First, it trains its weights given a dataset and then it can classify a new item by running it through the network. +# +# Its input layer consists of the item features, while the output layer consists of nodes (also called neurons). Each node in the output layer has *n* synapses (for every item feature), each with its own weight. Then, the nodes find the dot product of the item features and the synapse weights. These values then pass through an activation function (usually a sigmoid). Finally, we pick the largest of the values and we return its index. +# +# Note that in classification problems each node represents a class. The final classification is the class/node with the max output value. +# +# Below you can see a single node/neuron in the outer layer. With *f* we denote the item features, with *w* the synapse weights, then inside the node we have the dot product and the activation function, *g*. + +# %% [markdown] +# ![perceptron](images/perceptron.png) + +# %% [markdown] +# ### Implementation +# +# Perceptron learner is actually a neural network learner with only one hidden layer which is pre-defined in the algorithm of `perceptron_learner`: + +# %% +# input_size and output_size are derived from the dataset +# (here, the iris dataset: 4 features, 3 classes) +input_size, output_size = 4, 3 +raw_net = [InputLayer(input_size), DenseLayer(input_size, output_size)] + +# %% [markdown] +# Where `input_size` and `output_size` are calculated from dataset examples. In the perceptron learner, the gradient descent optimizer is used to update the weights of the network. we return a function `predict` which we will use in the future to classify a new item. The function computes the (algebraic) dot product of the item with the calculated weights for each node in the outer layer. Then it picks the greatest value and classifies the item in the corresponding class. + +# %% [markdown] +# ### Example +# +# Let's try the perceptron learner with the `iris` dataset examples, first let's regulate the dataset classes: + +# %% +import numpy as np + +iris = DataSet(name="iris") +classes = ["setosa", "versicolor", "virginica"] +iris.classes_to_numbers(classes) +X_iris = np.array([x[:iris.target] for x in iris.examples]) +y_iris = np.array([x[iris.target] for x in iris.examples]) + +# %% +pl = PerceptronLearner(iris, l_rate=0.01, epochs=500, verbose=50).fit(X_iris, y_iris) + +# %% [markdown] +# We can see from the printed lines that the final total loss is converged to around 10.50. If we check the error ratio of perceptron learner on the dataset after training, we will see it is much higher than randomly guess: + +# %% +print(err_ratio(pl, iris)) + +# %% [markdown] +# If we test the trained learner with some test cases: + +# %% +tests = [([5.0, 3.1, 0.9, 0.1], 0), + ([5.1, 3.5, 1.0, 0.0], 0), + ([4.9, 3.3, 1.1, 0.1], 0), + ([6.0, 3.0, 4.0, 1.1], 1), + ([6.1, 2.2, 3.5, 1.0], 1), + ([5.9, 2.5, 3.3, 1.1], 1), + ([7.5, 4.1, 6.2, 2.3], 2), + ([7.3, 4.0, 6.1, 2.4], 2), + ([7.0, 3.3, 6.1, 2.5], 2)] +print(grade_learner(pl, tests)) + +# %% [markdown] +# It seems the learner is correct on all the test examples. +# +# Now let's try perceptron learner on a more complicated dataset: the MNIST dataset, to see what the result will be. First, we import the dataset to make the examples a `Dataset` object: + +# %% +train_img, train_lbl, test_img, test_lbl = load_MNIST(path="../../aima-data/MNIST/Digits") +import numpy as np +import matplotlib.pyplot as plt +train_examples = [np.append(train_img[i], train_lbl[i]) for i in range(len(train_img))] +test_examples = [np.append(test_img[i], test_lbl[i]) for i in range(len(test_img))] +print("length of training dataset:", len(train_examples)) +print("length of test dataset:", len(test_examples)) + +# %% [markdown] +# Now let's train the perceptron learner on the first 1000 examples of the dataset: + +# %% +mnist = DataSet(examples=train_examples[:1000]) +X_mnist = np.array([x[:mnist.target] for x in mnist.examples]) +y_mnist = np.array([x[mnist.target] for x in mnist.examples]) +pl = PerceptronLearner(mnist, l_rate=0.01, epochs=10, verbose=1).fit(X_mnist, y_mnist) + +# %% +print(err_ratio(pl, mnist)) + +# %% [markdown] +# It looks like we have a near 90% error ratio on training data after the network is trained on it. Then we can investigate the model's performance on the test dataset which it never has seen before: + +# %% +test_mnist = DataSet(examples=test_examples[:100]) +print(err_ratio(pl, test_mnist)) + +# %% [markdown] +# It seems a single layer perceptron learner cannot simulate the structure of the MNIST dataset. To improve accuracy, we may not only increase training epochs but also consider changing to a more complicated network structure. + +# %% [markdown] +# ### Neural Network Learner +# +# Although there are many different types of neural networks, the dense neural network we implemented can be treated as a stacked perceptron learner. Adding more layers to the perceptron network could add to the non-linearity to the network thus model will be more flexible when fitting complex data-target relations. Whereas it also adds to the risk of overfitting as the side effect of flexibility. +# +# By default we use dense networks with two hidden layers, which has the architecture as the following: +# +# +# +# In our code, we implemented it as: + +# %% +# input_size and output_size are derived from the dataset and +# hidden_layer_sizes is the list of hidden layer sizes (here, iris with one hidden layer) +input_size, output_size, hidden_layer_sizes = 4, 3, [4] +# initialize the network +raw_net = [InputLayer(input_size)] +# add hidden layers +hidden_input_size = input_size +for h_size in hidden_layer_sizes: + raw_net.append(DenseLayer(hidden_input_size, h_size)) + hidden_input_size = h_size +raw_net.append(DenseLayer(hidden_input_size, output_size)) + +# %% [markdown] +# Where hidden_layer_sizes are the sizes of each hidden layer in a list which can be specified by user. Neural network learner uses gradient descent as default optimizer but user can specify any optimizer when calling `neural_net_learner`. The other special attribute that can be changed in `neural_net_learner` is `batch_size` which controls the number of examples used in each round of update. `neural_net_learner` also returns a `predict` function which calculates prediction by multiplying weight to inputs and applying activation functions. +# +# ### Example +# +# Let's also try `neural_net_learner` on the `iris` dataset: + +# %% +nn = NeuralNetworkLearner(iris, [4], l_rate=0.15, epochs=100, optimizer=stochastic_gradient_descent, verbose=10).fit(X_iris, y_iris) + +# %% [markdown] +# Similarly we check the model's accuracy on both training and test dataset: + +# %% +print("error ration on training set:",err_ratio(nn, iris)) + +# %% +tests = [([5.0, 3.1, 0.9, 0.1], 0), + ([5.1, 3.5, 1.0, 0.0], 0), + ([4.9, 3.3, 1.1, 0.1], 0), + ([6.0, 3.0, 4.0, 1.1], 1), + ([6.1, 2.2, 3.5, 1.0], 1), + ([5.9, 2.5, 3.3, 1.1], 1), + ([7.5, 4.1, 6.2, 2.3], 2), + ([7.3, 4.0, 6.1, 2.4], 2), + ([7.0, 3.3, 6.1, 2.5], 2)] +print("accuracy on test set:",grade_learner(nn, tests)) + +# %% [markdown] +# We can see that the error ratio on the training set is smaller than the perceptron learner. As the error ratio is relatively small, let's try the model on the MNIST dataset to see whether there will be a larger difference. + +# %% +nn = NeuralNetworkLearner(mnist, [10], l_rate=0.01, epochs=100, optimizer=stochastic_gradient_descent, verbose=10).fit(X_mnist, y_mnist) + +# %% +print(err_ratio(nn, mnist)) + +# %% [markdown] +# After the model converging, the model's error ratio on the training set is still high. We will introduce the convolutional network in the following chapters to see how it helps improve accuracy on learning this dataset. diff --git a/notebooks/chapter19/Loss Functions and Layers.ipynb b/notebooks/chapter19/Loss Functions and Layers.ipynb index 25676e899..17e4d9e61 100644 --- a/notebooks/chapter19/Loss Functions and Layers.ipynb +++ b/notebooks/chapter19/Loss Functions and Layers.ipynb @@ -116,8 +116,8 @@ "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from deep_learning4e import *\n", - "from notebook4e import *" + "from aima.deep_learning import *\n", + "from aima.notebook_utils import *" ] }, { @@ -257,8 +257,8 @@ } ], "source": [ - "s = sigmoid()\n", - "print(\"Sigmoid at 0:\", s.f(0))\n", + "s = Sigmoid()\n", + "print(\"Sigmoid at 0:\", s.function(0))\n", "print(\"Deriavation of sigmoid at 0:\", s.derivative(0))" ] }, @@ -289,7 +289,7 @@ } ], "source": [ - "layer = DenseLayer(in_size=4, out_size=3, activation=sigmoid())\n", + "layer = DenseLayer(in_size=4, out_size=3, activation=Sigmoid)\n", "example = [1,2,3,4]\n", "print(layer.forward(example))" ] diff --git a/notebooks/chapter19/Loss Functions and Layers.py b/notebooks/chapter19/Loss Functions and Layers.py new file mode 100644 index 000000000..54d883bc8 --- /dev/null +++ b/notebooks/chapter19/Loss Functions and Layers.py @@ -0,0 +1,183 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Loss Function +# +# Loss functions evaluate how well specific algorithm models the given data. Commonly loss functions are used to compare the target data and model's prediction. If predictions deviate too much from actual targets, loss function would output a large value. Usually, loss functions can help other optimization functions to improve the accuracy of the model. +# +# However, there’s no one-size-fits-all loss function to algorithms in machine learning. For each algorithm and machine learning projects, specifying certain loss functions could assist the user in getting better model performance. Here we will demonstrate two loss functions: `mse_loss` and `cross_entropy_loss`. + +# %% [markdown] +# ## Min Square Error +# +# Min square error(MSE) is the most commonly used loss function in machine learning. The intuition of MSE is straight forward: the distance between two points represents the difference between them. + +# %% [markdown] +# $$MSE = -\sum_i{(y_i-t_i)^2/n}$$ + +# %% [markdown] +# Where $y_i$ is the prediction of the ith example and $t_i$ is the target of the ith example. And n is the total number of examples. +# +# Below is a plot of an MSE function where the true target value is 100, and the predicted values range between -10,000 to 10,000. The MSE loss (Y-axis) reaches its minimum value at prediction (X-axis) = 100. + +# %% [markdown] +# + +# %% [markdown] +# ## Cross-Entropy +# +# For most deep learning applications, we can get away with just one loss function: cross-entropy loss function. We can think of most deep learning algorithms as learning probability distributions and what we are learning is a distribution of predictions $P(y|x)$ given a series of inputs. +# +# To associate input examples x with output examples y, the parameters that maximize the likelihood of the training set should be: + +# %% [markdown] +# $$\theta^* = argmax_\theta \prod_{i=0}^n p(y^{(i)}/x^{(i)})$$ + +# %% [markdown] +# Maxmizing the above formula equals to minimizing the negative log form of it: + +# %% [markdown] +# $$\theta^* = argmin_\theta -\sum_{i=0}^n logp(y^{(i)}/x^{(i)})$$ + +# %% [markdown] +# It can be proven that the above formula equals to minimizing MSE loss. +# +# The majority of deep learning algorithms use cross-entropy in some way. Classifiers that use deep learning calculate the cross-entropy between categorical distributions over the output class. For a given class, its contribution to the loss is dependent on its probability in the following trend: + +# %% [markdown] +# + +# %% [markdown] +# ## Examples +# +# First let's import necessary packages. + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.deep_learning import * +from aima.notebook_utils import * + +# %% [markdown] +# # Neural Network Layers +# +# Neural networks may be conveniently described using data structures of computational graphs. A computational graph is a directed graph describing how many variables should be computed, with each variable by computed by applying a specific operation to a set of other variables. +# +# In our code, we provide class `NNUnit` as the basic structure of a neural network. The structure of `NNUnit` is simple, it only stores the following information: +# +# - **val**: the value of the current node. +# - **parent**: parents of the current node. +# - **weights**: weights between parent nodes and current node. It should be in the same size as parents. +# +# There is another class `Layer` inheriting from `NNUnit`. A `Layer` object holds a list of nodes that represents all the nodes in a layer. It also has a method `forward` to pass a value through the current layer. Here we will demonstrate several pre-defined types of layers in a Neural Network. + +# %% [markdown] +# ### Output Layers +# +# Neural networks need specialized output layers for each type of data we might ask them to produce. For many problems, we need to model discrete variables that have k distinct values instead of just binary variables. For example, models of natural language may predict a single word from among of vocabulary of tens of thousands or even more choices. To represent these distributions, we use a softmax layer: + +# %% [markdown] +# $$P(y=i|x)=softmax(h(x)^TW+b)_i$$ + +# %% [markdown] +# where $W$ is matrix of learned weights of output layer $b$ is a vector of learned biases, and the softmax function is: +# +# $$softmax(z_i)=exp(z_i)/\sum_i exp(z_i)$$ + +# %% [markdown] +# It is simple to create a output layer and feed an example into it: + +# %% +layer = OutputLayer(size=4) +example = [1,2,3,4] +print(layer.forward(example)) + +# %% [markdown] +# The output can be treated like normalized probability when the input of output layer is calculated by probability. + +# %% [markdown] +# ### Input Layers +# +# Input layers can be treated like a mapping layer that maps each element of the input vector to each input layer node. The input layer acts as a storage of input vector information which can be used when doing forward propagation. +# +# In our realization of input layers, the size of the input vector and input layer should match. + +# %% +layer = InputLayer(size=3) +example = [1,2,3] +print(layer.forward(example)) + +# %% [markdown] +# ### Hidden Layers +# +# While processing an input vector x of the neural network, it performs several intermediate computations before producing the output y. We can think of these intermediate computations as the state of memory during the execution of a multi-step program. We call the intermediate computations hidden because the data does not specify the values of these variables. +# +# Most neural network hidden layers are based on a linear transformation followed by the application of an elementwise nonlinear function called the activation function g: +# +# $$h=g(W+b)$$ +# +# where W is a learned matrix of weights and b is a learned set of bias parameters. +# +# Here we pre-defined several activation functions in `utils.py`: `sigmoid`, `relu`, `elu`, `tanh` and `leaky_relu`. They are all inherited from the `Activation` class. You can get the value of the function or its derivative at a certain point of x: + +# %% +s = Sigmoid() +print("Sigmoid at 0:", s.function(0)) +print("Deriavation of sigmoid at 0:", s.derivative(0)) + +# %% [markdown] +# To create a hidden layer object, there are several attributes need to be specified: +# +# - **in_size**: the input vector size of each hidden layer node. +# - **out_size**: the size of the output vector of the hidden layer. Thus each node will hide the weight of the size of (in_size). The weights will be initialized randomly. +# - **activation**: the activation function used for this layer. +# +# Now let's demonstrate how a dense hidden layer works briefly: + +# %% +layer = DenseLayer(in_size=4, out_size=3, activation=Sigmoid) +example = [1,2,3,4] +print(layer.forward(example)) + +# %% [markdown] +# This layer mapped input of size 4 to output of size 3. + +# %% [markdown] +# ### Convolutional Layers +# +# The convolutional layer is similar to the hidden layer except they use a different forward strategy. The convolutional layer takes an input of multiple channels and does convolution on each channel with a pre-defined kernel function. Thus the output of the convolutional layer will still be with the same number of channels. If we image each input as an image, then channels represent its color model such as RGB. The output will still have the same color model as the input. +# +# Now let's try the one-dimensional convolution layer: + +# %% +layer = ConvLayer1D(size=3, kernel_size=3) +example = [[1]*3 for _ in range(3)] +print(layer.forward(example)) + +# %% [markdown] +# Which can be deemed as a one-dimensional image with three channels. + +# %% [markdown] +# ### Pooling Layers +# +# Pooling layers can be treated as a special kind of convolutional layer that uses a special kind of kernel to extract a certain value in the kernel region. Here we use max-pooling to report the maximum value in each group. + +# %% +layer = MaxPoolingLayer1D(size=3, kernel_size=3) +example = [[1,2,3,4], [2,3,4,1],[3,4,1,2]] +print(layer.forward(example)) + +# %% [markdown] +# We can see that each time kernel picks up the maximum value in its region. diff --git a/notebooks/chapter19/Optimizer and Backpropagation.ipynb b/notebooks/chapter19/Optimizer and Backpropagation.ipynb index 5194adc7a..3081e0e09 100644 --- a/notebooks/chapter19/Optimizer and Backpropagation.ipynb +++ b/notebooks/chapter19/Optimizer and Backpropagation.ipynb @@ -47,8 +47,8 @@ "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from deep_learning4e import *\n", - "from notebook4e import *" + "from aima.deep_learning import *\n", + "from aima.notebook_utils import *" ] }, { @@ -188,7 +188,7 @@ } ], "source": [ - "psource(gradient_descent)" + "psource(stochastic_gradient_descent)" ] }, { @@ -222,8 +222,7 @@ "metadata": {}, "outputs": [], "source": [ - "pseudocode(adam_optimizer)\n", - "psource(adam_optimizer)" + "psource(adam)" ] }, { diff --git a/notebooks/chapter19/Optimizer and Backpropagation.py b/notebooks/chapter19/Optimizer and Backpropagation.py new file mode 100644 index 000000000..6343a950b --- /dev/null +++ b/notebooks/chapter19/Optimizer and Backpropagation.py @@ -0,0 +1,91 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Optimization Algorithms +# +# Training a neural network consists of modifying the network’s parameters to minimize the cost function on the training set. In principle, any kind of optimization algorithm could be used. In practice, modern neural networks are almost always trained with some variant of stochastic gradient descent(SGD). Here we will provide two optimization algorithms: SGD and Adam optimizer. +# +# ## Stochastic Gradient Descent +# +# The goal of an optimization algorithm is to find the value of the parameter to make loss function very low. For some types of models, an optimization algorithm might find the global minimum value of loss function, but for neural network, the most efficient way to converge loss function to a local minimum is to minimize loss function according to each example. +# +# Gradient descent uses the following update rule to minimize loss function: + +# %% [markdown] +# $$\theta^{(t+1)} = \theta^{(t)}-\alpha\nabla_\theta L(\theta^{(t)})$$ + +# %% [markdown] +# where t is the time step of the algorithm and $\alpha$ is the learning rate. But this rule could be very costly when $L(\theta)$ is defined as a sum across the entire training set. Using SGD can accelerate the learning process as we can use only a batch of examples to update the parameters. +# +# We implemented the gradient descent algorithm, which can be viewed with the following code: + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.deep_learning import * +from aima.notebook_utils import * + +# %% +psource(stochastic_gradient_descent) + +# %% [markdown] +# There several key elements need to specify when using a `gradient_descent` optimizer: +# +# - **dataset**: A dataset object we used in the previous chapter, such as `iris` and `orings`. +# - **net**: A neural network object which we will cover in the next chapter. +# - **loss**: The loss function used in representing accuracy. +# - **epochs**: How many rounds the training set is used. +# - **l_rate**: learning rate. +# - **batch_size**: The number of examples is used in each update. When very small batch size is used, gradient descent and be treated as SGD. + +# %% [markdown] +# ## Adam Optimizer +# +# To mitigate some of the problems caused by the fact that the gradient ignores the second derivatives, some optimization algorithms incorporate the idea of momentum which keeps a running average of the gradients of past mini-batches. Thus Adam optimizer maintains a table saving the previous gradient result. +# +# To view the pseudocode and the implementation, you can use the following codes: + +# %% +psource(adam) + +# %% [markdown] +# There are several attributes to specify when using Adam optimizer that is different from gradient descent: rho and delta. These parameters determine the percentage of the last iteration is memorized. For more details of how this algorithm work, please refer to the article [here](https://arxiv.org/abs/1412.6980). +# +# In the Stanford course on deep learning for computer vision, the Adam algorithm is suggested as the default optimization method for deep learning applications: +# >In practice Adam is currently recommended as the default algorithm to use, and often works slightly better than RMSProp. However, it is often also worth trying SGD+Nesterov Momentum as an alternative. + +# %% [markdown] +# # Backpropagation +# +# The above algorithms are optimization algorithms: they update parameters like $\theta$ to get smaller loss values. And back-propagation is the method to calculate the gradient for each layer. For complicated models like deep neural networks, the gradients can not be calculated directly as there are enormous array-valued variables. +# +# Fortunately, back-propagation can calculate the gradients briefly which we can interpret as calculating gradients from the last layer to the first which is the inverse process to the forwarding procedure. The derivation of the loss function is passed to previous layers to make them changing toward the direction of minimizing the loss function. + +# %% [markdown] +# + +# %% [markdown] +# Applying optimizers and back-propagation algorithm together, we can update the weights of a neural network to minimize the loss function with alternatively doing forward and back-propagation process. Here is a figure form [here](https://medium.com/datathings/neural-networks-and-backpropagation-explained-in-a-simple-way-f540a3611f5e) describing how a neural network updates its weights: +# +# + +# %% [markdown] +# In our implementation, all the steps are integrated into the optimizer objects. The forward-backward process of passing information through the whole neural network is put into the method `BackPropagation`. You can view the code with: + +# %% +psource(BackPropagation) + +# %% [markdown] +# The demonstration of optimizers and back-propagation algorithm will be made together with neural network learners. diff --git a/notebooks/chapter19/RNN.ipynb b/notebooks/chapter19/RNN.ipynb index b6971b36a..93a9bafad 100644 --- a/notebooks/chapter19/RNN.ipynb +++ b/notebooks/chapter19/RNN.ipynb @@ -62,8 +62,8 @@ "warnings.filterwarnings(\"ignore\", category=FutureWarning)\n", "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from deep_learning4e import *\n", - "from notebook4e import *" + "from aima.deep_learning import *\n", + "from aima.notebook_utils import *" ] }, { diff --git a/notebooks/chapter19/RNN.py b/notebooks/chapter19/RNN.py new file mode 100644 index 000000000..d33f7648f --- /dev/null +++ b/notebooks/chapter19/RNN.py @@ -0,0 +1,96 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # RNN +# +# ## Overview +# +# When human is thinking, they are thinking based on the understanding of previous time steps but not from scratch. Traditional neural networks can’t do this, and it seems like a major shortcoming. For example, imagine you want to do sentimental analysis of some texts. It will be unclear if the traditional network cannot recognize the short phrase and sentences. +# +# Recurrent neural networks address this issue. They are networks with loops in them, allowing information to persist. +# +# + +# %% [markdown] +# A recurrent neural network can be thought of as multiple copies of the same network, each passing a message to a successor. Consider what happens if we unroll the above loop: +# +# + +# %% [markdown] +# As demonstrated in the book, recurrent neural networks may be connected in many different ways: sequences in the input, the output, or in the most general case both. +# +# + +# %% [markdown] +# ## Implementation +# +# In our case, we implemented rnn with modules offered by the package of `keras`. To use `keras` and our module, you must have both `tensorflow` and `keras` installed as a prerequisite. `keras` offered very well defined high-level neural networks API which allows for easy and fast prototyping. `keras` supports many different types of networks such as convolutional and recurrent neural networks as well as user-defined networks. About how to get started with `keras`, please read the [tutorial](https://keras.io/). +# +# To view our implementation of a simple rnn, please use the following code: + +# %% +import warnings +warnings.filterwarnings("ignore", category=FutureWarning) +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.deep_learning import * +from aima.notebook_utils import * + +# %% +psource(SimpleRNNLearner) + +# %% [markdown] +# `train_data` and `val_data` are needed when creating a simple rnn learner. Both attributes take lists of examples and the targets in a tuple. Please note that we build the network by adding layers to a `Sequential()` model which means data are passed through the network one by one. `SimpleRNN` layer is the key layer of rnn which acts the recursive role. Both `Embedding` and `Dense` layers before and after the rnn layer are used to map inputs and outputs to data in rnn form. And the optimizer used in this case is the Adam optimizer. + +# %% [markdown] +# ## Example +# +# Here is an example of how we train the rnn network made with `keras`. In this case, we used the IMDB dataset which can be viewed [here](https://keras.io/datasets/#imdb-movie-reviews-sentiment-classification) in detail. In short, the dataset is consist of movie reviews in text and their labels of sentiment (positive/negative). After loading the dataset we use `keras_dataset_loader` to split it into training, validation and test datasets. + +# %% +from keras.datasets import imdb +data = imdb.load_data(num_words=5000) +train, val, test = keras_dataset_loader(data) + +# %% [markdown] +# Then we build and train the rnn model for 10 epochs: + +# %% +model = SimpleRNNLearner(train, val, epochs=10) + +# %% [markdown] +# The accuracy of the training dataset and validation dataset are both over 80% which is very promising. Now let's try on some random examples in the test set: + +# %% [markdown] +# ## Autoencoder +# +# Autoencoders are an unsupervised learning technique in which we leverage neural networks for the task of representation learning. It works by compressing the input into a latent-space representation, to do transformations on the data. +# +# + +# %% [markdown] +# Autoencoders are learned automatically from data examples. It means that it is easy to train specialized instances of the algorithm that will perform well on a specific type of input and that it does not require any new engineering, only the appropriate training data. +# +# Autoencoders have different architectures for different kinds of data. Here we only provide a simple example of a vanilla encoder, which means they're only one hidden layer in the network: +# +# +# +# You can view the source code by: + +# %% +psource(AutoencoderLearner) + +# %% [markdown] +# It shows we added two dense layers to the network structures. diff --git a/notebooks/chapter21/Active Reinforcement Learning.ipynb b/notebooks/chapter21/Active Reinforcement Learning.ipynb index 1ce3c79e0..912d27b25 100644 --- a/notebooks/chapter21/Active Reinforcement Learning.ipynb +++ b/notebooks/chapter21/Active Reinforcement Learning.ipynb @@ -48,8 +48,8 @@ "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from rl4e import *\n", - "from mdp import sequential_decision_environment, value_iteration" + "from aima.reinforcement_learning import *\n", + "from aima.mdp import sequential_decision_environment, value_iteration" ] }, { diff --git a/notebooks/chapter21/Active Reinforcement Learning.py b/notebooks/chapter21/Active Reinforcement Learning.py new file mode 100644 index 000000000..268c14dd8 --- /dev/null +++ b/notebooks/chapter21/Active Reinforcement Learning.py @@ -0,0 +1,91 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # ACTIVE REINFORCEMENT LEARNING +# +# This notebook mainly focuses on active reinforce learning algorithms. For a general introduction to reinforcement learning and passive algorithms, please refer to the notebook of **[Passive Reinforcement Learning](./Passive%20Reinforcement%20Learning.ipynb)**. +# +# Unlike Passive Reinforcement Learning in Active Reinforcement Learning, we are not bound by a policy pi and we need to select our actions. In other words, the agent needs to learn an optimal policy. The fundamental tradeoff the agent needs to face is that of exploration vs. exploitation. +# +# ## QLearning Agent +# +# The QLearningAgent class in the rl module implements the Agent Program described in **Fig 21.8** of the AIMA Book. In Q-Learning the agent learns an action-value function Q which gives the utility of taking a given action in a particular state. Q-Learning does not require a transition model and hence is a model-free method. Let us look into the source before we see some usage examples. + +# %% +# %psource QLearningAgent + +# %% [markdown] +# The Agent Program can be obtained by creating the instance of the class by passing the appropriate parameters. Because of the __ call __ method the object that is created behaves like a callable and returns an appropriate action as most Agent Programs do. To instantiate the object we need a `mdp` object similar to the `PassiveTDAgent`. +# +# Let us use the same `GridMDP` object we used above. **Figure 17.1 (sequential_decision_environment)** is similar to **Figure 21.1** but has some discounting parameter as **gamma = 0.9**. The enviroment also implements an exploration function **f** which returns fixed **Rplus** until agent has visited state, action **Ne** number of times. The method **actions_in_state** returns actions possible in given state. It is useful when applying max and argmax operations. + +# %% [markdown] +# Let us create our object now. We also use the **same alpha** as given in the footnote of the book on **page 769**: $\alpha(n)=60/(59+n)$ We use **Rplus = 2** and **Ne = 5** as defined in the book. The pseudocode can be referred from **Fig 21.7** in the book. + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.reinforcement_learning import * +from aima.mdp import sequential_decision_environment, value_iteration + +# %% +q_agent = QLearningAgent(sequential_decision_environment, Ne=5, Rplus=2, + alpha=lambda n: 60./(59+n)) + +# %% [markdown] +# Now to try out the q_agent we make use of the **run_single_trial** function in rl.py (which was also used above). Let us use **200** iterations. + +# %% +for i in range(200): + run_single_trial(q_agent,sequential_decision_environment) + +# %% [markdown] +# Now let us see the Q Values. The keys are state-action pairs. Where different actions correspond according to: +# +# north = (0, 1) +# south = (0,-1) +# west = (-1, 0) +# east = (1, 0) + +# %% +q_agent.Q + +# %% [markdown] +# The Utility U of each state is related to Q by the following equation. +# +# $$U (s) = max_a Q(s, a)$$ +# +# Let us convert the Q Values above into U estimates. +# +# + +# %% +U = defaultdict(lambda: -1000.) # Very Large Negative Value for Comparison see below. +for state_action, value in q_agent.Q.items(): + state, action = state_action + if U[state] < value: + U[state] = value + +# %% [markdown] +# Now we can output the estimated utility values at each state: + +# %% +U + +# %% [markdown] +# Let us finally compare these estimates to value_iteration results. + +# %% +print(value_iteration(sequential_decision_environment)) diff --git a/notebooks/chapter21/Passive Reinforcement Learning.ipynb b/notebooks/chapter21/Passive Reinforcement Learning.ipynb index cbb5ae9e3..90f0db103 100644 --- a/notebooks/chapter21/Passive Reinforcement Learning.ipynb +++ b/notebooks/chapter21/Passive Reinforcement Learning.ipynb @@ -17,7 +17,7 @@ "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from rl4e import *" + "from aima.reinforcement_learning import *" ] }, { @@ -55,7 +55,7 @@ "\n", "### Implementation\n", "\n", - "Passive agents are implemented in `rl4e.py` as various `Agent-Class`es.\n", + "Passive agents are implemented in `reinforcement_learning.py` as various `Agent-Class`es.\n", "\n", "To demonstrate these agents, we make use of the `GridMDP` object from the `MDP` module. `sequential_decision_environment` is similar to that used for the `MDP` notebook but has discounting with $\\gamma = 0.9$.\n", "\n", @@ -68,7 +68,7 @@ "metadata": {}, "outputs": [], "source": [ - "from mdp import sequential_decision_environment" + "from aima.mdp import sequential_decision_environment" ] }, { diff --git a/notebooks/chapter21/Passive Reinforcement Learning.py b/notebooks/chapter21/Passive Reinforcement Learning.py new file mode 100644 index 000000000..71c2e86a2 --- /dev/null +++ b/notebooks/chapter21/Passive Reinforcement Learning.py @@ -0,0 +1,192 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Introduction to Reinforcement Learning +# +# This Jupyter notebook and the others in the same folder act as supporting materials for **Chapter 21 Reinforcement Learning** of the book* Artificial Intelligence: A Modern Approach*. The notebooks make use of the implementations in `rl.py` module. We also make use of the implementation of MDPs in the `mdp.py` module to test our agents. It might be helpful if you have already gone through the Jupyter notebook dealing with the Markov decision process. Let us import everything from the `rl` module. It might be helpful to view the source of some of our implementations. + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.reinforcement_learning import * + +# %% [markdown] +# Before we start playing with the actual implementations let us review a couple of things about RL. +# +# 1. Reinforcement Learning is concerned with how software agents ought to take actions in an environment so as to maximize some notion of cumulative reward. +# +# 2. Reinforcement learning differs from standard supervised learning in that correct input/output pairs are never presented, nor sub-optimal actions explicitly corrected. Further, there is a focus on on-line performance, which involves finding a balance between exploration (of uncharted territory) and exploitation (of current knowledge). +# +# -- Source: [Wikipedia](https://en.wikipedia.org/wiki/Reinforcement_learning) +# +# In summary, we have a sequence of state action transitions with rewards associated with some states. Our goal is to find the optimal policy $\pi$ which tells us what action to take in each state. + +# %% [markdown] +# # Passive Reinforcement Learning +# +# In passive Reinforcement Learning the agent follows a fixed policy $\pi$. Passive learning attempts to evaluate the given policy $pi$ - without any knowledge of the Reward function $R(s)$ and the Transition model $P(s'\ |\ s, a)$. +# +# This is usually done by some method of **utility estimation**. The agent attempts to directly learn the utility of each state that would result from following the policy. Note that at each step, it has to *perceive* the reward and the state - it has no global knowledge of these. Thus, if a certain the entire set of actions offers a very low probability of attaining some state $s_+$ - the agent may never perceive the reward $R(s_+)$. +# +# Consider a situation where an agent is given the policy to follow. Thus, at any point, it knows only its current state and current reward, and the action it must take next. This action may lead it to more than one state, with different probabilities. +# +# For a series of actions given by $\pi$, the estimated utility $U$: +# $$U^{\pi}(s) = E(\sum_{t=0}^\inf \gamma^t R^t(s'))$$ +# Or the expected value of summed discounted rewards until termination. +# +# Based on this concept, we discuss three methods of estimating utility: direct utility estimation, adaptive dynamic programming, and temporal-difference learning. +# +# ### Implementation +# +# Passive agents are implemented in `reinforcement_learning.py` as various `Agent-Class`es. +# +# To demonstrate these agents, we make use of the `GridMDP` object from the `MDP` module. `sequential_decision_environment` is similar to that used for the `MDP` notebook but has discounting with $\gamma = 0.9$. +# +# The `Agent-Program` can be obtained by creating an instance of the relevant `Agent-Class`. The `__call__` method allows the `Agent-Class` to be called as a function. The class needs to be instantiated with a policy ($\pi$) and an `MDP` whose utility of states will be estimated. +# + +# %% +from aima.mdp import sequential_decision_environment + +# %% [markdown] +# The `sequential_decision_environment` is a GridMDP object as shown below. The rewards are **+1** and **-1** in the terminal states, and **-0.04** in the rest. Now we define actions and a policy similar to **Fig 21.1** in the book. + +# %% +# Action Directions +north = (0, 1) +south = (0,-1) +west = (-1, 0) +east = (1, 0) + +policy = { + (0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, + (0, 1): north, (2, 1): north, (3, 1): None, + (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west, +} + +# %% [markdown] +# This enviroment will be extensively used in the following demonstrations. + +# %% [markdown] +# ## Direct Utility Estimation (DUE) +# +# The first, most naive method of estimating utility comes from the simplest interpretation of the above definition. We construct an agent that follows the policy until it reaches the terminal state. At each step, it logs its current state, reward. Once it reaches the terminal state, it can estimate the utility for each state for *that* iteration, by simply summing the discounted rewards from that state to the terminal one. +# +# It can now run this 'simulation' $n$ times and calculate the average utility of each state. If a state occurs more than once in a simulation, both its utility values are counted separately. +# +# Note that this method may be prohibitively slow for very large state-spaces. Besides, **it pays no attention to the transition probability $P(s'\ |\ s, a)$.** It misses out on information that it is capable of collecting (say, by recording the number of times an action from one state led to another state). The next method addresses this issue. +# +# ### Examples +# +# The `PassiveDEUAgent` class in the `rl` module implements the Agent Program described in **Fig 21.2** of the AIMA Book. `PassiveDEUAgent` sums over rewards to find the estimated utility for each state. It thus requires the running of several iterations. + +# %% +# %psource PassiveDUEAgent + +# %% [markdown] +# Now let's try the `PassiveDEUAgent` on the newly defined `sequential_decision_environment`: + +# %% +DUEagent = PassiveDUEAgent(policy, sequential_decision_environment) + +# %% [markdown] +# We can try passing information through the markove model for 200 times in order to get the converged utility value: + +# %% +for i in range(200): + run_single_trial(DUEagent, sequential_decision_environment) + DUEagent.estimate_U() + +# %% [markdown] +# Now let's print our estimated utility for each position: + +# %% +print('\n'.join([str(k)+':'+str(v) for k, v in DUEagent.U.items()])) + +# %% [markdown] +# ## Adaptive Dynamic Programming (ADP) +# +# This method makes use of knowledge of the past state $s$, the action $a$, and the new perceived state $s'$ to estimate the transition probability $P(s'\ |\ s,a)$. It does this by the simple counting of new states resulting from previous states and actions.
+# The program runs through the policy a number of times, keeping track of: +# - each occurrence of state $s$ and the policy-recommended action $a$ in $N_{sa}$ +# - each occurrence of $s'$ resulting from $a$ on $s$ in $N_{s'|sa}$. +# +# It can thus estimate $P(s'\ |\ s,a)$ as $N_{s'|sa}/N_{sa}$, which in the limit of infinite trials, will converge to the true value.
+# Using the transition probabilities thus estimated, it can apply `POLICY-EVALUATION` to estimate the utilities $U(s)$ using properties of convergence of the Bellman functions. +# +# ### Examples +# +# The `PassiveADPAgent` class in the `rl` module implements the Agent Program described in **Fig 21.2** of the AIMA Book. `PassiveADPAgent` uses state transition and occurrence counts to estimate $P$, and then $U$. Go through the source below to understand the agent. + +# %% +# %psource + +# %% [markdown] +# We instantiate a `PassiveADPAgent` below with the `GridMDP` shown and train it for 200 steps. The `rl` module has a simple implementation to simulate a single step of the iteration. The function is called `run_single_trial`. + +# %% +ADPagent = PassiveADPAgent(policy, sequential_decision_environment) +for i in range(200): + run_single_trial(ADPagent, sequential_decision_environment) + +# %% [markdown] +# The utilities are calculated as : + +# %% +print('\n'.join([str(k)+':'+str(v) for k, v in ADPagent.U.items()])) + +# %% [markdown] +# When comparing to the result of `PassiveDUEAgent`, they both have -1.0 for utility at (3,1) and 1.0 at (3,2). Another point to notice is that the spot with the highest utility for both agents is (2,2) beside the terminal states, which is easy to understand when referring to the map. + +# %% [markdown] +# ## Temporal-difference learning (TD) +# +# Instead of explicitly building the transition model $P$, the temporal-difference model makes use of the expected closeness between the utilities of two consecutive states $s$ and $s'$. +# For the transition $s$ to $s'$, the update is written as: +# $$U^{\pi}(s) \leftarrow U^{\pi}(s) + \alpha \left( R(s) + \gamma U^{\pi}(s') - U^{\pi}(s) \right)$$ +# This model implicitly incorporates the transition probabilities by being weighed for each state by the number of times it is achieved from the current state. Thus, over a number of iterations, it converges similarly to the Bellman equations. +# The advantage of the TD learning model is its relatively simple computation at each step, rather than having to keep track of various counts. +# For $n_s$ states and $n_a$ actions the ADP model would have $n_s \times n_a$ numbers $N_{sa}$ and $n_s^2 \times n_a$ numbers $N_{s'|sa}$ to keep track of. The TD model must only keep track of a utility $U(s)$ for each state. +# +# ### Examples +# +# `PassiveTDAgent` uses temporal differences to learn utility estimates. We learn the difference between the states and back up the values to previous states. Let us look into the source before we see some usage examples. + +# %% +# %psource PassiveTDAgent + +# %% [markdown] +# In creating the `TDAgent`, we use the **same learning rate** $\alpha$ as given in the footnote of the book: $\alpha(n)=60/(59+n)$ + +# %% +TDagent = PassiveTDAgent(policy, sequential_decision_environment, alpha = lambda n: 60./(59+n)) + +# %% [markdown] +# Now we run **200 trials** for the agent to estimate Utilities. + +# %% +for i in range(200): + run_single_trial(TDagent,sequential_decision_environment) + +# %% [markdown] +# The calculated utilities are: + +# %% +print('\n'.join([str(k)+':'+str(v) for k, v in TDagent.U.items()])) + +# %% [markdown] +# When comparing to previous agents, the result of `PassiveTDAgent` is closer to `PassiveADPAgent`. + +# %% diff --git a/notebooks/chapter22/Grammar.ipynb b/notebooks/chapter22/Grammar.ipynb index 3c1a2a005..d0f25a55f 100644 --- a/notebooks/chapter22/Grammar.ipynb +++ b/notebooks/chapter22/Grammar.ipynb @@ -145,8 +145,8 @@ "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from nlp4e import *\n", - "from notebook4e import psource" + "from aima.nlp import *\n", + "from aima.notebook_utils import psource" ] }, { diff --git a/notebooks/chapter22/Grammar.py b/notebooks/chapter22/Grammar.py new file mode 100644 index 000000000..d0e037fcb --- /dev/null +++ b/notebooks/chapter22/Grammar.py @@ -0,0 +1,291 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Grammar +# +# Languages can be represented by a set of grammar rules over a lexicon of words. Different languages can be represented by different types of grammar, but in Natural Language Processing we are mainly interested in context-free grammars. +# +# ## Context-Free Grammar +# +# A lot of natural and programming languages can be represented by a **Context-Free Grammar (CFG)**. A CFG is a grammar that has a single non-terminal symbol on the left-hand side. That means a non-terminal can be replaced by the right-hand side of the rule regardless of context. An example of a CFG: +# +# ``` +# S -> aSb | ε +# ``` +# +# That means `S` can be replaced by either `aSb` or `ε` (with `ε` we denote the empty string). The lexicon of the language is comprised of the terminals `a` and `b`, while with `S` we denote the non-terminal symbol. In general, non-terminals are capitalized while terminals are not, and we usually name the starting non-terminal `S`. The language generated by the above grammar is the language anbn for n greater or equal than 1. + +# %% [markdown] +# ## Probabilistic Context-Free Grammar +# +# While a simple CFG can be very useful, we might want to know the chance of each rule occurring. Above, we do not know if `S` is more likely to be replaced by `aSb` or `ε`. **Probabilistic Context-Free Grammars (PCFG)** are built to fill exactly that need. Each rule has a probability, given in brackets, and the probabilities of a rule sum up to 1: +# +# ``` +# S -> aSb [0.7] | ε [0.3] +# ``` +# +# Now we know it is more likely for `S` to be replaced by `aSb` than by `ε`. +# +# An issue with *PCFGs* is how we will assign the various probabilities to the rules. We could use our knowledge as humans to assign the probabilities, but that is laborious and prone to error task. Instead, we can *learn* the probabilities from data. Data is categorized as labeled (with correctly parsed sentences, usually called a **treebank**) or unlabeled (given only lexical and syntactic category names). +# +# With labeled data, we can simply count the occurrences. For the above grammar, if we have 100 `S` rules and 30 of them are of the form `S -> ε`, we assign a probability of 0.3 to the transformation. +# +# With unlabeled data, we have to learn both the grammar rules and the probability of each rule. We can go with many approaches, one of them the **inside-outside** algorithm. It uses a dynamic programming approach, that first finds the probability of a substring being generated by each rule and then estimates the probability of each rule. + +# %% [markdown] +# ## Chomsky Normal Form +# +# Grammar is in Chomsky Normal Form (or **CNF**, not to be confused with *Conjunctive Normal Form*) if its rules are one of the three: +# +# * `X -> Y Z` +# * `A -> a` +# * `S -> ε` +# +# Where *X*, *Y*, *Z*, *A* are non-terminals, *a* is a terminal, *ε* is the empty string and *S* is the start symbol (the start symbol should not be appearing on the right-hand side of rules). Note that there can be multiple rules for each left-hand side non-terminal, as long they follow the above. For example, a rule for *X* might be: `X -> Y Z | A B | a | b`. +# +# Of course, we can also have a *CNF* with probabilities. +# +# This type of grammar may seem restrictive, but it can be proven that any context-free grammar can be converted to CNF. + +# %% [markdown] +# ## Lexicon +# +# The lexicon of a language is defined as a list of allowable words. These words are grouped into the usual classes: `verbs`, `nouns`, `adjectives`, `adverbs`, `pronouns`, `names`, `articles`, `prepositions` and `conjunctions`. For the first five classes, it is impossible to list all words since words are continuously being added in the classes. Recently "google" was added to the list of verbs, and words like that will continue to pop up and get added to the lists. For that reason, these first five categories are called **open classes**. The rest of the categories have much fewer words and much less development. While words like "thou" were commonly used in the past but have declined almost completely in usage, most changes take many decades or centuries to manifest, so we can safely assume the categories will remain static for the foreseeable future. Thus, these categories are called **closed classes**. +# +# An example lexicon for a PCFG (note that other classes can also be used according to the language, like `digits`, or `RelPro` for relative pronoun): +# +# ``` +# Verb -> is [0.3] | say [0.1] | are [0.1] | ... +# Noun -> robot [0.1] | sheep [0.05] | fence [0.05] | ... +# Adjective -> good [0.1] | new [0.1] | sad [0.05] | ... +# Adverb -> here [0.1] | lightly [0.05] | now [0.05] | ... +# Pronoun -> me [0.1] | you [0.1] | he [0.05] | ... +# RelPro -> that [0.4] | who [0.2] | which [0.2] | ... +# Name -> john [0.05] | mary [0.05] | peter [0.01] | ... +# Article -> the [0.35] | a [0.25] | an [0.025] | ... +# Preposition -> to [0.25] | in [0.2] | at [0.1] | ... +# Conjunction -> and [0.5] | or [0.2] | but [0.2] | ... +# Digit -> 1 [0.3] | 2 [0.2] | 0 [0.2] | ... +# ``` + +# %% [markdown] +# ## Grammer Rules +# +# With grammars we combine words from the lexicon into valid phrases. A grammar is comprised of **grammar rules**. Each rule transforms the left-hand side of the rule into the right-hand side. For example, `A -> B` means that `A` transforms into `B`. Let's build a grammar for the language we started building with the lexicon. We will use a PCFG. +# +# ``` +# S -> NP VP [0.9] | S Conjunction S [0.1] +# +# NP -> Pronoun [0.3] | Name [0.1] | Noun [0.1] | Article Noun [0.25] | +# Article Adjs Noun [0.05] | Digit [0.05] | NP PP [0.1] | +# NP RelClause [0.05] +# +# VP -> Verb [0.4] | VP NP [0.35] | VP Adjective [0.05] | VP PP [0.1] +# VP Adverb [0.1] +# +# Adjs -> Adjective [0.8] | Adjective Adjs [0.2] +# +# PP -> Preposition NP [1.0] +# +# RelClause -> RelPro VP [1.0] +# ``` +# +# Some valid phrases the grammar produces: "`mary is sad`", "`you are a robot`" and "`she likes mary and a good fence`". +# +# What if we wanted to check if the phrase "`mary is sad`" is actually a valid sentence? We can use a **parse tree** to constructively prove that a string of words is a valid phrase in the given language and even calculate the probability of the generation of the sentence. +# +# ![parse_tree](images/parse_tree.png) +# +# The probability of the whole tree can be calculated by multiplying the probabilities of each individual rule transormation: `0.9 * 0.1 * 0.05 * 0.05 * 0.4 * 0.05 * 0.3 = 0.00000135`. +# +# To conserve space, we can also write the tree in linear form: +# +# [S [NP [Name **mary**]] [VP [VP [Verb **is**]] [Adjective **sad**]]] +# +# Unfortunately, the current grammar **overgenerates**, that is, it creates sentences that are not grammatically correct (according to the English language), like "`the fence are john which say`". It also **undergenerates**, which means there are valid sentences it does not generate, like "`he believes mary is sad`". + +# %% [markdown] +# ## Implementation +# +# In the module, we have implemented both probabilistic and non-probabilistic grammars. Both of these implementations follow the same format. There are functions for the lexicon and the rules which can be combined to create a grammar object. +# +# ### Non-Probabilistic +# +# Execute the cell below to view the implementations: + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.nlp import * +from aima.notebook_utils import psource + +# %% +psource(Lexicon, Rules, Grammar) + +# %% [markdown] +# Let's build a lexicon and a grammar for the above language: + +# %% +lexicon = Lexicon( + Verb = "is | say | are", + Noun = "robot | sheep | fence", + Adjective = "good | new | sad", + Adverb = "here | lightly | now", + Pronoun = "me | you | he", + RelPro = "that | who | which", + Name = "john | mary | peter", + Article = "the | a | an", + Preposition = "to | in | at", + Conjunction = "and | or | but", + Digit = "1 | 2 | 0" +) + +print("Lexicon", lexicon) + +rules = Rules( + S = "NP VP | S Conjunction S", + NP = "Pronoun | Name | Noun | Article Noun \ + | Article Adjs Noun | Digit | NP PP | NP RelClause", + VP = "Verb | VP NP | VP Adjective | VP PP | VP Adverb", + Adjs = "Adjective | Adjective Adjs", + PP = "Preposition NP", + RelClause = "RelPro VP" +) + +print("\nRules:", rules) + +# %% [markdown] +# Both the functions return a dictionary with keys to the left-hand side of the rules. For the lexicon, the values are the terminals for each left-hand side non-terminal, while for the rules the values are the right-hand sides as lists. +# +# We can now use the variables `lexicon` and `rules` to build a grammar. After we've done so, we can find the transformations of a non-terminal (the `Noun`, `Verb` and the other basic classes do **not** count as proper non-terminals in the implementation). We can also check if a word is in a particular class. + +# %% +grammar = Grammar("A Simple Grammar", rules, lexicon) + +print("How can we rewrite 'VP'?", grammar.rewrites_for('VP')) +print("Is 'the' an article?", grammar.isa('the', 'Article')) +print("Is 'here' a noun?", grammar.isa('here', 'Noun')) + +# %% [markdown] +# ### Chomsky Normal Form +# If the grammar is in **Chomsky Normal Form**, we can call the class function `cnf_rules` to get all the rules in the form of `(X, Y, Z)` for each `X -> Y Z` rule. Since the above grammar is not in *CNF* though, we have to create a new one. + +# %% +E_Chomsky = Grammar("E_Prob_Chomsky", # A Grammar in Chomsky Normal Form + Rules( + S = "NP VP", + NP = "Article Noun | Adjective Noun", + VP = "Verb NP | Verb Adjective", + ), + Lexicon( + Article = "the | a | an", + Noun = "robot | sheep | fence", + Adjective = "good | new | sad", + Verb = "is | say | are" + )) + +# %% +print(E_Chomsky.cnf_rules()) + +# %% [markdown] +# Finally, we can generate random phrases using our grammar. Most of them will be complete gibberish, falling under the overgenerated phrases of the grammar. That goes to show that in the grammar the valid phrases are much fewer than the overgenerated ones. + +# %% +grammar.generate_random('S') + +# %% [markdown] +# ### Probabilistic +# +# The probabilistic grammars follow the same approach. They take as input a string, are assembled from grammar and a lexicon and can generate random sentences (giving the probability of the sentence). The main difference is that in the lexicon we have tuples (terminal, probability) instead of strings and for the rules, we have a list of tuples (list of non-terminals, probability) instead of the list of lists of non-terminals. +# +# Execute the cells to read the code: + +# %% +psource(ProbLexicon, ProbRules, ProbGrammar) + +# %% [markdown] +# Let's build a lexicon and rules for the probabilistic grammar: + +# %% +lexicon = ProbLexicon( + Verb = "is [0.5] | say [0.3] | are [0.2]", + Noun = "robot [0.4] | sheep [0.4] | fence [0.2]", + Adjective = "good [0.5] | new [0.2] | sad [0.3]", + Adverb = "here [0.6] | lightly [0.1] | now [0.3]", + Pronoun = "me [0.3] | you [0.4] | he [0.3]", + RelPro = "that [0.5] | who [0.3] | which [0.2]", + Name = "john [0.4] | mary [0.4] | peter [0.2]", + Article = "the [0.5] | a [0.25] | an [0.25]", + Preposition = "to [0.4] | in [0.3] | at [0.3]", + Conjunction = "and [0.5] | or [0.2] | but [0.3]", + Digit = "0 [0.35] | 1 [0.35] | 2 [0.3]" +) + +print("Lexicon", lexicon) + +rules = ProbRules( + S = "NP VP [0.6] | S Conjunction S [0.4]", + NP = "Pronoun [0.2] | Name [0.05] | Noun [0.2] | Article Noun [0.15] \ + | Article Adjs Noun [0.1] | Digit [0.05] | NP PP [0.15] | NP RelClause [0.1]", + VP = "Verb [0.3] | VP NP [0.2] | VP Adjective [0.25] | VP PP [0.15] | VP Adverb [0.1]", + Adjs = "Adjective [0.5] | Adjective Adjs [0.5]", + PP = "Preposition NP [1]", + RelClause = "RelPro VP [1]" +) + +print("\nRules:", rules) + +# %% [markdown] +# Let's use the above to assemble our probabilistic grammar and run some simple queries: + +# %% +grammar = ProbGrammar("A Simple Probabilistic Grammar", rules, lexicon) + +print("How can we rewrite 'VP'?", grammar.rewrites_for('VP')) +print("Is 'the' an article?", grammar.isa('the', 'Article')) +print("Is 'here' a noun?", grammar.isa('here', 'Noun')) + +# %% [markdown] +# If we have a grammar in *CNF*, we can get a list of all the rules. Let's create a grammar in the form and print the *CNF* rules: + +# %% +E_Prob_Chomsky = ProbGrammar("E_Prob_Chomsky", # A Probabilistic Grammar in CNF + ProbRules( + S = "NP VP [1]", + NP = "Article Noun [0.6] | Adjective Noun [0.4]", + VP = "Verb NP [0.5] | Verb Adjective [0.5]", + ), + ProbLexicon( + Article = "the [0.5] | a [0.25] | an [0.25]", + Noun = "robot [0.4] | sheep [0.4] | fence [0.2]", + Adjective = "good [0.5] | new [0.2] | sad [0.3]", + Verb = "is [0.5] | say [0.3] | are [0.2]" + )) + +# %% +print(E_Prob_Chomsky.cnf_rules()) + +# %% [markdown] +# Lastly, we can generate random sentences from this grammar. The function `prob_generation` returns a tuple (sentence, probability). + +# %% +sentence, prob = grammar.generate_random('S') +print(sentence) +print(prob) + +# %% [markdown] +# As with the non-probabilistic grammars, this one mostly overgenerates. You can also see that the probability is very, very low, which means there are a ton of generate able sentences (in this case infinite, since we have recursion; notice how `VP` can produce another `VP`, for example). + +# %% diff --git a/notebooks/chapter22/Introduction.ipynb b/notebooks/chapter22/Introduction.ipynb index 0905b91a9..d7a8c3d72 100644 --- a/notebooks/chapter22/Introduction.ipynb +++ b/notebooks/chapter22/Introduction.ipynb @@ -6,7 +6,7 @@ "source": [ "# NATURAL LANGUAGE PROCESSING\n", "\n", - "The notebooks in this folder cover chapters 23 of the book *Artificial Intelligence: A Modern Approach*, 4th Edition. The implementations of the algorithms can be found in [nlp.py](https://github.com/aimacode/aima-python/blob/master/nlp4e.py).\n", + "The notebooks in this folder cover chapters 23 of the book *Artificial Intelligence: A Modern Approach*, 4th Edition. The implementations of the algorithms can be found in [nlp.py](https://github.com/aimacode/aima-python/blob/master/nlp.py).\n", "\n", "Run the below cell to import the code from the module and get started!" ] @@ -19,8 +19,8 @@ "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from nlp4e import *\n", - "from notebook4e import psource" + "from aima.nlp import *\n", + "from aima.notebook_utils import psource" ] }, { diff --git a/notebooks/chapter22/Introduction.py b/notebooks/chapter22/Introduction.py new file mode 100644 index 000000000..cd8ea6c85 --- /dev/null +++ b/notebooks/chapter22/Introduction.py @@ -0,0 +1,57 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # NATURAL LANGUAGE PROCESSING +# +# The notebooks in this folder cover chapters 23 of the book *Artificial Intelligence: A Modern Approach*, 4th Edition. The implementations of the algorithms can be found in [nlp.py](https://github.com/aimacode/aima-python/blob/master/nlp.py). +# +# Run the below cell to import the code from the module and get started! + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.nlp import * +from aima.notebook_utils import psource + +# %% [markdown] +# ## OVERVIEW +# +# **Natural Language Processing (NLP)** is a field of AI concerned with understanding, analyzing and using natural languages. This field is considered a difficult yet intriguing field of study since it is connected to how humans and their languages work. +# +# Applications of the field include translation, speech recognition, topic segmentation, information extraction and retrieval, and a lot more. +# +# Below we take a look at some algorithms in the field. Before we get right into it though, we will take a look at a very useful form of language, **context-free** languages. Even though they are a bit restrictive, they have been used a lot in research in natural language processing. +# +# Below is a summary of the demonstration files in this chapter. + +# %% [markdown] +# ## CONTENTS +# +# - Introduction: Introduction to the field of nlp and the table of contents. +# - Grammars: Introduction to grammar rules and lexicon of words of a language. +# - Context-free Grammar +# - Probabilistic Context-Free Grammar +# - Chomsky Normal Form +# - Lexicon +# - Grammar Rules +# - Implementation of Different Grammars +# - Parsing: The algorithms parsing sentences according to a certain kind of grammar. +# - Chart Parsing +# - CYK Parsing +# - A-star Parsing +# - Beam Search Parsing +# + +# %% diff --git a/notebooks/chapter22/Parsing.ipynb b/notebooks/chapter22/Parsing.ipynb index 50a4264fb..5790825ea 100644 --- a/notebooks/chapter22/Parsing.ipynb +++ b/notebooks/chapter22/Parsing.ipynb @@ -1,5 +1,18 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath('../../')] + sys.path # make the aima package importable\n", + "from aima.nlp import *\n", + "from aima import nlp\n", + "from aima.notebook_utils import psource\n" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -195,8 +208,9 @@ "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from nlp4e import *\n", - "from notebook4e import psource" + "from aima.nlp import *\n", + "from aima import nlp\n", + "from aima.notebook_utils import psource" ] }, { @@ -255,7 +269,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "defaultdict(, {('Article', 0, 0): , ('Noun', 1, 1): , ('Verb', 2, 2): , ('Adjective', 3, 3): , ('VP', 2, 3): })\n" + "defaultdict(, {('Article', 0, 0): , ('Noun', 1, 1): , ('Verb', 2, 2): , ('Adjective', 3, 3): , ('VP', 2, 3): })\n" ] } ], @@ -283,12 +297,12 @@ "name": "stdout", "output_type": "stream", "text": [ - "{('Article', 0, 0): ['the'], ('Noun', 1, 1): ['robot'], ('Verb', 2, 2): ['is'], ('Adjective', 3, 3): ['good'], ('VP', 2, 3): [, ]}\n" + "{('Article', 0, 0): ['the'], ('Noun', 1, 1): ['robot'], ('Verb', 2, 2): ['is'], ('Adjective', 3, 3): ['good'], ('VP', 2, 3): [, ]}\n" ] } ], "source": [ - "parses = {k: p.leaves for k, p in P.items()}\n", + "parses = {k: p for k, p in P.items() if p}\n", "\n", "print(parses)" ] @@ -315,8 +329,9 @@ } ], "source": [ - "for subtree in P['VP', 2, 3].leaves:\n", - " print(subtree.leaves)" + "# CYK_parse returns a probability table P[(symbol, start, length)] -> probability;\n", + "# show where the grammar can parse a VP (verb phrase) and with what probability\n", + "print({k: p for k, p in P.items() if k[0] == 'VP' and p})" ] }, { @@ -356,7 +371,7 @@ "source": [ "### Example\n", "\n", - "Now let's try \"the wumpus is dead\" example. First we need to define the grammer and words in the sentence." + "Now let's try \"the wumpus is dead\" example. First we need to define the grammar and words in the sentence." ] }, { diff --git a/notebooks/chapter22/Parsing.py b/notebooks/chapter22/Parsing.py new file mode 100644 index 000000000..dca0720be --- /dev/null +++ b/notebooks/chapter22/Parsing.py @@ -0,0 +1,243 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +import os, sys +sys.path = [os.path.abspath('../../')] + sys.path # make the aima package importable +from aima.nlp import * +from aima import nlp +from aima.notebook_utils import psource + + +# %% [markdown] +# # Parsing +# +# ## Overview +# +# Syntactic analysis (or **parsing**) of a sentence is the process of uncovering the phrase structure of the sentence according to the rules of grammar. +# +# There are two main approaches to parsing. *Top-down*, start with the starting symbol and build a parse tree with the given words as its leaves, and *bottom-up*, where we start from the given words and build a tree that has the starting symbol as its root. Both approaches involve "guessing" ahead, so it may take longer to parse a sentence (the wrong guess mean a lot of backtracking). Thankfully, a lot of effort is spent in analyzing already analyzed substrings, so we can follow a dynamic programming approach to store and reuse these parses instead of recomputing them. +# +# In dynamic programming, we use a data structure known as a chart, thus the algorithms parsing a chart is called **chart parsing**. We will cover several different chart parsing algorithms. + +# %% [markdown] +# ## Chart Parsing +# +# ### Overview +# +# The chart parsing algorithm is a general form of the following algorithms. Given a non-probabilistic grammar and a sentence, this algorithm builds a parse tree in a top-down manner, with the words of the sentence as the leaves. It works with a dynamic programming approach, building a chart to store parses for substrings so that it doesn't have to analyze them again (just like the CYK algorithm). Each non-terminal, starting from S, gets replaced by its right-hand side rules in the chart until we end up with the correct parses. +# +# ### Implementation +# +# A parse is in the form `[start, end, non-terminal, sub-tree, expected-transformation]`, where `sub-tree` is a tree with the corresponding `non-terminal` as its root and `expected-transformation` is a right-hand side rule of the `non-terminal`. +# +# The chart parsing is implemented in a class, `Chart`. It is initialized with grammar and can return the list of all the parses of a sentence with the `parses` function. +# +# The chart is a list of lists. The lists correspond to the lengths of substrings (including the empty string), from start to finish. When we say 'a point in the chart', we refer to a list of a certain length. +# +# A quick rundown of the class functions: + +# %% [markdown] +# * `parses`: Returns a list of parses for a given sentence. If the sentence can't be parsed, it will return an empty list. Initializes the process by calling `parse` from the starting symbol. +# +# +# * `parse`: Parses the list of words and builds the chart. +# +# +# * `add_edge`: Adds another edge to the chart at a given point. Also, examines whether the edge extends or predicts another edge. If the edge itself is not expecting a transformation, it will extend other edges and it will predict edges otherwise. +# +# +# * `scanner`: Given a word and a point in the chart, it extends edges that were expecting a transformation that can result in the given word. For example, if the word 'the' is an 'Article' and we are examining two edges at a chart's point, with one expecting an 'Article' and the other a 'Verb', the first one will be extended while the second one will not. +# +# +# * `predictor`: If an edge can't extend other edges (because it is expecting a transformation itself), we will add to the chart rules/transformations that can help extend the edge. The new edges come from the right-hand side of the expected transformation's rules. For example, if an edge is expecting the transformation 'Adjective Noun', we will add to the chart an edge for each right-hand side rule of the non-terminal 'Adjective'. +# +# +# * `extender`: Extends edges given an edge (called `E`). If `E`'s non-terminal is the same as the expected transformation of another edge (let's call it `A`), add to the chart a new edge with the non-terminal of `A` and the transformations of `A` minus the non-terminal that matched with `E`'s non-terminal. For example, if an edge `E` has 'Article' as its non-terminal and is expecting no transformation, we need to see what edges it can extend. Let's examine the edge `N`. This expects a transformation of 'Noun Verb'. 'Noun' does not match with 'Article', so we move on. Another edge, `A`, expects a transformation of 'Article Noun' and has a non-terminal of 'NP'. We have a match! A new edge will be added with 'NP' as its non-terminal (the non-terminal of `A`) and 'Noun' as the expected transformation (the rest of the expected transformation of `A`). +# +# You can view the source code by running the cell below: + +# %% +psource(Chart) + +# %% [markdown] +# ### Example +# +# We will use the grammar `E0` to parse the sentence "the stench is in 2 2". +# +# First, we need to build a `Chart` object: + +# %% +chart = Chart(E0) + +# %% [markdown] +# And then we simply call the `parses` function: + +# %% +print(chart.parses('the stench is in 2 2')) + +# %% [markdown] +# You can see which edges get added by setting the optional initialization argument `trace` to true. + +# %% +chart_trace = Chart(nlp.E0, trace=True) +chart_trace.parses('the stench is in 2 2') + +# %% [markdown] +# Let's try and parse a sentence that is not recognized by the grammar: + +# %% +print(chart.parses('the stench 2 2')) + +# %% [markdown] +# An empty list was returned. + +# %% [markdown] +# ## CYK Parse +# +# The *CYK Parsing Algorithm* (named after its inventors, Cocke, Younger, and Kasami) utilizes dynamic programming to parse sentences of grammar in *Chomsky Normal Form*. +# +# The CYK algorithm returns an *M x N x N* array (named *P*), where *N* is the number of words in the sentence and *M* the number of non-terminal symbols in the grammar. Each element in this array shows the probability of a substring being transformed from a particular non-terminal. To find the most probable parse of the sentence, a search in the resulting array is required. Search heuristic algorithms work well in this space, and we can derive the heuristics from the properties of the grammar. +# +# The algorithm in short works like this: There is an external loop that determines the length of the substring. Then the algorithm loops through the words in the sentence. For each word, it again loops through all the words to its right up to the first-loop length. The substring will work on in this iteration is the words from the second-loop word with the first-loop length. Finally, it loops through all the rules in the grammar and updates the substring's probability for each right-hand side non-terminal. + +# %% [markdown] +# ### Implementation +# +# The implementation takes as input a list of words and a probabilistic grammar (from the `ProbGrammar` class detailed above) in CNF and returns the table/dictionary *P*. An item's key in *P* is a tuple in the form `(Non-terminal, the start of a substring, length of substring)`, and the value is a `Tree` object. The `Tree` data structure has two attributes: `root` and `leaves`. `root` stores the value of current tree node and `leaves` is a list of children nodes which may be terminal states(words in the sentence) or a sub tree. +# +# For example, for the sentence "the monkey is dancing" and the substring "the monkey" an item can be `('NP', 0, 2): `, which means the first two words (the substring from index 0 and length 2) can be parse to a `NP` and the detailed operations are recorded by a `Tree` object. +# +# Before we continue, you can take a look at the source code by running the cell below: + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.nlp import * +from aima import nlp +from aima.notebook_utils import psource + +# %% +psource(CYK_parse) + +# %% [markdown] +# When updating the probability of a substring, we pick the max of its current one and the probability of the substring broken into two parts: one from the second-loop word with third-loop length, and the other from the first part's end to the remainder of the first-loop length. +# +# ### Example +# +# Let's build a probabilistic grammar in CNF: + +# %% +E_Prob_Chomsky = ProbGrammar("E_Prob_Chomsky", # A Probabilistic Grammar in CNF + ProbRules( + S = "NP VP [1]", + NP = "Article Noun [0.6] | Adjective Noun [0.4]", + VP = "Verb NP [0.5] | Verb Adjective [0.5]", + ), + ProbLexicon( + Article = "the [0.5] | a [0.25] | an [0.25]", + Noun = "robot [0.4] | sheep [0.4] | fence [0.2]", + Adjective = "good [0.5] | new [0.2] | sad [0.3]", + Verb = "is [0.5] | say [0.3] | are [0.2]" + )) + +# %% [markdown] +# Now let's see the probabilities table for the sentence "the robot is good": + +# %% +words = ['the', 'robot', 'is', 'good'] +grammar = E_Prob_Chomsky + +P = CYK_parse(words, grammar) +print(P) + +# %% [markdown] +# A `defaultdict` object is returned (`defaultdict` is basically a dictionary but with a default value/type). Keys are tuples in the form mentioned above and the values are the corresponding parse trees which demonstrates how the sentence will be parsed. Let's check the details of each parsing: + +# %% +parses = {k: p for k, p in P.items() if p} + +print(parses) + +# %% [markdown] +# Please note that each item in the returned dict represents a parsing strategy. For instance, `('Article', 0, 0): ['the']` means parsing the article at position 0 from the word `the`. For the key `'VP', 2, 3`, it is mapped to another `Tree` which means this is a nested parsing step. If we print this item in detail: + +# %% +# CYK_parse returns a probability table P[(symbol, start, length)] -> probability; +# show where the grammar can parse a VP (verb phrase) and with what probability +print({k: p for k, p in P.items() if k[0] == 'VP' and p}) + +# %% [markdown] +# So we can interpret this step as parsing the word at index 2 and 3 together('is' and 'good') as a verh phrase. + +# %% [markdown] +# ## A-star Parsing +# +# The CYK algorithm uses space of $O(n^2m)$ for the P and T tables, where n is the number of words in the sentence, and m is the number of nonterminal symbols in the grammar and takes time $O(n^3m)$. This is the best algorithm if we want to find the best parse and works for all possible context-free grammars. But actually, we only want to parse natural languages, not all possible grammars, which allows us to apply more efficient algorithms. +# +# By applying a-start search, we are using the state-space search and we can get $O(n)$ running time. In this situation, each state is a list of items (words or categories), the start state is a list of words, and a goal state is the single item S. +# +# In our code, we implemented a demonstration of `astar_search_parsing` which deals with the text parsing problem. By specifying different `words` and `gramma`, we can use this searching strategy to deal with different text parsing problems. The algorithm returns a boolean telling whether the input words is a sentence under the given grammar. +# +# For detailed implementation, please execute the following block: + +# %% +psource(astar_search_parsing) + +# %% [markdown] +# ### Example +# +# Now let's try "the wumpus is dead" example. First we need to define the grammar and words in the sentence. + +# %% +grammar = E0 +words = ['the', 'wumpus', 'is', 'dead'] + +# %% +astar_search_parsing(words, grammar) + +# %% [markdown] +# The algorithm returns a 'S' which means it treats the inputs as a sentence. If we change the order of words to make it unreadable: + +# %% +words_swaped = ["the", "is", "wupus", "dead"] +astar_search_parsing(words_swaped, grammar) + +# %% [markdown] +# Then the algorithm asserts that out words cannot be a sentence. + +# %% [markdown] +# ## Beam Search Parsing +# +# In the beam searching algorithm, we still treat the text parsing problem as a state-space searching algorithm. when using beam search, we consider only the b most probable alternative parses. This means we are not guaranteed to find the parse with the highest probability, but (with a careful implementation) the parser can operate in $O(n)$ time and still finds the best parse most of the time. A beam search parser with b = 1 is called a **deterministic parser**. +# +# ### Implementation +# +# In the beam search, we maintain a `frontier` which is a priority queue keep tracking of the current frontier of searching. In each step, we explore all the examples in `frontier` and saves the best n examples as the frontier of the exploration of the next step. +# +# For detailed implementation, please view with the following code: + +# %% +psource(beam_search_parsing) + +# %% [markdown] +# ### Example +# +# Let's try both the positive and negative wumpus example on this algorithm: + +# %% +beam_search_parsing(words, grammar) + +# %% +beam_search_parsing(words_swaped, grammar) diff --git a/notebooks/chapter22/nlp_apps.ipynb b/notebooks/chapter22/nlp_apps.ipynb index bd38efadf..58d0b9bef 100644 --- a/notebooks/chapter22/nlp_apps.ipynb +++ b/notebooks/chapter22/nlp_apps.ipynb @@ -1,5 +1,15 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path = [os.path.abspath('../../')] + sys.path # make the aima package importable\n" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -42,8 +52,8 @@ "metadata": {}, "outputs": [], "source": [ - "from utils import open_data\n", - "from text import *\n", + "from aima.utils import open_data\n", + "from aima.text import *\n", "\n", "flatland = open_data(\"EN-text/flatland.txt\").read()\n", "wordseq = words(flatland)\n", @@ -71,7 +81,7 @@ "metadata": {}, "outputs": [], "source": [ - "from learning import NaiveBayesLearner\n", + "from aima.learning import NaiveBayesLearner\n", "\n", "dist = {('English', 1): P_flatland, ('German', 1): P_faust}\n", "\n", @@ -253,8 +263,8 @@ "metadata": {}, "outputs": [], "source": [ - "from utils import open_data\n", - "from text import *\n", + "from aima.utils import open_data\n", + "from aima.text import *\n", "\n", "flatland = open_data(\"EN-text/flatland.txt\").read()\n", "wordseq = words(flatland)\n", @@ -282,7 +292,7 @@ "metadata": {}, "outputs": [], "source": [ - "from learning import NaiveBayesLearner\n", + "from aima.learning import NaiveBayesLearner\n", "\n", "dist = {('Abbott', 1): P_Abbott, ('Austen', 1): P_Austen}\n", "\n", @@ -395,8 +405,8 @@ "metadata": {}, "outputs": [], "source": [ - "from utils import open_data\n", - "from text import *\n", + "from aima.utils import open_data\n", + "from aima.text import *\n", "\n", "federalist = open_data(\"EN-text/federalist.txt\").read()" ] @@ -957,7 +967,7 @@ "metadata": {}, "outputs": [], "source": [ - "from learning import NaiveBayesLearner\n", + "from aima.learning import NaiveBayesLearner\n", "\n", "dist = {('company', 1): model_words_0, ('fruit', 1): model_words_1}\n", "\n", diff --git a/notebooks/chapter22/nlp_apps.py b/notebooks/chapter22/nlp_apps.py new file mode 100644 index 000000000..d3c96a1ae --- /dev/null +++ b/notebooks/chapter22/nlp_apps.py @@ -0,0 +1,534 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +import os, sys +sys.path = [os.path.abspath('../../')] + sys.path # make the aima package importable + + +# %% [markdown] +# # NATURAL LANGUAGE PROCESSING APPLICATIONS +# +# In this notebook we will take a look at some indicative applications of natural language processing. We will cover content from [`nlp.py`](https://github.com/aimacode/aima-python/blob/master/nlp.py) and [`text.py`](https://github.com/aimacode/aima-python/blob/master/text.py), for chapters 22 and 23 of Stuart Russel's and Peter Norvig's book [*Artificial Intelligence: A Modern Approach*](http://aima.cs.berkeley.edu/). + +# %% [markdown] +# ## CONTENTS +# +# * Language Recognition +# * Author Recognition +# * The Federalist Papers +# * Text Classification + +# %% [markdown] +# # LANGUAGE RECOGNITION +# +# A very useful application of text models (you can read more on them on the [`text notebook`](https://github.com/aimacode/aima-python/blob/master/text.ipynb)) is categorizing text into a language. In fact, with enough data we can categorize correctly mostly any text. That is because different languages have certain characteristics that set them apart. For example, in German it is very usual for 'c' to be followed by 'h' while in English we see 't' followed by 'h' a lot. +# +# Here we will build an application to categorize sentences in either English or German. +# +# First we need to build our dataset. We will take as input text in English and in German and we will extract n-gram character models (in this case, *bigrams* for n=2). For English, we will use *Flatland* by Edwin Abbott and for German *Faust* by Goethe. +# +# Let's build our text models for each language, which will hold the probability of each bigram occuring in the text. + +# %% +from aima.utils import open_data +from aima.text import * + +flatland = open_data("EN-text/flatland.txt").read() +wordseq = words(flatland) + +P_flatland = NgramCharModel(2, wordseq) + +faust = open_data("GE-text/faust.txt").read() +wordseq = words(faust) + +P_faust = NgramCharModel(2, wordseq) + +# %% [markdown] +# We can use this information to build a *Naive Bayes Classifier* that will be used to categorize sentences (you can read more on Naive Bayes on the [`learning notebook`](https://github.com/aimacode/aima-python/blob/master/learning.ipynb)). The classifier will take as input the probability distribution of bigrams and given a list of bigrams (extracted from the sentence to be classified), it will calculate the probability of the example/sentence coming from each language and pick the maximum. +# +# Let's build our classifier, with the assumption that English is as probable as German (the input is a dictionary with values the text models and keys the tuple `language, probability`): + +# %% +from aima.learning import NaiveBayesLearner + +dist = {('English', 1): P_flatland, ('German', 1): P_faust} + +nBS = NaiveBayesLearner(dist, simple=True) + + +# %% [markdown] +# Now we need to write a function that takes as input a sentence, breaks it into a list of bigrams and classifies it with the naive bayes classifier from above. +# +# Once we get the text model for the sentence, we need to unravel it. The text models show the probability of each bigram, but the classifier can't handle that extra data. It requires a simple *list* of bigrams. So, if the text model shows that a bigram appears three times, we need to add it three times in the list. Since the text model stores the n-gram information in a dictionary (with the key being the n-gram and the value the number of times the n-gram appears) we need to iterate through the items of the dictionary and manually add them to the list of n-grams. + +# %% +def recognize(sentence, nBS, n): + sentence = sentence.lower() + wordseq = words(sentence) + + P_sentence = NgramCharModel(n, wordseq) + + ngrams = [] + for b, p in P_sentence.dictionary.items(): + ngrams += [b]*p + + print(ngrams) + + return nBS(ngrams) + + +# %% [markdown] +# Now we can start categorizing sentences. + +# %% +recognize("Ich bin ein platz", nBS, 2) + +# %% +recognize("Turtles fly high", nBS, 2) + +# %% +recognize("Der pelikan ist hier", nBS, 2) + +# %% +recognize("And thus the wizard spoke", nBS, 2) + +# %% [markdown] +# You can add more languages if you want, the algorithm works for as many as you like! Also, you can play around with *n*. Here we used 2, but other numbers work too (even though 2 suffices). The algorithm is not perfect, but it has high accuracy even for small samples like the ones we used. That is because English and German are very different languages. The closer together languages are (for example, Norwegian and Swedish share a lot of common ground) the lower the accuracy of the classifier. + +# %% [markdown] +# ## AUTHOR RECOGNITION +# +# Another similar application to language recognition is recognizing who is more likely to have written a sentence, given text written by them. Here we will try and predict text from Edwin Abbott and Jane Austen. They wrote *Flatland* and *Pride and Prejudice* respectively. +# +# We are optimistic we can determine who wrote what based on the fact that Abbott wrote his novella on much later date than Austen, which means there will be linguistic differences between the two works. Indeed, *Flatland* uses more modern and direct language while *Pride and Prejudice* is written in a more archaic tone containing more sophisticated wording. +# +# Similarly with Language Recognition, we will first import the two datasets. This time though we are not looking for connections between characters, since that wouldn't give that great results. Why? Because both authors use English and English follows a set of patterns, as we show earlier. Trying to determine authorship based on this patterns would not be very efficient. +# +# Instead, we will abstract our querying to a higher level. We will use words instead of characters. That way we can more accurately pick at the differences between their writing style and thus have a better chance at guessing the correct author. +# +# Let's go right ahead and import our data: + +# %% +from aima.utils import open_data +from aima.text import * + +flatland = open_data("EN-text/flatland.txt").read() +wordseq = words(flatland) + +P_Abbott = UnigramWordModel(wordseq, 5) + +pride = open_data("EN-text/pride.txt").read() +wordseq = words(pride) + +P_Austen = UnigramWordModel(wordseq, 5) + +# %% [markdown] +# This time we set the `default` parameter of the model to 5, instead of 0. If we leave it at 0, then when we get a sentence containing a word we have not seen from that particular author, the chance of that sentence coming from that author is exactly 0 (since to get the probability, we multiply all the separate probabilities; if one is 0 then the result is also 0). To avoid that, we tell the model to add 5 to the count of all the words that appear. +# +# Next we will build the Naive Bayes Classifier: + +# %% +from aima.learning import NaiveBayesLearner + +dist = {('Abbott', 1): P_Abbott, ('Austen', 1): P_Austen} + +nBS = NaiveBayesLearner(dist, simple=True) + + +# %% [markdown] +# Now that we have build our classifier, we will start classifying. First, we need to convert the given sentence to the format the classifier needs. That is, a list of words. + +# %% +def recognize(sentence, nBS): + sentence = sentence.lower() + sentence_words = words(sentence) + + return nBS(sentence_words) + + +# %% [markdown] +# First we will input a sentence that is something Abbott would write. Note the use of square and the simpler language. + +# %% +recognize("the square is mad", nBS) + +# %% [markdown] +# The classifier correctly guessed Abbott. +# +# Next we will input a more sophisticated sentence, similar to the style of Austen. + +# %% +recognize("a most peculiar acquaintance", nBS) + +# %% [markdown] +# The classifier guessed correctly again. +# +# You can try more sentences on your own. Unfortunately though, since the datasets are pretty small, chances are the guesses will not always be correct. + +# %% [markdown] +# ## THE FEDERALIST PAPERS +# +# Let's now take a look at a harder problem, classifying the authors of the [Federalist Papers](https://en.wikipedia.org/wiki/The_Federalist_Papers). The *Federalist Papers* are a series of papers written by Alexander Hamilton, James Madison and John Jay towards establishing the United States Constitution. +# +# What is interesting about these papers is that they were all written under a pseudonym, "Publius", to keep the identity of the authors a secret. Only after Hamilton's death, when a list was found written by him detailing the authorship of the papers, did the rest of the world learn what papers each of the authors wrote. After the list was published, Madison chimed in to make a couple of corrections: Hamilton, Madison said, hastily wrote down the list and assigned some papers to the wrong author! +# +# Here we will try and find out who really wrote these mysterious papers. +# +# To solve this we will learn from the undisputed papers to predict the disputed ones. First, let's read the texts from the file: + +# %% +from aima.utils import open_data +from aima.text import * + +federalist = open_data("EN-text/federalist.txt").read() + +# %% [markdown] +# Let's see how the text looks. We will print the first 500 characters: + +# %% +federalist[:500] + +# %% [markdown] +# It seems that the text file opens with a license agreement, hardly useful in our case. In fact, the license spans 113 words, while there is also a licensing agreement at the end of the file, which spans 3098 words. We need to remove them. To do so, we will first convert the text into words, to make our lives easier. + +# %% +wordseq = words(federalist) +wordseq = wordseq[114:-3098] + +# %% [markdown] +# Let's now take a look at the first 100 words: + +# %% +' '.join(wordseq[:100]) + +# %% [markdown] +# Much better. +# +# As with any Natural Language Processing problem, it is prudent to do some text pre-processing and clean our data before we start building our model. Remember that all the papers are signed as 'Publius', so we can safely remove that word, since it doesn't give us any information as to the real author. +# +# NOTE: Since we are only removing a single word from each paper, this step can be skipped. We add it here to show that processing the data in our hands is something we should always be considering. Oftentimes pre-processing the data in just the right way is the difference between a robust model and a flimsy one. + +# %% +wordseq = [w for w in wordseq if w != 'publius'] + +# %% [markdown] +# Now we have to separate the text from a block of words into papers and assign them to their authors. We can see that each paper starts with the word 'federalist', so we will split the text on that word. +# +# The disputed papers are the papers from 49 to 58, from 18 to 20 and paper 64. We want to leave these papers unassigned. Also, note that there are two versions of paper 70; both from Hamilton. +# +# Finally, to keep the implementation intuitive, we add a `None` object at the start of the `papers` list to make the list index match up with the paper numbering (for example, `papers[5]` now corresponds to paper no. 5 instead of the paper no.6 in the 0-indexed Python). + +# %% +import re + +papers = re.split(r'federalist\s', ' '.join(wordseq)) +papers = [p for p in papers if p not in ['', ' ']] +papers = [None] + papers + +disputed = list(range(49, 58+1)) + [18, 19, 20, 64] +jay, madison, hamilton = [], [], [] +for i, p in enumerate(papers): + if i in disputed or i == 0: + continue + + if 'jay' in p: + jay.append(p) + elif 'madison' in p: + madison.append(p) + else: + hamilton.append(p) + +len(jay), len(madison), len(hamilton) + +# %% [markdown] +# As we can see, from the undisputed papers Jay wrote 4, Madison 17 and Hamilton 51 (+1 duplicate). Let's now build our word models. The Unigram Word Model again will come in handy. + +# %% +hamilton = ''.join(hamilton) +hamilton_words = words(hamilton) +P_hamilton = UnigramWordModel(hamilton_words, default=1) + +madison = ''.join(madison) +madison_words = words(madison) +P_madison = UnigramWordModel(madison_words, default=1) + +jay = ''.join(jay) +jay_words = words(jay) +P_jay = UnigramWordModel(jay_words, default=1) + +# %% [markdown] +# Now it is time to build our new Naive Bayes Learner. It is very similar to the one found in `learning.py`, but with an important difference: it doesn't classify an example, but instead returns the probability of the example belonging to each class. This will allow us to not only see to whom a paper belongs to, but also the probability of authorship as well. +# We will build two versions of Learners, one will multiply probabilities as is and other will add the logarithms of them. +# +# Finally, since we are dealing with long text and the string of probability multiplications is long, we will end up with the results being rounded to 0 due to floating point underflow. To work around this problem we will use the built-in Python library `decimal`, which allows as to set decimal precision to much larger than normal. +# +# Note that the logarithmic learner will compute a negative likelihood since the logarithm of values less than 1 will be negative. +# Thus, the author with the lesser magnitude of proportion is more likely to have written that paper. +# +# + +# %% +import random +import decimal +import math +from decimal import Decimal + +decimal.getcontext().prec = 100 + +def precise_product(numbers): + result = 1 + for x in numbers: + result *= Decimal(x) + return result + +def log_product(numbers): + result = 0.0 + for x in numbers: + result += math.log(x) + return result + +def NaiveBayesLearner(dist): + """A simple naive bayes classifier that takes as input a dictionary of + Counter distributions and can then be used to find the probability + of a given item belonging to each class. + The input dictionary is in the following form: + ClassName: Counter""" + attr_dist = {c_name: count_prob for c_name, count_prob in dist.items()} + + def predict(example): + """Predict the probabilities for each class.""" + def class_prob(target, e): + attr = attr_dist[target] + return precise_product([attr[a] for a in e]) + + pred = {t: class_prob(t, example) for t in dist.keys()} + + total = sum(pred.values()) + for k, v in pred.items(): + pred[k] = v / total + + return pred + + return predict + +def NaiveBayesLearnerLog(dist): + """A simple naive bayes classifier that takes as input a dictionary of + Counter distributions and can then be used to find the probability + of a given item belonging to each class. It will compute the likelihood by adding the logarithms of probabilities. + The input dictionary is in the following form: + ClassName: Counter""" + attr_dist = {c_name: count_prob for c_name, count_prob in dist.items()} + + def predict(example): + """Predict the probabilities for each class.""" + def class_prob(target, e): + attr = attr_dist[target] + return log_product([attr[a] for a in e]) + + pred = {t: class_prob(t, example) for t in dist.keys()} + + total = -sum(pred.values()) + for k, v in pred.items(): + pred[k] = v/total + + return pred + + return predict + + + +# %% [markdown] +# Next we will build our Learner. Note that even though Hamilton wrote the most papers, that doesn't make it more probable that he wrote the rest, so all the class probabilities will be equal. We can change them if we have some external knowledge, which for this tutorial we do not have. + +# %% +dist = {('Madison', 1): P_madison, ('Hamilton', 1): P_hamilton, ('Jay', 1): P_jay} +nBS = NaiveBayesLearner(dist) +nBSL = NaiveBayesLearnerLog(dist) + + +# %% [markdown] +# As usual, the `recognize` function will take as input a string and after removing capitalization and splitting it into words, will feed it into the Naive Bayes Classifier. + +# %% +def recognize(sentence, nBS): + return nBS(words(sentence.lower())) + + +# %% [markdown] +# Now we can start predicting the disputed papers: + +# %% +print('\nStraightforward Naive Bayes Learner\n') +for d in disputed: + probs = recognize(papers[d], nBS) + results = ['{}: {:.4f}'.format(name, probs[(name, 1)]) for name in 'Hamilton Madison Jay'.split()] + print('Paper No. {}: {}'.format(d, ' '.join(results))) + +print('\nLogarithmic Naive Bayes Learner\n') +for d in disputed: + probs = recognize(papers[d], nBSL) + results = ['{}: {:.6f}'.format(name, probs[(name, 1)]) for name in 'Hamilton Madison Jay'.split()] + print('Paper No. {}: {}'.format(d, ' '.join(results))) + + + +# %% [markdown] +# We can see that both learners classify the papers identically. Because of underflow in the straightforward learner, only one author remains with a positive value. The log learner is more accurate with marginal differences between all the authors. +# +# This is a simple approach to the problem and thankfully researchers are fairly certain that papers 49-58 were all written by Madison, while 18-20 were written in collaboration between Hamilton and Madison, with Madison being credited for most of the work. Our classifier is not that far off. It correctly identifies the papers written by Madison, even the ones in collaboration with Hamilton. +# +# Unfortunately, it misses paper 64. Consensus is that the paper was written by John Jay, while our classifier believes it was written by Hamilton. The classifier is wrong there because it does not have much information on Jay's writing; only 4 papers. This is one of the problems with using unbalanced datasets such as this one, where information on some classes is sparser than information on the rest. To avoid this, we can add more writings for Jay and Madison to end up with an equal amount of data for each author. + +# %% [markdown] +# ## Text Classification + +# %% [markdown] +# **Text Classification** is assigning a category to a document based on the content of the document. Text Classification is one of the most popular and fundamental tasks of Natural Language Processing. Text classification can be applied on a variety of texts like *Short Documents* (like tweets, customer reviews, etc.) and *Long Document* (like emails, media articles, etc.). +# +# We already have seen an example of Text Classification in the above tasks like Language Identification, Author Recognition and Federalist Paper Identification. +# +# ### Applications +# Some of the broad applications of Text Classification are:- +# - Language Identification +# - Author Recognition +# - Sentiment Analysis +# - Spam Mail Detection +# - Topic Labelling +# - Word Sense Disambiguation +# +# ### Use Cases +# Some of the use cases of Text classification are:- +# - Social Media Monitoring +# - Brand Monitoring +# - Auto-tagging of user queries +# +# For Text Classification, we would be using the Naive Bayes Classifier. The reasons for using Naive Bayes Classifier are:- +# - Being a probabilistic classifier, therefore, will calculate the probability of each category +# - It is fast, reliable and accurate +# - Naive Bayes Classifiers have already been used to solve many Natural Language Processing (NLP) applications. +# +# Here we would here be covering an example of **Word Sense Disambiguation** as an application of Text Classification. It is used to remove the ambiguity of a given word if the word has two different meanings. +# +# As we know that we would be working on determining whether the word *apple* in a sentence refers to `fruit` or to a `company`. + +# %% [markdown] +# **Step 1:- Defining the dataset** +# +# The dataset has been defined here so that everything is clear and can be tested with other things as well. + +# %% +train_data = [ + "Apple targets big business with new iOS 7 features. Finally... A corp iTunes account!", + "apple inc is searching for people to help and try out all their upcoming tablet within our own net page No.", + "Microsoft to bring Xbox and PC games to Apple, Android phones: Report: Microsoft Corp", + "When did green skittles change from lime to green apple?", + "Myra Oltman is the best. I told her I wanted to learn how to make apple pie, so she made me a kit!", + "Surreal Sat in a sewing room, surrounded by crap, listening to beautiful music eating apple pie." +] + +train_target = [ + "company", + "company", + "company", + "fruit", + "fruit", + "fruit", +] + +class_0 = "company" +class_1 = "fruit" + +test_data = [ + "Apple Inc. supplier Foxconn demos its own iPhone-compatible smartwatch", + "I now know how to make a delicious apple pie thanks to the best teachers ever" +] + +# %% [markdown] +# **Step 2:- Preprocessing the dataset** +# +# In this step, we would be doing some preprocessing on the dataset like breaking the sentence into words and converting to lower case. +# +# We already have a `words(sent)` function defined in `text.py` which does the task of splitting the sentence into words. + +# %% +train_data_processed = [words(i) for i in train_data] + +# %% [markdown] +# **Step 3:- Feature Extraction from the text** +# +# Now we would be extracting features from the text like extracting the set of words used in both the categories i.e. `company` and `fruit`. +# +# The frequency of a word would help in calculating the probability of that word being in a particular class. + +# %% +words_0 = [] +words_1 = [] + +for sent, tag in zip(train_data_processed, train_target): + if(tag == class_0): + words_0 += sent + elif(tag == class_1): + words_1 += sent + +print("Number of words in `{}` class: {}".format(class_0, len(words_0))) +print("Number of words in `{}` class: {}".format(class_1, len(words_1))) + +# %% [markdown] +# As you might have observed, that our dataset is equally balanced, i.e. we have an equal number of words in both the classes. + +# %% [markdown] +# **Step 4:- Building the Naive Bayes Model** +# +# Using the Naive Bayes classifier we can calculate the probability of a word in `company` and `fruit` class and then multiplying all of them to get the probability of that sentence belonging each of the given classes. But if a word is not in our dictionary then this leads to the probability of that word belonging to that class becoming zero. For example:- the word *Foxconn* is not in the dictionary of any of the classes. Due to this, the probability of word *Foxconn* being in any of these classes becomes zero, and since all the probabilities are multiplied, this leads to the probability of that sentence belonging to any of the classes becoming zero. +# +# To solve the problem we need to use **smoothing**, i.e. providing a minimum non-zero threshold probability to every word that we come across. +# +# The `UnigramWordModel` class has implemented smoothing by taking an additional argument from the user, i.e. the minimum frequency that we would be giving to every word even if it is new to the dictionary. + +# %% +model_words_0 = UnigramWordModel(words_0, 1) +model_words_1 = UnigramWordModel(words_1, 1) + +# %% [markdown] +# Now we would be building the Naive Bayes model. For that, we would be making `dist` as we had done earlier in the Authorship Recognition Task. + +# %% +from aima.learning import NaiveBayesLearner + +dist = {('company', 1): model_words_0, ('fruit', 1): model_words_1} + +nBS = NaiveBayesLearner(dist, simple=True) + + +# %% [markdown] +# **Step 5:- Predict the class of a sentence** +# +# Now we will be writing a function that does pre-process of the sentences which we have taken for testing. And then predicting the class of every sentence in the document. + +# %% +def recognize(sentence, nBS): + sentence_words = words(sentence) + return nBS(sentence_words) + + +# %% +# predicting the class of sentences in the test set +for i in test_data: + print(i + "\t-" + recognize(i, nBS)) + +# %% [markdown] +# You might have observed that the predictions made by the model are correct and we are able to differentiate between sentences of different classes. You can try more sentences on your own. Unfortunately though, since the datasets are pretty small, chances are the guesses will not always be correct. +# +# As you might have observed, the above method is very much similar to the Author Recognition, which is also a type of Text Classification. Like this most of Text Classification have the same underlying structure and follow a similar procedure. diff --git a/notebooks/chapter24/Image Edge Detection.ipynb b/notebooks/chapter24/Image Edge Detection.ipynb index 6429943a1..db3c47eef 100644 --- a/notebooks/chapter24/Image Edge Detection.ipynb +++ b/notebooks/chapter24/Image Edge Detection.ipynb @@ -27,8 +27,8 @@ "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from perception4e import *\n", - "from notebook4e import *" + "from aima.perception import *\n", + "from aima.notebook_utils import *" ] }, { @@ -244,6 +244,7 @@ "metadata": {}, "outputs": [], "source": [ + "gaussian_filter = gaussian_kernel_2D() # 2-D Gaussian smoothing kernel\n", "x_filter = scipy.signal.convolve2d(gaussian_filter, np.asarray([[1, -1]]), 'same')\n", "y_filter = scipy.signal.convolve2d(gaussian_filter, np.asarray([[1], [-1]]), 'same')" ] diff --git a/notebooks/chapter24/Image Edge Detection.py b/notebooks/chapter24/Image Edge Detection.py new file mode 100644 index 000000000..7d72af199 --- /dev/null +++ b/notebooks/chapter24/Image Edge Detection.py @@ -0,0 +1,171 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Edge Detection +# +# Edge detection is one of the earliest and popular image processing tasks. Edges are straight lines or curves in the image plane across which there is a “significant” change in image brightness. The goal of edge detection is to abstract away from the messy, multi-megabyte image and towards a more compact, abstract representation. +# +# There are multiple ways to detect an edge in an image but the most may be grouped into two categories, gradient, and Laplacian. Here we will introduce some algorithms among them and their intuitions. First, let's import the necessary packages. +# + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.perception import * +from aima.notebook_utils import * + +# %% [markdown] +# ## Gradient Edge Detection +# +# Because edges correspond to locations in images where the brightness undergoes a sharp change, a naive idea would be to differentiate the image and look for places where the magnitude of the derivative is large. For many simple cases with regular geometry topologies, this simple method could work. +# +# Here we introduce a 2D function $f(x,y)$ to represent the pixel values on a 2D image plane. Thus this method follows the math intuition below: +# +# $$\frac{\partial f(x,y)}{\partial x} = \lim_{\epsilon \rightarrow 0} \frac{f(x+\epsilon,y)-\partial f(x,y)}{\epsilon}$$ + +# %% [markdown] +# Above is exactly the definition of the edges in an image. In real cases, $\epsilon$ cannot be 0. We can only investigate the pixels in the neighborhood of the current one to get the derivation of a pixel. Thus the previous formula becomes + +# %% [markdown] +# $$\frac{\partial f(x,y)}{\partial x} = \lim_{\epsilon \rightarrow 0} \frac{f(x+1,y)-\partial f(x,y)}{1}$$ + +# %% [markdown] +# To implement the above formula, we can simply apply a filter $[1,-1]$ to extract the differentiated image. For the case of derivation in the y-direction, we can transpose the above filter and apply it to the original image. The relation of partial deviation of the direction of edges are summarized in the following picture: + +# %% [markdown] +# + +# %% [markdown] +# ### Implementation +# +# We implemented an edge detector using a gradient method as `gradient_edge_detector` in `perceptron.py`. There are two filters defined as $[[1, -1]], [[1], [-1]]$ to extract edges in x and y directions respectively. The filters are applied to an image using `convolve2d` method in `scipy.single` package. The image passed into the function needs to be in the form of `numpy.ndarray` or an iterable object that can be transformed into a `ndarray`. +# +# To view the detailed implementation, please execute the following block + +# %% +psource(gradient_edge_detector) + +# %% [markdown] +# ### Example +# +# Now let's try the detector for real case pictures. First, we will show the original picture before edge detection: + +# %% [markdown] +# +# +# We will use `matplotlib` to read the image as a numpy ndarray: + +# %% +import matplotlib.pyplot as plt +import numpy as np +import matplotlib.image as mpimg + +im =mpimg.imread('images/stapler.png') +print("image height:", len(im)) +print("image width:", len(im[0])) + +# %% [markdown] +# The code shows we get an image with a size of $787*590$. `gaussian_derivative_edge_detector` can extract images in both x and y direction and then put them together in a ndarray: + +# %% +edges = gradient_edge_detector(im) +print("image height:", len(edges)) +print("image width:", len(edges[0])) + +# %% [markdown] +# The edges are in the same shape of the original image. Now we will try print out the image, we implemented a `show_edges` function to do this: + +# %% +show_edges(edges) + +# %% [markdown] +# We can see that the edges are extracted well. We can use the result of this simple algorithm as a baseline and compare the results of other algorithms to it. + +# %% [markdown] +# ## Derivative of Gaussian +# +# When considering the situation when there is strong noise in an image, the ups and downs of the noise will induce strong peaks in the gradient profile. In order to be more noise-robust, an algorithm introduced a Gaussian filter before applying the gradient filer. In another way, convolving a gradient filter after a Gaussian filter equals to convolving a derivative of Gaussian filter directly to the image. +# +# Here is how this intuition is represented in math: + +# %% [markdown] +# $$(I\bigotimes g)\bigotimes h = I\bigotimes (g\bigotimes h) $$ + +# %% [markdown] +# Where $I$ is the image, $g$ is the gradient filter and $h$ is the Gaussian filter. A two dimensional derivative of Gaussian kernel is dipicted in the following figure: + +# %% [markdown] +# + +# %% [markdown] +# ### Implementation +# +# In our implementation, we initialize Gaussian filters by applying the 2D Gaussian function on a given size of the grid which is the same as the kernel size. Then the x and y direction image filters are calculated as the convolution of the Gaussian filter and the gradient filter: + +# %% +gaussian_filter = gaussian_kernel_2D() # 2-D Gaussian smoothing kernel +x_filter = scipy.signal.convolve2d(gaussian_filter, np.asarray([[1, -1]]), 'same') +y_filter = scipy.signal.convolve2d(gaussian_filter, np.asarray([[1], [-1]]), 'same') + +# %% [markdown] +# Then both of the filters are applied to the input image to extract the x and y direction edges. For detailed implementation, please view by: + +# %% +psource(gaussian_derivative_edge_detector) + +# %% [markdown] +# ### Example +# +# Now let's try again on the stapler image and plot the extracted edges: + +# %% +e = gaussian_derivative_edge_detector(im) +show_edges(e) + +# %% [markdown] +# We can see that the extracted edges are more similar to the original one. The resulting edges are depending on the initial Gaussian kernel size and how it is initialized. + +# %% [markdown] +# ## Laplacian Edge Detector +# +# Laplacian is somewhat different from the methods we have discussed so far. Unlike the above kernels which are only using the first-order derivatives of the original image, the Laplacian edge detector uses the second-order derivatives of the image. Using the second derivatives also makes the detector very sensitive to noise. Thus the image is often Gaussian smoothed before applying the Laplacian filter. +# +# Here are how the Laplacian detector looks like: + +# %% [markdown] +# + +# %% [markdown] +# ### Implementation +# +# There are two commonly used small Laplacian kernels: + +# %% [markdown] +# + +# %% [markdown] +# In our implementation, we used the first one as the default kernel and convolve it with the original image using packages provided by `scipy`. + +# %% [markdown] +# ### Example +# +# Now let's use the Laplacian edge detector to extract edges of the staple example: + +# %% +e = laplacian_edge_detector(im) +show_edges(e) + +# %% [markdown] +# The edges are more subtle but meanwhile showing small zigzag structures that may be affected by noise. However, the overall performance of edge extracting is still promising. diff --git a/notebooks/chapter24/Image Segmentation.ipynb b/notebooks/chapter24/Image Segmentation.ipynb index d0a8b36af..681a3f48f 100644 --- a/notebooks/chapter24/Image Segmentation.ipynb +++ b/notebooks/chapter24/Image Segmentation.ipynb @@ -62,8 +62,8 @@ "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from perception4e import *\n", - "from notebook4e import *\n", + "from aima.perception import *\n", + "from aima.notebook_utils import *\n", "import matplotlib.pyplot as plt" ] }, diff --git a/notebooks/chapter24/Image Segmentation.py b/notebooks/chapter24/Image Segmentation.py new file mode 100644 index 000000000..959eb97f6 --- /dev/null +++ b/notebooks/chapter24/Image Segmentation.py @@ -0,0 +1,165 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Segmentation +# +# Image segmentation is another early as well as an important image processing task. Segmentation is the process of breaking an image into groups, based on similarities of the pixels. Pixels can be similar to each other in multiple ways like brightness, color, or texture. The segmentation algorithms are to find a partition of the image into sets of similar pixels which usually indicating objects or certain scenes in an image. +# +# The segmentations in this chapter can be categorized into two complementary ways: one focussing on detecting the boundaries of these groups, and the other on detecting the groups themselves, typically called regions. We will introduce some principles of some algorithms in this notebook to present the basic ideas in segmentation. + +# %% [markdown] +# ## Probability Boundary Detection +# +# A boundary curve passing through a pixel $(x,y)$ in an image will have an orientation $\theta$, so we can formulize boundary detection problem as a classification problem. Based on features from a local neighborhood, we want to compute the probability $P_b(x,y,\theta)$ that indeed there is a boundary curve at that pixel along that orientation. +# +# One of the sampling ways to calculate $P_b(x,y,\theta)$ is to generate a series sub-divided into two half disks by a diameter oriented at θ. If there is a boundary at (x, y, θ) the two half disks might be expected to differ significantly in their brightness, color, and texture. For detailed proof of this algorithm, please refer to this [article](https://people.eecs.berkeley.edu/~malik/papers/MFM-boundaries.pdf). + +# %% [markdown] +# ### Implementation +# +# We implemented a simple demonstration of probability boundary detector as `probability_contour_detection` in `perception.py`. This method takes three inputs: +# +# - image: an image already transformed into the type of numpy ndarray. +# - discs: a list of sub-divided discs. +# - threshold: the standard to tell whether the difference between intensities of two discs implying there is a boundary passing the current pixel. +# +# we also provide a helper function `gen_discs` to gen a list of discs. It takes `scales` as the number of sizes of discs will be generated which is default 1. Please note that for each scale size, there will be 8 sub discs generated which are in the horizontal, verticle and two diagnose directions. Another `init_scale` indicates the starting scale size. For instance, if we use `init_scale` of 10 and `scales` of 2, then scales of sizes of 10 and 20 will be generated and thus we will have 16 sub-divided scales. + +# %% [markdown] +# ### Example +# +# Now let's demonstrate the inner mechanism with our navie implementation of the algorithm. First, let's generate some very simple test images. We already generated a grayscale image with only three steps of gray scales in `perceptron.py`: + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.perception import * +from aima.notebook_utils import * +import matplotlib.pyplot as plt + +# %% [markdown] +# Let's take a look at it: + +# %% +plt.imshow(gray_scale_image, cmap='gray', vmin=0, vmax=255) +plt.axis('off') +plt.show() + +# %% [markdown] +# You can also generate your own grayscale images by calling `gen_gray_scale_picture` and pass the image size and grayscale levels needed: + +# %% +gray_img = gen_gray_scale_picture(100, 5) +plt.imshow(gray_img, cmap='gray', vmin=0, vmax=255) +plt.axis('off') +plt.show() + +# %% [markdown] +# Now let's generate the discs we are going to use as sampling masks to tell the intensity difference between two half of the care area of an image. We can generate the discs of size 100 pixels and show them: + +# %% +discs = gen_discs(100, 1) +fig=plt.figure(figsize=(10, 10)) +for i in range(8): + img = discs[0][i] + fig.add_subplot(1, 8, i+1) + plt.axis('off') + plt.imshow(img, cmap='gray', vmin=0, vmax=255) +plt.show() + +# %% [markdown] +# The white part of disc images is of value 1 while dark places are of value 0. Thus convolving the half-disc image with the corresponding area of an image will yield only half of its content. Of course, discs of size 100 is too large for an image of the same size. We will use discs of size 10 and pass them to the detector. + +# %% +discs = gen_discs(10, 1) +contours = probability_contour_detection(gray_img, discs[0]) + +# %% +show_edges(contours) + +# %% [markdown] +# As we are using discs of size 10 and some boundary conditions are not dealt with in our naive algorithm, the extracted contour has a bold edge with missings near the image border. But the main structures of contours are extracted correctly which shows the ability of this algorithm. + +# %% [markdown] +# ## Group Contour Detection +# +# The alternative approach is based on trying to “cluster” the pixels into regions based on their brightness, color and texture properties. There are multiple grouping algorithms and the simplest and the most popular one is k-means clustering. Basically, the k-means algorithm starts with k randomly selected centroids, which are used as the beginning points for every cluster, and then performs iterative calculations to optimize the positions of the centroids. For a detailed description, please refer to the chapter of unsupervised learning. + +# %% [markdown] +# ### Implementation +# +# Here we will use the module of `cv2` to perform K-means clustering and show the image. To use it you need to have `opencv-python` pre-installed. Using `cv2.kmeans` is quite simple, you only need to specify the input image and the characters of cluster initialization. Here we use modules provide by `cv2` to initialize the clusters. `cv2.KMEANS_RANDOM_CENTERS` can randomly generate centers of clusters and the cluster number is defined by the user. +# +# `kmeans` method will return the centers and labels of clusters, which can be used to classify pixels of an image. Let's try this algorithm again on the small grayscale image we imported: + +# %% +contours = group_contour_detection(gray_scale_image, 3) + +# %% [markdown] +# Now let's show the extracted contours: + +# %% +show_edges(contours) + +# %% [markdown] +# It is not obvious as our generated image already has very clear boundaries. Let's apply the algorithm on the stapler example to see whether it will be more obvious: + +# %% +import numpy as np +import matplotlib.image as mpimg + +stapler_img = mpimg.imread('images/stapler.png', format="gray") + +# %% +contours = group_contour_detection(stapler_img, 5) +plt.axis('off') +plt.imshow(contours, cmap="gray") + +# %% [markdown] +# The segmentation is very rough when using only 5 clusters. Adding to the cluster number will increase the degree of subtle of each group thus the whole picture will be more alike the original one: + +# %% +contours = group_contour_detection(stapler_img, 15) +plt.axis('off') +plt.imshow(contours, cmap="gray") + +# %% [markdown] +# ## Minimum Cut Segmentation +# +# Another way to do clustering is by applying the minimum cut algorithm in graph theory. Roughly speaking, the criterion for partitioning the graph is to minimize the sum of weights of connections across the groups and maximize the sum of weights of connections within the groups. +# +# ### Implementation +# +# There are several kinds of representations of a graph such as a matrix or an adjacent list. Here we are using a util function `image_to_graph` to convert an image in ndarray type to an adjacent list. It is integrated into the class of `Graph`. `Graph` takes an image as input and offer the following implementations of some graph theory algorithms: + +# %% [markdown] +# - bfs: performing bread searches from a source vertex to a terminal vertex. Return `True` if there is a path between the two nodes else return `False`. +# +# - min_cut: performing minimum cut on a graph from a source vertex to sink vertex. The method will return the edges to be cut. +# +# Now let's try the minimum cut method on a simple generated grayscale image of size 10: + +# %% +image = gen_gray_scale_picture(size=10, level=2) +show_edges(image) + +# %% +graph = Graph(image) +graph.min_cut((0,0), (9,9)) + +# %% [markdown] +# There are ten edges to be cut. By cutting the ten edges, we can separate the pictures into two parts by the pixel intensities. + +# %% diff --git a/notebooks/chapter24/Objects in Images.ipynb b/notebooks/chapter24/Objects in Images.ipynb index 03fc92235..01ae3af9d 100644 --- a/notebooks/chapter24/Objects in Images.ipynb +++ b/notebooks/chapter24/Objects in Images.ipynb @@ -63,8 +63,8 @@ "source": [ "import os, sys\n", "sys.path = [os.path.abspath(\"../../\")] + sys.path\n", - "from perception4e import *\n", - "from notebook4e import *" + "from aima.perception import *\n", + "from aima.notebook_utils import *" ] }, { diff --git a/notebooks/chapter24/Objects in Images.py b/notebooks/chapter24/Objects in Images.py new file mode 100644 index 000000000..1a471a335 --- /dev/null +++ b/notebooks/chapter24/Objects in Images.py @@ -0,0 +1,177 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Objects in Images +# +# There are two key problems shaping all thinking about objects in images: image classification and object detection. They are much more complicated than the problems like boundary detection. Thus more complicated models are needed to deal with the problems even challenging to human's eyes. For the image classification problem, we use a convolutional neural network to extract patterns of an image. For the case of object detection, we use Recursive CNN, which can assist to find the locations of objects of a set of classes in the image. These two models will be detailly introduced in the following sections. + +# %% [markdown] +# ## Image Classification +# +# Image classification is a task where we decide what class an image of a fixed size belongs to. Traditional ways convert grayscale or RGB images into a list of numbers representing the intensity of that pixel and then do classification job on top of this procedure. Currently One of the most popular techniques used in improving the accuracy of traditional image classification ways is Convolutional Neural Networks which is more similar to the principle of human seeing things. +# +# CNN is different from other neural networks in that it has a convolution layer at the beginning. Instead of converting the image to an array of numbers, the image is broken up into some sections by the convolutional kernel, the machine then tries to predict what each section is. Finally, the computer tries to predict what’s in the picture based on the votes of all sections. +# +# A classic CNN would has the following architecture: +# +# $$Input ->Convolution ->ReLU ->Convolution ->ReLU ->Pooling -> ... -> Fully Connected$$ + +# %% [markdown] +# CNNs have an input layer, an output layer, as well as hidden layers. The hidden layers usually consist of convolutional layers, ReLU layers, pooling layers, and fully connected layers. Their functionality can be briefly described as : +# +# - Convolutional layers apply a convolution operation to the input. This layer extracted the features of an image that are used for further processing or classification. +# - Pooling layers combines the outputs of clusters of neurons into a single neuron in the next layer. +# - Fully connected layers connect every neuron in one layer to every neuron in the next layer. +# - RELU layer will apply an elementwise activation function, such as the max(0,x) thresholding at zero. +# +# For a more detailed guidance, please refer to the [course note](http://cs231n.github.io/convolutional-networks/) of Stanford. + +# %% [markdown] +# ### Implementation +# +# We implemented a simple CNN with a package of keras which is an advanced level API of TensorFlow. For a more detailed guide, please refer to our previous notebooks or the [official guide](https://keras.io/). The source code can be viewed by importing the necessary packages and executing the following block: + +# %% +import os, sys +sys.path = [os.path.abspath("../../")] + sys.path +from aima.perception import * +from aima.notebook_utils import * + +# %% +psource(simple_convnet) + +# %% [markdown] +# The `simple_convnet` function takes two inputs and returns a Keras `Sequential` model. The input attributes are the number of hidden layers and the number of output classes. One hidden layer is defined as a pair of convolutional layer and max-pooling layer: + +# %% +model.add(Conv2D(32, (2, 2), padding='same', kernel_initializer='random_uniform')) +model.add(MaxPooling2D(padding='same')) + +# %% [markdown] +# The convolution kernel size we used is of size 2x2 and it is initialized by applying random uniform distribution. We also implemented a helper demonstration function `train_model` to show how the convolutional net performs on a certain dataset. This function only takes a CNN model as input and feeds an MNIST dataset into it. The MNIST dataset is split into the training set, validation set and test set by the number of 1000, 100 and 100. + +# %% [markdown] +# ### Example +# +# Now let's try the simple CNN on the MNIST dataset. For the MNIST dataset, there are totally 10 classes: 0-9. Thus we will build a CNN with 10 prediction classes: + +# %% +cnn_model = simple_convnet(size=3, num_classes=10) + +# %% [markdown] +# The brief description of the CNN architecture is described as above. Please note that each layer has the number of parameters needs to be trained. More parameters meaning longer to train the network on a dataset. We have 3 convolutional layers and 3 max-pooling layers in total and more than 10000 parameters to train. +# +# Now lets train the model for 5 epochs with the pre-defined training parameters: `epochs=5` and `batch_size=32`. + +# %% +train_model(cnn_model) + +# %% [markdown] +# Within 5 epochs of training, the model accuracy on the training set improves from 35% to 42% while validation accuracy is improved to 46%. This is still relatively low but much higher than the 10% probability of random guess. To improve the accuracy further, you can try both adding more examples to a dataset such as using 20000 training examples and meanwhile training for more rounds. + +# %% [markdown] +# ## Object Detection +# +# An object detection program must mark the locations of each object from a known set of classes in test images. Object detection is hard in many aspects: objects can be in various shapes and sometimes maybe deformed or vague. Objects can appear in an image in any position and they are often mixed up with noisy objects or scenes. +# +# Many object detectors are built out of image classifiers.On top of the classifier, there is an additional task needed for detecting an object: select objects to be classified with windows and report their precise locations. We usually call windows as bounding boxes and there are multiple ways to build it. The very simplest procedure for choosing windows is to use all windows on some grid. Here we will introduce two main procedures of finding a bounding box. + +# %% [markdown] +# ### Selective Search +# +# The simplest procedure for building boxes is to slide a window over the image. It produces a large number of boxes, and the boxes themselves ignore important image evidence but it is designed to be fast. +# +# Selective Search starts by over-segmenting the image based on the intensity of the pixels using a graph-based segmentation method. Selective Search algorithm takes these segments as initial input and then add all bounding boxes corresponding to segmented parts to the list of regional proposals. Then the algorithm group adjacent segments based on similarity and continue then go repeat the previous steps. +# +# +# #### Implementation +# +# Here we use the selective search method provided by the `opencv-python` package. To use it, please make sure the additional `opencv-contrib-python` version is also installed. You can create a selective search with the following line of code: + +# %% +ss = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation() + +# %% [markdown] +# Then what to do is to set the input image and selective search mode. Then the model is ready to train: + +# %% +ss.setBaseImage(im) +ss.switchToSelectiveSearchQuality() +rects = ss.process() + +# %% [markdown] +# The returned `rects` will be the coordinates of the bounding box corners. +# +# #### Example +# +# Here we provided the `selective_search` method to demonstrate the result of the selective search. The method takes a path to the image as input. To execute the demo, please use the following line of code: + +# %% +image_path = "./images/stapler.png" +selective_search(image_path) + +# %% [markdown] +# The bounding boxes are drawn on the original picture showed in the following: +# +# + +# %% [markdown] +# Some of the bounding boxes do have the stapler or at least most of it in the box, which can assist the classification process. + +# %% [markdown] +# ### R-CNN and Faster R-CNN +# +# [Ross Girshick et al.](https://arxiv.org/pdf/1311.2524.pdf) proposed a method where they use selective search to extract just 2000 regions from the image. Then the regions in bounding boxes are feed into a convolutional neural network to perform classification. The brief architecture can be shown as: +# +# + +# %% [markdown] +# The problem with R-CNN is that one must pass each box independently through an image classifier thus it takes a huge amount of time to train the network. And meanwhile, the selective search is not that stable and sometimes may generate bad examples. +# +# Faster R-CNN solved the drawbacks of R-CNN by applying a faster object detection algorithm. Instead of feeding the region proposals to the CNN, we feed the input image to the CNN to generate a convolutional feature map. Then we identify the region of interests on the feature map and then reshape them into a fixed size with an ROI pooling layer so it can be put into another classifier. +# +# This algorithm is faster than R-CNN as the image is not frequently fed into the CNN to extract feature maps. + +# %% [markdown] +# #### Implementation +# +# For an ROI pooling layer, we implemented a simple demo of it as `pool_rois`. We can fake a simple feature map with `numpy`: + +# %% +import numpy as np + +feature_maps_shape = (200, 100, 1) +feature_map = np.ones(feature_maps_shape, dtype='float32') +feature_map[200 - 1, 100 - 3, 0] = 50 + +# %% [markdown] +# Note that the fake feature map is all 1 except for one spot with a value of 50. Now let's generate some regio of interests: + +# %% +roiss = np.asarray([[0.5, 0.2, 0.7, 0.4], [0.0, 0.0, 1.0, 1.0]]) + +# %% [markdown] +# Here we only made up two regions of interest. The first only crops some part of the image where all pixels are '1' which ranges from 0.5-0.7 of the length of the horizontal edge and 0.2-0.4 of verticle edge. The range of the second region is the whole image. Now let's pool a 3x7 area out of each region of interest. + +# %% +pool_rois(feature_map, roiss, 3, 7) + +# %% [markdown] +# What we are expecting is that the second pooled region is different from the first one as there is an artificial feature-the '50' in its input. The printed result is exactly the same as we expected. + +# %% [markdown] +# In order to try the whole algorithm of the Faster R-CNN, you can refer to [this GitHub repository](https://github.com/endernewton/tf-faster-rcnn) for more detailed guidance. + +# %% diff --git a/classical_planning_approaches.ipynb b/notebooks/classical_planning_approaches.ipynb similarity index 99% rename from classical_planning_approaches.ipynb rename to notebooks/classical_planning_approaches.ipynb index b3373b367..f24008207 100644 --- a/classical_planning_approaches.ipynb +++ b/notebooks/classical_planning_approaches.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -24,7 +33,7 @@ "metadata": {}, "outputs": [], "source": [ - "from planning import *" + "from aima.planning import *" ] }, { @@ -390,7 +399,7 @@ "metadata": {}, "outputs": [], "source": [ - "from search import *" + "from aima.search import *" ] }, { @@ -592,7 +601,7 @@ "metadata": {}, "outputs": [], "source": [ - "from csp import *" + "from aima.csp import *" ] }, { @@ -712,7 +721,7 @@ "metadata": {}, "outputs": [], "source": [ - "from logic import *" + "from aima.logic import *" ] }, { @@ -890,7 +899,7 @@ "\u001b[0;34m also known as the Sussman Anomaly.\u001b[0m\n", "\u001b[0;34m\u001b[0m\n", "\u001b[0;34m Example:\u001b[0m\n", - "\u001b[0;34m >>> from planning import *\u001b[0m\n", + "\u001b[0;34m >>> from aima.planning import *\u001b[0m\n", "\u001b[0;34m >>> tbt = three_block_tower()\u001b[0m\n", "\u001b[0;34m >>> tbt.goal_test()\u001b[0m\n", "\u001b[0;34m False\u001b[0m\n", @@ -1279,7 +1288,7 @@ "\u001b[0;34m with a spare tire from the trunk.\u001b[0m\n", "\u001b[0;34m\u001b[0m\n", "\u001b[0;34m Example:\u001b[0m\n", - "\u001b[0;34m >>> from planning import *\u001b[0m\n", + "\u001b[0;34m >>> from aima.planning import *\u001b[0m\n", "\u001b[0;34m >>> st = spare_tire()\u001b[0m\n", "\u001b[0;34m >>> st.goal_test()\u001b[0m\n", "\u001b[0;34m False\u001b[0m\n", @@ -1672,7 +1681,7 @@ "\u001b[0;34m A problem of acquiring some items given their availability at certain stores.\u001b[0m\n", "\u001b[0;34m\u001b[0m\n", "\u001b[0;34m Example:\u001b[0m\n", - "\u001b[0;34m >>> from planning import *\u001b[0m\n", + "\u001b[0;34m >>> from aima.planning import *\u001b[0m\n", "\u001b[0;34m >>> sp = shopping_problem()\u001b[0m\n", "\u001b[0;34m >>> sp.goal_test()\u001b[0m\n", "\u001b[0;34m False\u001b[0m\n", @@ -2029,7 +2038,7 @@ "\u001b[0;34m given the starting location and airplanes.\u001b[0m\n", "\u001b[0;34m\u001b[0m\n", "\u001b[0;34m Example:\u001b[0m\n", - "\u001b[0;34m >>> from planning import *\u001b[0m\n", + "\u001b[0;34m >>> from aima.planning import *\u001b[0m\n", "\u001b[0;34m >>> ac = air_cargo()\u001b[0m\n", "\u001b[0;34m >>> ac.goal_test()\u001b[0m\n", "\u001b[0;34m False\u001b[0m\n", diff --git a/notebooks/classical_planning_approaches.py b/notebooks/classical_planning_approaches.py new file mode 100644 index 000000000..ef392c12b --- /dev/null +++ b/notebooks/classical_planning_approaches.py @@ -0,0 +1,445 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Classical Planning +# --- +# # Classical Planning Approaches +# +# ## Introduction +# ***Planning*** combines the two major areas of AI: *search* and *logic*. A planner can be seen either as a program that searches for a solution or as one that constructively proves the existence of a solution. +# +# Currently, the most popular and effective approaches to fully automated planning are: +# - searching using a *planning graph*; +# - *state-space search* with heuristics; +# - translating to a *constraint satisfaction (CSP) problem*; +# - translating to a *boolean satisfiability (SAT) problem*. + +# %% +from aima.planning import * + +# %% [markdown] +# ## Planning as Planning Graph Search +# +# A *planning graph* is a directed graph organized into levels each of which contains information about the current state of the knowledge base and the possible state-action links to and from that level. +# +# The first level contains the initial state with nodes representing each fluent that holds in that level. This level has state-action links linking each state to valid actions in that state. Each action is linked to all its preconditions and its effect states. Based on these effects, the next level is constructed and contains similarly structured information about the next state. In this way, the graph is expanded using state-action links till we reach a state where all the required goals hold true simultaneously. +# +# In every planning problem, we are allowed to carry out the *no-op* action, ie, we can choose no action for a particular state. These are called persistence actions and has effects same as its preconditions. This enables us to carry a state to the next level. +# +# Mutual exclusivity (*mutex*) between two actions means that these cannot be taken together and occurs in the following cases: +# - *inconsistent effects*: one action negates the effect of the other; +# - *interference*: one of the effects of an action is the negation of a precondition of the other; +# - *competing needs*: one of the preconditions of one action is mutually exclusive with a precondition of the other. +# +# We can say that we have reached our goal if none of the goal states in the current level are mutually exclusive. + +# %% +# %psource Graph + +# %% +# %psource Level + +# %% [markdown] +# A *planning graph* can be used to give better heuristic estimates which can be applied to any of the search techniques. Alternatively, we can search for a solution over the space formed by the planning graph, using an algorithm called `GraphPlan`. +# +# The `GraphPlan` algorithm repeatedly adds a level to a planning graph. Once all the goals show up as non-mutex in the graph, the algorithm runs backward from the last level to the first searching for a plan that solves the problem. If that fails, it records the (level , goals) pair as a *no-good* (as in constraint learning for CSPs), expands another level and tries again, terminating with failure when there is no reason to go on. + +# %% +# %psource GraphPlan + +# %% [markdown] +# ## Planning as State-Space Search + +# %% [markdown] +# The description of a planning problem defines a search problem: we can search from the initial state through the space of states, looking for a goal. One of the nice advantages of the declarative representation of action schemas is that we can also search backward from the goal, looking for the initial state. +# +# However, neither forward nor backward search is efficient without a good heuristic function because the real-world planning problems often have large state spaces. A heuristic function $h(s)$ estimates the distance from a state $s$ to the goal and, if it is admissible, ie if does not overestimate, then we can use $A^∗$ search to find optimal solutions. +# +# Planning uses a factored representation for states and action schemas which makes it possible to define good domain-independent heuristics to prune the search space. +# +# An admissible heuristic can be derived by defining a relaxed problem that is easier to solve. The length of the solution of this easier problem then becomes the heuristic for the original problem. Assume that all goals and preconditions contain only positive literals, ie that the problem is defined according to the *Stanford Research Institute Problem Solver* (STRIPS) notation: we want to create a relaxed version of the original problem that will be easier to solve by ignoring delete lists from all actions, ie removing all negative literals from effects. As shown in [[1]](#cite-hoffmann2001ff) the planning graph of a relaxed problem does not contain any mutex relations at all (which is the crucial thing when building a planning graph) and for this reason GraphPlan will never backtrack looking for a solution: for this reason the **ignore delete lists** heuristic makes it possible to find the optimal solution for relaxed problem in polynomial time through `GraphPlan` algorithm. + +# %% +from aima.search import * + +# %% [markdown] +# ### Forward State-Space Search + +# %% [markdown] +# Forward search through the space of states, starting in the initial state and using the problem’s actions to search forward for a member of the set of goal states. + +# %% +# %psource ForwardPlan + +# %% [markdown] +# ### Backward Relevant-States Search + +# %% [markdown] +# Backward search through sets of relevant states, starting at the set of states representing the goal and using the inverse of the actions to search backward for the initial state. + +# %% +# %psource BackwardPlan + +# %% [markdown] +# ## Planning as Constraint Satisfaction Problem + +# %% [markdown] +# In forward planning, the search is constrained by the initial state and only uses the goal as a stopping criterion and as a source for heuristics. In regression planning, the search is constrained by the goal and only uses the start state as a stopping criterion and as a source for heuristics. By converting the problem to a constraint satisfaction problem (CSP), the initial state can be used to prune what is not reachable and the goal to prune what is not useful. The CSP will be defined for a finite number of steps; the number of steps can be adjusted to find the shortest plan. One of the CSP methods can then be used to solve the CSP and thus find a plan. +# +# To construct a CSP from a planning problem, first choose a fixed planning *horizon*, which is the number of time steps over which to plan. Suppose the horizon is +# $k$. The CSP has the following variables: +# +# - a *state variable* for each feature and each time from 0 to $k$. If there are $n$ features for a horizon of $k$, there are $n \cdot (k+1)$ state variables. The domain of the state variable is the domain of the corresponding feature; +# - an *action variable*, $Action_t$, for each $t$ in the range 0 to $k-1$. The domain of $Action_t$, represents the action that takes the agent from the state at time $t$ to the state at time $t+1$. +# +# There are several types of constraints: +# +# - a *precondition constraint* between a state variable at time $t$ and the variable $Actiont_t$ constrains what actions are legal at time $t$; +# - an *effect constraint* between $Action_t$ and a state variable at time $t+1$ constrains the values of a state variable that is a direct effect of the action; +# - a *frame constraint* among a state variable at time $t$, the variable $Action_t$, and the corresponding state variable at time $t+1$ specifies when the variable that does not change as a result of an action has the same value before and after the action; +# - an *initial-state constraint* constrains a variable on the initial state (at time 0). The initial state is represented as a set of domain constraints on the state variables at time 0; +# - a *goal constraint* constrains the final state to be a state that satisfies the achievement goal. These are domain constraints on the variables that appear in the goal; +# - a *state constraint* is a constraint among variables at the same time step. These can include physical constraints on the state or can ensure that states that violate maintenance goals are forbidden. This is extra knowledge beyond the power of the feature-based or PDDL representations of the action. +# +# The PDDL representation gives precondition, effect and frame constraints for each time +# $t$ as follows: +# +# - for each $Var = v$ in the precondition of action $A$, there is a precondition constraint: +# $$ Var_t = v \leftarrow Action_t = A $$ +# that specifies that if the action is to be $A$, $Var_t$ must have value $v$ immediately before. This constraint is violated when $Action_t = A$ and $Var_t \neq v$, and thus is equivalent to $\lnot{(Var_t \neq v \land Action_t = A)}$; +# - or each $Var = v$ in the effect of action $A$, there is a effect constraint: +# $$ Var_{t+1} = v \leftarrow Action_t = A $$ +# which is violated when $Action_t = A$ and $Var_{t+1} \neq v$, and thus is equivalent to $\lnot{(Var_{t+1} \neq v \land Action_t = A)}$; +# - for each $Var$, there is a frame constraint, where $As$ is the set of actions that include $Var$ in the effect of the action: +# $$ Var_{t+1} = Var_t \leftarrow Action_t \notin As $$ +# which specifies that the feature $Var$ has the same value before and after any action that does not affect $Var$. +# +# The CSP representation assumes a fixed planning horizon (ie a fixed number of steps). To find a plan over any number of steps, the algorithm can be run for a horizon of $k = 0, 1, 2, \dots$ until a solution is found. + +# %% +from aima.csp import * + +# %% +# %psource CSPlan + +# %% [markdown] +# ## Planning as Boolean Satisfiability Problem +# +# As shown in [[2]](cite-kautz1992planning) the translation of a *Planning Domain Definition Language* (PDDL) description into a *Conjunctive Normal Form* (CNF) formula is a series of straightforward steps: +# - *propositionalize the actions*: replace each action schema with a set of ground actions formed by substituting constants for each of the variables. These ground actions are not part of the translation, but will be used in subsequent steps; +# - *define the initial state*: assert $F^0$ for every fluent $F$ in the problem’s initial state, and $\lnot{F}$ for every fluent not mentioned in the initial state; +# - *propositionalize the goal*: for every variable in the goal, replace the literals that contain the variable with a disjunction over constants; +# - *add successor-state axioms*: for each fluent $F$, add an axiom of the form +# +# $$ F^{t+1} \iff ActionCausesF^t \lor (F^t \land \lnot{ActionCausesNotF^t}) $$ +# +# where $ActionCausesF$ is a disjunction of all the ground actions that have $F$ in their add list, and $ActionCausesNotF$ is a disjunction of all the ground actions that have $F$ in their delete list; +# - *add precondition axioms*: for each ground action $A$, add the axiom $A^t \implies PRE(A)^t$, that is, if an action is taken at time $t$, then the preconditions must have been true; +# - *add action exclusion axioms*: say that every action is distinct from every other action. +# +# A propositional planning procedure implements the basic idea just given but, because the agent does not know how many steps it will take to reach the goal, the algorithm tries each possible number of steps $t$, up to some maximum conceivable plan length $T_{max}$ . In this way, it is guaranteed to find the shortest plan if one exists. Because of the way the propositional planning procedure searches for a solution, this approach cannot be used in a partially observable environment, ie WalkSAT, but would just set the unobservable variables to the values it needs to create a solution. + +# %% +from aima.logic import * + +# %% +# %psource SATPlan + +# %% +# %psource SAT_plan + +# %% [markdown] pycharm={} +# ## Experimental Results + +# %% [markdown] +# ### Blocks World + +# %% +# %psource three_block_tower + +# %% [markdown] +# #### GraphPlan + +# %% +# %time blocks_world_solution = GraphPlan(three_block_tower()).execute() +linearize(blocks_world_solution) + +# %% [markdown] +# #### ForwardPlan + +# %% +# %time blocks_world_solution = uniform_cost_search(ForwardPlan(three_block_tower()), display=True).solution() +blocks_world_solution = list(map(lambda action: Expr(action.name, *action.args), blocks_world_solution)) +blocks_world_solution + +# %% [markdown] +# #### ForwardPlan with Ignore Delete Lists Heuristic + +# %% +# %time blocks_world_solution = astar_search(ForwardPlan(three_block_tower()), display=True).solution() +blocks_world_solution = list(map(lambda action: Expr(action.name, *action.args), blocks_world_solution)) +blocks_world_solution + +# %% [markdown] +# #### BackwardPlan + +# %% +# %time blocks_world_solution = uniform_cost_search(BackwardPlan(three_block_tower()), display=True).solution() +blocks_world_solution = list(map(lambda action: Expr(action.name, *action.args), blocks_world_solution)) +blocks_world_solution[::-1] + +# %% [markdown] +# #### BackwardPlan with Ignore Delete Lists Heuristic + +# %% +# %time blocks_world_solution = astar_search(BackwardPlan(three_block_tower()), display=True).solution() +blocks_world_solution = list(map(lambda action: Expr(action.name, *action.args), blocks_world_solution)) +blocks_world_solution[::-1] + +# %% [markdown] +# #### CSPlan + +# %% +# %time blocks_world_solution = CSPlan(three_block_tower(), 3, arc_heuristic=no_heuristic) +blocks_world_solution + +# %% [markdown] +# #### CSPlan with SAT UP Arc Heuristic + +# %% +# %time blocks_world_solution = CSPlan(three_block_tower(), 3, arc_heuristic=sat_up) +blocks_world_solution + +# %% [markdown] +# #### SATPlan with DPLL + +# %% +# %time blocks_world_solution = SATPlan(three_block_tower(), 4, SAT_solver=dpll_satisfiable) +blocks_world_solution + +# %% [markdown] +# #### SATPlan with CDCL + +# %% +# %time blocks_world_solution = SATPlan(three_block_tower(), 4, SAT_solver=cdcl_satisfiable) +blocks_world_solution + +# %% [markdown] +# ### Spare Tire + +# %% +# %psource spare_tire + +# %% [markdown] +# #### GraphPlan + +# %% +# %time spare_tire_solution = GraphPlan(spare_tire()).execute() +linearize(spare_tire_solution) + +# %% [markdown] +# #### ForwardPlan + +# %% +# %time spare_tire_solution = uniform_cost_search(ForwardPlan(spare_tire()), display=True).solution() +spare_tire_solution = list(map(lambda action: Expr(action.name, *action.args), spare_tire_solution)) +spare_tire_solution + +# %% [markdown] +# #### ForwardPlan with Ignore Delete Lists Heuristic + +# %% +# %time spare_tire_solution = astar_search(ForwardPlan(spare_tire()), display=True).solution() +spare_tire_solution = list(map(lambda action: Expr(action.name, *action.args), spare_tire_solution)) +spare_tire_solution + +# %% [markdown] +# #### BackwardPlan + +# %% +# %time spare_tire_solution = uniform_cost_search(BackwardPlan(spare_tire()), display=True).solution() +spare_tire_solution = list(map(lambda action: Expr(action.name, *action.args), spare_tire_solution)) +spare_tire_solution[::-1] + +# %% [markdown] +# #### BackwardPlan with Ignore Delete Lists Heuristic + +# %% +# %time spare_tire_solution = astar_search(BackwardPlan(spare_tire()), display=True).solution() +spare_tire_solution = list(map(lambda action: Expr(action.name, *action.args), spare_tire_solution)) +spare_tire_solution[::-1] + +# %% [markdown] +# #### CSPlan + +# %% +# %time spare_tire_solution = CSPlan(spare_tire(), 3, arc_heuristic=no_heuristic) +spare_tire_solution + +# %% [markdown] +# #### CSPlan with SAT UP Arc Heuristic + +# %% +# %time spare_tire_solution = CSPlan(spare_tire(), 3, arc_heuristic=sat_up) +spare_tire_solution + +# %% [markdown] +# #### SATPlan with DPLL + +# %% +# %time spare_tire_solution = SATPlan(spare_tire(), 4, SAT_solver=dpll_satisfiable) +spare_tire_solution + +# %% [markdown] +# #### SATPlan with CDCL + +# %% +# %time spare_tire_solution = SATPlan(spare_tire(), 4, SAT_solver=cdcl_satisfiable) +spare_tire_solution + +# %% [markdown] +# ### Shopping Problem + +# %% +# %psource shopping_problem + +# %% [markdown] +# #### GraphPlan + +# %% +# %time shopping_problem_solution = GraphPlan(shopping_problem()).execute() +linearize(shopping_problem_solution) + +# %% [markdown] +# #### ForwardPlan + +# %% +# %time shopping_problem_solution = uniform_cost_search(ForwardPlan(shopping_problem()), display=True).solution() +shopping_problem_solution = list(map(lambda action: Expr(action.name, *action.args), shopping_problem_solution)) +shopping_problem_solution + +# %% [markdown] +# #### ForwardPlan with Ignore Delete Lists Heuristic + +# %% +# %time shopping_problem_solution = astar_search(ForwardPlan(shopping_problem()), display=True).solution() +shopping_problem_solution = list(map(lambda action: Expr(action.name, *action.args), shopping_problem_solution)) +shopping_problem_solution + +# %% [markdown] +# #### BackwardPlan + +# %% +# %time shopping_problem_solution = uniform_cost_search(BackwardPlan(shopping_problem()), display=True).solution() +shopping_problem_solution = list(map(lambda action: Expr(action.name, *action.args), shopping_problem_solution)) +shopping_problem_solution[::-1] + +# %% [markdown] +# #### BackwardPlan with Ignore Delete Lists Heuristic + +# %% +# %time shopping_problem_solution = astar_search(BackwardPlan(shopping_problem()), display=True).solution() +shopping_problem_solution = list(map(lambda action: Expr(action.name, *action.args), shopping_problem_solution)) +shopping_problem_solution[::-1] + +# %% [markdown] +# #### CSPlan + +# %% +# %time shopping_problem_solution = CSPlan(shopping_problem(), 5, arc_heuristic=no_heuristic) +shopping_problem_solution + +# %% [markdown] +# #### CSPlan with SAT UP Arc Heuristic + +# %% +# %time shopping_problem_solution = CSPlan(shopping_problem(), 5, arc_heuristic=sat_up) +shopping_problem_solution + +# %% [markdown] +# #### SATPlan with CDCL + +# %% +# %time shopping_problem_solution = SATPlan(shopping_problem(), 5, SAT_solver=cdcl_satisfiable) +shopping_problem_solution + +# %% [markdown] +# ### Air Cargo + +# %% +# %psource air_cargo + +# %% [markdown] +# #### GraphPlan + +# %% +# %time air_cargo_solution = GraphPlan(air_cargo()).execute() +linearize(air_cargo_solution) + +# %% [markdown] +# #### ForwardPlan + +# %% +# %time air_cargo_solution = uniform_cost_search(ForwardPlan(air_cargo()), display=True).solution() +air_cargo_solution = list(map(lambda action: Expr(action.name, *action.args), air_cargo_solution)) +air_cargo_solution + +# %% [markdown] +# #### ForwardPlan with Ignore Delete Lists Heuristic + +# %% +# %time air_cargo_solution = astar_search(ForwardPlan(air_cargo()), display=True).solution() +air_cargo_solution = list(map(lambda action: Expr(action.name, *action.args), air_cargo_solution)) +air_cargo_solution + +# %% [markdown] +# #### BackwardPlan + +# %% +# %time air_cargo_solution = uniform_cost_search(BackwardPlan(air_cargo()), display=True).solution() +air_cargo_solution = list(map(lambda action: Expr(action.name, *action.args), air_cargo_solution)) +air_cargo_solution[::-1] + +# %% [markdown] +# #### BackwardPlan with Ignore Delete Lists Heuristic + +# %% +# %time air_cargo_solution = astar_search(BackwardPlan(air_cargo()), display=True).solution() +air_cargo_solution = list(map(lambda action: Expr(action.name, *action.args), air_cargo_solution)) +air_cargo_solution[::-1] + +# %% [markdown] +# #### CSPlan + +# %% +# %time air_cargo_solution = CSPlan(air_cargo(), 6, arc_heuristic=no_heuristic) +air_cargo_solution + +# %% [markdown] +# #### CSPlan with SAT UP Arc Heuristic + +# %% +# %time air_cargo_solution = CSPlan(air_cargo(), 6, arc_heuristic=sat_up) +air_cargo_solution + +# %% [markdown] +# ## References +# +# [[1]](#ref-1) Hoffmann, Jörg. 2001. _FF: The fast-forward planning system_. +# +# [[2]](#ref-2) Kautz, Henry A and Selman, Bart and others. 1992. _Planning as Satisfiability_. diff --git a/csp.ipynb b/notebooks/csp.ipynb similarity index 99% rename from csp.ipynb rename to notebooks/csp.ipynb index 5d490846b..488462706 100644 --- a/csp.ipynb +++ b/notebooks/csp.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -15,8 +24,8 @@ "metadata": {}, "outputs": [], "source": [ - "from csp import *\n", - "from notebook import psource, plot_NQueens\n", + "from aima.csp import *\n", + "from aima.notebook_utils import psource, plot_NQueens\n", "%matplotlib inline\n", "\n", "# Hide warnings in the matplotlib sections\n", @@ -2671,7 +2680,7 @@ }, { "cell_type": "code", - "execution_count": 43, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -2694,11 +2703,11 @@ " current = defaultdict(lambda: 'Black', current)\n", "\n", " # Now we use colors in the list and default to black otherwise.\n", - " colors = [current[node] for node in G.node.keys()]\n", + " colors = [current[node] for node in G.nodes.keys()]\n", " # Finally drawing the nodes.\n", " nx.draw(G, pos, node_color=colors, node_size=500)\n", "\n", - " labels = {label:label for label in G.node}\n", + " labels = {label:label for label in G.nodes}\n", " # Labels shifted by offset so that nodes don't overlap\n", " label_pos = {key:[value[0], value[1]+0.03] for key, value in pos.items()}\n", " nx.draw_networkx_labels(G, label_pos, labels, font_size=20)\n", @@ -3081,4 +3090,4 @@ }, "nbformat": 4, "nbformat_minor": 1 -} \ No newline at end of file +} diff --git a/notebooks/csp.py b/notebooks/csp.py new file mode 100644 index 000000000..596f2ba43 --- /dev/null +++ b/notebooks/csp.py @@ -0,0 +1,619 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # CONSTRAINT SATISFACTION PROBLEMS +# +# This IPy notebook acts as supporting material for topics covered in **Chapter 6 Constraint Satisfaction Problems** of the book* Artificial Intelligence: A Modern Approach*. We make use of the implementations in **csp.py** module. Even though this notebook includes a brief summary of the main topics, familiarity with the material present in the book is expected. We will look at some visualizations and solve some of the CSP problems described in the book. Let us import everything from the csp module to get started. + +# %% +from aima.csp import * +from aima.notebook_utils import psource, plot_NQueens +# %matplotlib inline + +# Hide warnings in the matplotlib sections +import warnings +warnings.filterwarnings("ignore") + +# %% [markdown] +# ## CONTENTS +# +# * Overview +# * Graph Coloring +# * N-Queens +# * AC-3 +# * Backtracking Search +# * Tree CSP Solver +# * Graph Coloring Visualization +# * N-Queens Visualization + +# %% [markdown] +# ## OVERVIEW +# +# CSPs are a special kind of search problems. Here we don't treat the space as a black box but the state has a particular form and we use that to our advantage to tweak our algorithms to be more suited to the problems. A CSP State is defined by a set of variables which can take values from corresponding domains. These variables can take only certain values in their domains to satisfy the constraints. A set of assignments which satisfies all constraints passes the goal test. Let us start by exploring the CSP class which we will use to model our CSPs. You can keep the popup open and read the main page to get a better idea of the code. + +# %% +psource(CSP) + +# %% [markdown] +# The __ _ _init_ _ __ method parameters specify the CSP. Variables can be passed as a list of strings or integers. Domains are passed as dict (dictionary datatpye) where "key" specifies the variables and "value" specifies the domains. The variables are passed as an empty list. Variables are extracted from the keys of the domain dictionary. Neighbor is a dict of variables that essentially describes the constraint graph. Here each variable key has a list of its values which are the variables that are constraint along with it. The constraint parameter should be a function **f(A, a, B, b**) that **returns true** if neighbors A, B **satisfy the constraint** when they have values **A=a, B=b**. We have additional parameters like nassings which is incremented each time an assignment is made when calling the assign method. You can read more about the methods and parameters in the class doc string. We will talk more about them as we encounter their use. Let us jump to an example. + +# %% [markdown] +# ## GRAPH COLORING +# +# We use the graph coloring problem as our running example for demonstrating the different algorithms in the **csp module**. The idea of map coloring problem is that the adjacent nodes (those connected by edges) should not have the same color throughout the graph. The graph can be colored using a fixed number of colors. Here each node is a variable and the values are the colors that can be assigned to them. Given that the domain will be the same for all our nodes we use a custom dict defined by the **UniversalDict** class. The **UniversalDict** Class takes in a parameter and returns it as a value for all the keys of the dict. It is very similar to **defaultdict** in Python except that it does not support item assignment. + +# %% +s = UniversalDict(['R','G','B']) +s[5] + +# %% [markdown] +# For our CSP we also need to define a constraint function **f(A, a, B, b)**. In this, we need to ensure that the neighbors don't have the same color. This is defined in the function **different_values_constraint** of the module. + +# %% +psource(different_values_constraint) + +# %% [markdown] +# The CSP class takes neighbors in the form of a Dict. The module specifies a simple helper function named **parse_neighbors** which allows us to take input in the form of strings and return a Dict of a form that is compatible with the **CSP Class**. + +# %% +# %pdoc parse_neighbors + +# %% [markdown] +# The **MapColoringCSP** function creates and returns a CSP with the above constraint function and states. The variables are the keys of the neighbors dict and the constraint is the one specified by the **different_values_constratint** function. **Australia**, **USA** and **France** are three CSPs that have been created using **MapColoringCSP**. **Australia** corresponds to ** Figure 6.1 ** in the book. + +# %% +psource(MapColoringCSP) + +# %% +australia_csp, usa_csp, france_csp + +# %% [markdown] +# ## N-QUEENS +# +# The N-queens puzzle is the problem of placing N chess queens on an N×N chessboard in a way such that no two queens threaten each other. Here N is a natural number. Like the graph coloring problem, NQueens is also implemented in the csp module. The **NQueensCSP** class inherits from the **CSP** class. It makes some modifications in the methods to suit this particular problem. The queens are assumed to be placed one per column, from left to right. That means position (x, y) represents (var, val) in the CSP. The constraint that needs to be passed to the CSP is defined in the **queen_constraint** function. The constraint is satisfied (true) if A, B are really the same variable, or if they are not in the same row, down diagonal, or up diagonal. + +# %% +psource(queen_constraint) + +# %% [markdown] +# The **NQueensCSP** method implements methods that support solving the problem via **min_conflicts** which is one of the many popular techniques for solving CSPs. Because **min_conflicts** hill climbs the number of conflicts to solve, the CSP **assign** and **unassign** are modified to record conflicts. More details about the structures: **rows**, **downs**, **ups** which help in recording conflicts are explained in the docstring. + +# %% +psource(NQueensCSP) + +# %% [markdown] +# The _ ___init___ _ method takes only one parameter **n** i.e. the size of the problem. To create an instance, we just pass the required value of n into the constructor. + +# %% +eight_queens = NQueensCSP(8) + +# %% [markdown] +# We have defined our CSP. +# Now, we need to solve this. +# +# ### Min-conflicts +# As stated above, the `min_conflicts` algorithm is an efficient method to solve such a problem. +#
+# In the start, all the variables of the CSP are _randomly_ initialized. +#
+# The algorithm then randomly selects a variable that has conflicts and violates some constraints of the CSP. +#
+# The selected variable is then assigned a value that _minimizes_ the number of conflicts. +#
+# This is a simple **stochastic algorithm** which works on a principle similar to **Hill-climbing**. +# The conflicting state is repeatedly changed into a state with fewer conflicts in an attempt to reach an approximate solution. +#
+# This algorithm sometimes benefits from having a good initial assignment. +# Using greedy techniques to get a good initial assignment and then using `min_conflicts` to solve the CSP can speed up the procedure dramatically, especially for CSPs with a large state space. + +# %% +psource(min_conflicts) + +# %% [markdown] +# Let's use this algorithm to solve the `eight_queens` CSP. + +# %% +solution = min_conflicts(eight_queens) + +# %% [markdown] +# This is indeed a valid solution. +#
+# `notebook.py` has a helper function to visualize the solution space. + +# %% +plot_NQueens(solution) + +# %% [markdown] +# Lets' see if we can find a different solution. + +# %% +eight_queens = NQueensCSP(8) +solution = min_conflicts(eight_queens) +plot_NQueens(solution) + +# %% [markdown] +# The solution is a bit different this time. +# Running the above cell several times should give you different valid solutions. +#
+# In the `search.ipynb` notebook, we will see how NQueensProblem can be solved using a **heuristic search method** such as `uniform_cost_search` and `astar_search`. + +# %% [markdown] +# ### Helper Functions +# +# We will now implement a few helper functions that will allow us to visualize the Coloring Problem; we'll also make a few modifications to the existing classes and functions for additional record keeping. To begin, we modify the **assign** and **unassign** methods in the **CSP** in order to add a copy of the assignment to the **assignment_history**. We name this new class as **InstruCSP**; it will allow us to see how the assignment evolves over time. + +# %% +import copy +class InstruCSP(CSP): + + def __init__(self, variables, domains, neighbors, constraints): + super().__init__(variables, domains, neighbors, constraints) + self.assignment_history = [] + + def assign(self, var, val, assignment): + super().assign(var,val, assignment) + self.assignment_history.append(copy.deepcopy(assignment)) + + def unassign(self, var, assignment): + super().unassign(var,assignment) + self.assignment_history.append(copy.deepcopy(assignment)) + + +# %% [markdown] +# Next, we define **make_instru** which takes an instance of **CSP** and returns an instance of **InstruCSP**. + +# %% +def make_instru(csp): + return InstruCSP(csp.variables, csp.domains, csp.neighbors, csp.constraints) + + +# %% [markdown] +# We will now use a graph defined as a dictionary for plotting purposes in our Graph Coloring Problem. The keys are the nodes and their values are the corresponding nodes they are connected to. + +# %% +neighbors = { + 0: [6, 11, 15, 18, 4, 11, 6, 15, 18, 4], + 1: [12, 12, 14, 14], + 2: [17, 6, 11, 6, 11, 10, 17, 14, 10, 14], + 3: [20, 8, 19, 12, 20, 19, 8, 12], + 4: [11, 0, 18, 5, 18, 5, 11, 0], + 5: [4, 4], + 6: [8, 15, 0, 11, 2, 14, 8, 11, 15, 2, 0, 14], + 7: [13, 16, 13, 16], + 8: [19, 15, 6, 14, 12, 3, 6, 15, 19, 12, 3, 14], + 9: [20, 15, 19, 16, 15, 19, 20, 16], + 10: [17, 11, 2, 11, 17, 2], + 11: [6, 0, 4, 10, 2, 6, 2, 0, 10, 4], + 12: [8, 3, 8, 14, 1, 3, 1, 14], + 13: [7, 15, 18, 15, 16, 7, 18, 16], + 14: [8, 6, 2, 12, 1, 8, 6, 2, 1, 12], + 15: [8, 6, 16, 13, 18, 0, 6, 8, 19, 9, 0, 19, 13, 18, 9, 16], + 16: [7, 15, 13, 9, 7, 13, 15, 9], + 17: [10, 2, 2, 10], + 18: [15, 0, 13, 4, 0, 15, 13, 4], + 19: [20, 8, 15, 9, 15, 8, 3, 20, 3, 9], + 20: [3, 19, 9, 19, 3, 9] +} + +# %% [markdown] +# Now we are ready to create an InstruCSP instance for our problem. We are doing this for an instance of **MapColoringProblem** class which inherits from the **CSP** Class. This means that our **make_instru** function will work perfectly for it. + +# %% +coloring_problem = MapColoringCSP('RGBY', neighbors) + +# %% +coloring_problem1 = make_instru(coloring_problem) + +# %% [markdown] +# ### CONSTRAINT PROPAGATION +# Algorithms that solve CSPs have a choice between searching and or doing a _constraint propagation_, a specific type of inference. +# The constraints can be used to reduce the number of legal values for another variable, which in turn can reduce the legal values for some other variable, and so on. +#
+# Constraint propagation tries to enforce _local consistency_. +# Consider each variable as a node in a graph and each binary constraint as an arc. +# Enforcing local consistency causes inconsistent values to be eliminated throughout the graph, +# a lot like the `GraphPlan` algorithm in planning, where mutex links are removed from a planning graph. +# There are different types of local consistencies: +# 1. Node consistency +# 2. Arc consistency +# 3. Path consistency +# 4. K-consistency +# 5. Global constraints +# +# Refer __section 6.2__ in the book for details. +#
+ +# %% [markdown] +# ## AC-3 +# Before we dive into AC-3, we need to know what _arc-consistency_ is. +#
+# A variable $X_i$ is __arc-consistent__ with respect to another variable $X_j$ if for every value in the current domain $D_i$ there is some value in the domain $D_j$ that satisfies the binary constraint on the arc $(X_i, X_j)$. +#
+# A network is arc-consistent if every variable is arc-consistent with every other variable. +#
+# +# AC-3 is an algorithm that enforces arc consistency. +# After applying AC-3, either every arc is arc-consistent, or some variable has an empty domain, indicating that the CSP cannot be solved. +# Let's see how `AC3` is implemented in the module. + +# %% +psource(AC3) + +# %% [markdown] +# `AC3` also employs a helper function `revise`. + +# %% +psource(revise) + +# %% [markdown] +# `AC3` maintains a queue of arcs to consider which initially contains all the arcs in the CSP. +# An arbitrary arc $(X_i, X_j)$ is popped from the queue and $X_i$ is made _arc-consistent_ with respect to $X_j$. +#
+# If in doing so, $D_i$ is left unchanged, the algorithm just moves to the next arc, +# but if the domain $D_i$ is revised, then we add all the neighboring arcs $(X_k, X_i)$ to the queue. +#
+# We repeat this process and if at any point, the domain $D_i$ is reduced to nothing, then we know the whole CSP has no consistent solution and `AC3` can immediately return failure. +#
+# Otherwise, we keep removing values from the domains of variables until the queue is empty. +# We finally get the arc-consistent CSP which is faster to search because the variables have smaller domains. + +# %% [markdown] +# Let's see how `AC3` can be used. +#
+# We'll first define the required variables. + +# %% +neighbors = parse_neighbors('A: B; B: ') +domains = {'A': [0, 1, 2, 3, 4], 'B': [0, 1, 2, 3, 4]} +constraints = lambda X, x, Y, y: x % 2 == 0 and (x + y) == 4 and y % 2 != 0 +removals = [] + +# %% [markdown] +# We'll now define a `CSP` object. + +# %% +csp = CSP(variables=None, domains=domains, neighbors=neighbors, constraints=constraints) + +# %% +AC3(csp, removals=removals) + +# %% [markdown] +# This configuration is inconsistent. + +# %% +constraints = lambda X, x, Y, y: (x % 2) == 0 and (x + y) == 4 +removals = [] +csp = CSP(variables=None, domains=domains, neighbors=neighbors, constraints=constraints) + +# %% +AC3(csp,removals=removals) + +# %% [markdown] +# This configuration is consistent. + +# %% [markdown] +# ## BACKTRACKING SEARCH +# +# The main issue with using Naive Search Algorithms to solve a CSP is that they can continue to expand obviously wrong paths; whereas, in **backtracking search**, we check the constraints as we go and we deal with only one variable at a time. Backtracking Search is implemented in the repository as the function **backtracking_search**. This is the same as **Figure 6.5** in the book. The function takes as input a CSP and a few other optional parameters which can be used to speed it up further. The function returns the correct assignment if it satisfies the goal. However, we will discuss these later. For now, let us solve our **coloring_problem1** with **backtracking_search**. + +# %% +result = backtracking_search(coloring_problem1) + +# %% +result # A dictonary of assignments. + +# %% [markdown] +# Let us also check the number of assignments made. + +# %% +coloring_problem1.nassigns + +# %% [markdown] +# Now, let us check the total number of assignments and unassignments, which would be the length of our assignment history. We can see it by using the command below. + +# %% +len(coloring_problem1.assignment_history) + +# %% [markdown] +# Now let us explore the optional keyword arguments that the **backtracking_search** function takes. These optional arguments help speed up the assignment further. Along with these, we will also point out the methods in the CSP class that help to make this work. +# +# The first one is **select_unassigned_variable**. It takes in, as a parameter, a function that helps in deciding the order in which the variables will be selected for assignment. We use a heuristic called Most Restricted Variable which is implemented by the function **mrv**. The idea behind **mrv** is to choose the variable with the least legal values left in its domain. The intuition behind selecting the **mrv** or the most constrained variable is that it allows us to encounter failure quickly before going too deep into a tree if we have selected a wrong step before. The **mrv** implementation makes use of another function **num_legal_values** to sort out the variables by the number of legal values left in its domain. This function, in turn, calls the **nconflicts** method of the **CSP** to return such values. + +# %% +psource(mrv) + +# %% +psource(num_legal_values) + +# %% +psource(CSP.nconflicts) + +# %% [markdown] +# Another ordering related parameter **order_domain_values** governs the value ordering. Here we select the Least Constraining Value which is implemented by the function **lcv**. The idea is to select the value which rules out least number of values in the remaining variables. The intuition behind selecting the **lcv** is that it allows a lot of freedom to assign values later. The idea behind selecting the mrc and lcv makes sense because we need to do all variables but for values, and it's better to try the ones that are likely. So for vars, we face the hard ones first. + +# %% +psource(lcv) + +# %% [markdown] +# Finally, the third parameter **inference** can make use of one of the two techniques called Arc Consistency or Forward Checking. The details of these methods can be found in the **Section 6.3.2** of the book. In short the idea of inference is to detect the possible failure before it occurs and to look ahead to not make mistakes. **mac** and **forward_checking** implement these two techniques. The **CSP** methods **support_pruning**, **suppose**, **prune**, **choices**, **infer_assignment** and **restore** help in using these techniques. You can find out more about these by looking up the source code. + +# %% [markdown] +# Now let us compare the performance with these parameters enabled vs the default parameters. We will use the Graph Coloring problem instance 'usa' for comparison. We will call the instances **solve_simple** and **solve_parameters** and solve them using backtracking and compare the number of assignments. + +# %% +solve_simple = copy.deepcopy(usa_csp) +solve_parameters = copy.deepcopy(usa_csp) + +# %% +backtracking_search(solve_simple) +backtracking_search(solve_parameters, order_domain_values=lcv, select_unassigned_variable=mrv, inference=mac) + +# %% +solve_simple.nassigns + +# %% +solve_parameters.nassigns + +# %% [markdown] +# ## TREE CSP SOLVER +# +# The `tree_csp_solver` function (**Figure 6.11** in the book) can be used to solve problems whose constraint graph is a tree. Given a CSP, with `neighbors` forming a tree, it returns an assignment that satisfies the given constraints. The algorithm works as follows: +# +# First it finds the *topological sort* of the tree. This is an ordering of the tree where each variable/node comes after its parent in the tree. The function that accomplishes this is `topological_sort`; it builds the topological sort using the recursive function `build_topological`. That function is an augmented DFS (Depth First Search), where each newly visited node of the tree is pushed on a stack. The stack in the end holds the variables topologically sorted. +# +# Then the algorithm makes arcs between each parent and child consistent. *Arc-consistency* between two variables, *a* and *b*, occurs when for every possible value of *a* there is an assignment in *b* that satisfies the problem's constraints. If such an assignment cannot be found, the problematic value is removed from *a*'s possible values. This is done with the use of the function `make_arc_consistent`, which takes as arguments a variable `Xj` and its parent, and makes the arc between them consistent by removing any values from the parent which do not allow for a consistent assignment in `Xj`. +# +# If an arc cannot be made consistent, the solver fails. If every arc is made consistent, we move to assigning values. +# +# First we assign a random value to the root from its domain and then we assign values to the rest of the variables. Since the graph is now arc-consistent, we can simply move from variable to variable picking any remaining consistent values. At the end we are left with a valid assignment. If at any point though we find a variable where no consistent value is left in its domain, the solver fails. +# +# Run the cell below to see the implementation of the algorithm: + +# %% +psource(tree_csp_solver) + +# %% [markdown] +# We will now use the above function to solve a problem. More specifically, we will solve the problem of coloring Australia's map. We have two colors at our disposal: Red and Blue. As a reminder, this is the graph of Australia: +# +# `"SA: WA NT Q NSW V; NT: WA Q; NSW: Q V; T: "` +# +# Unfortunately, as you can see, the above is not a tree. However, if we remove `SA`, which has arcs to `WA`, `NT`, `Q`, `NSW` and `V`, we are left with a tree (we also remove `T`, since it has no in-or-out arcs). We can now solve this using our algorithm. Let's define the map coloring problem at hand: + +# %% +australia_small = MapColoringCSP(list('RB'), + 'NT: WA Q; NSW: Q V') + +# %% [markdown] +# We will input `australia_small` to the `tree_csp_solver` and print the given assignment. + +# %% +assignment = tree_csp_solver(australia_small) +print(assignment) + +# %% [markdown] +# `WA`, `Q` and `V` got painted with the same color and `NT` and `NSW` got painted with the other. + +# %% [markdown] +# ## GRAPH COLORING VISUALIZATION +# +# Next, we define some functions to create the visualisation from the assignment_history of **coloring_problem1**. The readers need not concern themselves with the code that immediately follows as it is the usage of Matplotib with IPython Widgets. If you are interested in reading more about these, visit [ipywidgets.readthedocs.io](http://ipywidgets.readthedocs.io). We will be using the **networkx** library to generate graphs. These graphs can be treated as graphs that need to be colored or as constraint graphs for this problem. If interested you can check out a fairly simple tutorial [here](https://www.udacity.com/wiki/creating-network-graphs-with-python). We start by importing the necessary libraries and initializing matplotlib inline. +# + +# %% +# %matplotlib inline +import networkx as nx +import matplotlib.pyplot as plt +import matplotlib +import time + + +# %% [markdown] +# The ipython widgets we will be using require the plots in the form of a step function such that there is a graph corresponding to each value. We define the **make_update_step_function** which returns such a function. It takes in as inputs the neighbors/graph along with an instance of the **InstruCSP**. The example below will elaborate it further. If this sounds confusing, don't worry. This is not part of the core material and our only goal is to help you visualize how the process works. + +# %% +def make_update_step_function(graph, instru_csp): + + #define a function to draw the graphs + def draw_graph(graph): + + G=nx.Graph(graph) + pos = nx.spring_layout(G,k=0.15) + return (G, pos) + + G, pos = draw_graph(graph) + + def update_step(iteration): + # here iteration is the index of the assignment_history we want to visualize. + current = instru_csp.assignment_history[iteration] + # We convert the particular assignment to a default dict so that the color for nodes which + # have not been assigned defaults to black. + current = defaultdict(lambda: 'Black', current) + + # Now we use colors in the list and default to black otherwise. + colors = [current[node] for node in G.nodes.keys()] + # Finally drawing the nodes. + nx.draw(G, pos, node_color=colors, node_size=500) + + labels = {label:label for label in G.nodes} + # Labels shifted by offset so that nodes don't overlap + label_pos = {key:[value[0], value[1]+0.03] for key, value in pos.items()} + nx.draw_networkx_labels(G, label_pos, labels, font_size=20) + + # display the graph + plt.show() + + return update_step # <-- this is a function + +def make_visualize(slider): + ''' Takes an input a slider and returns + callback function for timer and animation + ''' + + def visualize_callback(Visualize, time_step): + if Visualize is True: + for i in range(slider.min, slider.max + 1): + slider.value = i + time.sleep(float(time_step)) + + return visualize_callback + + + +# %% [markdown] +# Finally let us plot our problem. We first use the function below to obtain a step function. + +# %% +step_func = make_update_step_function(neighbors, coloring_problem1) + +# %% [markdown] +# Next, we set the canvas size. + +# %% +matplotlib.rcParams['figure.figsize'] = (18.0, 18.0) + +# %% [markdown] +# Finally, our plot using ipywidget slider and matplotib. You can move the slider to experiment and see the colors change. It is also possible to move the slider using arrow keys or to jump to the value by directly editing the number with a double click. The **Visualize Button** will automatically animate the slider for you. The **Extra Delay Box** allows you to set time delay in seconds (upto one second) for each time step. + +# %% +import ipywidgets as widgets +from IPython.display import display + +iteration_slider = widgets.IntSlider(min=0, max=len(coloring_problem1.assignment_history)-1, step=1, value=0) +w=widgets.interactive(step_func,iteration=iteration_slider) +display(w) + +visualize_callback = make_visualize(iteration_slider) + +visualize_button = widgets.ToggleButton(description = "Visualize", value = False) +time_select = widgets.ToggleButtons(description='Extra Delay:',options=['0', '0.1', '0.2', '0.5', '0.7', '1.0']) + +a = widgets.interactive(visualize_callback, Visualize = visualize_button, time_step=time_select) +display(a) + + +# %% [markdown] +# ## N-QUEENS VISUALIZATION +# +# Just like the Graph Coloring Problem, we will start with defining a few helper functions to help us visualize the assignments as they evolve over time. The **make_plot_board_step_function** behaves similar to the **make_update_step_function** introduced earlier. It initializes a chess board in the form of a 2D grid with alternating 0s and 1s. This is used by **plot_board_step** function which draws the board using matplotlib and adds queens to it. This function also calls the **label_queen_conflicts** which modifies the grid placing a 3 in any position where there is a conflict. + +# %% +def label_queen_conflicts(assignment,grid): + ''' Mark grid with queens that are under conflict. ''' + for col, row in assignment.items(): # check each queen for conflict + conflicts = {temp_col:temp_row for temp_col,temp_row in assignment.items() + if (temp_row == row and temp_col != col) + or (temp_row+temp_col == row+col and temp_col != col) + or (temp_row-temp_col == row-col and temp_col != col)} + + # Place a 3 in positions where this is a conflict + for col, row in conflicts.items(): + grid[col][row] = 3 + + return grid + +def make_plot_board_step_function(instru_csp): + '''ipywidgets interactive function supports + single parameter as input. This function + creates and return such a function by taking + in input other parameters. + ''' + n = len(instru_csp.variables) + + + def plot_board_step(iteration): + ''' Add Queens to the Board.''' + data = instru_csp.assignment_history[iteration] + + grid = [[(col+row+1)%2 for col in range(n)] for row in range(n)] + grid = label_queen_conflicts(data, grid) # Update grid with conflict labels. + + # color map of fixed colors + cmap = matplotlib.colors.ListedColormap(['white','lightsteelblue','red']) + bounds=[0,1,2,3] # 0 for white 1 for black 2 onwards for conflict labels (red). + norm = matplotlib.colors.BoundaryNorm(bounds, cmap.N) + + fig = plt.imshow(grid, interpolation='nearest', cmap = cmap,norm=norm) + + plt.axis('off') + fig.axes.get_xaxis().set_visible(False) + fig.axes.get_yaxis().set_visible(False) + + # Place the Queens Unicode Symbol + for col, row in data.items(): + fig.axes.text(row, col, u"\u265B", va='center', ha='center', family='Dejavu Sans', fontsize=32) + plt.show() + + return plot_board_step + +# %% [markdown] +# Now let us visualize a solution obtained via backtracking. We make use of the previosuly defined **make_instru** function for keeping a history of steps. + +# %% +twelve_queens_csp = NQueensCSP(12) +backtracking_instru_queen = make_instru(twelve_queens_csp) +result = backtracking_search(backtracking_instru_queen) + +# %% +backtrack_queen_step = make_plot_board_step_function(backtracking_instru_queen) # Step Function for Widgets + +# %% [markdown] +# Now finally we set some matplotlib parameters to adjust how our plot will look like. The font is necessary because the Black Queen Unicode character is not a part of all fonts. You can move the slider to experiment and observe how the queens are assigned. It is also possible to move the slider using arrow keys or to jump to the value by directly editing the number with a double click. The **Visualize Button** will automatically animate the slider for you. The **Extra Delay Box** allows you to set time delay in seconds of upto one second for each time step. + +# %% +matplotlib.rcParams['figure.figsize'] = (8.0, 8.0) +matplotlib.rcParams['font.family'].append(u'Dejavu Sans') + +iteration_slider = widgets.IntSlider(min=0, max=len(backtracking_instru_queen.assignment_history)-1, step=0, value=0) +w=widgets.interactive(backtrack_queen_step,iteration=iteration_slider) +display(w) + +visualize_callback = make_visualize(iteration_slider) + +visualize_button = widgets.ToggleButton(description = "Visualize", value = False) +time_select = widgets.ToggleButtons(description='Extra Delay:',options=['0', '0.1', '0.2', '0.5', '0.7', '1.0']) + +a = widgets.interactive(visualize_callback, Visualize = visualize_button, time_step=time_select) +display(a) + +# %% [markdown] +# Now let us finally repeat the above steps for **min_conflicts** solution. + +# %% +conflicts_instru_queen = make_instru(twelve_queens_csp) +result = min_conflicts(conflicts_instru_queen) + +# %% +conflicts_step = make_plot_board_step_function(conflicts_instru_queen) + +# %% [markdown] +# This visualization has same features as the one above; however, this one also highlights the conflicts by labeling the conflicted queens with a red background. + +# %% +iteration_slider = widgets.IntSlider(min=0, max=len(conflicts_instru_queen.assignment_history)-1, step=0, value=0) +w=widgets.interactive(conflicts_step,iteration=iteration_slider) +display(w) + +visualize_callback = make_visualize(iteration_slider) + +visualize_button = widgets.ToggleButton(description = "Visualize", value = False) +time_select = widgets.ToggleButtons(description='Extra Delay:',options=['0', '0.1', '0.2', '0.5', '0.7', '1.0']) + +a = widgets.interactive(visualize_callback, Visualize = visualize_button, time_step=time_select) +display(a) + +# %% diff --git a/notebooks/dynamic_decision_network.ipynb b/notebooks/dynamic_decision_network.ipynb new file mode 100644 index 000000000..deb71059a --- /dev/null +++ b/notebooks/dynamic_decision_network.ipynb @@ -0,0 +1,161 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, + { + "cell_type": "markdown", + "id": "07e68a51", + "metadata": {}, + "source": [ + "# Dynamic Decision Networks (Section 17.4)\n", + "\n", + "Online decision making for a POMDP modeled as a dynamic decision network, using the belief-state look-ahead agent in [`mdp.py`](mdp.py)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "bded6ebb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:10.709493Z", + "iopub.status.busy": "2026-06-23T10:42:10.709226Z", + "iopub.status.idle": "2026-06-23T10:42:10.874538Z", + "shell.execute_reply": "2026-06-23T10:42:10.872073Z" + } + }, + "outputs": [], + "source": [ + "from aima.mdp import POMDP, update_belief, pomdp_lookahead" + ] + }, + { + "cell_type": "markdown", + "id": "f0fb1298", + "metadata": {}, + "source": [ + "A two-state 'tiger-like' POMDP: action 0 pays off in state 0, action 1 in state 1, and action 2 is a sensing action (small cost, informative observation)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cccf717e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:10.877731Z", + "iopub.status.busy": "2026-06-23T10:42:10.877319Z", + "iopub.status.idle": "2026-06-23T10:42:10.883821Z", + "shell.execute_reply": "2026-06-23T10:42:10.882142Z" + } + }, + "outputs": [], + "source": [ + "t_prob = [[[0.65, 0.35], [0.65, 0.35]], [[0.65, 0.35], [0.65, 0.35]], [[1.0, 0.0], [0.0, 1.0]]]\n", + "e_prob = [[[0.5, 0.5], [0.5, 0.5]], [[0.5, 0.5], [0.5, 0.5]], [[0.8, 0.2], [0.3, 0.7]]]\n", + "rewards = [[5, -10], [-20, 5], [-1, -1]]\n", + "pomdp = POMDP(('0', '1', '2'), t_prob, e_prob, rewards, ('0', '1'), gamma=0.95)" + ] + }, + { + "cell_type": "markdown", + "id": "1b6315c8", + "metadata": {}, + "source": [ + "### Belief update (POMDP filtering, Equation 17.17)\n", + "Observation 0 (more likely in state 0) shifts a uniform belief towards state 0." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "e117ad73", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:10.888179Z", + "iopub.status.busy": "2026-06-23T10:42:10.887720Z", + "iopub.status.idle": "2026-06-23T10:42:10.910700Z", + "shell.execute_reply": "2026-06-23T10:42:10.909496Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "belief after sensing obs 0: [0.727, 0.273]\n" + ] + } + ], + "source": [ + "print('belief after sensing obs 0:', [round(b, 3) for b in update_belief(pomdp, [0.5, 0.5], '2', 0)])" + ] + }, + { + "cell_type": "markdown", + "id": "578f7466", + "metadata": {}, + "source": [ + "### Look-ahead decisions\n", + "When the state is known the agent commits to the rewarding action; when it is unknown it prefers to gather information first." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "d904ab41", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:10.914146Z", + "iopub.status.busy": "2026-06-23T10:42:10.913844Z", + "iopub.status.idle": "2026-06-23T10:42:10.937111Z", + "shell.execute_reply": "2026-06-23T10:42:10.936126Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "belief [0.9, 0.1], depth 1 -> action 0\n", + "belief [0.1, 0.9], depth 1 -> action 1\n", + "belief [0.5, 0.5], depth 2 -> action 2 (sense)\n" + ] + } + ], + "source": [ + "print('belief [0.9, 0.1], depth 1 -> action', pomdp_lookahead(pomdp, [0.9, 0.1], depth=1))\n", + "print('belief [0.1, 0.9], depth 1 -> action', pomdp_lookahead(pomdp, [0.1, 0.9], depth=1))\n", + "print('belief [0.5, 0.5], depth 2 -> action', pomdp_lookahead(pomdp, [0.5, 0.5], depth=2), '(sense)')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/dynamic_decision_network.py b/notebooks/dynamic_decision_network.py new file mode 100644 index 000000000..ad16c99fd --- /dev/null +++ b/notebooks/dynamic_decision_network.py @@ -0,0 +1,49 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Dynamic Decision Networks (Section 17.4) +# +# Online decision making for a POMDP modeled as a dynamic decision network, using the belief-state look-ahead agent in [`mdp.py`](mdp.py). + +# %% +from aima.mdp import POMDP, update_belief, pomdp_lookahead + +# %% [markdown] +# A two-state 'tiger-like' POMDP: action 0 pays off in state 0, action 1 in state 1, and action 2 is a sensing action (small cost, informative observation). + +# %% +t_prob = [[[0.65, 0.35], [0.65, 0.35]], [[0.65, 0.35], [0.65, 0.35]], [[1.0, 0.0], [0.0, 1.0]]] +e_prob = [[[0.5, 0.5], [0.5, 0.5]], [[0.5, 0.5], [0.5, 0.5]], [[0.8, 0.2], [0.3, 0.7]]] +rewards = [[5, -10], [-20, 5], [-1, -1]] +pomdp = POMDP(('0', '1', '2'), t_prob, e_prob, rewards, ('0', '1'), gamma=0.95) + +# %% [markdown] +# ### Belief update (POMDP filtering, Equation 17.17) +# Observation 0 (more likely in state 0) shifts a uniform belief towards state 0. + +# %% +print('belief after sensing obs 0:', [round(b, 3) for b in update_belief(pomdp, [0.5, 0.5], '2', 0)]) + +# %% [markdown] +# ### Look-ahead decisions +# When the state is known the agent commits to the rewarding action; when it is unknown it prefers to gather information first. + +# %% +print('belief [0.9, 0.1], depth 1 -> action', pomdp_lookahead(pomdp, [0.9, 0.1], depth=1)) +print('belief [0.1, 0.9], depth 1 -> action', pomdp_lookahead(pomdp, [0.1, 0.9], depth=1)) +print('belief [0.5, 0.5], depth 2 -> action', pomdp_lookahead(pomdp, [0.5, 0.5], depth=2), '(sense)') diff --git a/notebooks/expectation_maximization.ipynb b/notebooks/expectation_maximization.ipynb new file mode 100644 index 000000000..e1889d455 --- /dev/null +++ b/notebooks/expectation_maximization.ipynb @@ -0,0 +1,207 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, + { + "cell_type": "markdown", + "id": "eec33447", + "metadata": {}, + "source": [ + "# Expectation-Maximization (Section 20.3)\n", + "\n", + "The three instances of EM from the book, implemented in [`learning.py`](learning.py) and [`probability.py`](probability.py)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "6adde261", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:04.240499Z", + "iopub.status.busy": "2026-06-23T10:42:04.240195Z", + "iopub.status.idle": "2026-06-23T10:42:06.059481Z", + "shell.execute_reply": "2026-06-23T10:42:06.057558Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "from aima.learning import gaussian_mixture_em, naive_bayes_em\n", + "from aima.probability import baum_welch, HiddenMarkovModel, T, F" + ] + }, + { + "cell_type": "markdown", + "id": "b6d161a3", + "metadata": {}, + "source": [ + "## 20.3.1 Unsupervised clustering: mixture of Gaussians\n", + "EM recovers two Gaussian blobs without any labels." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "3cb0c395", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:06.062328Z", + "iopub.status.busy": "2026-06-23T10:42:06.061924Z", + "iopub.status.idle": "2026-06-23T10:42:06.430470Z", + "shell.execute_reply": "2026-06-23T10:42:06.429308Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "recovered means:\n", + " [[-0.03 0.02]\n", + " [ 8.01 7.94]]\n" + ] + }, + { + "data": { + "image/png": "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TCYTBoOBwMBAbrvtNubMmcP06dPx8PDgk08+QdM0m0CoIlVJVi1177338vzzz/P222/z008/MXny5CrdL2qGzIgIGzfffDMeHh6MGTOG2NhY7rvvvgt6ztatWzl+/DiKorB+/XrrWnlllG4HPbdQVtn18XOVftCfy8fHh6CgIIKDgwkODiYkJISGDRsyY8YMunfv7vCe0t/QHY25dExlf4uvDF9fXxo3bszOnTttfqMFy2/PpbMhpR9IcHbGoHv37mzcuJE33niDKVOmMGnSJLstsy4uLg4Lizn6nk2dOpVDhw7xzDPPUFhYyOTJk2nYsCE//fRTla45V1XGC5Z8mHN/Ey79+st+LRcylrLPKy/5Mjg42PqzERISQkhICEOHDmXGjBkYjUbrf2Oz2Vzu88/3s1L25+S2227D29ubr776CrDMSJhMJocfxOcbW6nyfu7d3d0JCgqyeUZoaChTpkyxBrmKojj82ir6eh0p7xmKoth8/Y8++ihZWVn8+OOPFBYW8s0339CtWzeHQdzFcuedd1JcXMw999xDVlYW99xzT7X1JS6czIgIGx4eHtx666189dVXdOrUyToDUBXJycncfvvttG/fnmeeeYZbb72Vl156iZdfftl6TekHzrkfyoB1Cef06dM27Xv37q30GCIjIwkKCsLT07PKv0XVqVMHX19f646FskrbLuQfzzFjxvDss8/yxx9/MGzYsPNen5eXR0JCAg8++KDNP+hJSUmcOHGCDh062Iw5JSUFs9lsE8yU9z1r0qQJTzzxBE888QR5eXl06dKFqVOnMnLkyCpdc6HjraqqjqUipTMcnTt35uabbz7vdVu3bi13lqC0fffu3TazE5qmsWfPHpufE09PT0aOHMncuXN5//33mT17Nr1796Zx48ZVHltF2rdvz5IlS5gwYUKFNV5atWrF6tWrKSkpsbmubPJ5Zezdu5dbbrnFpm3Pnj00b97c5mexS5cuXHXVVXzyySe4uLiQlpbGG2+8UaW+qqp0abm0bk91Lg+LCyczIsLOlClTmDFjBu+++26V7zWbzYwaNYrCwkLmz5/PiBEjePrpp3nttdesW10B69bLsltVSzVt2pTw8HDmzp1rbSsoKOCLL76o9Dh0Oh2TJk1i8eLF1uJFZf3zzz/WvI1z6fV6xowZw5IlS6y7LABiY2P58MMP6dWrV5WnrwGe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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "np.random.seed(42)\n", + "data = np.vstack([np.random.randn(150, 2) + [0, 0], np.random.randn(150, 2) + [8, 8]])\n", + "model = gaussian_mixture_em(data, k=2)\n", + "print('recovered means:\\n', np.round(model['means'], 2))\n", + "labels = model['responsibilities'].argmax(axis=1)\n", + "plt.scatter(data[:, 0], data[:, 1], c=labels, cmap='coolwarm', s=12)\n", + "plt.scatter(model['means'][:, 0], model['means'][:, 1], c='black', marker='X', s=120)\n", + "plt.title('Mixture of Gaussians recovered by EM'); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "31f53132", + "metadata": {}, + "source": [ + "## 20.3.2 Bayes net with a hidden variable: the candy bags\n", + "Two bags of candy are mixed; the bag is hidden. EM recovers each bag's feature probabilities (bag 1 ~ 0.8, bag 2 ~ 0.3)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6964eed0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:06.435537Z", + "iopub.status.busy": "2026-06-23T10:42:06.435081Z", + "iopub.status.idle": "2026-06-23T10:42:06.538439Z", + "shell.execute_reply": "2026-06-23T10:42:06.536494Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "class weights : [0.528 0.472]\n", + "P(feature=1 | bag) :\n", + " [[0.767 0.769 0.792]\n", + " [0.287 0.308 0.308]]\n" + ] + } + ], + "source": [ + "np.random.seed(0)\n", + "bag1 = (np.random.rand(1000, 3) < 0.8).astype(int)\n", + "bag2 = (np.random.rand(1000, 3) < 0.3).astype(int)\n", + "candy = naive_bayes_em(np.vstack([bag1, bag2]), k=2)\n", + "print('class weights :', np.round(candy['weights'], 3))\n", + "print('P(feature=1 | bag) :\\n', np.round(candy['probabilities'], 3))" + ] + }, + { + "cell_type": "markdown", + "id": "b444572e", + "metadata": {}, + "source": [ + "## 20.3.3 Learning an HMM: Baum-Welch\n", + "Starting from a guess, Baum-Welch increases the likelihood of the umbrella observation sequence at every iteration." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "fad88173", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:06.541531Z", + "iopub.status.busy": "2026-06-23T10:42:06.541229Z", + "iopub.status.idle": "2026-06-23T10:42:06.626153Z", + "shell.execute_reply": "2026-06-23T10:42:06.624475Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "log-likelihood per iteration: [np.float64(-8.676), np.float64(-7.811), np.float64(-7.712), np.float64(-7.666), np.float64(-7.637), np.float64(-7.617), np.float64(-7.603), np.float64(-7.594), np.float64(-7.587), np.float64(-7.582), np.float64(-7.579)]\n", + "monotonically non-decreasing: True\n" + ] + } + ], + "source": [ + "hmm = HiddenMarkovModel([[0.7, 0.3], [0.3, 0.7]], [[0.9, 0.2], [0.1, 0.8]])\n", + "obs = [T, T, F, T, T, F, F, F, T, F, T, T]\n", + "\n", + "def loglik(h, obs):\n", + " A, sensor = np.array(h.transition_model), np.array(h.sensor_model)\n", + " B = np.array([sensor[0] if e else sensor[1] for e in obs])\n", + " a = np.array(h.prior) * B[0]; ll = np.log(a.sum()); a /= a.sum()\n", + " for t in range(1, len(obs)):\n", + " a = B[t] * (a @ A); ll += np.log(a.sum()); a /= a.sum()\n", + " return ll\n", + "\n", + "lls = [loglik(baum_welch(hmm, obs, iterations=k), obs) for k in range(0, 21, 2)]\n", + "print('log-likelihood per iteration:', [round(x, 3) for x in lls])\n", + "print('monotonically non-decreasing:', all(b >= a - 1e-9 for a, b in zip(lls, lls[1:])))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/expectation_maximization.py b/notebooks/expectation_maximization.py new file mode 100644 index 000000000..f3fc08aea --- /dev/null +++ b/notebooks/expectation_maximization.py @@ -0,0 +1,73 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Expectation-Maximization (Section 20.3) +# +# The three instances of EM from the book, implemented in [`learning.py`](learning.py) and [`probability.py`](probability.py). + +# %% +import numpy as np +import matplotlib.pyplot as plt +from aima.learning import gaussian_mixture_em, naive_bayes_em +from aima.probability import baum_welch, HiddenMarkovModel, T, F + +# %% [markdown] +# ## 20.3.1 Unsupervised clustering: mixture of Gaussians +# EM recovers two Gaussian blobs without any labels. + +# %% +np.random.seed(42) +data = np.vstack([np.random.randn(150, 2) + [0, 0], np.random.randn(150, 2) + [8, 8]]) +model = gaussian_mixture_em(data, k=2) +print('recovered means:\n', np.round(model['means'], 2)) +labels = model['responsibilities'].argmax(axis=1) +plt.scatter(data[:, 0], data[:, 1], c=labels, cmap='coolwarm', s=12) +plt.scatter(model['means'][:, 0], model['means'][:, 1], c='black', marker='X', s=120) +plt.title('Mixture of Gaussians recovered by EM'); plt.show() + +# %% [markdown] +# ## 20.3.2 Bayes net with a hidden variable: the candy bags +# Two bags of candy are mixed; the bag is hidden. EM recovers each bag's feature probabilities (bag 1 ~ 0.8, bag 2 ~ 0.3). + +# %% +np.random.seed(0) +bag1 = (np.random.rand(1000, 3) < 0.8).astype(int) +bag2 = (np.random.rand(1000, 3) < 0.3).astype(int) +candy = naive_bayes_em(np.vstack([bag1, bag2]), k=2) +print('class weights :', np.round(candy['weights'], 3)) +print('P(feature=1 | bag) :\n', np.round(candy['probabilities'], 3)) + +# %% [markdown] +# ## 20.3.3 Learning an HMM: Baum-Welch +# Starting from a guess, Baum-Welch increases the likelihood of the umbrella observation sequence at every iteration. + +# %% +hmm = HiddenMarkovModel([[0.7, 0.3], [0.3, 0.7]], [[0.9, 0.2], [0.1, 0.8]]) +obs = [T, T, F, T, T, F, F, F, T, F, T, T] + +def loglik(h, obs): + A, sensor = np.array(h.transition_model), np.array(h.sensor_model) + B = np.array([sensor[0] if e else sensor[1] for e in obs]) + a = np.array(h.prior) * B[0]; ll = np.log(a.sum()); a /= a.sum() + for t in range(1, len(obs)): + a = B[t] * (a @ A); ll += np.log(a.sum()); a /= a.sum() + return ll + +lls = [loglik(baum_welch(hmm, obs, iterations=k), obs) for k in range(0, 21, 2)] +print('log-likelihood per iteration:', [round(x, 3) for x in lls]) +print('monotonically non-decreasing:', all(b >= a - 1e-9 for a, b in zip(lls, lls[1:]))) diff --git a/notebooks/game_theory.ipynb b/notebooks/game_theory.ipynb new file mode 100644 index 000000000..4f7fcbf96 --- /dev/null +++ b/notebooks/game_theory.ipynb @@ -0,0 +1,274 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, + { + "cell_type": "markdown", + "id": "172aa32c", + "metadata": {}, + "source": [ + "# Game Theory and Social Choice (Chapter 18)\n", + "\n", + "Demonstrations of the non-cooperative game theory, cooperative game theory and social-choice algorithms in [`game_theory.py`](game_theory.py)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "3ef4f748", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:01.803870Z", + "iopub.status.busy": "2026-06-23T10:42:01.803624Z", + "iopub.status.idle": "2026-06-23T10:42:02.525662Z", + "shell.execute_reply": "2026-06-23T10:42:02.524026Z" + } + }, + "outputs": [], + "source": [ + "from aima.game_theory import *" + ] + }, + { + "cell_type": "markdown", + "id": "7c29c30e", + "metadata": {}, + "source": [ + "## 18.2 Non-cooperative game theory\n", + "\n", + "### Dominant strategies in the prisoner's dilemma\n", + "Payoffs are utilities (minus the years in prison); row 0 = *testify*, row 1 = *refuse*." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "7c27c8af", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:02.528013Z", + "iopub.status.busy": "2026-06-23T10:42:02.527706Z", + "iopub.status.idle": "2026-06-23T10:42:02.535439Z", + "shell.execute_reply": "2026-06-23T10:42:02.534219Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Ali dominant strategy (0 = testify): 0\n", + "Iterated dominance leaves (rows, cols): ([0], [0])\n", + "Pure Nash equilibria: [(0, 0)]\n" + ] + } + ], + "source": [ + "ali = [[-5, 0], [-10, -1]]\n", + "bo = [[-5, -10], [0, -1]]\n", + "print('Ali dominant strategy (0 = testify):', dominant_strategy(ali))\n", + "print('Iterated dominance leaves (rows, cols):', iterated_dominance(ali, bo))\n", + "print('Pure Nash equilibria:', pure_nash_equilibria(ali, bo))" + ] + }, + { + "cell_type": "markdown", + "id": "e8b2e804", + "metadata": {}, + "source": [ + "### Zero-sum games: two-finger Morra\n", + "Solved by linear programming (von Neumann's minimax theorem). The value is $-1/12$ and the optimal mixed strategy is $[7/12, 5/12]$." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2a459e05", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:02.537737Z", + "iopub.status.busy": "2026-06-23T10:42:02.537462Z", + "iopub.status.idle": "2026-06-23T10:42:02.598999Z", + "shell.execute_reply": "2026-06-23T10:42:02.598176Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "value = -0.0833 (exact -1/12 = -0.0833 )\n", + "row player strategy = [np.float64(0.5833), np.float64(0.4167)]\n", + "matching pennies = -0.0\n" + ] + } + ], + "source": [ + "value, row, col = solve_zero_sum_game([[2, -3], [-3, 4]])\n", + "print('value =', round(value, 4), ' (exact -1/12 =', round(-1/12, 4), ')')\n", + "print('row player strategy =', [round(p, 4) for p in row])\n", + "print('matching pennies =', solve_zero_sum_game([[1, -1], [-1, 1]])[0])" + ] + }, + { + "cell_type": "markdown", + "id": "d89ded35", + "metadata": {}, + "source": [ + "## 18.3 Cooperative game theory\n", + "\n", + "### Shapley value and the core: the glove market\n", + "Players 1 and 2 each hold a left glove, player 3 a right glove; a coalition is worth the number of complete pairs it can make. The scarce right glove captures most of the value." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "28bf885d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:02.605949Z", + "iopub.status.busy": "2026-06-23T10:42:02.605353Z", + "iopub.status.idle": "2026-06-23T10:42:02.615872Z", + "shell.execute_reply": "2026-06-23T10:42:02.613758Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Shapley value: {1: 0.167, 2: 0.167, 3: 0.667}\n", + "(0, 0, 1) in the core? True\n", + "(0.5, 0, 0.5) in the core? False\n" + ] + } + ], + "source": [ + "def gloves(coalition):\n", + " return min(len({1, 2} & coalition), len({3} & coalition))\n", + "\n", + "phi = shapley_value([1, 2, 3], gloves)\n", + "print('Shapley value:', {k: round(v, 3) for k, v in phi.items()})\n", + "print('(0, 0, 1) in the core?', is_in_core([1, 2, 3], gloves, {1: 0, 2: 0, 3: 1}))\n", + "print('(0.5, 0, 0.5) in the core?', is_in_core([1, 2, 3], gloves, {1: 0.5, 2: 0, 3: 0.5}))" + ] + }, + { + "cell_type": "markdown", + "id": "29beaf04", + "metadata": {}, + "source": [ + "## 18.4 Making collective decisions\n", + "\n", + "### Voting rules can disagree\n", + "An election with 4 voters `a>b>c`, 3 voters `b>c>a`, 2 voters `c>b>a`: plurality, Borda count and the Condorcet winner all pick different (or no) winners." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "27788c85", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:02.622124Z", + "iopub.status.busy": "2026-06-23T10:42:02.621817Z", + "iopub.status.idle": "2026-06-23T10:42:02.646127Z", + "shell.execute_reply": "2026-06-23T10:42:02.645193Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "plurality: a\n", + "Borda : b\n", + "Condorcet: b\n", + "Condorcet's paradox -> None\n" + ] + } + ], + "source": [ + "election = [['a', 'b', 'c']] * 4 + [['b', 'c', 'a']] * 3 + [['c', 'b', 'a']] * 2\n", + "print('plurality:', plurality_winner(election))\n", + "print('Borda :', borda_winner(election))\n", + "print('Condorcet:', condorcet_winner(election))\n", + "paradox = [['a', 'b', 'c'], ['c', 'a', 'b'], ['b', 'c', 'a']]\n", + "print(\"Condorcet's paradox ->\", condorcet_winner(paradox))" + ] + }, + { + "cell_type": "markdown", + "id": "242ab579", + "metadata": {}, + "source": [ + "### Auctions, contract net and bargaining" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "12d77b9a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:02.648670Z", + "iopub.status.busy": "2026-06-23T10:42:02.648373Z", + "iopub.status.idle": "2026-06-23T10:42:02.673784Z", + "shell.execute_reply": "2026-06-23T10:42:02.672151Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Vickrey winner, price: ('a', 8)\n", + "Contract net: {'paint': ('cheap_painter', 7), 'wire': ('electrician', 5)}\n", + "Bargaining (equal patience 0.8): (0.5555555555555556, 0.4444444444444444)\n", + "Bargaining (A more patient) : (0.9090909090909091, 0.09090909090909094)\n" + ] + } + ], + "source": [ + "print('Vickrey winner, price:', vickrey_auction({'a': 10, 'b': 8, 'c': 5}))\n", + "\n", + "costs = {('painter', 'paint'): 10, ('cheap_painter', 'paint'): 7, ('electrician', 'wire'): 5}\n", + "print('Contract net:', contract_net(['paint', 'wire'],\n", + " ['painter', 'cheap_painter', 'electrician'],\n", + " bid=lambda agent, task: costs.get((agent, task))))\n", + "\n", + "print('Bargaining (equal patience 0.8):', alternating_offers_bargaining(0.8, 0.8))\n", + "print('Bargaining (A more patient) :', alternating_offers_bargaining(0.9, 0.5))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/game_theory.py b/notebooks/game_theory.py new file mode 100644 index 000000000..3ac5fcebe --- /dev/null +++ b/notebooks/game_theory.py @@ -0,0 +1,91 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Game Theory and Social Choice (Chapter 18) +# +# Demonstrations of the non-cooperative game theory, cooperative game theory and social-choice algorithms in [`game_theory.py`](game_theory.py). + +# %% +from aima.game_theory import * + +# %% [markdown] +# ## 18.2 Non-cooperative game theory +# +# ### Dominant strategies in the prisoner's dilemma +# Payoffs are utilities (minus the years in prison); row 0 = *testify*, row 1 = *refuse*. + +# %% +ali = [[-5, 0], [-10, -1]] +bo = [[-5, -10], [0, -1]] +print('Ali dominant strategy (0 = testify):', dominant_strategy(ali)) +print('Iterated dominance leaves (rows, cols):', iterated_dominance(ali, bo)) +print('Pure Nash equilibria:', pure_nash_equilibria(ali, bo)) + +# %% [markdown] +# ### Zero-sum games: two-finger Morra +# Solved by linear programming (von Neumann's minimax theorem). The value is $-1/12$ and the optimal mixed strategy is $[7/12, 5/12]$. + +# %% +value, row, col = solve_zero_sum_game([[2, -3], [-3, 4]]) +print('value =', round(value, 4), ' (exact -1/12 =', round(-1/12, 4), ')') +print('row player strategy =', [round(p, 4) for p in row]) +print('matching pennies =', solve_zero_sum_game([[1, -1], [-1, 1]])[0]) + + +# %% [markdown] +# ## 18.3 Cooperative game theory +# +# ### Shapley value and the core: the glove market +# Players 1 and 2 each hold a left glove, player 3 a right glove; a coalition is worth the number of complete pairs it can make. The scarce right glove captures most of the value. + +# %% +def gloves(coalition): + return min(len({1, 2} & coalition), len({3} & coalition)) + +phi = shapley_value([1, 2, 3], gloves) +print('Shapley value:', {k: round(v, 3) for k, v in phi.items()}) +print('(0, 0, 1) in the core?', is_in_core([1, 2, 3], gloves, {1: 0, 2: 0, 3: 1})) +print('(0.5, 0, 0.5) in the core?', is_in_core([1, 2, 3], gloves, {1: 0.5, 2: 0, 3: 0.5})) + +# %% [markdown] +# ## 18.4 Making collective decisions +# +# ### Voting rules can disagree +# An election with 4 voters `a>b>c`, 3 voters `b>c>a`, 2 voters `c>b>a`: plurality, Borda count and the Condorcet winner all pick different (or no) winners. + +# %% +election = [['a', 'b', 'c']] * 4 + [['b', 'c', 'a']] * 3 + [['c', 'b', 'a']] * 2 +print('plurality:', plurality_winner(election)) +print('Borda :', borda_winner(election)) +print('Condorcet:', condorcet_winner(election)) +paradox = [['a', 'b', 'c'], ['c', 'a', 'b'], ['b', 'c', 'a']] +print("Condorcet's paradox ->", condorcet_winner(paradox)) + +# %% [markdown] +# ### Auctions, contract net and bargaining + +# %% +print('Vickrey winner, price:', vickrey_auction({'a': 10, 'b': 8, 'c': 5})) + +costs = {('painter', 'paint'): 10, ('cheap_painter', 'paint'): 7, ('electrician', 'wire'): 5} +print('Contract net:', contract_net(['paint', 'wire'], + ['painter', 'cheap_painter', 'electrician'], + bid=lambda agent, task: costs.get((agent, task)))) + +print('Bargaining (equal patience 0.8):', alternating_offers_bargaining(0.8, 0.8)) +print('Bargaining (A more patient) :', alternating_offers_bargaining(0.9, 0.5)) diff --git a/games.ipynb b/notebooks/games.ipynb similarity index 97% rename from games.ipynb rename to notebooks/games.ipynb index edf955be8..737ca7d47 100644 --- a/games.ipynb +++ b/notebooks/games.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -33,8 +42,8 @@ }, "outputs": [], "source": [ - "from games import *\n", - "from notebook import psource, pseudocode" + "from aima.games import *\n", + "from aima.notebook_utils import psource, pseudocode" ] }, { @@ -551,7 +560,7 @@ }, "outputs": [], "source": [ - "psource(minimax_decision)" + "psource(minmax_decision)" ] }, { @@ -585,9 +594,9 @@ } ], "source": [ - "print(minimax_decision('B', fig52))\n", - "print(minimax_decision('C', fig52))\n", - "print(minimax_decision('D', fig52))" + "print(minmax_decision('B', fig52))\n", + "print(minmax_decision('C', fig52))\n", + "print(minmax_decision('D', fig52))" ] }, { @@ -611,7 +620,7 @@ } ], "source": [ - "print(minimax_decision('A', fig52))" + "print(minmax_decision('A', fig52))" ] }, { @@ -631,7 +640,7 @@ }, "outputs": [], "source": [ - "from notebook import Canvas_minimax\n", + "from aima.notebook_utils import Canvas_min_max\n", "from random import randint" ] }, @@ -641,7 +650,7 @@ "metadata": {}, "outputs": [], "source": [ - "minimax_viz = Canvas_minimax('minimax_viz', [randint(1, 50) for i in range(27)])" + "minimax_viz = Canvas_min_max('minimax_viz', [randint(1, 50) for i in range(27)])" ] }, { @@ -750,7 +759,7 @@ }, "outputs": [], "source": [ - "%psource alphabeta_search" + "%psource alpha_beta_search" ] }, { @@ -776,7 +785,7 @@ } ], "source": [ - "print(alphabeta_search('A', fig52))" + "print(alpha_beta_search('A', fig52))" ] }, { @@ -804,9 +813,9 @@ } ], "source": [ - "print(alphabeta_search('B', fig52))\n", - "print(alphabeta_search('C', fig52))\n", - "print(alphabeta_search('D', fig52))" + "print(alpha_beta_search('B', fig52))\n", + "print(alpha_beta_search('C', fig52))\n", + "print(alpha_beta_search('D', fig52))" ] }, { @@ -826,7 +835,7 @@ }, "outputs": [], "source": [ - "from notebook import Canvas_alphabeta\n", + "from aima.notebook_utils import Canvas_alpha_beta\n", "from random import randint" ] }, @@ -836,7 +845,7 @@ "metadata": {}, "outputs": [], "source": [ - "alphabeta_viz = Canvas_alphabeta('alphabeta_viz', [randint(1, 50) for i in range(27)])" + "alphabeta_viz = Canvas_alpha_beta('alphabeta_viz', [randint(1, 50) for i in range(27)])" ] }, { @@ -934,9 +943,9 @@ } ], "source": [ - "print( alphabeta_player(game52, 'A') )\n", - "print( alphabeta_player(game52, 'B') )\n", - "print( alphabeta_player(game52, 'C') )" + "print( alpha_beta_player(game52, 'A') )\n", + "print( alpha_beta_player(game52, 'B') )\n", + "print( alpha_beta_player(game52, 'C') )" ] }, { @@ -963,7 +972,7 @@ } ], "source": [ - "minimax_decision('A', game52)" + "minmax_decision('A', game52)" ] }, { @@ -983,7 +992,7 @@ } ], "source": [ - "alphabeta_search('A', game52)" + "alpha_beta_search('A', game52)" ] }, { @@ -1017,7 +1026,7 @@ } ], "source": [ - "game52.play_game(alphabeta_player, alphabeta_player)" + "game52.play_game(alpha_beta_player, alpha_beta_player)" ] }, { @@ -1044,7 +1053,7 @@ } ], "source": [ - "game52.play_game(alphabeta_player, random_player)" + "game52.play_game(alpha_beta_player, random_player)" ] }, { @@ -1076,7 +1085,7 @@ } ], "source": [ - "game52.play_game(query_player, alphabeta_player)" + "game52.play_game(query_player, alpha_beta_player)" ] }, { @@ -1108,7 +1117,7 @@ } ], "source": [ - "game52.play_game(alphabeta_player, query_player)" + "game52.play_game(alpha_beta_player, query_player)" ] }, { @@ -1289,7 +1298,7 @@ } ], "source": [ - "alphabeta_player(ttt, my_state)" + "alpha_beta_player(ttt, my_state)" ] }, { @@ -1325,7 +1334,7 @@ } ], "source": [ - "ttt.play_game(random_player, alphabeta_player)" + "ttt.play_game(random_player, alpha_beta_player)" ] }, { @@ -1391,7 +1400,7 @@ ], "source": [ "for _ in range(10):\n", - " print(ttt.play_game(alphabeta_player, alphabeta_player))" + " print(ttt.play_game(alpha_beta_player, alpha_beta_player))" ] }, { @@ -1455,7 +1464,7 @@ ], "source": [ "for _ in range(10):\n", - " print(ttt.play_game(random_player, alphabeta_player))" + " print(ttt.play_game(random_player, alpha_beta_player))" ] }, { @@ -1477,7 +1486,7 @@ }, "outputs": [], "source": [ - "from notebook import Canvas_TicTacToe" + "from aima.notebook_utils import Canvas_TicTacToe" ] }, { @@ -1527,7 +1536,7 @@ } ], "source": [ - "bot_play = Canvas_TicTacToe('bot_play', 'random', 'alphabeta')" + "bot_play = Canvas_TicTacToe('bot_play', 'random', 'alpha_beta')" ] }, { @@ -1641,7 +1650,7 @@ } ], "source": [ - "ab_play = Canvas_TicTacToe('ab_play', 'human', 'alphabeta')" + "ab_play = Canvas_TicTacToe('ab_play', 'human', 'alpha_beta')" ] } ], @@ -1666,4 +1675,4 @@ }, "nbformat": 4, "nbformat_minor": 1 -} \ No newline at end of file +} diff --git a/notebooks/games.py b/notebooks/games.py new file mode 100644 index 000000000..1c6377df4 --- /dev/null +++ b/notebooks/games.py @@ -0,0 +1,527 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # GAMES OR ADVERSARIAL SEARCH +# +# This notebook serves as supporting material for topics covered in **Chapter 5 - Adversarial Search** in the book *Artificial Intelligence: A Modern Approach.* This notebook uses implementations from [games.py](https://github.com/aimacode/aima-python/blob/master/games.py) module. Let's import required classes, methods, global variables etc., from games module. + +# %% [markdown] +# # CONTENTS +# +# * Game Representation +# * Game Examples +# * Tic-Tac-Toe +# * Figure 5.2 Game +# * Min-Max +# * Alpha-Beta +# * Players +# * Let's Play Some Games! + +# %% +from aima.games import * +from aima.notebook_utils import psource, pseudocode + +# %% [markdown] +# # GAME REPRESENTATION +# +# To represent games we make use of the `Game` class, which we can subclass and override its functions to represent our own games. A helper tool is the namedtuple `GameState`, which in some cases can come in handy, especially when our game needs us to remember a board (like chess). + +# %% [markdown] +# ## `GameState` namedtuple +# +# `GameState` is a [namedtuple](https://docs.python.org/3.5/library/collections.html#collections.namedtuple) which represents the current state of a game. It is used to help represent games whose states can't be easily represented normally, or for games that require memory of a board, like Tic-Tac-Toe. +# +# `Gamestate` is defined as follows: +# +# `GameState = namedtuple('GameState', 'to_move, utility, board, moves')` +# +# * `to_move`: It represents whose turn it is to move next. +# +# * `utility`: It stores the utility of the game state. Storing this utility is a good idea, because, when you do a Minimax Search or an Alphabeta Search, you generate many recursive calls, which travel all the way down to the terminal states. When these recursive calls go back up to the original callee, we have calculated utilities for many game states. We store these utilities in their respective `GameState`s to avoid calculating them all over again. +# +# * `board`: A dict that stores the board of the game. +# +# * `moves`: It stores the list of legal moves possible from the current position. + +# %% [markdown] +# ## `Game` class +# +# Let's have a look at the class `Game` in our module. We see that it has functions, namely `actions`, `result`, `utility`, `terminal_test`, `to_move` and `display`. +# +# We see that these functions have not actually been implemented. This class is just a template class; we are supposed to create the class for our game, by inheriting this `Game` class and implementing all the methods mentioned in `Game`. + +# %% +# %psource Game + +# %% [markdown] +# Now let's get into details of all the methods in our `Game` class. You have to implement these methods when you create new classes that would represent your game. +# +# * `actions(self, state)`: Given a game state, this method generates all the legal actions possible from this state, as a list or a generator. Returning a generator rather than a list has the advantage that it saves space and you can still operate on it as a list. +# +# +# * `result(self, state, move)`: Given a game state and a move, this method returns the game state that you get by making that move on this game state. +# +# +# * `utility(self, state, player)`: Given a terminal game state and a player, this method returns the utility for that player in the given terminal game state. While implementing this method assume that the game state is a terminal game state. The logic in this module is such that this method will be called only on terminal game states. +# +# +# * `terminal_test(self, state)`: Given a game state, this method should return `True` if this game state is a terminal state, and `False` otherwise. +# +# +# * `to_move(self, state)`: Given a game state, this method returns the player who is to play next. This information is typically stored in the game state, so all this method does is extract this information and return it. +# +# +# * `display(self, state)`: This method prints/displays the current state of the game. + +# %% [markdown] +# # GAME EXAMPLES +# +# Below we give some examples for games you can create and experiment on. + +# %% [markdown] +# ## Tic-Tac-Toe +# +# Take a look at the class `TicTacToe`. All the methods mentioned in the class `Game` have been implemented here. + +# %% +# %psource TicTacToe + +# %% [markdown] +# The class `TicTacToe` has been inherited from the class `Game`. As mentioned earlier, you really want to do this. Catching bugs and errors becomes a whole lot easier. +# +# Additional methods in TicTacToe: +# +# * `__init__(self, h=3, v=3, k=3)` : When you create a class inherited from the `Game` class (class `TicTacToe` in our case), you'll have to create an object of this inherited class to initialize the game. This initialization might require some additional information which would be passed to `__init__` as variables. For the case of our `TicTacToe` game, this additional information would be the number of rows `h`, number of columns `v` and how many consecutive X's or O's are needed in a row, column or diagonal for a win `k`. Also, the initial game state has to be defined here in `__init__`. +# +# +# * `compute_utility(self, board, move, player)` : A method to calculate the utility of TicTacToe game. If 'X' wins with this move, this method returns 1; if 'O' wins return -1; else return 0. +# +# +# * `k_in_row(self, board, move, player, delta_x_y)` : This method returns `True` if there is a line formed on TicTacToe board with the latest move else `False.` + +# %% [markdown] +# ### TicTacToe GameState +# +# Now, before we start implementing our `TicTacToe` game, we need to decide how we will be representing our game state. Typically, a game state will give you all the current information about the game at any point in time. When you are given a game state, you should be able to tell whose turn it is next, how the game will look like on a real-life board (if it has one) etc. A game state need not include the history of the game. If you can play the game further given a game state, you game state representation is acceptable. While we might like to include all kinds of information in our game state, we wouldn't want to put too much information into it. Modifying this game state to generate a new one would be a real pain then. +# +# Now, as for our `TicTacToe` game state, would storing only the positions of all the X's and O's be sufficient to represent all the game information at that point in time? Well, does it tell us whose turn it is next? Looking at the 'X's and O's on the board and counting them should tell us that. But that would mean extra computing. To avoid this, we will also store whose move it is next in the game state. +# +# Think about what we've done here. We have reduced extra computation by storing additional information in a game state. Now, this information might not be absolutely essential to tell us about the state of the game, but it does save us additional computation time. We'll do more of this later on. + +# %% [markdown] +# To store game states will will use the `GameState` namedtuple. +# +# * `to_move`: A string of a single character, either 'X' or 'O'. +# +# * `utility`: 1 for win, -1 for loss, 0 otherwise. +# +# * `board`: All the positions of X's and O's on the board. +# +# * `moves`: All the possible moves from the current state. Note here, that storing the moves as a list, as it is done here, increases the space complexity of Minimax Search from `O(m)` to `O(bm)`. Refer to Sec. 5.2.1 of the book. + +# %% [markdown] +# ### Representing a move in TicTacToe game +# +# Now that we have decided how our game state will be represented, it's time to decide how our move will be represented. Becomes easy to use this move to modify a current game state to generate a new one. +# +# For our `TicTacToe` game, we'll just represent a move by a tuple, where the first and the second elements of the tuple will represent the row and column, respectively, where the next move is to be made. Whether to make an 'X' or an 'O' will be decided by the `to_move` in the `GameState` namedtuple. + +# %% [markdown] +# ## Fig52 Game +# +# For a more trivial example we will represent the game in **Figure 5.2** of the book. +# +# +# +# The states are represented with capital letters inside the triangles (eg. "A") while moves are the labels on the edges between states (eg. "a1"). Terminal nodes carry utility values. Note that the terminal nodes are named in this example 'B1', 'B2' and 'B2' for the nodes below 'B', and so forth. +# +# We will model the moves, utilities and initial state like this: + +# %% +moves = dict(A=dict(a1='B', a2='C', a3='D'), + B=dict(b1='B1', b2='B2', b3='B3'), + C=dict(c1='C1', c2='C2', c3='C3'), + D=dict(d1='D1', d2='D2', d3='D3')) +utils = dict(B1=3, B2=12, B3=8, C1=2, C2=4, C3=6, D1=14, D2=5, D3=2) +initial = 'A' + +# %% [markdown] +# In `moves`, we have a nested dictionary system. The outer's dictionary has keys as the states and values the possible moves from that state (as a dictionary). The inner dictionary of moves has keys the move names and values the next state after the move is complete. +# +# Below is an example that showcases `moves`. We want the next state after move 'a1' from 'A', which is 'B'. A quick glance at the above image confirms that this is indeed the case. + +# %% +print(moves['A']['a1']) + +# %% [markdown] +# We will now take a look at the functions we need to implement. First we need to create an object of the `Fig52Game` class. + +# %% +fig52 = Fig52Game() + +# %% [markdown] +# `actions`: Returns the list of moves one can make from a given state. + +# %% +psource(Fig52Game.actions) + +# %% +print(fig52.actions('B')) + +# %% [markdown] +# `result`: Returns the next state after we make a specific move. + +# %% +psource(Fig52Game.result) + +# %% +print(fig52.result('A', 'a1')) + +# %% [markdown] +# `utility`: Returns the value of the terminal state for a player ('MAX' and 'MIN'). Note that for 'MIN' the value returned is the negative of the utility. + +# %% +psource(Fig52Game.utility) + +# %% +print(fig52.utility('B1', 'MAX')) +print(fig52.utility('B1', 'MIN')) + +# %% [markdown] +# `terminal_test`: Returns `True` if the given state is a terminal state, `False` otherwise. + +# %% +psource(Fig52Game.terminal_test) + +# %% +print(fig52.terminal_test('C3')) + +# %% [markdown] +# `to_move`: Return the player who will move in this state. + +# %% +psource(Fig52Game.to_move) + +# %% +print(fig52.to_move('A')) + +# %% [markdown] +# As a whole the class `Fig52` that inherits from the class `Game` and overrides its functions: + +# %% +psource(Fig52Game) + +# %% [markdown] +# # MIN-MAX +# +# ## Overview +# +# This algorithm (often called *Minimax*) computes the next move for a player (MIN or MAX) at their current state. It recursively computes the minimax value of successor states, until it reaches terminals (the leaves of the tree). Using the `utility` value of the terminal states, it computes the values of parent states until it reaches the initial node (the root of the tree). +# +# It is worth noting that the algorithm works in a depth-first manner. The pseudocode can be found below: + +# %% +pseudocode("Minimax-Decision") + +# %% [markdown] +# ## Implementation +# +# In the implementation we are using two functions, `max_value` and `min_value` to calculate the best move for MAX and MIN respectively. These functions interact in an alternating recursion; one calls the other until a terminal state is reached. When the recursion halts, we are left with scores for each move. We return the max. Despite returning the max, it will work for MIN too since for MIN the values are their negative (hence the order of values is reversed, so the higher the better for MIN too). + +# %% +psource(minmax_decision) + +# %% [markdown] +# ## Example +# +# We will now play the Fig52 game using this algorithm. Take a look at the Fig52Game from above to follow along. +# +# It is the turn of MAX to move, and he is at state A. He can move to B, C or D, using moves a1, a2 and a3 respectively. MAX's goal is to maximize the end value. So, to make a decision, MAX needs to know the values at the aforementioned nodes and pick the greatest one. After MAX, it is MIN's turn to play. So MAX wants to know what will the values of B, C and D be after MIN plays. +# +# The problem then becomes what move will MIN make at B, C and D. The successor states of all these nodes are terminal states, so MIN will pick the smallest value for each node. So, for B he will pick 3 (from move b1), for C he will pick 2 (from move c1) and for D he will again pick 2 (from move d3). +# +# Let's see this in code: + +# %% +print(minmax_decision('B', fig52)) +print(minmax_decision('C', fig52)) +print(minmax_decision('D', fig52)) + +# %% [markdown] +# Now MAX knows that the values for B, C and D are 3, 2 and 2 (produced by the above moves of MIN). The greatest is 3, which he will get with move a1. This is then the move MAX will make. Let's see the algorithm in full action: + +# %% +print(minmax_decision('A', fig52)) + +# %% [markdown] +# ## Visualization +# +# Below we have a simple game visualization using the algorithm. After you run the command, click on the cell to move the game along. You can input your own values via a list of 27 integers. + +# %% +from aima.notebook_utils import Canvas_min_max +from random import randint + +# %% +minimax_viz = Canvas_min_max('minimax_viz', [randint(1, 50) for i in range(27)]) + +# %% [markdown] +# # ALPHA-BETA +# +# ## Overview +# +# While *Minimax* is great for computing a move, it can get tricky when the number of game states gets bigger. The algorithm needs to search all the leaves of the tree, which increase exponentially to its depth. +# +# For Tic-Tac-Toe, where the depth of the tree is 9 (after the 9th move, the game ends), we can have at most 9! terminal states (at most because not all terminal nodes are at the last level of the tree; some are higher up because the game ended before the 9th move). This isn't so bad, but for more complex problems like chess, we have over $10^{40}$ terminal nodes. Unfortunately we have not found a way to cut the exponent away, but we nevertheless have found ways to alleviate the workload. +# +# Here we examine *pruning* the game tree, which means removing parts of it that we do not need to examine. The particular type of pruning is called *alpha-beta*, and the search in whole is called *alpha-beta search*. +# +# To showcase what parts of the tree we don't need to search, we will take a look at the example `Fig52Game`. +# +# In the example game, we need to find the best move for player MAX at state A, which is the maximum value of MIN's possible moves at successor states. +# +# `MAX(A) = MAX( MIN(B), MIN(C), MIN(D) )` +# +# `MIN(B)` is the minimum of 3, 12, 8 which is 3. So the above formula becomes: +# +# `MAX(A) = MAX( 3, MIN(C), MIN(D) )` +# +# Next move we will check is c1, which leads to a terminal state with utility of 2. Before we continue searching under state C, let's pop back into our formula with the new value: +# +# `MAX(A) = MAX( 3, MIN(2, c2, .... cN), MIN(D) )` +# +# We do not know how many moves state C allows, but we know that the first one results in a value of 2. Do we need to keep searching under C? The answer is no. The value MIN will pick on C will at most be 2. Since MAX already has the option to pick something greater than that, 3 from B, he does not need to keep searching under C. +# +# In *alpha-beta* we make use of two additional parameters for each state/node, *a* and *b*, that describe bounds on the possible moves. The parameter *a* denotes the best choice (highest value) for MAX along that path, while *b* denotes the best choice (lowest value) for MIN. As we go along we update *a* and *b* and prune a node branch when the value of the node is worse than the value of *a* and *b* for MAX and MIN respectively. +# +# In the above example, after the search under state B, MAX had an *a* value of 3. So, when searching node C we found a value less than that, 2, we stopped searching under C. +# +# You can read the pseudocode below: + +# %% +pseudocode("Alpha-Beta-Search") + +# %% [markdown] +# ## Implementation +# +# Like *minimax*, we again make use of functions `max_value` and `min_value`, but this time we utilise the *a* and *b* values, updating them and stopping the recursive call if we end up on nodes with values worse than *a* and *b* (for MAX and MIN). The algorithm finds the maximum value and returns the move that results in it. +# +# The implementation: + +# %% +# %psource alpha_beta_search + +# %% [markdown] +# ## Example +# +# We will play the Fig52 Game with the *alpha-beta* search algorithm. It is the turn of MAX to play at state A. + +# %% +print(alpha_beta_search('A', fig52)) + +# %% [markdown] +# The optimal move for MAX is a1, for the reasons given above. MIN will pick move b1 for B resulting in a value of 3, updating the *a* value of MAX to 3. Then, when we find under C a node of value 2, we will stop searching under that sub-tree since it is less than *a*. From D we have a value of 2. So, the best move for MAX is the one resulting in a value of 3, which is a1. +# +# Below we see the best moves for MIN starting from B, C and D respectively. Note that the algorithm in these cases works the same way as *minimax*, since all the nodes below the aforementioned states are terminal. + +# %% +print(alpha_beta_search('B', fig52)) +print(alpha_beta_search('C', fig52)) +print(alpha_beta_search('D', fig52)) + +# %% [markdown] +# ## Visualization +# +# Below you will find the visualization of the alpha-beta algorithm for a simple game. Click on the cell after you run the command to move the game along. You can input your own values via a list of 27 integers. + +# %% +from aima.notebook_utils import Canvas_alpha_beta +from random import randint + +# %% +alphabeta_viz = Canvas_alpha_beta('alphabeta_viz', [randint(1, 50) for i in range(27)]) + +# %% [markdown] +# # PLAYERS +# +# So, we have finished the implementation of the `TicTacToe` and `Fig52Game` classes. What these classes do is defining the rules of the games. We need more to create an AI that can actually play games. This is where `random_player` and `alphabeta_player` come in. +# +# ## query_player +# The `query_player` function allows you, a human opponent, to play the game. This function requires a `display` method to be implemented in your game class, so that successive game states can be displayed on the terminal, making it easier for you to visualize the game and play accordingly. +# +# ## random_player +# The `random_player` is a function that plays random moves in the game. That's it. There isn't much more to this guy. +# +# ## alphabeta_player +# The `alphabeta_player`, on the other hand, calls the `alphabeta_search` function, which returns the best move in the current game state. Thus, the `alphabeta_player` always plays the best move given a game state, assuming that the game tree is small enough to search entirely. +# +# ## minimax_player +# The `minimax_player`, on the other hand calls the `minimax_search` function which returns the best move in the current game state. +# +# ## play_game +# The `play_game` function will be the one that will actually be used to play the game. You pass as arguments to it an instance of the game you want to play and the players you want in this game. Use it to play AI vs AI, AI vs human, or even human vs human matches! + +# %% [markdown] +# # LET'S PLAY SOME GAMES! +# +# ## Game52 +# +# Let's start by experimenting with the `Fig52Game` first. For that we'll create an instance of the subclass Fig52Game inherited from the class Game: + +# %% +game52 = Fig52Game() + +# %% [markdown] +# First we try out our `random_player(game, state)`. Given a game state it will give us a random move every time: + +# %% +print(random_player(game52, 'A')) +print(random_player(game52, 'A')) + +# %% [markdown] +# The `alphabeta_player(game, state)` will always give us the best move possible, for the relevant player (MAX or MIN): + +# %% +print( alpha_beta_player(game52, 'A') ) +print( alpha_beta_player(game52, 'B') ) +print( alpha_beta_player(game52, 'C') ) + +# %% [markdown] +# What the `alphabeta_player` does is, it simply calls the method `alphabeta_full_search`. They both are essentially the same. In the module, both `alphabeta_full_search` and `minimax_decision` have been implemented. They both do the same job and return the same thing, which is, the best move in the current state. It's just that `alphabeta_full_search` is more efficient with regards to time because it prunes the search tree and hence, explores lesser number of states. + +# %% +minmax_decision('A', game52) + +# %% +alpha_beta_search('A', game52) + +# %% [markdown] +# Demonstrating the play_game function on the game52: + +# %% +game52.play_game(alpha_beta_player, alpha_beta_player) + +# %% +game52.play_game(alpha_beta_player, random_player) + +# %% +game52.play_game(query_player, alpha_beta_player) + +# %% +game52.play_game(alpha_beta_player, query_player) + +# %% [markdown] +# Note that if you are the first player then alphabeta_player plays as MIN, and if you are the second player then alphabeta_player plays as MAX. This happens because that's the way the game is defined in the class Fig52Game. Having a look at the code of this class should make it clear. + +# %% [markdown] +# ## TicTacToe +# +# Now let's play `TicTacToe`. First we initialize the game by creating an instance of the subclass TicTacToe inherited from the class Game: + +# %% +ttt = TicTacToe() + +# %% [markdown] +# We can print a state using the display method: + +# %% +ttt.display(ttt.initial) + +# %% [markdown] +# Hmm, so that's the initial state of the game; no X's and no O's. +# +# Let us create a new game state by ourselves to experiment: + +# %% +my_state = GameState( + to_move = 'X', + utility = '0', + board = {(1,1): 'X', (1,2): 'O', (1,3): 'X', + (2,1): 'O', (2,3): 'O', + (3,1): 'X', + }, + moves = [(2,2), (3,2), (3,3)] + ) + +# %% [markdown] +# So, how does this game state look like? + +# %% +ttt.display(my_state) + +# %% [markdown] +# The `random_player` will behave how he is supposed to i.e. *pseudo-randomly*: + +# %% +random_player(ttt, my_state) + +# %% +random_player(ttt, my_state) + +# %% [markdown] +# But the `alphabeta_player` will always give the best move, as expected: + +# %% +alpha_beta_player(ttt, my_state) + +# %% [markdown] +# Now let's make two players play against each other. We use the `play_game` function for this. The `play_game` function makes players play the match against each other and returns the utility for the first player, of the terminal state reached when the game ends. Hence, for our `TicTacToe` game, if we get the output +1, the first player wins, -1 if the second player wins, and 0 if the match ends in a draw. + +# %% +ttt.play_game(random_player, alpha_beta_player) + +# %% [markdown] +# The output is (usually) -1, because `random_player` loses to `alphabeta_player`. Sometimes, however, `random_player` manages to draw with `alphabeta_player`. +# +# Since an `alphabeta_player` plays perfectly, a match between two `alphabeta_player`s should always end in a draw. Let's see if this happens: + +# %% +for _ in range(10): + print(ttt.play_game(alpha_beta_player, alpha_beta_player)) + +# %% [markdown] +# A `random_player` should never win against an `alphabeta_player`. Let's test that. + +# %% +for _ in range(10): + print(ttt.play_game(random_player, alpha_beta_player)) + +# %% [markdown] +# ## Canvas_TicTacToe(Canvas) +# +# This subclass is used to play TicTacToe game interactively in Jupyter notebooks. TicTacToe class is called while initializing this subclass. +# +# Let's have a match between `random_player` and `alphabeta_player`. Click on the board to call players to make a move. + +# %% +from aima.notebook_utils import Canvas_TicTacToe + +# %% +bot_play = Canvas_TicTacToe('bot_play', 'random', 'alpha_beta') + +# %% [markdown] +# Now, let's play a game ourselves against a `random_player`: + +# %% +rand_play = Canvas_TicTacToe('rand_play', 'human', 'random') + +# %% [markdown] +# Yay! We (usually) win. But we cannot win against an `alphabeta_player`, however hard we try. + +# %% +ab_play = Canvas_TicTacToe('ab_play', 'human', 'alpha_beta') diff --git a/improving_sat_algorithms.ipynb b/notebooks/improving_sat_algorithms.ipynb similarity index 99% rename from improving_sat_algorithms.ipynb rename to notebooks/improving_sat_algorithms.ipynb index d461e99c4..ee8e11378 100644 --- a/improving_sat_algorithms.ipynb +++ b/notebooks/improving_sat_algorithms.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": { @@ -24,7 +33,8 @@ "metadata": {}, "outputs": [], "source": [ - "from logic import *" + "from aima.logic import *\n", + "from aima.utils import open_data" ] }, { @@ -998,7 +1008,7 @@ "metadata": {}, "outputs": [], "source": [ - "from csp import *" + "from aima.csp import *" ] }, { @@ -2248,7 +2258,7 @@ "metadata": {}, "outputs": [], "source": [ - "zebra_sat = associate('&', map(to_cnf, map(expr, filter(lambda line: line[0] not in ('c', 'p'), open('aima-data/zebra.cnf').read().splitlines()))))" + "zebra_sat = associate('&', map(to_cnf, map(expr, filter(lambda line: line[0] not in ('c', 'p'), open_data('zebra.cnf').read().splitlines()))))" ] }, { diff --git a/notebooks/improving_sat_algorithms.py b/notebooks/improving_sat_algorithms.py new file mode 100644 index 000000000..8191b6646 --- /dev/null +++ b/notebooks/improving_sat_algorithms.py @@ -0,0 +1,537 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] pycharm={} +# # Propositional Logic +# --- +# # Improving Boolean Satisfiability Algorithms +# +# ## Introduction +# A propositional formula $\Phi$ in *Conjunctive Normal Form* (CNF) is a conjunction of clauses $\omega_j$, with $j \in \{1,...,m\}$. Each clause being a disjunction of literals and each literal being either a positive ($x_i$) or a negative ($\lnot{x_i}$) propositional variable, with $i \in \{1,...,n\}$. By denoting with $[\lnot]$ the possible presence of $\lnot$, we can formally define $\Phi$ as: +# +# $$\bigwedge_{j = 1,...,m}\bigg(\bigvee_{i \in \omega_j} [\lnot] x_i\bigg)$$ +# +# The ***Boolean Satisfiability Problem*** (SAT) consists in determining whether there exists a truth assignment in $\{0, 1\}$ (or equivalently in $\{True,False\}$) for the variables in $\Phi$. + +# %% +from aima.logic import * +from aima.utils import open_data + +# %% [markdown] +# ## DPLL with Branching Heuristics +# The ***Davis-Putnam-Logemann-Loveland*** (DPLL) algorithm is a *complete* (will answer SAT if a solution exists) and *sound* (it will not answer SAT for an unsatisfiable formula) procedue that combines *backtracking search* and *deduction* to decide satisfiability of propositional logic formula in CNF. At each search step a variable and a propositional value are selected for branching purposes. With each branching step, two values can be assigned to a variable, either 0 or 1. Branching corresponds to assigning the chosen value to the chosen variable. Afterwards, the logical consequences of each branching step are evaluated. Each time an unsatisfied clause (ie a *conflict*) is identified, backtracking is executed. Backtracking corresponds to undoing branching steps until an unflipped branch is reached. When both values have been assigned to the selected variable at a branching step, backtracking will undo this branching step. If for the first branching step both values have been considered, and backtracking undoes this first branching step, then the CNF formula can be declared unsatisfiable. This kind of backtracking is called *chronological backtracking*. +# +# Essentially, `DPLL` is a backtracking depth-first search through partial truth assignments which uses a *splitting rule* to replaces the original problem with two smaller subproblems, whereas the original Davis-Putnam procedure uses a variable elimination rule which replaces the original problem with one larger subproblem. Over the years, many heuristics have been proposed in choosing the splitting variable (which variable should be assigned a truth value next). +# +# Search algorithms that are based on a predetermined order of search are called static algorithms, whereas the ones that select them at the runtime are called dynamic. The first SAT search algorithm, the Davis-Putnam procedure is a static algorithm. Static search algorithms are usually very slow in practice and for this reason perform worse than dynamic search algorithms. However, dynamic search algorithms are much harder to design, since they require a heuristic for predetermining the order of search. The fundamental element of a heuristic is a branching strategy for selecting the next branching literal. This must not require a lot of time to compute and yet it must provide a powerful insight into the problem instance. +# +# Two basic heuristics are applied to this algorithm with the potential of cutting the search space in half. These are the *pure literal rule* and the *unit clause rule*. +# - the *pure literal* rule is applied whenever a variable appears with a single polarity in all the unsatisfied clauses. In this case, assigning a truth value to the variable so that all the involved clauses are satisfied is highly effective in the search; +# - if some variable occurs in the current formula in a clause of length 1 then the *unit clause* rule is applied. Here, the literal is selected and a truth value so the respective clause is satisfied is assigned. The iterative application of the unit rule is commonly reffered to as *Boolean Constraint Propagation* (BCP). + +# %% +# %psource dpll_satisfiable + +# %% +# %psource dpll + +# %% [markdown] +# Each of these branching heuristics was applied only after the *pure literal* and the *unit clause* heuristic failed in selecting a splitting variable. + +# %% [markdown] +# ### MOMs + +# %% [markdown] +# MOMs heuristics are simple, efficient and easy to implement. The goal of these heuristics is to prefer the literal having ***Maximum number of Occurences in the Minimum length clauses***. Intuitively, the literals belonging to the minimum length clauses are the most constrained literals in the formula. Branching on them will maximize the effect of BCP and the likelihood of hitting a dead end early in the search tree (for unsatisfiable problems). Conversely, in the case of satisfiable formulas, branching on a highly constrained variable early in the tree will also increase the likelihood of a correct assignment of the remained open literals. +# The MOMs heuristics main disadvatage is that their effectiveness highly depends on the problem instance. It is easy to see that the ideal setting for these heuristics is considering the unsatisfied binary clauses. + +# %% +# %psource min_clauses + +# %% +# %psource moms + +# %% [markdown] +# Over the years, many types of MOMs heuristics have been proposed. +# +# ***MOMSf*** choose the variable $x$ with a maximize the function: +# +# $$[f(x) + f(\lnot{x})] * 2^k + f(x) * f(\lnot{x})$$ +# +# where $f(x)$ is the number of occurrences of $x$ in the smallest unknown clauses, k is a parameter. + +# %% +# %psource momsf + +# %% [markdown] +# ***Freeman’s POSIT*** [[1]](#cite-freeman1995improvements) version counts both the number of positive $x$ and negative $\lnot{x}$ occurrences of a given variable $x$. + +# %% +# %psource posit + +# %% [markdown] +# ***Zabih and McAllester’s*** [[2]](#cite-zabih1988rearrangement) version of the heuristic counts the negative occurrences $\lnot{x}$ of each given variable $x$. + +# %% +# %psource zm + +# %% [markdown] +# ### DLIS & DLCS + +# %% [markdown] +# Literal count heuristics count the number of unresolved clauses in which a given variable $x$ appears as a positive literal, $C_P$ , and as negative literal, $C_N$. These two numbers an either be onsidered individually or ombined. +# +# ***Dynamic Largest Individual Sum*** heuristic considers the values $C_P$ and $C_N$ separately: select the variable with the largest individual value and assign to it value true if $C_P \geq C_N$, value false otherwise. + +# %% +# %psource dlis + +# %% [markdown] +# ***Dynamic Largest Combined Sum*** considers the values $C_P$ and $C_N$ combined: select the variable with the largest sum $C_P + C_N$ and assign to it value true if $C_P \geq C_N$, value false otherwise. + +# %% +# %psource dlcs + +# %% [markdown] +# ### JW & JW2 + +# %% [markdown] +# Two branching heuristics were proposed by ***Jeroslow and Wang*** in [[3]](#cite-jeroslow1990solving). +# +# The *one-sided Jeroslow and Wang*’s heuristic compute: +# +# $$J(l) = \sum_{l \in \omega \land \omega \in \phi} 2^{-|\omega|}$$ +# +# and selects the assignment that satisfies the literal with the largest value $J(l)$. + +# %% +# %psource jw + +# %% [markdown] +# The *two-sided Jeroslow and Wang*’s heuristic identifies the variable $x$ with the largest sum $J(x) + J(\lnot{x})$, and assigns to $x$ value true, if $J(x) \geq J(\lnot{x})$, and value false otherwise. + +# %% +# %psource jw2 + +# %% [markdown] +# ## CDCL with 1UIP Learning Scheme, 2WL Lazy Data Structure, VSIDS Branching Heuristic & Restarts +# +# The ***Conflict-Driven Clause Learning*** (CDCL) solver is an evolution of the *DPLL* algorithm that involves a number of additional key techniques: +# +# - non-chronological backtracking or *backjumping*; +# - *learning* new *clauses* from conflicts during search by exploiting its structure; +# - using *lazy data structures* for storing clauses; +# - *branching heuristics* with low computational overhead and which receive feedback from search; +# - periodically *restarting* search. +# +# The first difference between a DPLL solver and a CDCL solver is the introduction of the *non-chronological backtracking* or *backjumping* when a conflict is identified. This requires an iterative implementation of the algorithm because only if the backtrack stack is managed explicitly it is possible to backtrack more than one level. + +# %% +# %psource cdcl_satisfiable + +# %% [markdown] +# ### Clause Learning with 1UIP Scheme + +# %% [markdown] +# The second important difference between a DPLL solver and a CDCL solver is that the information about a conflict is reused by learning: if a conflicting clause is found, the solver derive a new clause from the conflict and add it to the clauses database. +# +# Whenever a conflict is identified due to unit propagation, a conflict analysis procedure is invoked. As a result, one or more new clauses are learnt, and a backtracking decision level is computed. The conflict analysis procedure analyzes the structure of unit propagation and decides which literals to include in the learnt clause. The decision levels associated with assigned variables define a partial order of the variables. Starting from a given unsatisfied clause (represented in the implication graph with vertex $\kappa$), the conflict analysis procedure visits variables implied at the most recent decision level (ie the current largest decision level), identifies the antecedents of visited variables, and keeps from the antecedents the literals assigned at decision levels less than the most recent decision level. The clause learning procedure used in the CDCL can be defined by a sequence of selective resolution operations, that at each step yields a new temporary clause. This process is repeated until the most recent decision variable is visited. +# +# The structure of implied assignments induced by unit propagation is a key aspect of the clause learning procedure. Moreover, the idea of exploiting the structure induced by unit propagation was further exploited with ***Unit Implication Points*** (UIPs). A UIP is a *dominator* in the implication graph and represents an alternative decision assignment at the current decision level that results in the same conflict. The main motivation for identifying UIPs is to reduce the size of learnt clauses. Clause learning could potentially stop at any UIP, being quite straightforward to conclude that the set of literals of a clause learnt at the first UIP has clear advantages. Considering the largest decision level of the literals of the clause learnt at each UIP, the clause learnt at the first UIP is guaranteed to contain the smallest one. This guarantees the highest backtrack jump in the search tree. + +# %% +# %psource conflict_analysis + +# %% +# %psource pl_binary_resolution + +# %% +# %psource backjump + +# %% [markdown] +# ### 2WL Lazy Data Structure + +# %% [markdown] +# Implementation issues for SAT solvers include the design of suitable data structures for storing clauses. The implemented data structures dictate the way BCP are implemented and have a significant impact on the run time performance of the SAT solver. Recent state-of-the-art SAT solvers are characterized by using very efficient data structures, intended to reduce the CPU time required per each node in the search tree. Conversely, traditional SAT data structures are accurate, meaning that is possible to know exactly the value of each literal in the clause. Examples of the most recent SAT data structures, which are not accurate and therefore are called lazy, include the watched literals used in Chaff . +# +# The more recent Chaff SAT solver [[4]](#cite-moskewicz2001chaff) proposed a new data structure, the ***2 Watched Literals*** (2WL), in which two references are associated with each clause. There is no order relation between the two references, allowing the references to move in any direction. The lack of order between the two references has the key advantage that no literal references need to be updated when backtracking takes place. In contrast, unit or unsatisfied clauses are identified only after traversing all the clauses’ literals; a clear drawback. The two watched literal pointers are undifferentiated as there is no order relation. Again, each time one literal pointed by one of these pointers is assigned, the pointer has to move inwards. These pointers may move in both directions. This causes the whole clause to be traversed when the clause becomes unit. In addition, no references have to be kept to the just assigned literals, since pointers do not move when backtracking. + +# %% +# %psource unit_propagation + +# %% +# %psource TwoWLClauseDatabase + +# %% [markdown] +# ### VSIDS Branching Heuristic + +# %% [markdown] +# The early branching heuristics made use of all the information available from the data structures, namely the number of satisfied, unsatisfied and unassigned literals. These heuristics are updated during the search and also take into account the clauses that are learnt. +# +# More recently, a different kind of variable selection heuristic, referred to as ***Variable State Independent Decaying Sum*** (VSIDS), has been proposed by Chaff authors in [[4]](#cite-moskewicz2001chaff). One of the reasons for proposing this new heuristic was the introduction of lazy data structures, where the knowledge of the dynamic size of a clause is not accurate. Hence, the heuristics described above cannot be used. VSIDS selects the literal that appears most frequently over all the clauses, which means that one counter is required for each one of the literals. Initially, all counters are set to zero. During the search, the metrics only have to be updated when a new recorded clause is created. More than to develop an accurate heuristic, the motivation has been to design a fast (but dynamically adapting) heuristic. In fact, one of the key properties of this strategy is the very low overhead, due to being independent of the variable state. + +# %% pycharm={} +# %psource assign_decision_literal + +# %% [markdown] +# ### Restarts + +# %% [markdown] +# Solving NP-complete problems, such as SAT, naturally leads to heavy-tailed run times. To deal with this, SAT solvers frequently restart their search to avoid the runs that take disproportionately longer. What restarting here means is that the solver unsets all variables and starts the search using different variable assignment order. +# +# While at first glance it might seem that restarts should be rare and become rarer as the solving has been going on for longer, so that the SAT solver can actually finish solving the problem, the trend has been towards more aggressive (frequent) restarts. +# +# The reason why frequent restarts help solve problems faster is that while the solver does forget all current variable assignments, it does keep some information, specifically it keeps learnt clauses, effectively sampling the search space, and it keeps the last assigned truth value of each variable, assigning them the same value the next time they are picked to be assigned. + +# %% [markdown] +# #### Luby + +# %% [markdown] +# In this strategy, the number of conflicts between 2 restarts is based on the *Luby* sequence. The *Luby* restart sequence is interesting in that it was proven to be optimal restart strategy for randomized search algorithms where the runs do not share information. While this is not true for SAT solving, as shown in [[5]](cite-haim2014towards) and [[6]](cite-huang2007effect), *Luby* restarts have been quite successful anyway. +# +# The exact description of *Luby* restarts is that the $ith$ restart happens after $u \cdot Luby(i)$ conflicts, where $u$ is a constant and $Luby(i)$ is defined as: +# +# $$Luby(i) = \begin{cases} +# 2^{k-1} & i = 2^k - 1 \\ +# Luby(i - 2^{k-1} + 1) & 2^{k-1} \leq i < 2^k - 1 +# \end{cases} +# $$ +# +# A less exact but more intuitive description of the *Luby* sequence is that all numbers in it are powers of two, and after a number is seen for the second time, the next number is twice as big. The following are the first 16 numbers in the sequence: +# +# $$ (1,1,2,1,1,2,4,1,1,2,1,1,2,4,8,1,...) $$ +# +# From the above, we can see that this restart strategy tends towards frequent restarts, but some runs are kept running for much longer, and there is no upper limit on the longest possible time between two restarts. + +# %% +# %psource luby + +# %% [markdown] +# #### Glucose + +# %% [markdown] +# Glucose restarts were popularized by the *Glucose* solver, and it is an extremely aggressive, dynamic restart strategy. The idea behind it and described in [[7]](cite-audemard2012refining) is that instead of waiting for a fixed amount of conflicts, we restart when the last couple of learnt clauses are, on average, bad. +# +# A bit more precisely, if there were at least $X$ conflicts (and thus $X$ learnt clauses) since the last restart, and the average *Literal Block Distance* (LBD) (a criterion to evaluate the quality of learnt clauses as shown in [[8]](#cite-audemard2009predicting) of the last $X$ learnt clauses was at least $K$ times higher than the average LBD of all learnt clauses, it is time for another restart. Parameters $X$ and $K$ can be tweaked to achieve different restart frequency, and they are usually kept quite small. + +# %% +# %psource glucose + +# %% [markdown] pycharm={} +# ## Experimental Results + +# %% +from aima.csp import * + +# %% [markdown] +# ### Australia + +# %% [markdown] +# #### CSP + +# %% +australia_csp = MapColoringCSP(list('RGB'), """SA: WA NT Q NSW V; NT: WA Q; NSW: Q V; T: """) + +# %% +# %time _, checks = AC3b(australia_csp, arc_heuristic=dom_j_up) +f'AC3b with DOM J UP needs {checks} consistency-checks' + +# %% +# %time backtracking_search(australia_csp, select_unassigned_variable=mrv, inference=forward_checking) + +# %% [markdown] +# #### SAT + +# %% +australia_sat = MapColoringSAT(list('RGB'), """SA: WA NT Q NSW V; NT: WA Q; NSW: Q V; T: """) + +# %% [markdown] +# ##### DPLL + +# %% +# %time model = dpll_satisfiable(australia_sat, branching_heuristic=no_branching_heuristic) + +# %% +# %time model = dpll_satisfiable(australia_sat, branching_heuristic=moms) + +# %% +# %time model = dpll_satisfiable(australia_sat, branching_heuristic=momsf) + +# %% +# %time model = dpll_satisfiable(australia_sat, branching_heuristic=posit) + +# %% +# %time model = dpll_satisfiable(australia_sat, branching_heuristic=zm) + +# %% +# %time model = dpll_satisfiable(australia_sat, branching_heuristic=dlis) + +# %% +# %time model = dpll_satisfiable(australia_sat, branching_heuristic=dlcs) + +# %% +# %time model = dpll_satisfiable(australia_sat, branching_heuristic=jw) + +# %% +# %time model = dpll_satisfiable(australia_sat, branching_heuristic=jw2) + +# %% [markdown] +# ##### CDCL + +# %% +# %time model = cdcl_satisfiable(australia_sat) + +# %% +{var for var, val in model.items() if val} + +# %% [markdown] +# ### France + +# %% [markdown] +# #### CSP + +# %% +france_csp = MapColoringCSP(list('RGBY'), + """AL: LO FC; AQ: MP LI PC; AU: LI CE BO RA LR MP; BO: CE IF CA FC RA + AU; BR: NB PL; CA: IF PI LO FC BO; CE: PL NB NH IF BO AU LI PC; FC: BO + CA LO AL RA; IF: NH PI CA BO CE; LI: PC CE AU MP AQ; LO: CA AL FC; LR: + MP AU RA PA; MP: AQ LI AU LR; NB: NH CE PL BR; NH: PI IF CE NB; NO: + PI; PA: LR RA; PC: PL CE LI AQ; PI: NH NO CA IF; PL: BR NB CE PC; RA: + AU BO FC PA LR""") + +# %% +# %time _, checks = AC3b(france_csp, arc_heuristic=dom_j_up) +f'AC3b with DOM J UP needs {checks} consistency-checks' + +# %% +# %time backtracking_search(france_csp, select_unassigned_variable=mrv, inference=forward_checking) + +# %% [markdown] +# #### SAT + +# %% +france_sat = MapColoringSAT(list('RGBY'), + """AL: LO FC; AQ: MP LI PC; AU: LI CE BO RA LR MP; BO: CE IF CA FC RA + AU; BR: NB PL; CA: IF PI LO FC BO; CE: PL NB NH IF BO AU LI PC; FC: BO + CA LO AL RA; IF: NH PI CA BO CE; LI: PC CE AU MP AQ; LO: CA AL FC; LR: + MP AU RA PA; MP: AQ LI AU LR; NB: NH CE PL BR; NH: PI IF CE NB; NO: + PI; PA: LR RA; PC: PL CE LI AQ; PI: NH NO CA IF; PL: BR NB CE PC; RA: + AU BO FC PA LR""") + +# %% [markdown] +# ##### DPLL + +# %% +# %time model = dpll_satisfiable(france_sat, branching_heuristic=no_branching_heuristic) + +# %% +# %time model = dpll_satisfiable(france_sat, branching_heuristic=moms) + +# %% +# %time model = dpll_satisfiable(france_sat, branching_heuristic=momsf) + +# %% +# %time model = dpll_satisfiable(france_sat, branching_heuristic=posit) + +# %% +# %time model = dpll_satisfiable(france_sat, branching_heuristic=zm) + +# %% +# %time model = dpll_satisfiable(france_sat, branching_heuristic=dlis) + +# %% +# %time model = dpll_satisfiable(france_sat, branching_heuristic=dlcs) + +# %% +# %time model = dpll_satisfiable(france_sat, branching_heuristic=jw) + +# %% +# %time model = dpll_satisfiable(france_sat, branching_heuristic=jw2) + +# %% [markdown] +# ##### CDCL + +# %% +# %time model = cdcl_satisfiable(france_sat) + +# %% +{var for var, val in model.items() if val} + +# %% [markdown] +# ### USA + +# %% [markdown] +# #### CSP + +# %% +usa_csp = MapColoringCSP(list('RGBY'), + """WA: OR ID; OR: ID NV CA; CA: NV AZ; NV: ID UT AZ; ID: MT WY UT; + UT: WY CO AZ; MT: ND SD WY; WY: SD NE CO; CO: NE KA OK NM; NM: OK TX AZ; + ND: MN SD; SD: MN IA NE; NE: IA MO KA; KA: MO OK; OK: MO AR TX; + TX: AR LA; MN: WI IA; IA: WI IL MO; MO: IL KY TN AR; AR: MS TN LA; + LA: MS; WI: MI IL; IL: IN KY; IN: OH KY; MS: TN AL; AL: TN GA FL; + MI: OH IN; OH: PA WV KY; KY: WV VA TN; TN: VA NC GA; GA: NC SC FL; + PA: NY NJ DE MD WV; WV: MD VA; VA: MD DC NC; NC: SC; NY: VT MA CT NJ; + NJ: DE; DE: MD; MD: DC; VT: NH MA; MA: NH RI CT; CT: RI; ME: NH; + HI: ; AK: """) + +# %% +# %time _, checks = AC3b(usa_csp, arc_heuristic=dom_j_up) +f'AC3b with DOM J UP needs {checks} consistency-checks' + +# %% +# %time backtracking_search(usa_csp, select_unassigned_variable=mrv, inference=forward_checking) + +# %% [markdown] +# #### SAT + +# %% +usa_sat = MapColoringSAT(list('RGBY'), + """WA: OR ID; OR: ID NV CA; CA: NV AZ; NV: ID UT AZ; ID: MT WY UT; + UT: WY CO AZ; MT: ND SD WY; WY: SD NE CO; CO: NE KA OK NM; NM: OK TX AZ; + ND: MN SD; SD: MN IA NE; NE: IA MO KA; KA: MO OK; OK: MO AR TX; + TX: AR LA; MN: WI IA; IA: WI IL MO; MO: IL KY TN AR; AR: MS TN LA; + LA: MS; WI: MI IL; IL: IN KY; IN: OH KY; MS: TN AL; AL: TN GA FL; + MI: OH IN; OH: PA WV KY; KY: WV VA TN; TN: VA NC GA; GA: NC SC FL; + PA: NY NJ DE MD WV; WV: MD VA; VA: MD DC NC; NC: SC; NY: VT MA CT NJ; + NJ: DE; DE: MD; MD: DC; VT: NH MA; MA: NH RI CT; CT: RI; ME: NH; + HI: ; AK: """) + +# %% [markdown] +# ##### DPLL + +# %% +# %time model = dpll_satisfiable(usa_sat, branching_heuristic=no_branching_heuristic) + +# %% +# %time model = dpll_satisfiable(usa_sat, branching_heuristic=moms) + +# %% +# %time model = dpll_satisfiable(usa_sat, branching_heuristic=momsf) + +# %% +# %time model = dpll_satisfiable(usa_sat, branching_heuristic=posit) + +# %% +# %time model = dpll_satisfiable(usa_sat, branching_heuristic=zm) + +# %% +# %time model = dpll_satisfiable(usa_sat, branching_heuristic=dlis) + +# %% +# %time model = dpll_satisfiable(usa_sat, branching_heuristic=dlcs) + +# %% +# %time model = dpll_satisfiable(usa_sat, branching_heuristic=jw) + +# %% +# %time model = dpll_satisfiable(usa_sat, branching_heuristic=jw2) + +# %% [markdown] +# ##### CDCL + +# %% +# %time model = cdcl_satisfiable(usa_sat) + +# %% +{var for var, val in model.items() if val} + +# %% [markdown] +# ### Zebra Puzzle + +# %% [markdown] +# #### CSP + +# %% +zebra_csp = Zebra() + +# %% +zebra_csp.display(zebra_csp.infer_assignment()) + +# %% +# %time _, checks = AC3b(zebra_csp, arc_heuristic=dom_j_up) +f'AC3b with DOM J UP needs {checks} consistency-checks' + +# %% +zebra_csp.display(zebra_csp.infer_assignment()) + +# %% +# %time backtracking_search(zebra_csp, select_unassigned_variable=mrv, inference=forward_checking) + +# %% [markdown] +# #### SAT + +# %% +zebra_sat = associate('&', map(to_cnf, map(expr, filter(lambda line: line[0] not in ('c', 'p'), open_data('zebra.cnf').read().splitlines())))) + +# %% [markdown] +# ##### DPLL + +# %% +# %time model = dpll_satisfiable(zebra_sat, branching_heuristic=no_branching_heuristic) + +# %% +# %time model = dpll_satisfiable(zebra_sat, branching_heuristic=moms) + +# %% +# %time model = dpll_satisfiable(zebra_sat, branching_heuristic=momsf) + +# %% +# %time model = dpll_satisfiable(zebra_sat, branching_heuristic=posit) + +# %% +# %time model = dpll_satisfiable(zebra_sat, branching_heuristic=zm) + +# %% +# %time model = dpll_satisfiable(zebra_sat, branching_heuristic=dlis) + +# %% +# %time model = dpll_satisfiable(zebra_sat, branching_heuristic=dlcs) + +# %% +# %time model = dpll_satisfiable(zebra_sat, branching_heuristic=jw) + +# %% +# %time model = dpll_satisfiable(zebra_sat, branching_heuristic=jw2) + +# %% [markdown] +# ##### CDCL + +# %% pycharm={} +# %time model = cdcl_satisfiable(zebra_sat) + +# %% +{var for var, val in model.items() if val and var.op.startswith(('Englishman', 'Japanese', 'Norwegian', 'Spaniard', 'Ukrainian'))} + +# %% [markdown] +# ## References +# +# [[1]](#ref-1) Freeman, Jon William. 1995. _Improvements to propositional satisfiability search algorithms_. +# +# [[2]](#ref-2) Zabih, Ramin and McAllester, David A. 1988. _A Rearrangement Search Strategy for Determining Propositional Satisfiability_. +# +# [[3]](#ref-3) Jeroslow, Robert G and Wang, Jinchang. 1990. _Solving propositional satisfiability problems_. +# +# [[4]](#ref-4) Moskewicz, Matthew W and Madigan, Conor F and Zhao, Ying and Zhang, Lintao and Malik, Sharad. 2001. _Chaff: Engineering an efficient SAT solver_. +# +# [[5]](#ref-5) Haim, Shai and Heule, Marijn. 2014. _Towards ultra rapid restarts_. +# +# [[6]](#ref-6) Huang, Jinbo and others. 2007. _The Effect of Restarts on the Efficiency of Clause Learning_. +# +# [[7]](#ref-7) Audemard, Gilles and Simon, Laurent. 2012. _Refining restarts strategies for SAT and UNSAT_. +# +# [[8]](#ref-8) Audemard, Gilles and Simon, Laurent. 2009. _Predicting learnt clauses quality in modern SAT solvers_. diff --git a/index.ipynb b/notebooks/index.ipynb similarity index 94% rename from index.ipynb rename to notebooks/index.ipynb index f9da121f2..92295537f 100644 --- a/index.ipynb +++ b/notebooks/index.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -18,8 +27,6 @@ "\n", "3. [**Search**](./search.ipynb)\n", "\n", - "4. [**Search - 4th edition**](./search4e.ipynb)\n", - "\n", "4. [**Games**](./games.ipynb)\n", "\n", "5. [**Constraint Satisfaction Problems**](./csp.ipynb)\n", diff --git a/notebooks/index.py b/notebooks/index.py new file mode 100644 index 000000000..180b4d9fa --- /dev/null +++ b/notebooks/index.py @@ -0,0 +1,53 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # AIMA Python Binder Index +# +# Welcome to the AIMA Python Code Repository. You should be seeing this index notebook if you clicked on the **Launch Binder** button on the [repository](https://github.com/aimacode/aima-python). If you are viewing this notebook directly on Github we suggest that you use the **Launch Binder** button instead. Binder allows you to experiment with all the code in the browser itself without the need of installing anything on your local machine. Below is the list of notebooks that should assist you in navigating the different notebooks available. +# +# If you are completely new to AIMA Python or Jupyter Notebooks we suggest that you start with the Introduction Notebook. +# +# # List of Notebooks +# +# 1. [**Introduction**](./intro.ipynb) +# +# 2. [**Agents**](./agents.ipynb) +# +# 3. [**Search**](./search.ipynb) +# +# 4. [**Games**](./games.ipynb) +# +# 5. [**Constraint Satisfaction Problems**](./csp.ipynb) +# +# 6. [**Logic**](./logic.ipynb) +# +# 7. [**Planning**](./planning.ipynb) +# +# 8. [**Probability**](./probability.ipynb) +# +# 9. [**Markov Decision Processes**](./mdp.ipynb) +# +# 10. [**Learning**](./learning.ipynb) +# +# 11. [**Reinforcement Learning**](./rl.ipynb) +# +# 12. [**Statistical Language Processing Tools**](./text.ipynb) +# +# 13. [**Natural Language Processing**](./nlp.ipynb) +# +# Besides the notebooks it is also possible to make direct modifications to the Python/JS code. To view/modify the complete set of files [click here](.) to view the Directory structure. diff --git a/intro.ipynb b/notebooks/intro.ipynb similarity index 96% rename from intro.ipynb rename to notebooks/intro.ipynb index 896ed9498..228880c85 100644 --- a/intro.ipynb +++ b/notebooks/intro.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -53,7 +62,7 @@ }, "outputs": [], "source": [ - "from logic import *" + "from aima.logic import *" ] }, { @@ -82,7 +91,7 @@ "metadata": {}, "outputs": [], "source": [ - "from notebook import psource, pseudocode\n", + "from aima.notebook_utils import psource, pseudocode\n", "\n", "psource(WalkSAT)\n", "pseudocode(\"WalkSAT\")" diff --git a/notebooks/intro.py b/notebooks/intro.py new file mode 100644 index 000000000..5789d8a38 --- /dev/null +++ b/notebooks/intro.py @@ -0,0 +1,79 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # An Introduction To `aima-python` +# +# The [aima-python](https://github.com/aimacode/aima-python) repository implements, in Python code, the algorithms in the textbook *[Artificial Intelligence: A Modern Approach](http://aima.cs.berkeley.edu)*. A typical module in the repository has the code for a single chapter in the book, but some modules combine several chapters. See [the index](https://github.com/aimacode/aima-python#index-of-code) if you can't find the algorithm you want. The code in this repository attempts to mirror the pseudocode in the textbook as closely as possible and to stress readability foremost; if you are looking for high-performance code with advanced features, there are other repositories for you. For each module, there are three/four files, for example: +# +# - [**`nlp.py`**](https://github.com/aimacode/aima-python/blob/master/nlp.py): Source code with data types and algorithms for natural language processing; functions have docstrings explaining their use. +# - [**`nlp.ipynb`**](https://github.com/aimacode/aima-python/blob/master/nlp.ipynb): A notebook like this one; gives more detailed examples and explanations of use. +# - [**`nlp_apps.ipynb`**](https://github.com/aimacode/aima-python/blob/master/nlp_apps.ipynb): A Jupyter notebook that gives example applications of the code. +# - [**`tests/test_nlp.py`**](https://github.com/aimacode/aima-python/blob/master/tests/test_nlp.py): Test cases, used to verify the code is correct, and also useful to see examples of use. +# +# There is also an [aima-java](https://github.com/aimacode/aima-java) repository, if you prefer Java. +# +# ## What version of Python? +# +# The code is tested in Python [3.4](https://www.python.org/download/releases/3.4.3/) and [3.5](https://www.python.org/downloads/release/python-351/). If you try a different version of Python 3 and find a problem, please report it as an [Issue](https://github.com/aimacode/aima-python/issues). +# +# We recommend the [Anaconda](https://www.anaconda.com/download/) distribution of Python 3.5. It comes with additional tools like the powerful IPython interpreter, the Jupyter Notebook and many helpful packages for scientific computing. After installing Anaconda, you will be good to go to run all the code and all the IPython notebooks. +# +# ## IPython notebooks +# +# The IPython notebooks in this repository explain how to use the modules, and give examples of usage. +# You can use them in three ways: +# +# 1. View static HTML pages. (Just browse to the [repository](https://github.com/aimacode/aima-python) and click on a `.ipynb` file link.) +# 2. Run, modify, and re-run code, live. (Download the repository (by [zip file](https://github.com/aimacode/aima-python/archive/master.zip) or by `git` commands), start a Jupyter notebook server with the shell command "`jupyter notebook`" (issued from the directory where the files are), and click on the notebook you want to interact with.) +# 3. Binder - Click on the binder badge on the [repository](https://github.com/aimacode/aima-python) main page to open the notebooks in an executable environment, online. This method does not require any extra installation. The code can be executed and modified from the browser itself. Note that this is an unstable option; there is a chance the notebooks will never load. +# +# +# You can [read about notebooks](https://jupyter-notebook-beginner-guide.readthedocs.org/en/latest/) and then [get started](https://nbviewer.jupyter.org/github/jupyter/notebook/blob/master/docs/source/examples/Notebook/Running%20Code.ipynb). + +# %% [markdown] +# # Helpful Tips +# +# Most of these notebooks start by importing all the symbols in a module: + +# %% +from aima.logic import * + +# %% [markdown] +# From there, the notebook alternates explanations with examples of use. You can run the examples as they are, and you can modify the code cells (or add new cells) and run your own examples. If you have some really good examples to add, you can make a github pull request. +# +# If you want to see the source code of a function, you can open a browser or editor and see it in another window, or from within the notebook you can use the IPython magic function `%psource` (for "print source") or the function `psource` from `notebook.py`. Also, if the algorithm has pseudocode available, you can read it by calling the `pseudocode` function with the name of the algorithm passed as a parameter. + +# %% +# %psource WalkSAT + +# %% +from aima.notebook_utils import psource, pseudocode + +psource(WalkSAT) +pseudocode("WalkSAT") + +# %% [markdown] +# Or see an abbreviated description of an object with a trailing question mark: + +# %% +# WalkSAT? + +# %% [markdown] +# # Authors +# +# This notebook is written by [Chirag Vertak](https://github.com/chiragvartak) and [Peter Norvig](https://github.com/norvig). diff --git a/notebooks/kalman_filter.ipynb b/notebooks/kalman_filter.ipynb new file mode 100644 index 000000000..b7f044c26 --- /dev/null +++ b/notebooks/kalman_filter.ipynb @@ -0,0 +1,157 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, + { + "cell_type": "markdown", + "id": "7de6191c", + "metadata": {}, + "source": [ + "# Kalman Filter and Dynamic Bayesian Networks\n", + "\n", + "Temporal probabilistic models from [`probability.py`](probability.py): the Kalman filter (Section 15.4) and dynamic Bayesian networks (Section 15.5)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "96ba1e6d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:08.169338Z", + "iopub.status.busy": "2026-06-23T10:42:08.169076Z", + "iopub.status.idle": "2026-06-23T10:42:08.886951Z", + "shell.execute_reply": "2026-06-23T10:42:08.885986Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "from aima.probability import KalmanFilter, kalman_filter, DynamicBayesNet, T, F" + ] + }, + { + "cell_type": "markdown", + "id": "706b05aa", + "metadata": {}, + "source": [ + "## 15.4 Kalman filter: tracking a 1-D random walk\n", + "A hidden value drifts as a random walk and is measured with noise; the Kalman filter recovers a smooth estimate from the noisy measurements." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "d537d976", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:08.889538Z", + "iopub.status.busy": "2026-06-23T10:42:08.889092Z", + "iopub.status.idle": "2026-06-23T10:42:09.149463Z", + "shell.execute_reply": "2026-06-23T10:42:09.148519Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "np.random.seed(7)\n", + "truth, x = [], 0.0\n", + "for _ in range(50):\n", + " x += np.random.normal(0, 0.3)\n", + " truth.append(x)\n", + "measurements = [v + np.random.normal(0, 1.0) for v in truth]\n", + "\n", + "kf = KalmanFilter(transition_model=[[1]], sensor_model=[[1]],\n", + " transition_noise=[[0.1]], sensor_noise=[[1]])\n", + "estimates = kalman_filter(kf, mean0=[0], cov0=[[1]], observations=measurements)\n", + "filtered = [m[0] for m, _ in estimates]\n", + "\n", + "plt.plot(truth, label='true state', linewidth=2)\n", + "plt.scatter(range(50), measurements, s=10, c='gray', label='noisy measurements')\n", + "plt.plot(filtered, label='Kalman estimate', linewidth=2)\n", + "plt.legend(); plt.title('Kalman filtering of a 1-D random walk'); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "5f1f127d", + "metadata": {}, + "source": [ + "## 15.5 Dynamic Bayesian network: the umbrella world\n", + "The DBN is unrolled into an ordinary Bayes net and queried by exact inference. Filtering reproduces the canonical umbrella values 0.818 and 0.883." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "88cf7e65", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:09.151520Z", + "iopub.status.busy": "2026-06-23T10:42:09.151221Z", + "iopub.status.idle": "2026-06-23T10:42:09.157898Z", + "shell.execute_reply": "2026-06-23T10:42:09.156797Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "unrolled (2 steps): ['Rain_0', 'Rain_1', 'Umbrella_1', 'Rain_2', 'Umbrella_2']\n", + "P(Rain_1 | umbrella) = 0.8182\n", + "P(Rain_2 | umbrella, umbrella) = 0.8834\n" + ] + } + ], + "source": [ + "dbn = DynamicBayesNet(prior=[('Rain', '', 0.5)],\n", + " transition=[('Rain', 'Rain_prev', {T: 0.7, F: 0.3})],\n", + " sensors=[('Umbrella', 'Rain', {T: 0.9, F: 0.2})])\n", + "print('unrolled (2 steps):', dbn.unroll(2).variables)\n", + "print('P(Rain_1 | umbrella) =', round(dbn.filter([{'Umbrella': True}], 'Rain')[True], 4))\n", + "print('P(Rain_2 | umbrella, umbrella) =',\n", + " round(dbn.filter([{'Umbrella': True}, {'Umbrella': True}], 'Rain')[True], 4))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/kalman_filter.py b/notebooks/kalman_filter.py new file mode 100644 index 000000000..59933cafe --- /dev/null +++ b/notebooks/kalman_filter.py @@ -0,0 +1,61 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Kalman Filter and Dynamic Bayesian Networks +# +# Temporal probabilistic models from [`probability.py`](probability.py): the Kalman filter (Section 15.4) and dynamic Bayesian networks (Section 15.5). + +# %% +import numpy as np +import matplotlib.pyplot as plt +from aima.probability import KalmanFilter, kalman_filter, DynamicBayesNet, T, F + +# %% [markdown] +# ## 15.4 Kalman filter: tracking a 1-D random walk +# A hidden value drifts as a random walk and is measured with noise; the Kalman filter recovers a smooth estimate from the noisy measurements. + +# %% +np.random.seed(7) +truth, x = [], 0.0 +for _ in range(50): + x += np.random.normal(0, 0.3) + truth.append(x) +measurements = [v + np.random.normal(0, 1.0) for v in truth] + +kf = KalmanFilter(transition_model=[[1]], sensor_model=[[1]], + transition_noise=[[0.1]], sensor_noise=[[1]]) +estimates = kalman_filter(kf, mean0=[0], cov0=[[1]], observations=measurements) +filtered = [m[0] for m, _ in estimates] + +plt.plot(truth, label='true state', linewidth=2) +plt.scatter(range(50), measurements, s=10, c='gray', label='noisy measurements') +plt.plot(filtered, label='Kalman estimate', linewidth=2) +plt.legend(); plt.title('Kalman filtering of a 1-D random walk'); plt.show() + +# %% [markdown] +# ## 15.5 Dynamic Bayesian network: the umbrella world +# The DBN is unrolled into an ordinary Bayes net and queried by exact inference. Filtering reproduces the canonical umbrella values 0.818 and 0.883. + +# %% +dbn = DynamicBayesNet(prior=[('Rain', '', 0.5)], + transition=[('Rain', 'Rain_prev', {T: 0.7, F: 0.3})], + sensors=[('Umbrella', 'Rain', {T: 0.9, F: 0.2})]) +print('unrolled (2 steps):', dbn.unroll(2).variables) +print('P(Rain_1 | umbrella) =', round(dbn.filter([{'Umbrella': True}], 'Rain')[True], 4)) +print('P(Rain_2 | umbrella, umbrella) =', + round(dbn.filter([{'Umbrella': True}, {'Umbrella': True}], 'Rain')[True], 4)) diff --git a/knowledge_current_best.ipynb b/notebooks/knowledge_current_best.ipynb similarity index 99% rename from knowledge_current_best.ipynb rename to notebooks/knowledge_current_best.ipynb index 5da492cd0..3a2913ddc 100644 --- a/knowledge_current_best.ipynb +++ b/notebooks/knowledge_current_best.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -17,9 +26,9 @@ "metadata": {}, "outputs": [], "source": [ - "from knowledge import *\n", + "from aima.knowledge import *\n", "\n", - "from notebook import pseudocode, psource" + "from aima.notebook_utils import pseudocode, psource" ] }, { diff --git a/notebooks/knowledge_current_best.py b/notebooks/knowledge_current_best.py new file mode 100644 index 000000000..8d7603795 --- /dev/null +++ b/notebooks/knowledge_current_best.py @@ -0,0 +1,220 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # KNOWLEDGE +# +# The [knowledge](https://github.com/aimacode/aima-python/blob/master/knowledge.py) module covers **Chapter 19: Knowledge in Learning** from Stuart Russel's and Peter Norvig's book *Artificial Intelligence: A Modern Approach*. +# +# Execute the cell below to get started. + +# %% +from aima.knowledge import * + +from aima.notebook_utils import pseudocode, psource + +# %% [markdown] +# ## CONTENTS +# +# * Overview +# * Current-Best Learning + +# %% [markdown] +# ## OVERVIEW +# +# Like the [learning module](https://github.com/aimacode/aima-python/blob/master/learning.ipynb), this chapter focuses on methods for generating a model/hypothesis for a domain; however, unlike the learning chapter, here we use prior knowledge to help us learn from new experiences and find a proper hypothesis. +# +# ### First-Order Logic +# +# Usually knowledge in this field is represented as **first-order logic**; a type of logic that uses variables and quantifiers in logical sentences. Hypotheses are represented by logical sentences with variables, while examples are logical sentences with set values instead of variables. The goal is to assign a value to a special first-order logic predicate, called **goal predicate**, for new examples given a hypothesis. We learn this hypothesis by infering knowledge from some given examples. +# +# ### Representation +# +# In this module, we use dictionaries to represent examples, with keys being the attribute names and values being the corresponding example values. Examples also have an extra boolean field, 'GOAL', for the goal predicate. A hypothesis is represented as a list of dictionaries. Each dictionary in that list represents a disjunction. Inside these dictionaries/disjunctions we have conjunctions. +# +# For example, say we want to predict if an animal (cat or dog) will take an umbrella given whether or not it rains or the animal wears a coat. The goal value is 'take an umbrella' and is denoted by the key 'GOAL'. An example: +# +# `{'Species': 'Cat', 'Coat': 'Yes', 'Rain': 'Yes', 'GOAL': True}` +# +# A hypothesis can be the following: +# +# `[{'Species': 'Cat'}]` +# +# which means an animal will take an umbrella if and only if it is a cat. +# +# ### Consistency +# +# We say that an example `e` is **consistent** with an hypothesis `h` if the assignment from the hypothesis for `e` is the same as `e['GOAL']`. If the above example and hypothesis are `e` and `h` respectively, then `e` is consistent with `h` since `e['Species'] == 'Cat'`. For `e = {'Species': 'Dog', 'Coat': 'Yes', 'Rain': 'Yes', 'GOAL': True}`, the example is no longer consistent with `h`, since the value assigned to `e` is *False* while `e['GOAL']` is *True*. + +# %% [markdown] +# ## CURRENT-BEST LEARNING +# +# ### Overview +# +# In **Current-Best Learning**, we start with a hypothesis and we refine it as we iterate through the examples. For each example, there are three possible outcomes: the example is consistent with the hypothesis, the example is a **false positive** (real value is false but got predicted as true) and the example is a **false negative** (real value is true but got predicted as false). Depending on the outcome we refine the hypothesis accordingly: +# +# * Consistent: We do not change the hypothesis and move on to the next example. +# +# * False Positive: We **specialize** the hypothesis, which means we add a conjunction. +# +# * False Negative: We **generalize** the hypothesis, either by removing a conjunction or a disjunction, or by adding a disjunction. +# +# When specializing or generalizing, we should make sure to not create inconsistencies with previous examples. To avoid that caveat, backtracking is needed. Thankfully, there is not just one specialization or generalization, so we have a lot to choose from. We will go through all the specializations/generalizations and we will refine our hypothesis as the first specialization/generalization consistent with all the examples seen up to that point. + +# %% [markdown] +# ### Pseudocode + +# %% +pseudocode('Current-Best-Learning') + +# %% [markdown] +# ### Implementation +# +# As mentioned earlier, examples are dictionaries (with keys being the attribute names) and hypotheses are lists of dictionaries (each dictionary is a disjunction). Also, in the hypothesis, we denote the *NOT* operation with an exclamation mark (!). +# +# We have functions to calculate the list of all specializations/generalizations, to check if an example is consistent/false positive/false negative with a hypothesis. We also have an auxiliary function to add a disjunction (or operation) to a hypothesis, and two other functions to check consistency of all (or just the negative) examples. +# +# You can read the source by running the cell below: + +# %% +psource(current_best_learning, specializations, generalizations) + +# %% [markdown] +# You can view the auxiliary functions in the [knowledge module](https://github.com/aimacode/aima-python/blob/master/knowledge.py). A few notes on the functionality of some of the important methods: + +# %% [markdown] +# * `specializations`: For each disjunction in the hypothesis, it adds a conjunction for values in the examples encountered so far (if the conjunction is consistent with all the examples). It returns a list of hypotheses. +# +# * `generalizations`: It adds to the list of hypotheses in three phases. First it deletes disjunctions, then it deletes conjunctions and finally it adds a disjunction. +# +# * `add_or`: Used by `generalizations` to add an *or operation* (a disjunction) to the hypothesis. Since the last example is the problematic one which wasn't consistent with the hypothesis, it will model the new disjunction to that example. It creates a disjunction for each combination of attributes in the example and returns the new hypotheses consistent with the negative examples encountered so far. We do not need to check the consistency of positive examples, since they are already consistent with at least one other disjunction in the hypotheses' set, so this new disjunction doesn't affect them. In other words, if the value of a positive example is negative under the disjunction, it doesn't matter since we know there exists a disjunction consistent with the example. + +# %% [markdown] +# Since the algorithm stops searching the specializations/generalizations after the first consistent hypothesis is found, usually you will get different results each time you run the code. + +# %% [markdown] +# ### Examples +# +# We will take a look at two examples. The first is a trivial one, while the second is a bit more complicated (you can also find it in the book). +# +# Earlier, we had the "animals taking umbrellas" example. Now we want to find a hypothesis to predict whether or not an animal will take an umbrella. The attributes are `Species`, `Rain` and `Coat`. The possible values are `[Cat, Dog]`, `[Yes, No]` and `[Yes, No]` respectively. Below we give seven examples (with `GOAL` we denote whether an animal will take an umbrella or not): + +# %% +animals_umbrellas = [ + {'Species': 'Cat', 'Rain': 'Yes', 'Coat': 'No', 'GOAL': True}, + {'Species': 'Cat', 'Rain': 'Yes', 'Coat': 'Yes', 'GOAL': True}, + {'Species': 'Dog', 'Rain': 'Yes', 'Coat': 'Yes', 'GOAL': True}, + {'Species': 'Dog', 'Rain': 'Yes', 'Coat': 'No', 'GOAL': False}, + {'Species': 'Dog', 'Rain': 'No', 'Coat': 'No', 'GOAL': False}, + {'Species': 'Cat', 'Rain': 'No', 'Coat': 'No', 'GOAL': False}, + {'Species': 'Cat', 'Rain': 'No', 'Coat': 'Yes', 'GOAL': True} +] + +# %% [markdown] +# Let our initial hypothesis be `[{'Species': 'Cat'}]`. That means every cat will be taking an umbrella. We can see that this is not true, but it doesn't matter since we will refine the hypothesis using the Current-Best algorithm. First, let's see how that initial hypothesis fares to have a point of reference. + +# %% +initial_h = [{'Species': 'Cat'}] + +for e in animals_umbrellas: + print(guess_value(e, initial_h)) + +# %% [markdown] +# We got 5/7 correct. Not terribly bad, but we can do better. Lets now run the algorithm and see how that performs in comparison to our current result. + +# %% +h = current_best_learning(animals_umbrellas, initial_h) + +for e in animals_umbrellas: + print(guess_value(e, h)) + +# %% [markdown] +# We got everything right! Let's print our hypothesis: + +# %% +print(h) + + +# %% [markdown] +# If an example meets any of the disjunctions in the list, it will be `True`, otherwise it will be `False`. +# +# Let's move on to a bigger example, the "Restaurant" example from the book. The attributes for each example are the following: +# +# * Alternative option (`Alt`) +# * Bar to hang out/wait (`Bar`) +# * Day is Friday (`Fri`) +# * Is hungry (`Hun`) +# * How much does it cost (`Price`, takes values in [$, $$, $$$]) +# * How many patrons are there (`Pat`, takes values in [None, Some, Full]) +# * Is raining (`Rain`) +# * Has made reservation (`Res`) +# * Type of restaurant (`Type`, takes values in [French, Thai, Burger, Italian]) +# * Estimated waiting time (`Est`, takes values in [0-10, 10-30, 30-60, >60]) +# +# We want to predict if someone will wait or not (Goal = WillWait). Below we show twelve examples found in the book. + +# %% [markdown] +# ![restaurant](images/restaurant.png) + +# %% [markdown] +# With the function `r_example` we will build the dictionary examples: + +# %% +def r_example(Alt, Bar, Fri, Hun, Pat, Price, Rain, Res, Type, Est, GOAL): + return {'Alt': Alt, 'Bar': Bar, 'Fri': Fri, 'Hun': Hun, 'Pat': Pat, + 'Price': Price, 'Rain': Rain, 'Res': Res, 'Type': Type, 'Est': Est, + 'GOAL': GOAL} + + +# %% [markdown] +# In code: + +# %% +restaurant = [ + r_example('Yes', 'No', 'No', 'Yes', 'Some', '$$$', 'No', 'Yes', 'French', '0-10', True), + r_example('Yes', 'No', 'No', 'Yes', 'Full', '$', 'No', 'No', 'Thai', '30-60', False), + r_example('No', 'Yes', 'No', 'No', 'Some', '$', 'No', 'No', 'Burger', '0-10', True), + r_example('Yes', 'No', 'Yes', 'Yes', 'Full', '$', 'Yes', 'No', 'Thai', '10-30', True), + r_example('Yes', 'No', 'Yes', 'No', 'Full', '$$$', 'No', 'Yes', 'French', '>60', False), + r_example('No', 'Yes', 'No', 'Yes', 'Some', '$$', 'Yes', 'Yes', 'Italian', '0-10', True), + r_example('No', 'Yes', 'No', 'No', 'None', '$', 'Yes', 'No', 'Burger', '0-10', False), + r_example('No', 'No', 'No', 'Yes', 'Some', '$$', 'Yes', 'Yes', 'Thai', '0-10', True), + r_example('No', 'Yes', 'Yes', 'No', 'Full', '$', 'Yes', 'No', 'Burger', '>60', False), + r_example('Yes', 'Yes', 'Yes', 'Yes', 'Full', '$$$', 'No', 'Yes', 'Italian', '10-30', False), + r_example('No', 'No', 'No', 'No', 'None', '$', 'No', 'No', 'Thai', '0-10', False), + r_example('Yes', 'Yes', 'Yes', 'Yes', 'Full', '$', 'No', 'No', 'Burger', '30-60', True) +] + +# %% [markdown] +# Say our initial hypothesis is that there should be an alternative option and lets run the algorithm. + +# %% +initial_h = [{'Alt': 'Yes'}] +h = current_best_learning(restaurant, initial_h) +for e in restaurant: + print(guess_value(e, h)) + +# %% [markdown] +# The predictions are correct. Let's see the hypothesis that accomplished that: + +# %% +print(h) + +# %% [markdown] +# It might be quite complicated, with many disjunctions if we are unlucky, but it will always be correct, as long as a correct hypothesis exists. + +# %% diff --git a/knowledge_FOIL.ipynb b/notebooks/knowledge_foil.ipynb similarity index 97% rename from knowledge_FOIL.ipynb rename to notebooks/knowledge_foil.ipynb index 4cefd7f69..d97981cc4 100644 --- a/knowledge_FOIL.ipynb +++ b/notebooks/knowledge_foil.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -17,8 +26,8 @@ "metadata": {}, "outputs": [], "source": [ - "from knowledge import *\n", - "from notebook import psource" + "from aima.knowledge import *\n", + "from aima.notebook_utils import psource" ] }, { @@ -96,7 +105,7 @@ " $$ FoilGain(L,R) = t \\big( \\log_2{\\frac{p_1}{p_1+n_1}} - \\log_2{\\frac{p_0}{p_0+n_0}} \\big) $$\n", " where: \n", " \n", - " $p_0: \\text{is the number of possitive bindings of rule R } \\\\ n_0: \\text{is the number of negative bindings of R} \\\\ p_1: \\text{is the is the number of possitive bindings of rule R'}\\\\ n_0: \\text{is the number of negative bindings of R'}\\\\ t: \\text{is the number of possitive bindings of rule R that are still covered after adding literal L to R}$\n", + " $p_0: \\text{is the number of positive bindings of rule R } \\\\ n_0: \\text{is the number of negative bindings of R} \\\\ p_1: \\text{is the is the number of positive bindings of rule R'}\\\\ n_0: \\text{is the number of negative bindings of R'}\\\\ t: \\text{is the number of positive bindings of rule R that are still covered after adding literal L to R}$\n", " \n", " - Calculate the extended examples for the chosen literal (function __extend_example()__)
\n", " (the set of examples created by extending example with each possible constant value for each new variable in literal)\n", @@ -286,11 +295,11 @@ "\n", " where: \n", " \n", - " pre_pos = number of possitive bindings of rule R (=current set of rules)\n", + " pre_pos = number of positive bindings of rule R (=current set of rules)\n", " pre_neg = number of negative bindings of rule R \n", - " post_pos = number of possitive bindings of rule R' (= R U {l} )\n", + " post_pos = number of positive bindings of rule R' (= R U {l} )\n", " post_neg = number of negative bindings of rule R' \n", - " T = number of possitive bindings of rule R that are still covered \n", + " T = number of positive bindings of rule R that are still covered \n", " after adding literal l \n", "\n", " """\n", @@ -335,7 +344,7 @@ } ], "source": [ - "psource(FOIL_container)" + "psource(FOILContainer)" ] }, { @@ -367,7 +376,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_family = FOIL_container([expr(\"Mother(Anne, Peter)\"),\n", + "small_family = FOILContainer([expr(\"Mother(Anne, Peter)\"),\n", " expr(\"Mother(Anne, Zara)\"),\n", " expr(\"Mother(Sarah, Beatrice)\"),\n", " expr(\"Mother(Sarah, Eugenie)\"),\n", @@ -444,7 +453,7 @@ "source": [ "Suppose that we have some positive and negative results for the relation 'GrandParent(x,y)' and we want to find a set of rules that satisfies the examples.
\n", "One possible set of rules for the relation $Grandparent(x,y)$ could be:
\n", - "![title](images/knowledge_FOIL_grandparent.png)\n", + "![title](images/knowledge_foil_grandparent.png)\n", "
\n", "Or, if $Background$ included the sentence $Parent(x,y) \\Leftrightarrow [Mother(x,y) \\lor Father(x,y)]$ then: \n", "\n", @@ -541,7 +550,7 @@ "vv v\n", "C F\n", "\"\"\"\n", - "small_network = FOIL_container([expr(\"Conn(A, B)\"),\n", + "small_network = FOILContainer([expr(\"Conn(A, B)\"),\n", " expr(\"Conn(A ,D)\"),\n", " expr(\"Conn(B, C)\"),\n", " expr(\"Conn(D, C)\"),\n", @@ -636,4 +645,4 @@ }, "nbformat": 4, "nbformat_minor": 2 -} \ No newline at end of file +} diff --git a/notebooks/knowledge_foil.py b/notebooks/knowledge_foil.py new file mode 100644 index 000000000..be88fcf87 --- /dev/null +++ b/notebooks/knowledge_foil.py @@ -0,0 +1,290 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # KNOWLEDGE +# +# The [knowledge](https://github.com/aimacode/aima-python/blob/master/knowledge.py) module covers **Chapter 19: Knowledge in Learning** from Stuart Russel's and Peter Norvig's book *Artificial Intelligence: A Modern Approach*. +# +# Execute the cell below to get started. + +# %% +from aima.knowledge import * +from aima.notebook_utils import psource + +# %% [markdown] +# ## CONTENTS +# +# * Overview +# * Inductive Logic Programming (FOIL) + +# %% [markdown] +# ## OVERVIEW +# +# Like the [learning module](https://github.com/aimacode/aima-python/blob/master/learning.ipynb), this chapter focuses on methods for generating a model/hypothesis for a domain; however, unlike the learning chapter, here we use prior knowledge to help us learn from new experiences and to find a proper hypothesis. +# +# ### First-Order Logic +# +# Usually knowledge in this field is represented as **first-order logic**, a type of logic that uses variables and quantifiers in logical sentences. Hypotheses are represented by logical sentences with variables, while examples are logical sentences with set values instead of variables. The goal is to assign a value to a special first-order logic predicate, called **goal predicate**, for new examples given a hypothesis. We learn this hypothesis by infering knowledge from some given examples. +# +# ### Representation +# +# In this module, we use dictionaries to represent examples, with keys being the attribute names and values being the corresponding example values. Examples also have an extra boolean field, 'GOAL', for the goal predicate. A hypothesis is represented as a list of dictionaries. Each dictionary in that list represents a disjunction. Inside these dictionaries/disjunctions we have conjunctions. +# +# For example, say we want to predict if an animal (cat or dog) will take an umbrella given whether or not it rains or the animal wears a coat. The goal value is 'take an umbrella' and is denoted by the key 'GOAL'. An example: +# +# `{'Species': 'Cat', 'Coat': 'Yes', 'Rain': 'Yes', 'GOAL': True}` +# +# A hypothesis can be the following: +# +# `[{'Species': 'Cat'}]` +# +# which means an animal will take an umbrella if and only if it is a cat. +# +# ### Consistency +# +# We say that an example `e` is **consistent** with an hypothesis `h` if the assignment from the hypothesis for `e` is the same as `e['GOAL']`. If the above example and hypothesis are `e` and `h` respectively, then `e` is consistent with `h` since `e['Species'] == 'Cat'`. For `e = {'Species': 'Dog', 'Coat': 'Yes', 'Rain': 'Yes', 'GOAL': True}`, the example is no longer consistent with `h`, since the value assigned to `e` is *False* while `e['GOAL']` is *True*. + +# %% [markdown] +# # Inductive Logic Programming (FOIL) +# +# Inductive logic programming (ILP) combines inductive methods with the power of first-order representations, concentrating in particular on the representation of hypotheses as logic programs. The general knowledge-based induction problem is to solve the entailment constraint:

+# $ Background ∧ Hypothesis ∧ Descriptions \vDash Classifications $ +# +# for the __unknown__ $Hypothesis$, given the $Background$ knowledge described by $Descriptions$ and $Classifications$. +# +# +# +# The first approach to ILP works by starting with a very general rule and gradually specializing +# it so that it fits the data.
+# This is essentially what happens in decision-tree learning, where a +# decision tree is gradually grown until it is consistent with the observations.
To do ILP we +# use first-order literals instead of attributes, and the $Hypothesis$ is a set of clauses (set of first order rules, where each rule is similar to a Horn clause) instead of a decision tree.
+# +# +# The FOIL algorithm learns new rules, one at a time, in order to cover all given positive and negative examples.
+# More precicely, FOIL contains an inner and an outer while loop.
+# - __outer loop__: (function __foil()__) add rules until all positive examples are covered.
+# (each rule is a conjuction of literals, which are chosen inside the inner loop) +# +# +# - __inner loop__: (function __new_clause()__) add new literals until all negative examples are covered, and some positive examples are covered.
+# - In each iteration, we select/add the most promising literal, according to an estimate of its utility. (function __new_literal()__)
+# +# - The evaluation function to estimate utility of adding literal $L$ to a set of rules $R$ is (function __gain()__) : +# +# $$ FoilGain(L,R) = t \big( \log_2{\frac{p_1}{p_1+n_1}} - \log_2{\frac{p_0}{p_0+n_0}} \big) $$ +# where: +# +# $p_0: \text{is the number of positive bindings of rule R } \\ n_0: \text{is the number of negative bindings of R} \\ p_1: \text{is the is the number of positive bindings of rule R'}\\ n_0: \text{is the number of negative bindings of R'}\\ t: \text{is the number of positive bindings of rule R that are still covered after adding literal L to R}$ +# +# - Calculate the extended examples for the chosen literal (function __extend_example()__)
+# (the set of examples created by extending example with each possible constant value for each new variable in literal) +# +# - Finally, the algorithm returns a disjunction of first order rules (= conjuction of literals) +# +# + +# %% +psource(FOILContainer) + +# %% [markdown] +# ### Example Family +# Suppose we have the following family relations: +#
+# ![title](images/knowledge_foil_family.png) +#
+# Given some positive and negative examples of the relation 'Parent(x,y)', we want to find a set of rules that satisfies all the examples.
+# +# A definition of Parent is $Parent(x,y) \Leftrightarrow Mother(x,y) \lor Father(x,y)$, which is the result that we expect from the algorithm. + +# %% +A, B, C, D, E, F, G, H, I, x, y, z = map(expr, 'ABCDEFGHIxyz') + +# %% +small_family = FOILContainer([expr("Mother(Anne, Peter)"), + expr("Mother(Anne, Zara)"), + expr("Mother(Sarah, Beatrice)"), + expr("Mother(Sarah, Eugenie)"), + expr("Father(Mark, Peter)"), + expr("Father(Mark, Zara)"), + expr("Father(Andrew, Beatrice)"), + expr("Father(Andrew, Eugenie)"), + expr("Father(Philip, Anne)"), + expr("Father(Philip, Andrew)"), + expr("Mother(Elizabeth, Anne)"), + expr("Mother(Elizabeth, Andrew)"), + expr("Male(Philip)"), + expr("Male(Mark)"), + expr("Male(Andrew)"), + expr("Male(Peter)"), + expr("Female(Elizabeth)"), + expr("Female(Anne)"), + expr("Female(Sarah)"), + expr("Female(Zara)"), + expr("Female(Beatrice)"), + expr("Female(Eugenie)"), +]) + +target = expr('Parent(x, y)') + +examples_pos = [{x: expr('Elizabeth'), y: expr('Anne')}, + {x: expr('Elizabeth'), y: expr('Andrew')}, + {x: expr('Philip'), y: expr('Anne')}, + {x: expr('Philip'), y: expr('Andrew')}, + {x: expr('Anne'), y: expr('Peter')}, + {x: expr('Anne'), y: expr('Zara')}, + {x: expr('Mark'), y: expr('Peter')}, + {x: expr('Mark'), y: expr('Zara')}, + {x: expr('Andrew'), y: expr('Beatrice')}, + {x: expr('Andrew'), y: expr('Eugenie')}, + {x: expr('Sarah'), y: expr('Beatrice')}, + {x: expr('Sarah'), y: expr('Eugenie')}] +examples_neg = [{x: expr('Anne'), y: expr('Eugenie')}, + {x: expr('Beatrice'), y: expr('Eugenie')}, + {x: expr('Mark'), y: expr('Elizabeth')}, + {x: expr('Beatrice'), y: expr('Philip')}] + +# %% +# run the FOIL algorithm +clauses = small_family.foil([examples_pos, examples_neg], target) +print (clauses) + + +# %% [markdown] +# Indeed the algorithm returned the rule: +#
$Parent(x,y) \Leftrightarrow Mother(x,y) \lor Father(x,y)$ + +# %% [markdown] +# Suppose that we have some positive and negative results for the relation 'GrandParent(x,y)' and we want to find a set of rules that satisfies the examples.
+# One possible set of rules for the relation $Grandparent(x,y)$ could be:
+# ![title](images/knowledge_foil_grandparent.png) +#
+# Or, if $Background$ included the sentence $Parent(x,y) \Leftrightarrow [Mother(x,y) \lor Father(x,y)]$ then: +# +# $$Grandparent(x,y) \Leftrightarrow \exists \: z \quad Parent(x,z) \land Parent(z,y)$$ +# + +# %% +target = expr('Grandparent(x, y)') + +examples_pos = [{x: expr('Elizabeth'), y: expr('Peter')}, + {x: expr('Elizabeth'), y: expr('Zara')}, + {x: expr('Elizabeth'), y: expr('Beatrice')}, + {x: expr('Elizabeth'), y: expr('Eugenie')}, + {x: expr('Philip'), y: expr('Peter')}, + {x: expr('Philip'), y: expr('Zara')}, + {x: expr('Philip'), y: expr('Beatrice')}, + {x: expr('Philip'), y: expr('Eugenie')}] +examples_neg = [{x: expr('Anne'), y: expr('Eugenie')}, + {x: expr('Beatrice'), y: expr('Eugenie')}, + {x: expr('Elizabeth'), y: expr('Andrew')}, + {x: expr('Elizabeth'), y: expr('Anne')}, + {x: expr('Elizabeth'), y: expr('Mark')}, + {x: expr('Elizabeth'), y: expr('Sarah')}, + {x: expr('Philip'), y: expr('Anne')}, + {x: expr('Philip'), y: expr('Andrew')}, + {x: expr('Anne'), y: expr('Peter')}, + {x: expr('Anne'), y: expr('Zara')}, + {x: expr('Mark'), y: expr('Peter')}, + {x: expr('Mark'), y: expr('Zara')}, + {x: expr('Andrew'), y: expr('Beatrice')}, + {x: expr('Andrew'), y: expr('Eugenie')}, + {x: expr('Sarah'), y: expr('Beatrice')}, + {x: expr('Mark'), y: expr('Elizabeth')}, + {x: expr('Beatrice'), y: expr('Philip')}, + {x: expr('Peter'), y: expr('Andrew')}, + {x: expr('Zara'), y: expr('Mark')}, + {x: expr('Peter'), y: expr('Anne')}, + {x: expr('Zara'), y: expr('Eugenie')}, ] + +clauses = small_family.foil([examples_pos, examples_neg], target) + +print(clauses) + +# %% [markdown] +# Indeed the algorithm returned the rule: +#
$Grandparent(x,y) \Leftrightarrow \exists \: v \: \: Parent(x,v) \land Parent(v,y)$ + +# %% [markdown] +# ### Example Network +# +# Suppose that we have the following directed graph and we want to find a rule that describes the reachability between two nodes (Reach(x,y)).
+# Such a rule could be recursive, since y can be reached from x if and only if there is a sequence of adjacent nodes from x to y: +# +# $$ Reach(x,y) \Leftrightarrow \begin{cases} +# Conn(x,y), \: \text{(if there is a directed edge from x to y)} \\ +# \lor \quad \exists \: z \quad Reach(x,z) \land Reach(z,y) \end{cases}$$ +# + +# %% +""" +A H +|\ /| +| \ / | +v v v v +B D-->E-->G-->I +| / | +| / | +vv v +C F +""" +small_network = FOILContainer([expr("Conn(A, B)"), + expr("Conn(A ,D)"), + expr("Conn(B, C)"), + expr("Conn(D, C)"), + expr("Conn(D, E)"), + expr("Conn(E ,F)"), + expr("Conn(E, G)"), + expr("Conn(G, I)"), + expr("Conn(H, G)"), + expr("Conn(H, I)")]) + + +# %% +target = expr('Reach(x, y)') +examples_pos = [{x: A, y: B}, + {x: A, y: C}, + {x: A, y: D}, + {x: A, y: E}, + {x: A, y: F}, + {x: A, y: G}, + {x: A, y: I}, + {x: B, y: C}, + {x: D, y: C}, + {x: D, y: E}, + {x: D, y: F}, + {x: D, y: G}, + {x: D, y: I}, + {x: E, y: F}, + {x: E, y: G}, + {x: E, y: I}, + {x: G, y: I}, + {x: H, y: G}, + {x: H, y: I}] +nodes = {A, B, C, D, E, F, G, H, I} +examples_neg = [example for example in [{x: a, y: b} for a in nodes for b in nodes] + if example not in examples_pos] +clauses = small_network.foil([examples_pos, examples_neg], target) + +print(clauses) + +# %% [markdown] +# The algorithm produced something close to the recursive rule: +# $$ Reach(x,y) \Leftrightarrow [Conn(x,y)] \: \lor \: [\exists \: z \: \: Reach(x,z) \, \land \, Reach(z,y)]$$ +# +# This happened because the size of the example is small. diff --git a/knowledge_version_space.ipynb b/notebooks/knowledge_version_space.ipynb similarity index 99% rename from knowledge_version_space.ipynb rename to notebooks/knowledge_version_space.ipynb index 8c8ec29f5..31f2bda1c 100644 --- a/knowledge_version_space.ipynb +++ b/notebooks/knowledge_version_space.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -17,9 +26,9 @@ "metadata": {}, "outputs": [], "source": [ - "from knowledge import *\n", + "from aima.knowledge import *\n", "\n", - "from notebook import pseudocode, psource" + "from aima.notebook_utils import pseudocode, psource" ] }, { diff --git a/notebooks/knowledge_version_space.py b/notebooks/knowledge_version_space.py new file mode 100644 index 000000000..61278ed8b --- /dev/null +++ b/notebooks/knowledge_version_space.py @@ -0,0 +1,197 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # KNOWLEDGE +# +# The [knowledge](https://github.com/aimacode/aima-python/blob/master/knowledge.py) module covers **Chapter 19: Knowledge in Learning** from Stuart Russel's and Peter Norvig's book *Artificial Intelligence: A Modern Approach*. +# +# Execute the cell below to get started. + +# %% +from aima.knowledge import * + +from aima.notebook_utils import pseudocode, psource + +# %% [markdown] +# ## CONTENTS +# +# * Overview +# * Version-Space Learning + +# %% [markdown] +# ## OVERVIEW +# +# Like the [learning module](https://github.com/aimacode/aima-python/blob/master/learning.ipynb), this chapter focuses on methods for generating a model/hypothesis for a domain. Unlike though the learning chapter, here we use prior knowledge to help us learn from new experiences and find a proper hypothesis. +# +# ### First-Order Logic +# +# Usually knowledge in this field is represented as **first-order logic**, a type of logic that uses variables and quantifiers in logical sentences. Hypotheses are represented by logical sentences with variables, while examples are logical sentences with set values instead of variables. The goal is to assign a value to a special first-order logic predicate, called **goal predicate**, for new examples given a hypothesis. We learn this hypothesis by infering knowledge from some given examples. +# +# ### Representation +# +# In this module, we use dictionaries to represent examples, with keys the attribute names and values the corresponding example values. Examples also have an extra boolean field, 'GOAL', for the goal predicate. A hypothesis is represented as a list of dictionaries. Each dictionary in that list represents a disjunction. Inside these dictionaries/disjunctions we have conjunctions. +# +# For example, say we want to predict if an animal (cat or dog) will take an umbrella given whether or not it rains or the animal wears a coat. The goal value is 'take an umbrella' and is denoted by the key 'GOAL'. An example: +# +# `{'Species': 'Cat', 'Coat': 'Yes', 'Rain': 'Yes', 'GOAL': True}` +# +# A hypothesis can be the following: +# +# `[{'Species': 'Cat'}]` +# +# which means an animal will take an umbrella if and only if it is a cat. +# +# ### Consistency +# +# We say that an example `e` is **consistent** with an hypothesis `h` if the assignment from the hypothesis for `e` is the same as `e['GOAL']`. If the above example and hypothesis are `e` and `h` respectively, then `e` is consistent with `h` since `e['Species'] == 'Cat'`. For `e = {'Species': 'Dog', 'Coat': 'Yes', 'Rain': 'Yes', 'GOAL': True}`, the example is no longer consistent with `h`, since the value assigned to `e` is *False* while `e['GOAL']` is *True*. + +# %% [markdown] +# ## VERSION-SPACE LEARNING +# +# ### Overview +# +# **Version-Space Learning** is a general method of learning in logic based domains. We generate the set of all the possible hypotheses in the domain and then we iteratively remove hypotheses inconsistent with the examples. The set of remaining hypotheses is called **version space**. Because hypotheses are being removed until we end up with a set of hypotheses consistent with all the examples, the algorithm is sometimes called **candidate elimination** algorithm. +# +# After we update the set on an example, all the hypotheses in the set are consistent with that example. So, when all the examples have been parsed, all the remaining hypotheses in the set are consistent with all the examples. That means we can pick hypotheses at random and we will always get a valid hypothesis. + +# %% [markdown] +# ### Pseudocode + +# %% +pseudocode('Version-Space-Learning') + +# %% [markdown] +# ### Implementation +# +# The set of hypotheses is represented by a list and each hypothesis is represented by a list of dictionaries, each dictionary a disjunction. For each example in the given examples we update the version space with the function `version_space_update`. In the end, we return the version-space. +# +# Before we can start updating the version space, we need to generate it. We do that with the `all_hypotheses` function, which builds a list of all the possible hypotheses (including hypotheses with disjunctions). The function works like this: first it finds the possible values for each attribute (using `values_table`), then it builds all the attribute combinations (and adds them to the hypotheses set) and finally it builds the combinations of all the disjunctions (which in this case are the hypotheses build by the attribute combinations). +# +# You can read the code for all the functions by running the cells below: + +# %% +psource(version_space_learning, version_space_update) + +# %% +psource(all_hypotheses, values_table) + +# %% +psource(build_attr_combinations, build_h_combinations) + +# %% [markdown] +# ### Example +# +# Since the set of all possible hypotheses is enormous and would take a long time to generate, we will come up with another, even smaller domain. We will try and predict whether we will have a party or not given the availability of pizza and soda. Let's do it: + +# %% +party = [ + {'Pizza': 'Yes', 'Soda': 'No', 'GOAL': True}, + {'Pizza': 'Yes', 'Soda': 'Yes', 'GOAL': True}, + {'Pizza': 'No', 'Soda': 'No', 'GOAL': False} +] + +# %% [markdown] +# Even though it is obvious that no-pizza no-party, we will run the algorithm and see what other hypotheses are valid. + +# %% +V = version_space_learning(party) +for e in party: + guess = False + for h in V: + if guess_value(e, h): + guess = True + break + + print(guess) + +# %% [markdown] +# The results are correct for the given examples. Let's take a look at the version space: + +# %% +print(len(V)) + +print(V[5]) +print(V[10]) + +print([{'Pizza': 'Yes'}] in V) + +# %% [markdown] +# There are almost 1000 hypotheses in the set. You can see that even with just two attributes the version space in very large. +# +# Our initial prediction is indeed in the set of hypotheses. Also, the two other random hypotheses we got are consistent with the examples (since they both include the "Pizza is available" disjunction). + +# %% [markdown] +# ## Minimal Consistent Determination + +# %% [markdown] +# This algorithm is based on a straightforward attempt to find the simplest determination consistent with the observations. A determinaton P > Q says that if any examples match on P, then they must also match on Q. A determination is therefore consistent with a set of examples if every pair that matches on the predicates on the left-hand side also matches on the goal predicate. + +# %% [markdown] +# ### Pseudocode + +# %% [markdown] +# Lets look at the pseudocode for this algorithm + +# %% +pseudocode('Minimal-Consistent-Det') + +# %% [markdown] +# You can read the code for the above algorithm by running the cells below: + +# %% +psource(minimal_consistent_det) + +# %% +psource(consistent_det) + +# %% [markdown] +# ### Example: + +# %% [markdown] +# We already know that no-pizza-no-party but we will still check it through the `minimal_consistent_det` algorithm. + +# %% +print(minimal_consistent_det(party, {'Pizza', 'Soda'})) + +# %% [markdown] +# We can also check it on some other example. Let's consider the following example : + +# %% +conductance = [ + {'Sample': 'S1', 'Mass': 12, 'Temp': 26, 'Material': 'Cu', 'Size': 3, 'GOAL': 0.59}, + {'Sample': 'S1', 'Mass': 12, 'Temp': 100, 'Material': 'Cu', 'Size': 3, 'GOAL': 0.57}, + {'Sample': 'S2', 'Mass': 24, 'Temp': 26, 'Material': 'Cu', 'Size': 6, 'GOAL': 0.59}, + {'Sample': 'S3', 'Mass': 12, 'Temp': 26, 'Material': 'Pb', 'Size': 2, 'GOAL': 0.05}, + {'Sample': 'S3', 'Mass': 12, 'Temp': 100, 'Material': 'Pb', 'Size': 2, 'GOAL': 0.04}, + {'Sample': 'S4', 'Mass': 18, 'Temp': 100, 'Material': 'Pb', 'Size': 3, 'GOAL': 0.04}, + {'Sample': 'S4', 'Mass': 18, 'Temp': 100, 'Material': 'Pb', 'Size': 3, 'GOAL': 0.04}, + {'Sample': 'S5', 'Mass': 24, 'Temp': 100, 'Material': 'Pb', 'Size': 4, 'GOAL': 0.04}, + {'Sample': 'S6', 'Mass': 36, 'Temp': 26, 'Material': 'Pb', 'Size': 6, 'GOAL': 0.05}, +] + + + +# %% [markdown] +# Now, we check the `minimal_consistent_det` algorithm on the above example: + +# %% +print(minimal_consistent_det(conductance, {'Mass', 'Temp', 'Material', 'Size'})) + +# %% +print(minimal_consistent_det(conductance, {'Mass', 'Temp', 'Size'})) + diff --git a/learning.ipynb b/notebooks/learning.ipynb similarity index 50% rename from learning.ipynb rename to notebooks/learning.ipynb index 0cadd4e7b..cce6f92f5 100644 --- a/learning.ipynb +++ b/notebooks/learning.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -12,12 +21,22 @@ { "cell_type": "code", "execution_count": 1, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:35.443128Z", + "iopub.status.busy": "2026-06-27T12:55:35.442770Z", + "iopub.status.idle": "2026-06-27T12:55:38.340569Z", + "shell.execute_reply": "2026-06-27T12:55:38.338462Z" + } + }, "outputs": [], "source": [ - "from learning import *\n", - "from probabilistic_learning import *\n", - "from notebook import *" + "import math\n", + "\n", + "from aima.utils import argmax_random_tie as argmax\n", + "from aima.learning import *\n", + "from aima.probabilistic_learning import *\n", + "from aima.notebook_utils import *" ] }, { @@ -107,9 +126,15 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.354614Z", + "iopub.status.busy": "2026-06-27T12:55:38.352319Z", + "iopub.status.idle": "2026-06-27T12:55:38.408101Z", + "shell.execute_reply": "2026-06-27T12:55:38.406335Z" + } }, "outputs": [], "source": [ @@ -173,7 +198,13 @@ "cell_type": "code", "execution_count": 3, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.414409Z", + "iopub.status.busy": "2026-06-27T12:55:38.413959Z", + "iopub.status.idle": "2026-06-27T12:55:38.429426Z", + "shell.execute_reply": "2026-06-27T12:55:38.428190Z" + } }, "outputs": [], "source": [ @@ -190,7 +221,14 @@ { "cell_type": "code", "execution_count": 4, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.444628Z", + "iopub.status.busy": "2026-06-27T12:55:38.440224Z", + "iopub.status.idle": "2026-06-27T12:55:38.453911Z", + "shell.execute_reply": "2026-06-27T12:55:38.452358Z" + } + }, "outputs": [ { "name": "stdout", @@ -223,7 +261,14 @@ { "cell_type": "code", "execution_count": 5, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.573527Z", + "iopub.status.busy": "2026-06-27T12:55:38.572601Z", + "iopub.status.idle": "2026-06-27T12:55:38.592943Z", + "shell.execute_reply": "2026-06-27T12:55:38.588777Z" + } + }, "outputs": [ { "name": "stdout", @@ -252,7 +297,14 @@ { "cell_type": "code", "execution_count": 6, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.597066Z", + "iopub.status.busy": "2026-06-27T12:55:38.596698Z", + "iopub.status.idle": "2026-06-27T12:55:38.606712Z", + "shell.execute_reply": "2026-06-27T12:55:38.604341Z" + } + }, "outputs": [ { "name": "stdout", @@ -276,7 +328,14 @@ { "cell_type": "code", "execution_count": 7, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.612338Z", + "iopub.status.busy": "2026-06-27T12:55:38.610682Z", + "iopub.status.idle": "2026-06-27T12:55:38.624868Z", + "shell.execute_reply": "2026-06-27T12:55:38.620495Z" + } + }, "outputs": [ { "name": "stdout", @@ -291,7 +350,7 @@ ], "source": [ "print(\"attrs:\", iris.attrs)\n", - "print(\"attrnames (by default same as attrs):\", iris.attrnames)\n", + "print(\"attrnames (by default same as attrs):\", iris.attr_names)\n", "print(\"target:\", iris.target)\n", "print(\"inputs:\", iris.inputs)" ] @@ -306,13 +365,20 @@ { "cell_type": "code", "execution_count": 8, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.632407Z", + "iopub.status.busy": "2026-06-27T12:55:38.630025Z", + "iopub.status.idle": "2026-06-27T12:55:38.642309Z", + "shell.execute_reply": "2026-06-27T12:55:38.641280Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "[4.7, 5.5, 6.3, 5.0, 4.9, 5.1, 4.6, 5.4, 4.4, 4.8, 5.8, 7.0, 7.1, 4.5, 5.9, 5.6, 6.9, 6.6, 6.5, 6.4, 6.0, 6.1, 7.6, 7.4, 7.9, 4.3, 5.7, 5.3, 5.2, 6.7, 6.2, 6.8, 7.3, 7.2, 7.7]\n" + "[4.7, 5.5, 5.0, 4.9, 5.1, 4.6, 5.4, 4.4, 4.8, 4.3, 5.8, 7.0, 7.1, 4.5, 5.9, 5.6, 6.9, 6.5, 6.4, 6.6, 6.0, 6.1, 7.6, 7.4, 7.9, 5.7, 5.3, 5.2, 6.3, 6.7, 6.2, 6.8, 7.3, 7.2, 7.7]\n" ] } ], @@ -330,7 +396,14 @@ { "cell_type": "code", "execution_count": 9, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.653354Z", + "iopub.status.busy": "2026-06-27T12:55:38.652897Z", + "iopub.status.idle": "2026-06-27T12:55:38.667683Z", + "shell.execute_reply": "2026-06-27T12:55:38.663514Z" + } + }, "outputs": [ { "name": "stdout", @@ -356,13 +429,20 @@ { "cell_type": "code", "execution_count": 10, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.671257Z", + "iopub.status.busy": "2026-06-27T12:55:38.670860Z", + "iopub.status.idle": "2026-06-27T12:55:38.680086Z", + "shell.execute_reply": "2026-06-27T12:55:38.679248Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "['setosa', 'virginica', 'versicolor']\n" + "['setosa', 'versicolor', 'virginica']\n" ] } ], @@ -393,7 +473,14 @@ { "cell_type": "code", "execution_count": 11, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.685610Z", + "iopub.status.busy": "2026-06-27T12:55:38.685061Z", + "iopub.status.idle": "2026-06-27T12:55:38.712005Z", + "shell.execute_reply": "2026-06-27T12:55:38.694127Z" + } + }, "outputs": [ { "name": "stdout", @@ -419,7 +506,14 @@ { "cell_type": "code", "execution_count": 12, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.718499Z", + "iopub.status.busy": "2026-06-27T12:55:38.717599Z", + "iopub.status.idle": "2026-06-27T12:55:38.749524Z", + "shell.execute_reply": "2026-06-27T12:55:38.744753Z" + } + }, "outputs": [ { "name": "stdout", @@ -446,7 +540,14 @@ { "cell_type": "code", "execution_count": 13, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.763865Z", + "iopub.status.busy": "2026-06-27T12:55:38.763094Z", + "iopub.status.idle": "2026-06-27T12:55:38.789843Z", + "shell.execute_reply": "2026-06-27T12:55:38.780079Z" + } + }, "outputs": [ { "name": "stdout", @@ -480,7 +581,14 @@ { "cell_type": "code", "execution_count": 14, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.800881Z", + "iopub.status.busy": "2026-06-27T12:55:38.800243Z", + "iopub.status.idle": "2026-06-27T12:55:38.845568Z", + "shell.execute_reply": "2026-06-27T12:55:38.837580Z" + } + }, "outputs": [ { "name": "stdout", @@ -488,7 +596,7 @@ "text": [ "Setosa feature means: [5.006, 3.418, 1.464, 0.244]\n", "Versicolor mean for first feature: 5.936\n", - "Setosa feature deviations: [0.3524896872134513, 0.38102439795469095, 0.17351115943644546, 0.10720950308167838]\n", + "Setosa feature deviations: [0.3524896872134513, 0.38102439795469095, 0.17351115943644543, 0.10720950308167838]\n", "Virginica deviation for second feature: 0.32249663817263746\n" ] } @@ -517,37 +625,26 @@ { "cell_type": "code", "execution_count": 15, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:38.852396Z", + "iopub.status.busy": "2026-06-27T12:55:38.852017Z", + "iopub.status.idle": "2026-06-27T12:55:39.215160Z", + "shell.execute_reply": "2026-06-27T12:55:39.212501Z" + } + }, "outputs": [ { - "data": { - "image/png": "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\nnjD6AAAAAElFTkSuQmCC\n", - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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\nRU5ErkJggg==\n", - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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\nm8OHDwtvb2/x6KOPipSUlKo+9GrPBx98IObMmSOEEGLNmjVi8uTJQgghcnNzRWZmpjh8+LDw8vIS\nM2fOFBqNRrz66qvCyclJrFixQgghhF6vF+np6VW2foWyoYgFhTJjMBiETqcTBoOh3PY5btw40axZ\nM2FjYyMaNGggevfuLQsFIYR45JFHxOjRo01+54cffhC+vr7C2tpatG7dWmzbtq3c1qNQMzAYDOLq\n1ati48aNYtKkScLPz0+oVCrh6+srxo4dK7744guRkJAgi4esrCzxzTffiJ07d4r4+HizoiInJ0d8\n+umnwsHBQcydO1cUFhZW9WGWYMmSJaJjx47C0dFRNGjQQAwePFicOXOmStc0depUERwcLHr06CHU\narXYtGmTyevTp08XY8aMEXl5eUIIIZYvXy4aNGggGjZsKP766y95O71eX6nrVrg7lJoFhTsi/i4e\nVLzXFaozQghu3bolt2pGRERw6tQpPD096dSpE8nJyVy9epVDhw7h7u5e4vdzc3N57bXX2Lt3L998\n8w39+/evlkWz/fv359lnn6VTp07odDrmzJlDTEwMcXFxZs2jKhKpGPHixYt06NCB/Px8Pv/8c0aO\nHGmSHhoyZAg6nY6ff/4ZgClTpuDu7k7//v1p3759pa5Z4d5RxILCv3L06FG+++47Tpw4gbu7O0OH\nDqVv377UqVOnqpdW6dytPXVoaChjx44t8Xx+fj62trYVudQHGiEEmZmZfPnllyxYsAAnJyeys7Nx\ncnIy8Xrw9fXl7NmzjBo1CmdnZzZv3oynp2dVL7/MSJ0cBw8eLDFcqaIofuPw22+/sWvXLo4cOULb\ntm158803adWqlSwYVqxYwRdffIG3tze3b98mKyuL/fv3y6JNKN1MNQKlwFHhjsTExPD444+TlJTE\n2LFjqVevHsuWLWPYsGGcOnWqqpdX6Xh4eLBs2TKOHz/O8ePHefTRRxk8eDCxsbGl/o6zszPXr183\neShCoWJRqVTs2rWLefPmMW/ePC5dusTVq1f56quvaNWqFdu2baNbt254eHjQuXNn+vTpQ1hYWI0S\nClBUiAlFrqeVgbFQOHnyJKmpqTzyyCOsXLmSadOmceLECUJDQ8nOzpadFl988UVef/11nJ2d6dix\nI6dPn8bd3V0WE4pQqBkokQWFO/L222+zefNmjh49iouLCwBJSUns2rWLzp07m4yxFkKg1+uxsLB4\n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\nrkJggg==\n", - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/david/Insync/donato.meoli.95@gmail.com/Google Drive/aima-python/notebook_utils.py:93: UserWarning: FigureCanvasAgg is non-interactive, and thus cannot be shown\n", + " plt.show()\n", + "/home/david/Insync/donato.meoli.95@gmail.com/Google Drive/aima-python/notebook_utils.py:93: UserWarning: FigureCanvasAgg is non-interactive, and thus cannot be shown\n", + " plt.show()\n", + "/home/david/Insync/donato.meoli.95@gmail.com/Google Drive/aima-python/notebook_utils.py:93: UserWarning: FigureCanvasAgg is non-interactive, and thus cannot be shown\n", + " plt.show()\n" + ] } ], "source": [ @@ -581,7 +678,14 @@ { "cell_type": "code", "execution_count": 16, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.222314Z", + "iopub.status.busy": "2026-06-27T12:55:39.221936Z", + "iopub.status.idle": "2026-06-27T12:55:39.235394Z", + "shell.execute_reply": "2026-06-27T12:55:39.232027Z" + } + }, "outputs": [ { "name": "stdout", @@ -612,7 +716,14 @@ { "cell_type": "code", "execution_count": 17, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.250666Z", + "iopub.status.busy": "2026-06-27T12:55:39.249714Z", + "iopub.status.idle": "2026-06-27T12:55:39.268062Z", + "shell.execute_reply": "2026-06-27T12:55:39.264400Z" + } + }, "outputs": [ { "name": "stdout", @@ -643,7 +754,14 @@ { "cell_type": "code", "execution_count": 18, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.274190Z", + "iopub.status.busy": "2026-06-27T12:55:39.273288Z", + "iopub.status.idle": "2026-06-27T12:55:39.295697Z", + "shell.execute_reply": "2026-06-27T12:55:39.294351Z" + } + }, "outputs": [ { "name": "stdout", @@ -674,7 +792,14 @@ { "cell_type": "code", "execution_count": 19, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.302076Z", + "iopub.status.busy": "2026-06-27T12:55:39.301714Z", + "iopub.status.idle": "2026-06-27T12:55:39.320877Z", + "shell.execute_reply": "2026-06-27T12:55:39.315861Z" + } + }, "outputs": [ { "name": "stdout", @@ -705,7 +830,14 @@ { "cell_type": "code", "execution_count": 20, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.328793Z", + "iopub.status.busy": "2026-06-27T12:55:39.328414Z", + "iopub.status.idle": "2026-06-27T12:55:39.347876Z", + "shell.execute_reply": "2026-06-27T12:55:39.342477Z" + } + }, "outputs": [ { "name": "stdout", @@ -736,7 +868,14 @@ { "cell_type": "code", "execution_count": 21, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.353032Z", + "iopub.status.busy": "2026-06-27T12:55:39.352676Z", + "iopub.status.idle": "2026-06-27T12:55:39.368244Z", + "shell.execute_reply": "2026-06-27T12:55:39.366279Z" + } + }, "outputs": [ { "name": "stdout", @@ -767,7 +906,14 @@ { "cell_type": "code", "execution_count": 22, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.372060Z", + "iopub.status.busy": "2026-06-27T12:55:39.371612Z", + "iopub.status.idle": "2026-06-27T12:55:39.391304Z", + "shell.execute_reply": "2026-06-27T12:55:39.387901Z" + } + }, "outputs": [ { "name": "stdout", @@ -796,7 +942,7 @@ "\n", "The Plurality Learner is a simple algorithm, used mainly as a baseline comparison for other algorithms. It finds the most popular class in the dataset and classifies any subsequent item to that class. Essentially, it classifies every new item to the same class. For that reason, it is not used very often, instead opting for more complicated algorithms when we want accurate classification.\n", "\n", - "![pL plot](images/pluralityLearner_plot.png)\n", + "![pL plot](images/plurality_learner_plot.png)\n", "\n", "Let's see how the classifier works with the plot above. There are three classes named **Class A** (orange-colored dots) and **Class B** (blue-colored dots) and **Class C** (green-colored dots). Every point in this plot has two **features** (i.e. X1, X2). Now, let's say we have a new point, a red star and we want to know which class this red star belongs to. Solving this problem by predicting the class of this new red star is our current classification problem.\n", "\n", @@ -814,11 +960,142 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 23, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.399384Z", + "iopub.status.busy": "2026-06-27T12:55:39.398967Z", + "iopub.status.idle": "2026-06-27T12:55:39.523465Z", + "shell.execute_reply": "2026-06-27T12:55:39.520880Z" + } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def PluralityLearner(dataset):\n",
+       "    """\n",
+       "    A very dumb algorithm: always pick the result that was most popular\n",
+       "    in the training data. Makes a baseline for comparison.\n",
+       "    """\n",
+       "    most_popular = mode([e[dataset.target] for e in dataset.examples])\n",
+       "\n",
+       "    def predict(example):\n",
+       "        """Always return same result: the most popular from the training set."""\n",
+       "        return most_popular\n",
+       "\n",
+       "    return predict\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(PluralityLearner)" ] @@ -844,7 +1121,14 @@ { "cell_type": "code", "execution_count": 24, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.528793Z", + "iopub.status.busy": "2026-06-27T12:55:39.528446Z", + "iopub.status.idle": "2026-06-27T12:55:39.539252Z", + "shell.execute_reply": "2026-06-27T12:55:39.537742Z" + } + }, "outputs": [ { "name": "stdout", @@ -906,72 +1190,207 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 25, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.550609Z", + "iopub.status.busy": "2026-06-27T12:55:39.549998Z", + "iopub.status.idle": "2026-06-27T12:55:39.566248Z", + "shell.execute_reply": "2026-06-27T12:55:39.564510Z" + } }, - "outputs": [], - "source": [ - "psource(NearestNeighborLearner)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "It takes as input a dataset and k (default value is 1) and it returns a function, which we can later use to classify a new item.\n", - "\n", - "To accomplish that, the function uses a heap-queue, where the items of the dataset are sorted according to their distance from *example* (the item to classify). We then take the k smallest elements from the heap-queue and we find the majority class. We classify the item to this class." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Example\n", - "\n", - "We measured a new flower with the following values: 5.1, 3.0, 1.1, 0.1. We want to classify that item/flower in a class. To do that, we write the following:" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": {}, "outputs": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "setosa\n" - ] - } - ], - "source": [ - "iris = DataSet(name=\"iris\")\n", - "\n", - "kNN = NearestNeighborLearner(iris,k=3)\n", - "print(kNN([5.1,3.0,1.1,0.1]))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The output of the above code is \"setosa\", which means the flower with the above measurements is of the \"setosa\" species." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## DECISION TREE LEARNER\n", - "\n", - "### Overview\n", - "\n", - "#### Decision Trees\n", - "A decision tree is a flowchart that uses a tree of decisions and their possible consequences for classification. At each non-leaf node of the tree an attribute of the input is tested, based on which corresponding branch leading to a child-node is selected. At the leaf node the input is classified based on the class label of this leaf node. The paths from root to leaves represent classification rules based on which leaf nodes are assigned class labels.\n", - "![perceptron](images/decisiontree_fruit.jpg)\n", - "#### Decision Tree Learning\n", + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def NearestNeighborLearner(dataset, k=1):\n",
+       "    """k-NearestNeighbor: the k nearest neighbors vote."""\n",
+       "\n",
+       "    def predict(example):\n",
+       "        """Find the k closest items, and have them vote for the best."""\n",
+       "        best = heapq.nsmallest(k, ((dataset.distance(e, example), e) for e in dataset.examples))\n",
+       "        return mode(e[dataset.target] for (d, e) in best)\n",
+       "\n",
+       "    return predict\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "psource(NearestNeighborLearner)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It takes as input a dataset and k (default value is 1) and it returns a function, which we can later use to classify a new item.\n", + "\n", + "To accomplish that, the function uses a heap-queue, where the items of the dataset are sorted according to their distance from *example* (the item to classify). We then take the k smallest elements from the heap-queue and we find the majority class. We classify the item to this class." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example\n", + "\n", + "We measured a new flower with the following values: 5.1, 3.0, 1.1, 0.1. We want to classify that item/flower in a class. To do that, we write the following:" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.572104Z", + "iopub.status.busy": "2026-06-27T12:55:39.571550Z", + "iopub.status.idle": "2026-06-27T12:55:39.590303Z", + "shell.execute_reply": "2026-06-27T12:55:39.587958Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "setosa\n" + ] + } + ], + "source": [ + "iris = DataSet(name=\"iris\")\n", + "\n", + "kNN = NearestNeighborLearner(iris,k=3)\n", + "print(kNN([5.1,3.0,1.1,0.1]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The output of the above code is \"setosa\", which means the flower with the above measurements is of the \"setosa\" species." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## DECISION TREE LEARNER\n", + "\n", + "### Overview\n", + "\n", + "#### Decision Trees\n", + "A decision tree is a flowchart that uses a tree of decisions and their possible consequences for classification. At each non-leaf node of the tree an attribute of the input is tested, based on which corresponding branch leading to a child-node is selected. At the leaf node the input is classified based on the class label of this leaf node. The paths from root to leaves represent classification rules based on which leaf nodes are assigned class labels.\n", + "![perceptron](images/decisiontree_fruit.jpg)\n", + "#### Decision Tree Learning\n", "Decision tree learning is the construction of a decision tree from class-labeled training data. The data is expected to be a tuple in which each record of the tuple is an attribute used for classification. The decision tree is built top-down, by choosing a variable at each step that best splits the set of items. There are different metrics for measuring the \"best split\". These generally measure the homogeneity of the target variable within the subsets.\n", "\n", "#### Gini Impurity\n", @@ -995,9 +1414,45 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 27, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.596196Z", + "iopub.status.busy": "2026-06-27T12:55:39.594246Z", + "iopub.status.idle": "2026-06-27T12:55:39.715938Z", + "shell.execute_reply": "2026-06-27T12:55:39.714362Z" + } + }, + "outputs": [ + { + "data": { + "text/markdown": [ + "### AIMA3e\n", + "__function__ DECISION-TREE-LEARNING(_examples_, _attributes_, _parent\\_examples_) __returns__ a tree \n", + " __if__ _examples_ is empty __then return__ PLURALITY\\-VALUE(_parent\\_examples_) \n", + " __else if__ all _examples_ have the same classification __then return__ the classification \n", + " __else if__ _attributes_ is empty __then return__ PLURALITY\\-VALUE(_examples_) \n", + " __else__ \n", + "   _A_ ← argmax_a_ ∈ _attributes_ IMPORTANCE(_a_, _examples_) \n", + "   _tree_ ← a new decision tree with root test _A_ \n", + "   __for each__ value _vk_ of _A_ __do__ \n", + "     _exs_ ← \\{ _e_ : _e_ ∈ _examples_ __and__ _e_._A_ = _vk_ \\} \n", + "     _subtree_ ← DECISION-TREE-LEARNING(_exs_, _attributes_ − _A_, _examples_) \n", + "     add a branch to _tree_ with label \\(_A_ = _vk_\\) and subtree _subtree_ \n", + "   __return__ _tree_ \n", + "\n", + "---\n", + "__Figure ??__ The decision\\-tree learning algorithm. The function IMPORTANCE is described in Section __??__. The function PLURALITY\\-VALUE selects the most common output value among a set of examples, breaking ties randomly." + ], + "text/plain": [ + "" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "pseudocode(\"Decision Tree Learning\")" ] @@ -1012,9 +1467,164 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 28, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.724691Z", + "iopub.status.busy": "2026-06-27T12:55:39.722362Z", + "iopub.status.idle": "2026-06-27T12:55:39.864476Z", + "shell.execute_reply": "2026-06-27T12:55:39.859525Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
class DecisionFork:\n",
+       "    """\n",
+       "    A fork of a decision tree holds an attribute to test, and a dict\n",
+       "    of branches, one for each of the attribute's values.\n",
+       "    """\n",
+       "\n",
+       "    def __init__(self, attr, attr_name=None, default_child=None, branches=None):\n",
+       "        """Initialize by saying what attribute this node tests."""\n",
+       "        self.attr = attr\n",
+       "        self.attr_name = attr_name or attr\n",
+       "        self.default_child = default_child\n",
+       "        self.branches = branches or {}\n",
+       "\n",
+       "    def __call__(self, example):\n",
+       "        """Given an example, classify it using the attribute and the branches."""\n",
+       "        attr_val = example[self.attr]\n",
+       "        if attr_val in self.branches:\n",
+       "            return self.branches[attr_val](example)\n",
+       "        else:\n",
+       "            # return default class when attribute is unknown\n",
+       "            return self.default_child(example)\n",
+       "\n",
+       "    def add(self, val, subtree):\n",
+       "        """Add a branch. If self.attr = val, go to the given subtree."""\n",
+       "        self.branches[val] = subtree\n",
+       "\n",
+       "    def display(self, indent=0):\n",
+       "        name = self.attr_name\n",
+       "        print('Test', name)\n",
+       "        for (val, subtree) in self.branches.items():\n",
+       "            print(' ' * 4 * indent, name, '=', val, '==>', end=' ')\n",
+       "            subtree.display(indent + 1)\n",
+       "\n",
+       "    def __repr__(self):\n",
+       "        return 'DecisionFork({0!r}, {1!r}, {2!r})'.format(self.attr, self.attr_name, self.branches)\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(DecisionFork)" ] @@ -1028,11 +1638,144 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 29, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.877242Z", + "iopub.status.busy": "2026-06-27T12:55:39.874487Z", + "iopub.status.idle": "2026-06-27T12:55:39.974034Z", + "shell.execute_reply": "2026-06-27T12:55:39.967338Z" + } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
class DecisionLeaf:\n",
+       "    """A leaf of a decision tree holds just a result."""\n",
+       "\n",
+       "    def __init__(self, result):\n",
+       "        self.result = result\n",
+       "\n",
+       "    def __call__(self, example):\n",
+       "        return self.result\n",
+       "\n",
+       "    def display(self):\n",
+       "        print('RESULT =', self.result)\n",
+       "\n",
+       "    def __repr__(self):\n",
+       "        return repr(self.result)\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(DecisionLeaf)" ] @@ -1046,11 +1789,185 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 30, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:39.982966Z", + "iopub.status.busy": "2026-06-27T12:55:39.982541Z", + "iopub.status.idle": "2026-06-27T12:55:40.010709Z", + "shell.execute_reply": "2026-06-27T12:55:40.009042Z" + } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def DecisionTreeLearner(dataset):\n",
+       "    """[Figure 18.5]"""\n",
+       "\n",
+       "    target, values = dataset.target, dataset.values\n",
+       "\n",
+       "    def decision_tree_learning(examples, attrs, parent_examples=()):\n",
+       "        if len(examples) == 0:\n",
+       "            return plurality_value(parent_examples)\n",
+       "        if all_same_class(examples):\n",
+       "            return DecisionLeaf(examples[0][target])\n",
+       "        if len(attrs) == 0:\n",
+       "            return plurality_value(examples)\n",
+       "        A = choose_attribute(attrs, examples)\n",
+       "        tree = DecisionFork(A, dataset.attr_names[A], plurality_value(examples))\n",
+       "        for (v_k, exs) in split_by(A, examples):\n",
+       "            subtree = decision_tree_learning(exs, remove_all(A, attrs), examples)\n",
+       "            tree.add(v_k, subtree)\n",
+       "        return tree\n",
+       "\n",
+       "    def plurality_value(examples):\n",
+       "        """\n",
+       "        Return the most popular target value for this set of examples.\n",
+       "        (If target is binary, this is the majority; otherwise plurality).\n",
+       "        """\n",
+       "        popular = argmax_random_tie(values[target], key=lambda v: count(target, v, examples))\n",
+       "        return DecisionLeaf(popular)\n",
+       "\n",
+       "    def count(attr, val, examples):\n",
+       "        """Count the number of examples that have example[attr] = val."""\n",
+       "        return sum(e[attr] == val for e in examples)\n",
+       "\n",
+       "    def all_same_class(examples):\n",
+       "        """Are all these examples in the same target class?"""\n",
+       "        class0 = examples[0][target]\n",
+       "        return all(e[target] == class0 for e in examples)\n",
+       "\n",
+       "    def choose_attribute(attrs, examples):\n",
+       "        """Choose the attribute with the highest information gain."""\n",
+       "        return argmax_random_tie(attrs, key=lambda a: information_gain(a, examples))\n",
+       "\n",
+       "    def information_gain(attr, examples):\n",
+       "        """Return the expected reduction in entropy from splitting by attr."""\n",
+       "\n",
+       "        def I(examples):\n",
+       "            return information_content([count(target, v, examples) for v in values[target]])\n",
+       "\n",
+       "        n = len(examples)\n",
+       "        remainder = sum((len(examples_i) / n) * I(examples_i) for (v, examples_i) in split_by(attr, examples))\n",
+       "        return I(examples) - remainder\n",
+       "\n",
+       "    def split_by(attr, examples):\n",
+       "        """Return a list of (val, examples) pairs for each val of attr."""\n",
+       "        return [(v, [e for e in examples if e[attr] == v]) for v in values[attr]]\n",
+       "\n",
+       "    return decision_tree_learning(dataset.examples, dataset.inputs)\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(DecisionTreeLearner)" ] @@ -1079,8 +1996,15 @@ }, { "cell_type": "code", - "execution_count": 2, - "metadata": {}, + "execution_count": 31, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.020013Z", + "iopub.status.busy": "2026-06-27T12:55:40.017869Z", + "iopub.status.idle": "2026-06-27T12:55:40.050350Z", + "shell.execute_reply": "2026-06-27T12:55:40.046557Z" + } + }, "outputs": [ { "name": "stdout", @@ -1109,40 +2033,182 @@ "0_tG-IWcxL1jg7RkT0.png": { "image/png": "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JsP8iaRnJiQkgkLIyzEUse+xu32S4DIet9vkXvSnSNxL4l6RifhcyaCQr/W2yupWpVtqDi9tNLer6kaBnwmLqbAUn0b/ndcPetvrnZ/ZxHD/Z2449MkZKdx3332ZTGZtbW19ff1//s//WavVTpw44Ywaer1etVq9du1auVxOpVJzc3NOou5yuR555JF33nnnl7/85fvvv08QxPr6+szMjCRJTrveZrN56dIlVVW/973v3XnnnbdUwHLORnfGF++99148Hrcs67333vvDH/5w4sQJ0zQDgcCDDz44Oztbq9XeeuutjY2NlZWVv/f3/t7TTz/98MMPUxT161//ut/vm6YJsQwcbVDAAodjgEHT9NLS0okTJ1ZXV1966aVQKPT444+73e79X9br9XK5HIZh8XjcWdMrCMJdd921srKyvLz84x//eH5+fmlpaf9GQqePVa1WEwQhlUp96Vb2/fr9frfbdbYHOquLnU9evXq11WrNzs72er1z585du3atWq1ms9nxeBwMBq9cuXLt2rXhcFir1d5+++1yufytb33rT1/AMkxTHU4ailaoDzbL3ZVcu6WMTNtyC8xsVJqLyTNhMeYXfBKDYSiGIvszVqhBHPM34+57Z/o/wUXyjJQMCgtROV/vZcvqalGpNgeXMs21vBIPCIsJeS4qR7ycX6IZmoAXDzgww/jdeQvn7j2Zgh5Y4MgzLas/NBrdQakxWC8q6wWlrgx13ZR45sysbyYspiPiXESQud2Jh/3L0lEUdoVD9N+N/i6anIlIMT+/mHQXar2NYjdTUkqNwdVse72oxPz8fEyenZ724xYZjiHw27zV4DMnVp3O6595Y0dRNBKJPP74441G46WXXrpw4cLGxkYqlYpGoxRFtdvtXC7Xbrej0eiZM2ecKWpnQn1+fv7ZZ5/Vdf3dd989d+7c5cuXU6mU3+83DKNSqZRKpclkcvr0aZ7nbzXhcblc8Xg8EAg0m81XXnml2+0Oh8NGo5FOpx966CGfz+f089U07fLly91uF0GQSCTy3HPPnTx50jmx3TRNiqL2+tMDcFRBAQt8A8OGrzDMQBDkgQce2NzcLJVK7777Lsuy3W73nnvucXaYm6bZ6/XW19fffffdQCDwj/7RP3IKWBzHPf3009ls9le/+tVvfvObYDD4xBNPJBIJkiRt255MJt1u97333stms3fffXc0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HWttbf3v//2/P/vss52dnaudfrW0ZjC65Z5vyefz586d+6d/+qcbN25885vf/LM/+zNBEE6fPv3GG2/Y7fbe3l6/39/Z2Xn48OGf//znP/nJT2ZmZr773e8ODAwYDAZoJAAAUPvdHi4CAGDdPhayrMfjEUXxxIkToiiaTCayrI+iKFmW5+fnT5w4YTQa9+7d+7/+1//as2fP/YWRuq7Pz8//9Kc//dWvfpXJZPbs2fOtb31r9+7ddrudjBbcRxms6vmvnAIjmbhwOPzTn/70yJEjwWDw+9///vPPP7+wsPDjH/84Fos9+uijmzdv7uvra2lp+eUvfzk0NHTx4sU//uM/fuGFF7xeL+SwAPiDuFPBJQDrGE3Tbrd769atQ0NDY2NjTqfz0KFDBw8edDgcGONisfjpp5/+53/+5/DwMMuygUDgwIED9xy3uVOxWDx9+vQ//MM/XL58ua+v73//7/+9a9cun89Hphyvticmr68xe0VePDk5+ZOf/GRsbGznzp3f+MY3GhoaOI574oknOjs7EULBYNBoNPb09LjdbqfT+dprrx07doxhGE3Tdu3aBZ0xAACsoFoDS5ZlSGABANZ32Nzf3z8wMHD06FFZljs7O//P//k//f39GGNVVcPh8I9+9KPz589//PHHRqOxo6OjqalptTEkWRvx7//+76+//jpN06+++urLL79MVi2QF9xHUIp+38ovTiQSH3/88bFjx3Rdf+yxx55++um6ujqbzfZ//+//FUXR6XQ6HA6WZV944YWmpqZ33nnntdde+8d//EeM8Te+8Y21KjkCAPg8IIEF1i2S3OE4zmAwkFySw+EYGBjYtWuXyWQinWhra2sikRiveP/993t7e1ebwFJVdXp6+j/+4z8+/vjjvr6+733vewcOHCBFKKupqAf6M2az2cuXLw8NDSmK0tfX19vbS8avHBXkcQtjbDQam5ubX331VZPJ9Dd/8zfvvPOO3W5vamqqr6+/j2liAADwVYAQEgSB53lVVUVRrG7wCgAA6zJsJtEjTdNms7m3t3dwcLCvr4+8IJ/PLywsKIpy4sSJs2fPzs7Oer1eo9G4qk/J5/NHjx598803s9ns888//z//5/9sbW0VBOEL+zHn5+fff//9ubm5Xbt2PfLII263m6zYaG9vX/oyt9u9Z88eo9EYj8ePHTv2i1/8wul0vvzyy1BD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MZFOp9etW5ekxquXYl1z3ht64dP968qVK6+++uqJEyeWLVvW2dl59913z2T7qqReffjhh6+++uru3bt7enq+8Y1vfP/731+1atUN7R8PwG0gYAEALFzTX+/r6+s3bdpUKBTS6XR/f/9zzz03MDDw4IMP3nfffXfdddfNHTyeklSP5IeJiYmhoaHe3t633377r3/968TExMaNG7ds2dLc3HzN8/noocrLy8fGxv7+978fPnx4//7977333te//vUnn3xy/fr1SVu5gzbbjqLozJkze/bs2b9/fxRF69at+853vrNs2bLKysob3U3s1ikWiydPnszn8+3t7W1tbckFgMlO8Inu7u6LFy+eO3du7dq1K1asuGZSxXG8ePHixx57bHh4eM+ePcePH//pT3/61a9+9YEHHlixYsWNblA1PW7Jf6eXB5ZKpcHBwf+ZcuDAgXQ6vXnz5i1btiTLrz5xGIeGhg5Pee21186fP799+/YnnnhizZo1yXpA27cDBEXAAgBY6JIv6s3Nzd/85jcXL1780ksvvf76688//3xPT88zzzxz0wErm83W1dU1NDTU1NQklxyOjY0dO3bs17/+9dGjR0dHRx999NGnnnpq6dKlM7nfXBzHg4ODP//5z7u7u+vq6h577LFdu3Yl98W741rDyMhIV1fXH/7wh3/961+rV6/etm3bI4880tDQEM4isjiOr1y5Mj4+ns1m29rapldgpVKpurq6tra2EydO7N27d9GiRatWrWppaUn++lHNzc1PP/10S0vLyy+/fOjQoXfffbezs/Ppp5++uYBVWVlZW1vb0NBQXV2dSqWSptnd3f2LX/zixIkThULha1/72vbt21esWDHD9VMnT5782c9+9u677+Zyuc7OzieeeOILX/jCnTijABYCAQsAYKGb3lG7pqZm/fr199xzz7Zt27q6uqIouman9plLp9Of+9znHnnkkZaWlo0bNyYVbHJycnx8PI7jtWvXrlmzZtOmTffdd19yx71P7AWTU9ra2lauXLlhw4b777+/ubn5jlt7lVS8d95557e//e17773X2NjY2dn5rW99a3rHpXACViqVeuCBB3K53OrVq1tbW1OpVPLL9vb2Xbt27d+/v7y8fOPGjRs2bFi+fPlHA9b0pKqvr9+6deuqVavefPPN3t7eXC53cxtLTc+olStXPvTQQ8meXMVi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" } - }, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## RANDOM FOREST LEARNER\n", - "\n", - "### Overview\n", - "\n", - "![random_forest.png](images/random_forest.png) \n", - "Image via [src](https://cdn-images-1.medium.com/max/800/0*tG-IWcxL1jg7RkT0.png)\n", - "\n", - "#### Random Forest\n", - "\n", - "As the name of the algorithm and image above suggest, this algorithm creates the forest with a number of trees. The more number of trees makes the forest robust. In the same way in random forest algorithm, the higher the number of trees in the forest, the higher is the accuray result. The main difference between Random Forest and Decision trees is that, finding the root node and splitting the feature nodes will be random. \n", - "\n", - "Let's see how Rnadom Forest Algorithm work : \n", - "Random Forest Algorithm works in two steps, first is the creation of random forest and then the prediction. Let's first see the creation : \n", - "\n", - "The first step in creation is to randomly select 'm' features out of total 'n' features. From these 'm' features calculate the node d using the best split point and then split the node into further nodes using best split. Repeat these steps until 'i' number of nodes are reached. Repeat the entire whole process to build the forest. \n", - "\n", - "Now, let's see how the prediction works\n", - "Take the test features and predict the outcome for each randomly created decision tree. Calculate the votes for each prediction and the prediction which gets the highest votes would be the final prediction.\n", - "\n", - "\n", - "### Implementation\n", - "\n", - "Below mentioned is the implementation of Random Forest Algorithm." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + }, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## RANDOM FOREST LEARNER\n", + "\n", + "### Overview\n", + "\n", + "![random_forest.png](images/random_forest.png) \n", + "Image via [src](https://cdn-images-1.medium.com/max/800/0*tG-IWcxL1jg7RkT0.png)\n", + "\n", + "#### Random Forest\n", + "\n", + "As the name of the algorithm and image above suggest, this algorithm creates the forest with a number of trees. The more number of trees makes the forest robust. In the same way in random forest algorithm, the higher the number of trees in the forest, the higher is the accuray result. The main difference between Random Forest and Decision trees is that, finding the root node and splitting the feature nodes will be random. \n", + "\n", + "Let's see how Rnadom Forest Algorithm work : \n", + "Random Forest Algorithm works in two steps, first is the creation of random forest and then the prediction. Let's first see the creation : \n", + "\n", + "The first step in creation is to randomly select 'm' features out of total 'n' features. From these 'm' features calculate the node d using the best split point and then split the node into further nodes using best split. Repeat these steps until 'i' number of nodes are reached. Repeat the entire whole process to build the forest. \n", + "\n", + "Now, let's see how the prediction works\n", + "Take the test features and predict the outcome for each randomly created decision tree. Calculate the votes for each prediction and the prediction which gets the highest votes would be the final prediction.\n", + "\n", + "\n", + "### Implementation\n", + "\n", + "Below mentioned is the implementation of Random Forest Algorithm." + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.063393Z", + "iopub.status.busy": "2026-06-27T12:55:40.062943Z", + "iopub.status.idle": "2026-06-27T12:55:40.086574Z", + "shell.execute_reply": "2026-06-27T12:55:40.082955Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def RandomForest(dataset, n=5):\n",
+       "    """An ensemble of Decision Trees trained using bagging and feature bagging."""\n",
+       "\n",
+       "    def data_bagging(dataset, m=0):\n",
+       "        """Sample m examples with replacement"""\n",
+       "        n = len(dataset.examples)\n",
+       "        return weighted_sample_with_replacement(m or n, dataset.examples, [1] * n)\n",
+       "\n",
+       "    def feature_bagging(dataset, p=0.7):\n",
+       "        """Feature bagging with probability p to retain an attribute"""\n",
+       "        inputs = [i for i in dataset.inputs if probability(p)]\n",
+       "        return inputs or dataset.inputs\n",
+       "\n",
+       "    def predict(example):\n",
+       "        print([predictor(example) for predictor in predictors])\n",
+       "        return mode(predictor(example) for predictor in predictors)\n",
+       "\n",
+       "    predictors = [DecisionTreeLearner(DataSet(examples=data_bagging(dataset), attrs=dataset.attrs,\n",
+       "                                              attr_names=dataset.attr_names, target=dataset.target,\n",
+       "                                              inputs=feature_bagging(dataset))) for _ in range(n)]\n",
+       "\n",
+       "    return predict\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(RandomForest)" ] @@ -1161,14 +2227,21 @@ }, { "cell_type": "code", - "execution_count": 4, - "metadata": {}, + "execution_count": 33, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.091994Z", + "iopub.status.busy": "2026-06-27T12:55:40.091616Z", + "iopub.status.idle": "2026-06-27T12:55:40.150769Z", + "shell.execute_reply": "2026-06-27T12:55:40.148387Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "['versicolor', 'setosa', 'setosa', 'setosa', 'setosa']\n", + "['setosa', 'setosa', 'setosa', 'versicolor', 'setosa']\n", "setosa\n" ] } @@ -1295,8 +2368,15 @@ }, { "cell_type": "code", - "execution_count": 30, - "metadata": {}, + "execution_count": 34, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.155239Z", + "iopub.status.busy": "2026-06-27T12:55:40.154860Z", + "iopub.status.idle": "2026-06-27T12:55:40.174178Z", + "shell.execute_reply": "2026-06-27T12:55:40.172313Z" + } + }, "outputs": [ { "name": "stdout", @@ -1337,8 +2417,15 @@ }, { "cell_type": "code", - "execution_count": 31, - "metadata": {}, + "execution_count": 35, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.177520Z", + "iopub.status.busy": "2026-06-27T12:55:40.177139Z", + "iopub.status.idle": "2026-06-27T12:55:40.185939Z", + "shell.execute_reply": "2026-06-27T12:55:40.184360Z" + } + }, "outputs": [ { "name": "stdout", @@ -1369,11 +2456,159 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 36, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.191002Z", + "iopub.status.busy": "2026-06-27T12:55:40.189990Z", + "iopub.status.idle": "2026-06-27T12:55:40.214695Z", + "shell.execute_reply": "2026-06-27T12:55:40.207715Z" + } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def NaiveBayesDiscrete(dataset):\n",
+       "    """\n",
+       "    Just count how many times each value of each input attribute\n",
+       "    occurs, conditional on the target value. Count the different\n",
+       "    target values too.\n",
+       "    """\n",
+       "\n",
+       "    target_vals = dataset.values[dataset.target]\n",
+       "    target_dist = CountingProbDist(target_vals)\n",
+       "    attr_dists = {(gv, attr): CountingProbDist(dataset.values[attr]) for gv in target_vals for attr in dataset.inputs}\n",
+       "    for example in dataset.examples:\n",
+       "        target_val = example[dataset.target]\n",
+       "        target_dist.add(target_val)\n",
+       "        for attr in dataset.inputs:\n",
+       "            attr_dists[target_val, attr].add(example[attr])\n",
+       "\n",
+       "    def predict(example):\n",
+       "        """\n",
+       "        Predict the target value for example. Consider each possible value,\n",
+       "        and pick the most likely by looking at each attribute independently.\n",
+       "        """\n",
+       "\n",
+       "        def class_probability(target_val):\n",
+       "            return (target_dist[target_val] * product(attr_dists[target_val, attr][example[attr]]\n",
+       "                                                      for attr in dataset.inputs))\n",
+       "\n",
+       "        return max(target_vals, key=class_probability)\n",
+       "\n",
+       "    return predict\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(NaiveBayesDiscrete)" ] @@ -1389,8 +2624,15 @@ }, { "cell_type": "code", - "execution_count": 33, - "metadata": {}, + "execution_count": 37, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.219592Z", + "iopub.status.busy": "2026-06-27T12:55:40.218075Z", + "iopub.status.idle": "2026-06-27T12:55:40.243006Z", + "shell.execute_reply": "2026-06-27T12:55:40.239243Z" + } + }, "outputs": [ { "name": "stdout", @@ -1425,8 +2667,15 @@ }, { "cell_type": "code", - "execution_count": 34, - "metadata": {}, + "execution_count": 38, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.247667Z", + "iopub.status.busy": "2026-06-27T12:55:40.247115Z", + "iopub.status.idle": "2026-06-27T12:55:40.267400Z", + "shell.execute_reply": "2026-06-27T12:55:40.264593Z" + } + }, "outputs": [ { "name": "stdout", @@ -1459,11 +2708,153 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 39, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.271469Z", + "iopub.status.busy": "2026-06-27T12:55:40.271088Z", + "iopub.status.idle": "2026-06-27T12:55:40.294897Z", + "shell.execute_reply": "2026-06-27T12:55:40.289892Z" + } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def NaiveBayesContinuous(dataset):\n",
+       "    """\n",
+       "    Count how many times each target value occurs.\n",
+       "    Also, find the means and deviations of input attribute values for each target value.\n",
+       "    """\n",
+       "    means, deviations = dataset.find_means_and_deviations()\n",
+       "\n",
+       "    target_vals = dataset.values[dataset.target]\n",
+       "    target_dist = CountingProbDist(target_vals)\n",
+       "\n",
+       "    def predict(example):\n",
+       "        """Predict the target value for example. Consider each possible value,\n",
+       "        and pick the most likely by looking at each attribute independently."""\n",
+       "\n",
+       "        def class_probability(target_val):\n",
+       "            prob = target_dist[target_val]\n",
+       "            for attr in dataset.inputs:\n",
+       "                prob *= gaussian(means[target_val][attr], deviations[target_val][attr], example[attr])\n",
+       "            return prob\n",
+       "\n",
+       "        return max(target_vals, key=class_probability)\n",
+       "\n",
+       "    return predict\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(NaiveBayesContinuous)" ] @@ -1483,11 +2874,151 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 40, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.303006Z", + "iopub.status.busy": "2026-06-27T12:55:40.302569Z", + "iopub.status.idle": "2026-06-27T12:55:40.334054Z", + "shell.execute_reply": "2026-06-27T12:55:40.330606Z" + } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def NaiveBayesSimple(distribution):\n",
+       "    """\n",
+       "    A simple naive bayes classifier that takes as input a dictionary of\n",
+       "    CountingProbDist objects and classifies items according to these distributions.\n",
+       "    The input dictionary is in the following form:\n",
+       "        (ClassName, ClassProb): CountingProbDist\n",
+       "    """\n",
+       "    target_dist = {c_name: prob for c_name, prob in distribution.keys()}\n",
+       "    attr_dists = {c_name: count_prob for (c_name, _), count_prob in distribution.items()}\n",
+       "\n",
+       "    def predict(example):\n",
+       "        """Predict the target value for example. Calculate probabilities for each\n",
+       "        class and pick the max."""\n",
+       "\n",
+       "        def class_probability(target_val):\n",
+       "            attr_dist = attr_dists[target_val]\n",
+       "            return target_dist[target_val] * product(attr_dist[a] for a in example)\n",
+       "\n",
+       "        return max(target_dist.keys(), key=class_probability)\n",
+       "\n",
+       "    return predict\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(NaiveBayesSimple)" ] @@ -1510,8 +3041,15 @@ }, { "cell_type": "code", - "execution_count": 36, - "metadata": {}, + "execution_count": 41, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.340479Z", + "iopub.status.busy": "2026-06-27T12:55:40.340015Z", + "iopub.status.idle": "2026-06-27T12:55:40.356698Z", + "shell.execute_reply": "2026-06-27T12:55:40.354533Z" + } + }, "outputs": [ { "name": "stdout", @@ -1557,9 +3095,15 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 42, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.362294Z", + "iopub.status.busy": "2026-06-27T12:55:40.361867Z", + "iopub.status.idle": "2026-06-27T12:55:40.373281Z", + "shell.execute_reply": "2026-06-27T12:55:40.369872Z" + } }, "outputs": [], "source": [ @@ -1580,9 +3124,15 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 43, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.377137Z", + "iopub.status.busy": "2026-06-27T12:55:40.376766Z", + "iopub.status.idle": "2026-06-27T12:55:40.385188Z", + "shell.execute_reply": "2026-06-27T12:55:40.383567Z" + } }, "outputs": [], "source": [ @@ -1599,8 +3149,15 @@ }, { "cell_type": "code", - "execution_count": 4, - "metadata": {}, + "execution_count": 44, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.390374Z", + "iopub.status.busy": "2026-06-27T12:55:40.389930Z", + "iopub.status.idle": "2026-06-27T12:55:40.409484Z", + "shell.execute_reply": "2026-06-27T12:55:40.406624Z" + } + }, "outputs": [ { "name": "stdout", @@ -1662,11 +3219,150 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 45, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.419072Z", + "iopub.status.busy": "2026-06-27T12:55:40.418702Z", + "iopub.status.idle": "2026-06-27T12:55:40.442352Z", + "shell.execute_reply": "2026-06-27T12:55:40.439149Z" + } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def PerceptronLearner(dataset, learning_rate=0.01, epochs=100):\n",
+       "    """Logistic Regression, NO hidden layer"""\n",
+       "    i_units = len(dataset.inputs)\n",
+       "    o_units = len(dataset.values[dataset.target])\n",
+       "    hidden_layer_sizes = []\n",
+       "    raw_net = network(i_units, hidden_layer_sizes, o_units)\n",
+       "    learned_net = BackPropagationLearner(dataset, raw_net, learning_rate, epochs)\n",
+       "\n",
+       "    def predict(example):\n",
+       "        o_nodes = learned_net[1]\n",
+       "\n",
+       "        # forward pass\n",
+       "        for node in o_nodes:\n",
+       "            in_val = dot_product(example, node.weights)\n",
+       "            node.value = node.activation(in_val)\n",
+       "\n",
+       "        # hypothesis\n",
+       "        return find_max_node(o_nodes)\n",
+       "\n",
+       "    return predict\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(PerceptronLearner)" ] @@ -1691,8 +3387,15 @@ }, { "cell_type": "code", - "execution_count": 38, - "metadata": {}, + "execution_count": 46, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:40.450973Z", + "iopub.status.busy": "2026-06-27T12:55:40.450588Z", + "iopub.status.idle": "2026-06-27T12:55:41.306361Z", + "shell.execute_reply": "2026-06-27T12:55:41.305276Z" + } + }, "outputs": [ { "name": "stdout", @@ -1737,9 +3440,168 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 47, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:41.321375Z", + "iopub.status.busy": "2026-06-27T12:55:41.320949Z", + "iopub.status.idle": "2026-06-27T12:55:41.367655Z", + "shell.execute_reply": "2026-06-27T12:55:41.366214Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def LinearLearner(dataset, learning_rate=0.01, epochs=100):\n",
+       "    """\n",
+       "    [Section 18.6.3]\n",
+       "    Linear classifier with hard threshold.\n",
+       "    """\n",
+       "    idx_i = dataset.inputs\n",
+       "    idx_t = dataset.target\n",
+       "    examples = dataset.examples\n",
+       "    num_examples = len(examples)\n",
+       "\n",
+       "    # X transpose: the actual value of each input feature across the examples\n",
+       "    X_col = [[example[i] for example in examples] for i in idx_i]  # vertical columns of X\n",
+       "\n",
+       "    # add dummy\n",
+       "    ones = [1 for _ in range(len(examples))]\n",
+       "    X_col = [ones] + X_col\n",
+       "\n",
+       "    # initialize random weights\n",
+       "    num_weights = len(idx_i) + 1\n",
+       "    w = random_weights(min_value=-0.5, max_value=0.5, num_weights=num_weights)\n",
+       "\n",
+       "    for epoch in range(epochs):\n",
+       "        err = []\n",
+       "        # pass over all examples\n",
+       "        for example in examples:\n",
+       "            x = [1] + [example[i] for i in idx_i]\n",
+       "            y = np.dot(w, x)\n",
+       "            t = example[idx_t]\n",
+       "            err.append(t - y)\n",
+       "\n",
+       "        # update weights\n",
+       "        for i in range(len(w)):\n",
+       "            w[i] = w[i] + learning_rate * (np.dot(err, X_col[i]) / num_examples)\n",
+       "\n",
+       "    def predict(example):\n",
+       "        x = [1] + [example[i] for i in idx_i]\n",
+       "        return np.dot(w, x)\n",
+       "\n",
+       "    return predict\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(LinearLearner)" ] @@ -1757,14 +3619,21 @@ }, { "cell_type": "code", - "execution_count": 21, - "metadata": {}, + "execution_count": 48, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:41.377922Z", + "iopub.status.busy": "2026-06-27T12:55:41.377459Z", + "iopub.status.idle": "2026-06-27T12:55:41.536451Z", + "shell.execute_reply": "2026-06-27T12:55:41.534600Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "0.2404650656510341\n" + "-0.30907513960973826\n" ] } ], @@ -1806,9 +3675,141 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 49, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:41.546856Z", + "iopub.status.busy": "2026-06-27T12:55:41.546508Z", + "iopub.status.idle": "2026-06-27T12:55:41.557297Z", + "shell.execute_reply": "2026-06-27T12:55:41.556249Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def EnsembleLearner(learners):\n",
+       "    """Given a list of learning algorithms, have them vote."""\n",
+       "\n",
+       "    def train(dataset):\n",
+       "        predictors = [learner(dataset) for learner in learners]\n",
+       "\n",
+       "        def predict(example):\n",
+       "            return mode(predictor(example) for predictor in predictors)\n",
+       "\n",
+       "        return predict\n",
+       "\n",
+       "    return train\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(EnsembleLearner)" ] @@ -1831,9 +3832,15 @@ }, { "cell_type": "code", - "execution_count": 42, + "execution_count": 50, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:41.564293Z", + "iopub.status.busy": "2026-06-27T12:55:41.563983Z", + "iopub.status.idle": "2026-06-27T12:55:41.579172Z", + "shell.execute_reply": "2026-06-27T12:55:41.578229Z" + } }, "outputs": [], "source": [ @@ -1851,14 +3858,21 @@ }, { "cell_type": "code", - "execution_count": 43, - "metadata": {}, + "execution_count": 51, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:41.582307Z", + "iopub.status.busy": "2026-06-27T12:55:41.581993Z", + "iopub.status.idle": "2026-06-27T12:55:41.605886Z", + "shell.execute_reply": "2026-06-27T12:55:41.605011Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Error ratio for Discrete: 0.040000000000000036\n", + "Error ratio for Discrete: 0.033333333333333326\n", "Error ratio for Continuous: 0.040000000000000036\n" ] } @@ -1889,16 +3903,41 @@ }, { "cell_type": "code", - "execution_count": 44, - "metadata": {}, + "execution_count": 52, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:41.609363Z", + "iopub.status.busy": "2026-06-27T12:55:41.609062Z", + "iopub.status.idle": "2026-06-27T12:55:43.664768Z", + "shell.execute_reply": "2026-06-27T12:55:43.663781Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Error ratio for k=1: 0.0\n", - "Error ratio for k=3: 0.06000000000000005\n", - "Error ratio for k=5: 0.1266666666666667\n", + "Error ratio for k=1: 0.0\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error ratio for k=3: 0.06000000000000005\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error ratio for k=5: 0.1266666666666667\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "Error ratio for k=7: 0.19999999999999996\n" ] } @@ -1935,8 +3974,15 @@ }, { "cell_type": "code", - "execution_count": 45, - "metadata": {}, + "execution_count": 53, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:43.672457Z", + "iopub.status.busy": "2026-06-27T12:55:43.672020Z", + "iopub.status.idle": "2026-06-27T12:55:44.372046Z", + "shell.execute_reply": "2026-06-27T12:55:44.370195Z" + } + }, "outputs": [ { "name": "stdout", @@ -1993,120 +4039,137 @@ }, { "cell_type": "code", - "execution_count": 3, - "metadata": {}, + "execution_count": 54, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:44.385682Z", + "iopub.status.busy": "2026-06-27T12:55:44.375524Z", + "iopub.status.idle": "2026-06-27T12:55:44.424855Z", + "shell.execute_reply": "2026-06-27T12:55:44.419458Z" + } + }, "outputs": [ { "data": { "text/html": [ "\n", - "\n", + "\n", "\n", "\n", " \n", " \n", " \n", "\n", "\n", "

\n", "\n", - "
def AdaBoost(L, K):\n",
-       "    """[Figure 18.34]"""\n",
-       "    def train(dataset):\n",
-       "        examples, target = dataset.examples, dataset.target\n",
-       "        N = len(examples)\n",
-       "        epsilon = 1. / (2 * N)\n",
-       "        w = [1. / N] * N\n",
-       "        h, z = [], []\n",
-       "        for k in range(K):\n",
-       "            h_k = L(dataset, w)\n",
-       "            h.append(h_k)\n",
-       "            error = sum(weight for example, weight in zip(examples, w)\n",
-       "                        if example[target] != h_k(example))\n",
-       "            # Avoid divide-by-0 from either 0% or 100% error rates:\n",
-       "            error = clip(error, epsilon, 1 - epsilon)\n",
-       "            for j, example in enumerate(examples):\n",
-       "                if example[target] == h_k(example):\n",
-       "                    w[j] *= error / (1. - error)\n",
-       "            w = normalize(w)\n",
-       "            z.append(math.log((1. - error) / error))\n",
-       "        return WeightedMajority(h, z)\n",
-       "    return train\n",
+       "
def ada_boost(dataset, L, K):\n",
+       "    """[Figure 18.34]"""\n",
+       "\n",
+       "    examples, target = dataset.examples, dataset.target\n",
+       "    n = len(examples)\n",
+       "    eps = 1 / (2 * n)\n",
+       "    w = [1 / n] * n\n",
+       "    h, z = [], []\n",
+       "    for k in range(K):\n",
+       "        h_k = L(dataset, w)\n",
+       "        h.append(h_k)\n",
+       "        error = sum(weight for example, weight in zip(examples, w) if example[target] != h_k(example))\n",
+       "        # avoid divide-by-0 from either 0% or 100% error rates\n",
+       "        error = np.clip(error, eps, 1 - eps)\n",
+       "        for j, example in enumerate(examples):\n",
+       "            if example[target] == h_k(example):\n",
+       "                w[j] *= error / (1 - error)\n",
+       "        w = normalize(w)\n",
+       "        z.append(np.log((1 - error) / error))\n",
+       "    return weighted_majority(h, z)\n",
        "
\n", "\n", "\n" @@ -2120,7 +4183,7 @@ } ], "source": [ - "psource(AdaBoost)" + "psource(ada_boost)" ] }, { @@ -2135,11 +4198,141 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 55, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:44.430078Z", + "iopub.status.busy": "2026-06-27T12:55:44.429620Z", + "iopub.status.idle": "2026-06-27T12:55:44.445203Z", + "shell.execute_reply": "2026-06-27T12:55:44.442762Z" + } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def WeightedLearner(unweighted_learner):\n",
+       "    """\n",
+       "    [Page 749 footnote 14]\n",
+       "    Given a learner that takes just an unweighted dataset, return\n",
+       "    one that takes also a weight for each example.\n",
+       "    """\n",
+       "\n",
+       "    def train(dataset, weights):\n",
+       "        return unweighted_learner(replicated_dataset(dataset, weights))\n",
+       "\n",
+       "    return train\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "psource(WeightedLearner)" ] @@ -2162,20 +4355,32 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 56, "metadata": { - "collapsed": true + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T12:55:44.450641Z", + "iopub.status.busy": "2026-06-27T12:55:44.450204Z", + "iopub.status.idle": "2026-06-27T12:55:44.458768Z", + "shell.execute_reply": "2026-06-27T12:55:44.456309Z" + } }, "outputs": [], "source": [ - "WeightedPerceptron = WeightedLearner(PerceptronLearner)\n", - "AdaboostLearner = AdaBoost(WeightedPerceptron, 5)" + "WeightedPerceptron = WeightedLearner(PerceptronLearner)" ] }, { "cell_type": "code", - "execution_count": 5, - "metadata": {}, + "execution_count": 57, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:44.461594Z", + "iopub.status.busy": "2026-06-27T12:55:44.461302Z", + "iopub.status.idle": "2026-06-27T12:55:49.889365Z", + "shell.execute_reply": "2026-06-27T12:55:49.885324Z" + } + }, "outputs": [ { "data": { @@ -2183,7 +4388,7 @@ "0" ] }, - "execution_count": 5, + "execution_count": 57, "metadata": {}, "output_type": "execute_result" } @@ -2192,7 +4397,7 @@ "iris2 = DataSet(name=\"iris\")\n", "iris2.classes_to_numbers()\n", "\n", - "adaboost = AdaboostLearner(iris2)\n", + "adaboost = ada_boost(iris2, WeightedPerceptron, 5)\n", "\n", "adaboost([5, 3, 1, 0.1])" ] @@ -2206,14 +4411,21 @@ }, { "cell_type": "code", - "execution_count": 6, - "metadata": {}, + "execution_count": 58, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:49.903548Z", + "iopub.status.busy": "2026-06-27T12:55:49.902992Z", + "iopub.status.idle": "2026-06-27T12:55:50.020946Z", + "shell.execute_reply": "2026-06-27T12:55:50.003020Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Error ratio for adaboost: 0.046666666666666634\n" + "Error ratio for adaboost: 0.013333333333333308\n" ] } ], @@ -2229,6 +4441,224 @@ "source": [ "It reduced the error rate considerably. Unlike the `PerceptronLearner`, `AdaBoost` was able to learn the complexity in the iris dataset." ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## CROSS-VALIDATION\n", + "\n", + "When we evaluate a learner we want an estimate of how well it will do on *unseen* data, not how well it memorizes the training set. **k-fold cross-validation** splits the examples into `k` equal folds; each fold is held out once as a validation set while the learner trains on the remaining `k-1` folds, and the training and validation errors are averaged over the `k` runs (optionally over several random shuffles via `trials`). This is implemented by `cross_validation` in `learning.py`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Implementation" + ] + }, + { + "cell_type": "code", + "execution_count": 59, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:50.031572Z", + "iopub.status.busy": "2026-06-27T12:55:50.031065Z", + "iopub.status.idle": "2026-06-27T12:55:50.107129Z", + "shell.execute_reply": "2026-06-27T12:55:50.100632Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

\n", + "\n", + "
def cross_validation(learner, dataset, size=None, k=10, trials=1):\n",
+       "    """\n",
+       "    Do k-fold cross_validate and return their mean.\n",
+       "    That is, keep out 1/k of the examples for testing on each of k runs.\n",
+       "    Shuffle the examples first; if trials > 1, average over several shuffles.\n",
+       "    Returns Training error, Validation error\n",
+       "    """\n",
+       "    k = k or len(dataset.examples)\n",
+       "    if trials > 1:\n",
+       "        trial_errT = 0\n",
+       "        trial_errV = 0\n",
+       "        for t in range(trials):\n",
+       "            errT, errV = cross_validation(learner, dataset, size, k, trials)\n",
+       "            trial_errT += errT\n",
+       "            trial_errV += errV\n",
+       "        return trial_errT / trials, trial_errV / trials\n",
+       "    else:\n",
+       "        fold_errT = 0\n",
+       "        fold_errV = 0\n",
+       "        n = len(dataset.examples)\n",
+       "        examples = dataset.examples\n",
+       "        random.shuffle(dataset.examples)\n",
+       "        for fold in range(k):\n",
+       "            train_data, val_data = train_test_split(dataset, fold * (n // k), (fold + 1) * (n // k))\n",
+       "            dataset.examples = train_data\n",
+       "            h = learner(dataset, size)\n",
+       "            fold_errT += err_ratio(h, dataset, train_data)\n",
+       "            fold_errV += err_ratio(h, dataset, val_data)\n",
+       "            # reverting back to original once test is completed\n",
+       "            dataset.examples = examples\n",
+       "        return fold_errT / k, fold_errV / k\n",
+       "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "psource(cross_validation)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 60, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T12:55:50.115975Z", + "iopub.status.busy": "2026-06-27T12:55:50.115463Z", + "iopub.status.idle": "2026-06-27T12:55:54.607343Z", + "shell.execute_reply": "2026-06-27T12:55:54.601766Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "training error: 0.090\n", + "validation error: 0.287\n" + ] + } + ], + "source": [ + "# cross-validate a k-NN learner on the iris dataset.\n", + "# cross_validation calls learner(dataset, size), so we let `size` be the\n", + "# number of neighbours k:\n", + "iris = DataSet(name='iris')\n", + "\n", + "def knn(dataset, size):\n", + " return NearestNeighborLearner(dataset, k=size or 1)\n", + "\n", + "err_train, err_val = cross_validation(knn, iris, size=3, k=5)\n", + "print('training error: {:.3f}'.format(err_train))\n", + "print('validation error: {:.3f}'.format(err_val))" + ] } ], "metadata": { @@ -2247,18 +4677,18 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.5.2" + "version": "3.12.13" }, "pycharm": { "stem_cell": { "cell_type": "raw", - "source": [], "metadata": { "collapsed": false - } + }, + "source": [] } } }, "nbformat": 4, "nbformat_minor": 2 -} \ No newline at end of file +} diff --git a/notebooks/learning.py b/notebooks/learning.py new file mode 100644 index 000000000..244a40b70 --- /dev/null +++ b/notebooks/learning.py @@ -0,0 +1,1055 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # LEARNING +# +# This notebook serves as supporting material for topics covered in **Chapter 18 - Learning from Examples** , **Chapter 19 - Knowledge in Learning**, **Chapter 20 - Learning Probabilistic Models** from the book *Artificial Intelligence: A Modern Approach*. This notebook uses implementations from [learning.py](https://github.com/aimacode/aima-python/blob/master/learning.py). Let's start by importing everything from the module: + +# %% +import math + +from aima.utils import argmax_random_tie as argmax +from aima.learning import * +from aima.probabilistic_learning import * +from aima.notebook_utils import * + +# %% [markdown] +# ## CONTENTS +# +# * Machine Learning Overview +# * Datasets +# * Iris Visualization +# * Distance Functions +# * Plurality Learner +# * k-Nearest Neighbours +# * Decision Tree Learner +# * Random Forest Learner +# * Naive Bayes Learner +# * Perceptron +# * Learner Evaluation + +# %% [markdown] +# ## MACHINE LEARNING OVERVIEW +# +# In this notebook, we learn about agents that can improve their behavior through diligent study of their own experiences. +# +# An agent is **learning** if it improves its performance on future tasks after making observations about the world. +# +# There are three types of feedback that determine the three main types of learning: +# +# * **Supervised Learning**: +# +# In Supervised Learning the agent observes some example input-output pairs and learns a function that maps from input to output. +# +# **Example**: Let's think of an agent to classify images containing cats or dogs. If we provide an image containing a cat or a dog, this agent should output a string "cat" or "dog" for that particular image. To teach this agent, we will give a lot of input-output pairs like {cat image-"cat"}, {dog image-"dog"} to the agent. The agent then learns a function that maps from an input image to one of those strings. +# +# * **Unsupervised Learning**: +# +# In Unsupervised Learning the agent learns patterns in the input even though no explicit feedback is supplied. The most common type is **clustering**: detecting potential useful clusters of input examples. +# +# **Example**: A taxi agent would develop a concept of *good traffic days* and *bad traffic days* without ever being given labeled examples. +# +# * **Reinforcement Learning**: +# +# In Reinforcement Learning the agent learns from a series of reinforcements—rewards or punishments. +# +# **Example**: Let's talk about an agent to play the popular Atari game—[Pong](http://www.ponggame.org). We will reward a point for every correct move and deduct a point for every wrong move from the agent. Eventually, the agent will figure out its actions prior to reinforcement were most responsible for it. + +# %% [markdown] +# ## DATASETS +# +# For the following tutorials we will use a range of datasets, to better showcase the strengths and weaknesses of the algorithms. The datasests are the following: +# +# * [Fisher's Iris](https://github.com/aimacode/aima-data/blob/a21fc108f52ad551344e947b0eb97df82f8d2b2b/iris.csv): Each item represents a flower, with four measurements: the length and the width of the sepals and petals. Each item/flower is categorized into one of three species: Setosa, Versicolor and Virginica. +# +# * [Zoo](https://github.com/aimacode/aima-data/blob/a21fc108f52ad551344e947b0eb97df82f8d2b2b/zoo.csv): The dataset holds different animals and their classification as "mammal", "fish", etc. The new animal we want to classify has the following measurements: 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 4, 1, 0, 1 (don't concern yourself with what the measurements mean). + +# %% [markdown] +# To make using the datasets easier, we have written a class, `DataSet`, in `learning.py`. The tutorials found here make use of this class. +# +# Let's have a look at how it works before we get started with the algorithms. + +# %% [markdown] +# ### Intro +# +# A lot of the datasets we will work with are .csv files (although other formats are supported too). We have a collection of sample datasets ready to use [on aima-data](https://github.com/aimacode/aima-data/tree/a21fc108f52ad551344e947b0eb97df82f8d2b2b). Two examples are the datasets mentioned above (*iris.csv* and *zoo.csv*). You can find plenty datasets online, and a good repository of such datasets is [UCI Machine Learning Repository](https://archive.ics.uci.edu/ml/datasets.html). +# +# In such files, each line corresponds to one item/measurement. Each individual value in a line represents a *feature* and usually there is a value denoting the *class* of the item. +# +# You can find the code for the dataset here: + +# %% +# %psource DataSet + +# %% [markdown] +# ### Class Attributes +# +# * **examples**: Holds the items of the dataset. Each item is a list of values. +# +# * **attrs**: The indexes of the features (by default in the range of [0,f), where *f* is the number of features). For example, `item[i]` returns the feature at index *i* of *item*. +# +# * **attrnames**: An optional list with attribute names. For example, `item[s]`, where *s* is a feature name, returns the feature of name *s* in *item*. +# +# * **target**: The attribute a learning algorithm will try to predict. By default the last attribute. +# +# * **inputs**: This is the list of attributes without the target. +# +# * **values**: A list of lists which holds the set of possible values for the corresponding attribute/feature. If initially `None`, it gets computed (by the function `setproblem`) from the examples. +# +# * **distance**: The distance function used in the learner to calculate the distance between two items. By default `mean_boolean_error`. +# +# * **name**: Name of the dataset. +# +# * **source**: The source of the dataset (url or other). Not used in the code. +# +# * **exclude**: A list of indexes to exclude from `inputs`. The list can include either attribute indexes (attrs) or names (attrnames). + +# %% [markdown] +# ### Class Helper Functions +# +# These functions help modify a `DataSet` object to your needs. +# +# * **sanitize**: Takes as input an example and returns it with non-input (target) attributes replaced by `None`. Useful for testing. Keep in mind that the example given is not itself sanitized, but instead a sanitized copy is returned. +# +# * **classes_to_numbers**: Maps the class names of a dataset to numbers. If the class names are not given, they are computed from the dataset values. Useful for classifiers that return a numerical value instead of a string. +# +# * **remove_examples**: Removes examples containing a given value. Useful for removing examples with missing values, or for removing classes (needed for binary classifiers). + +# %% [markdown] +# ### Importing a Dataset +# +# #### Importing from aima-data +# +# Datasets uploaded on aima-data can be imported with the following line: + +# %% +iris = DataSet(name="iris") + +# %% [markdown] +# To check that we imported the correct dataset, we can do the following: + +# %% +print(iris.examples[0]) +print(iris.inputs) + +# %% [markdown] +# Which correctly prints the first line in the csv file and the list of attribute indexes. + +# %% [markdown] +# When importing a dataset, we can specify to exclude an attribute (for example, at index 1) by setting the parameter `exclude` to the attribute index or name. + +# %% +iris2 = DataSet(name="iris",exclude=[1]) +print(iris2.inputs) + +# %% [markdown] +# ### Attributes +# +# Here we showcase the attributes. +# +# First we will print the first three items/examples in the dataset. + +# %% +print(iris.examples[:3]) + +# %% [markdown] +# Then we will print `attrs`, `attrnames`, `target`, `input`. Notice how `attrs` holds values in [0,4], but since the fourth attribute is the target, `inputs` holds values in [0,3]. + +# %% +print("attrs:", iris.attrs) +print("attrnames (by default same as attrs):", iris.attr_names) +print("target:", iris.target) +print("inputs:", iris.inputs) + +# %% [markdown] +# Now we will print all the possible values for the first feature/attribute. + +# %% +print(iris.values[0]) + +# %% [markdown] +# Finally we will print the dataset's name and source. Keep in mind that we have not set a source for the dataset, so in this case it is empty. + +# %% +print("name:", iris.name) +print("source:", iris.source) + +# %% [markdown] +# A useful combination of the above is `dataset.values[dataset.target]` which returns the possible values of the target. For classification problems, this will return all the possible classes. Let's try it: + +# %% +print(iris.values[iris.target]) + +# %% [markdown] +# ### Helper Functions + +# %% [markdown] +# We will now take a look at the auxiliary functions found in the class. +# +# First we will take a look at the `sanitize` function, which sets the non-input values of the given example to `None`. +# +# In this case we want to hide the class of the first example, so we will sanitize it. +# +# Note that the function doesn't actually change the given example; it returns a sanitized *copy* of it. + +# %% +print("Sanitized:",iris.sanitize(iris.examples[0])) +print("Original:",iris.examples[0]) + +# %% [markdown] +# Currently the `iris` dataset has three classes, setosa, virginica and versicolor. We want though to convert it to a binary class dataset (a dataset with two classes). The class we want to remove is "virginica". To accomplish that we will utilize the helper function `remove_examples`. + +# %% +iris2 = DataSet(name="iris") + +iris2.remove_examples("virginica") +print(iris2.values[iris2.target]) + +# %% [markdown] +# We also have `classes_to_numbers`. For a lot of the classifiers in the module (like the Neural Network), classes should have numerical values. With this function we map string class names to numbers. + +# %% +print("Class of first example:",iris2.examples[0][iris2.target]) +iris2.classes_to_numbers() +print("Class of first example:",iris2.examples[0][iris2.target]) + +# %% [markdown] +# As you can see "setosa" was mapped to 0. + +# %% [markdown] +# Finally, we take a look at `find_means_and_deviations`. It finds the means and standard deviations of the features for each class. + +# %% +means, deviations = iris.find_means_and_deviations() + +print("Setosa feature means:", means["setosa"]) +print("Versicolor mean for first feature:", means["versicolor"][0]) + +print("Setosa feature deviations:", deviations["setosa"]) +print("Virginica deviation for second feature:",deviations["virginica"][1]) + +# %% [markdown] +# ## IRIS VISUALIZATION +# +# Since we will use the iris dataset extensively in this notebook, below we provide a visualization tool that helps in comprehending the dataset and thus how the algorithms work. +# +# We plot the dataset in a 3D space using `matplotlib` and the function `show_iris` from `notebook.py`. The function takes as input three parameters, *i*, *j* and *k*, which are indicises to the iris features, "Sepal Length", "Sepal Width", "Petal Length" and "Petal Width" (0 to 3). By default we show the first three features. + +# %% +iris = DataSet(name="iris") + +show_iris() +show_iris(0, 1, 3) +show_iris(1, 2, 3) + + +# %% [markdown] +# You can play around with the values to get a good look at the dataset. + +# %% [markdown] +# ## DISTANCE FUNCTIONS +# +# In a lot of algorithms (like the *k-Nearest Neighbors* algorithm), there is a need to compare items, finding how *similar* or *close* they are. For that we have many different functions at our disposal. Below are the functions implemented in the module: +# +# ### Manhattan Distance (`manhattan_distance`) +# +# One of the simplest distance functions. It calculates the difference between the coordinates/features of two items. To understand how it works, imagine a 2D grid with coordinates *x* and *y*. In that grid we have two items, at the squares positioned at `(1,2)` and `(3,4)`. The difference between their two coordinates is `3-1=2` and `4-2=2`. If we sum these up we get `4`. That means to get from `(1,2)` to `(3,4)` we need four moves; two to the right and two more up. The function works similarly for n-dimensional grids. + +# %% +def manhattan_distance(X, Y): + return sum([abs(x - y) for x, y in zip(X, Y)]) + + +distance = manhattan_distance([1,2], [3,4]) +print("Manhattan Distance between (1,2) and (3,4) is", distance) + + +# %% [markdown] +# ### Euclidean Distance (`euclidean_distance`) +# +# Probably the most popular distance function. It returns the square root of the sum of the squared differences between individual elements of two items. + +# %% +def euclidean_distance(X, Y): + return math.sqrt(sum([(x - y)**2 for x, y in zip(X,Y)])) + + +distance = euclidean_distance([1,2], [3,4]) +print("Euclidean Distance between (1,2) and (3,4) is", distance) + + +# %% [markdown] +# ### Hamming Distance (`hamming_distance`) +# +# This function counts the number of differences between single elements in two items. For example, if we have two binary strings "111" and "011" the function will return 1, since the two strings only differ at the first element. The function works the same way for non-binary strings too. + +# %% +def hamming_distance(X, Y): + return sum(x != y for x, y in zip(X, Y)) + + +distance = hamming_distance(['a','b','c'], ['a','b','b']) +print("Hamming Distance between 'abc' and 'abb' is", distance) + + +# %% [markdown] +# ### Mean Boolean Error (`mean_boolean_error`) +# +# To calculate this distance, we find the ratio of different elements over all elements of two items. For example, if the two items are `(1,2,3)` and `(1,4,5)`, the ration of different/all elements is 2/3, since they differ in two out of three elements. + +# %% +def mean_boolean_error(X, Y): + return mean(int(x != y) for x, y in zip(X, Y)) + + +distance = mean_boolean_error([1,2,3], [1,4,5]) +print("Mean Boolean Error Distance between (1,2,3) and (1,4,5) is", distance) + + +# %% [markdown] +# ### Mean Error (`mean_error`) +# +# This function finds the mean difference of single elements between two items. For example, if the two items are `(1,0,5)` and `(3,10,5)`, their error distance is `(3-1) + (10-0) + (5-5) = 2 + 10 + 0 = 12`. The mean error distance therefore is `12/3=4`. + +# %% +def mean_error(X, Y): + return mean([abs(x - y) for x, y in zip(X, Y)]) + + +distance = mean_error([1,0,5], [3,10,5]) +print("Mean Error Distance between (1,0,5) and (3,10,5) is", distance) + + +# %% [markdown] +# ### Mean Square Error (`ms_error`) +# +# This is very similar to the `Mean Error`, but instead of calculating the difference between elements, we are calculating the *square* of the differences. + +# %% +def ms_error(X, Y): + return mean([(x - y)**2 for x, y in zip(X, Y)]) + + +distance = ms_error([1,0,5], [3,10,5]) +print("Mean Square Distance between (1,0,5) and (3,10,5) is", distance) + + +# %% [markdown] +# ### Root of Mean Square Error (`rms_error`) +# +# This is the square root of `Mean Square Error`. + +# %% +def rms_error(X, Y): + return math.sqrt(ms_error(X, Y)) + + +distance = rms_error([1,0,5], [3,10,5]) +print("Root of Mean Error Distance between (1,0,5) and (3,10,5) is", distance) + +# %% [markdown] +# ## PLURALITY LEARNER CLASSIFIER +# +# ### Overview +# +# The Plurality Learner is a simple algorithm, used mainly as a baseline comparison for other algorithms. It finds the most popular class in the dataset and classifies any subsequent item to that class. Essentially, it classifies every new item to the same class. For that reason, it is not used very often, instead opting for more complicated algorithms when we want accurate classification. +# +# ![pL plot](images/plurality_learner_plot.png) +# +# Let's see how the classifier works with the plot above. There are three classes named **Class A** (orange-colored dots) and **Class B** (blue-colored dots) and **Class C** (green-colored dots). Every point in this plot has two **features** (i.e. X1, X2). Now, let's say we have a new point, a red star and we want to know which class this red star belongs to. Solving this problem by predicting the class of this new red star is our current classification problem. +# +# The Plurality Learner will find the class most represented in the plot. ***Class A*** has four items, ***Class B*** has three and ***Class C*** has seven. The most popular class is ***Class C***. Therefore, the item will get classified in ***Class C***, despite the fact that it is closer to the other two classes. + +# %% [markdown] +# ### Implementation +# +# Below follows the implementation of the PluralityLearner algorithm: + +# %% +psource(PluralityLearner) + +# %% [markdown] +# It takes as input a dataset and returns a function. We can later call this function with the item we want to classify as the argument and it returns the class it should be classified in. +# +# The function first finds the most popular class in the dataset and then each time we call its "predict" function, it returns it. Note that the input ("example") does not matter. The function always returns the same class. + +# %% [markdown] +# ### Example +# +# For this example, we will not use the Iris dataset, since each class is represented the same. This will throw an error. Instead we will use the zoo dataset. + +# %% +zoo = DataSet(name="zoo") + +pL = PluralityLearner(zoo) +print(pL([1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 4, 1, 0, 1])) + +# %% [markdown] +# The output for the above code is "mammal", since that is the most popular and common class in the dataset. + +# %% [markdown] +# ## K-NEAREST NEIGHBOURS CLASSIFIER +# +# ### Overview +# The k-Nearest Neighbors algorithm is a non-parametric method used for classification and regression. We are going to use this to classify Iris flowers. More about kNN on [Scholarpedia](http://www.scholarpedia.org/article/K-nearest_neighbor). +# +# ![kNN plot](images/knn_plot.png) + +# %% [markdown] +# Let's see how kNN works with a simple plot shown in the above picture. +# +# We have co-ordinates (we call them **features** in Machine Learning) of this red star and we need to predict its class using the kNN algorithm. In this algorithm, the value of **k** is arbitrary. **k** is one of the **hyper parameters** for kNN algorithm. We choose this number based on our dataset and choosing a particular number is known as **hyper parameter tuning/optimising**. We learn more about this in coming topics. +# +# Let's put **k = 3**. It means you need to find 3-Nearest Neighbors of this red star and classify this new point into the majority class. Observe that smaller circle which contains three points other than **test point** (red star). As there are two violet points, which form the majority, we predict the class of red star as **violet- Class B**. +# +# Similarly if we put **k = 5**, you can observe that there are three yellow points, which form the majority. So, we classify our test point as **yellow- Class A**. +# +# In practical tasks, we iterate through a bunch of values for k (like [1, 3, 5, 10, 20, 50, 100]), see how it performs and select the best one. + +# %% [markdown] +# ### Implementation +# +# Below follows the implementation of the kNN algorithm: + +# %% +psource(NearestNeighborLearner) + +# %% [markdown] +# It takes as input a dataset and k (default value is 1) and it returns a function, which we can later use to classify a new item. +# +# To accomplish that, the function uses a heap-queue, where the items of the dataset are sorted according to their distance from *example* (the item to classify). We then take the k smallest elements from the heap-queue and we find the majority class. We classify the item to this class. + +# %% [markdown] +# ### Example +# +# We measured a new flower with the following values: 5.1, 3.0, 1.1, 0.1. We want to classify that item/flower in a class. To do that, we write the following: + +# %% +iris = DataSet(name="iris") + +kNN = NearestNeighborLearner(iris,k=3) +print(kNN([5.1,3.0,1.1,0.1])) + +# %% [markdown] +# The output of the above code is "setosa", which means the flower with the above measurements is of the "setosa" species. + +# %% [markdown] +# ## DECISION TREE LEARNER +# +# ### Overview +# +# #### Decision Trees +# A decision tree is a flowchart that uses a tree of decisions and their possible consequences for classification. At each non-leaf node of the tree an attribute of the input is tested, based on which corresponding branch leading to a child-node is selected. At the leaf node the input is classified based on the class label of this leaf node. The paths from root to leaves represent classification rules based on which leaf nodes are assigned class labels. +# ![perceptron](images/decisiontree_fruit.jpg) +# #### Decision Tree Learning +# Decision tree learning is the construction of a decision tree from class-labeled training data. The data is expected to be a tuple in which each record of the tuple is an attribute used for classification. The decision tree is built top-down, by choosing a variable at each step that best splits the set of items. There are different metrics for measuring the "best split". These generally measure the homogeneity of the target variable within the subsets. +# +# #### Gini Impurity +# Gini impurity of a set is the probability of a randomly chosen element to be incorrectly labeled if it was randomly labeled according to the distribution of labels in the set. +# +# $$I_G(p) = \sum{p_i(1 - p_i)} = 1 - \sum{p_i^2}$$ +# +# We select a split which minimizes the Gini impurity in child nodes. +# +# #### Information Gain +# Information gain is based on the concept of entropy from information theory. Entropy is defined as: +# +# $$H(p) = -\sum{p_i \log_2{p_i}}$$ +# +# Information Gain is difference between entropy of the parent and weighted sum of entropy of children. The feature used for splitting is the one which provides the most information gain. +# +# #### Pseudocode +# +# You can view the pseudocode by running the cell below: + +# %% +pseudocode("Decision Tree Learning") + +# %% [markdown] +# ### Implementation +# The nodes of the tree constructed by our learning algorithm are stored using either `DecisionFork` or `DecisionLeaf` based on whether they are a parent node or a leaf node respectively. + +# %% +psource(DecisionFork) + +# %% [markdown] +# `DecisionFork` holds the attribute, which is tested at that node, and a dict of branches. The branches store the child nodes, one for each of the attribute's values. Calling an object of this class as a function with input tuple as an argument returns the next node in the classification path based on the result of the attribute test. + +# %% +psource(DecisionLeaf) + +# %% [markdown] +# The leaf node stores the class label in `result`. All input tuples' classification paths end on a `DecisionLeaf` whose `result` attribute decide their class. + +# %% +psource(DecisionTreeLearner) + +# %% [markdown] +# The implementation of `DecisionTreeLearner` provided in [learning.py](https://github.com/aimacode/aima-python/blob/master/learning.py) uses information gain as the metric for selecting which attribute to test for splitting. The function builds the tree top-down in a recursive manner. Based on the input it makes one of the four choices: +#
    +#
  1. If the input at the current step has no training data we return the mode of classes of input data received in the parent step (previous level of recursion).
  2. +#
  3. If all values in training data belong to the same class it returns a `DecisionLeaf` whose class label is the class which all the data belongs to.
  4. +#
  5. If the data has no attributes that can be tested we return the class with highest plurality value in the training data.
  6. +#
  7. We choose the attribute which gives the highest amount of entropy gain and return a `DecisionFork` which splits based on this attribute. Each branch recursively calls `decision_tree_learning` to construct the sub-tree.
  8. +#
+ +# %% [markdown] +# ### Example +# +# We will now use the Decision Tree Learner to classify a sample with values: 5.1, 3.0, 1.1, 0.1. + +# %% +iris = DataSet(name="iris") + +DTL = DecisionTreeLearner(iris) +print(DTL([5.1, 3.0, 1.1, 0.1])) + +# %% [markdown] +# As expected, the Decision Tree learner classifies the sample as "setosa" as seen in the previous section. + +# %% [markdown] +# ## RANDOM FOREST LEARNER +# +# ### Overview +# +# ![random_forest.png](images/random_forest.png) +# Image via [src](https://cdn-images-1.medium.com/max/800/0*tG-IWcxL1jg7RkT0.png) +# +# #### Random Forest +# +# As the name of the algorithm and image above suggest, this algorithm creates the forest with a number of trees. The more number of trees makes the forest robust. In the same way in random forest algorithm, the higher the number of trees in the forest, the higher is the accuray result. The main difference between Random Forest and Decision trees is that, finding the root node and splitting the feature nodes will be random. +# +# Let's see how Rnadom Forest Algorithm work : +# Random Forest Algorithm works in two steps, first is the creation of random forest and then the prediction. Let's first see the creation : +# +# The first step in creation is to randomly select 'm' features out of total 'n' features. From these 'm' features calculate the node d using the best split point and then split the node into further nodes using best split. Repeat these steps until 'i' number of nodes are reached. Repeat the entire whole process to build the forest. +# +# Now, let's see how the prediction works +# Take the test features and predict the outcome for each randomly created decision tree. Calculate the votes for each prediction and the prediction which gets the highest votes would be the final prediction. +# +# +# ### Implementation +# +# Below mentioned is the implementation of Random Forest Algorithm. + +# %% +psource(RandomForest) + +# %% [markdown] +# This algorithm creates an ensemble of decision trees using bagging and feature bagging. It takes 'm' examples randomly from the total number of examples and then perform feature bagging with probability p to retain an attribute. All the predictors are predicted from the DecisionTreeLearner and then a final prediction is made. +# +# +# ### Example +# +# We will now use the Random Forest to classify a sample with values: 5.1, 3.0, 1.1, 0.1. + +# %% +iris = DataSet(name="iris") + +DTL = RandomForest(iris) +print(DTL([5.1, 3.0, 1.1, 0.1])) + +# %% [markdown] +# As expected, the Random Forest classifies the sample as "setosa". + +# %% [markdown] +# ## NAIVE BAYES LEARNER +# +# ### Overview +# +# #### Theory of Probabilities +# +# The Naive Bayes algorithm is a probabilistic classifier, making use of [Bayes' Theorem](https://en.wikipedia.org/wiki/Bayes%27_theorem). The theorem states that the conditional probability of **A** given **B** equals the conditional probability of **B** given **A** multiplied by the probability of **A**, divided by the probability of **B**. +# +# $$P(A|B) = \dfrac{P(B|A)*P(A)}{P(B)}$$ +# +# From the theory of Probabilities we have the Multiplication Rule, if the events *X* are independent the following is true: +# +# $$P(X_{1} \cap X_{2} \cap ... \cap X_{n}) = P(X_{1})*P(X_{2})*...*P(X_{n})$$ +# +# For conditional probabilities this becomes: +# +# $$P(X_{1}, X_{2}, ..., X_{n}|Y) = P(X_{1}|Y)*P(X_{2}|Y)*...*P(X_{n}|Y)$$ + +# %% [markdown] +# #### Classifying an Item +# +# How can we use the above to classify an item though? +# +# We have a dataset with a set of classes (**C**) and we want to classify an item with a set of features (**F**). Essentially what we want to do is predict the class of an item given the features. +# +# For a specific class, **Class**, we will find the conditional probability given the item features: +# +# $$P(Class|F) = \dfrac{P(F|Class)*P(Class)}{P(F)}$$ +# +# We will do this for every class and we will pick the maximum. This will be the class the item is classified in. +# +# The features though are a vector with many elements. We need to break the probabilities up using the multiplication rule. Thus the above equation becomes: +# +# $$P(Class|F) = \dfrac{P(Class)*P(F_{1}|Class)*P(F_{2}|Class)*...*P(F_{n}|Class)}{P(F_{1})*P(F_{2})*...*P(F_{n})}$$ +# +# The calculation of the conditional probability then depends on the calculation of the following: +# +# *a)* The probability of **Class** in the dataset. +# +# *b)* The conditional probability of each feature occurring in an item classified in **Class**. +# +# *c)* The probabilities of each individual feature. +# +# For *a)*, we will count how many times **Class** occurs in the dataset (aka how many items are classified in a particular class). +# +# For *b)*, if the feature values are discrete ('Blue', '3', 'Tall', etc.), we will count how many times a feature value occurs in items of each class. If the feature values are not discrete, we will go a different route. We will use a distribution function to calculate the probability of values for a given class and feature. If we know the distribution function of the dataset, then great, we will use it to compute the probabilities. If we don't know the function, we can assume the dataset follows the normal (Gaussian) distribution without much loss of accuracy. In fact, it can be proven that any distribution tends to the Gaussian the larger the population gets (see [Central Limit Theorem](https://en.wikipedia.org/wiki/Central_limit_theorem)). +# +# *NOTE:* If the values are continuous but use the discrete approach, there might be issues if we are not lucky. For one, if we have two values, '5.0 and 5.1', with the discrete approach they will be two completely different values, despite being so close. Second, if we are trying to classify an item with a feature value of '5.15', if the value does not appear for the feature, its probability will be 0. This might lead to misclassification. Generally, the continuous approach is more accurate and more useful, despite the overhead of calculating the distribution function. +# +# The last one, *c)*, is tricky. If feature values are discrete, we can count how many times they occur in the dataset. But what if the feature values are continuous? Imagine a dataset with a height feature. Is it worth it to count how many times each value occurs? Most of the time it is not, since there can be miscellaneous differences in the values (for example, 1.7 meters and 1.700001 meters are practically equal, but they count as different values). +# +# So as we cannot calculate the feature value probabilities, what are we going to do? +# +# Let's take a step back and rethink exactly what we are doing. We are essentially comparing conditional probabilities of all the classes. For two classes, **A** and **B**, we want to know which one is greater: +# +# $$\dfrac{P(F|A)*P(A)}{P(F)} vs. \dfrac{P(F|B)*P(B)}{P(F)}$$ +# +# Wait, **P(F)** is the same for both the classes! In fact, it is the same for every combination of classes. That is because **P(F)** does not depend on a class, thus being independent of the classes. +# +# So, for *c)*, we actually don't need to calculate it at all. + +# %% [markdown] +# #### Wrapping It Up +# +# Classifying an item to a class then becomes a matter of calculating the conditional probabilities of feature values and the probabilities of classes. This is something very desirable and computationally delicious. +# +# Remember though that all the above are true because we made the assumption that the features are independent. In most real-world cases that is not true though. Is that an issue here? Fret not, for the the algorithm is very efficient even with that assumption. That is why the algorithm is called **Naive** Bayes Classifier. We (naively) assume that the features are independent to make computations easier. + +# %% [markdown] +# ### Implementation +# +# The implementation of the Naive Bayes Classifier is split in two; *Learning* and *Simple*. The *learning* classifier takes as input a dataset and learns the needed distributions from that. It is itself split into two, for discrete and continuous features. The *simple* classifier takes as input not a dataset, but already calculated distributions (a dictionary of `CountingProbDist` objects). + +# %% [markdown] +# #### Discrete +# +# The implementation for discrete values counts how many times each feature value occurs for each class, and how many times each class occurs. The results are stored in a `CountinProbDist` object. + +# %% [markdown] +# With the below code you can see the probabilities of the class "Setosa" appearing in the dataset and the probability of the first feature (at index 0) of the same class having a value of 5. Notice that the second probability is relatively small, even though if we observe the dataset we will find that a lot of values are around 5. The issue arises because the features in the Iris dataset are continuous, and we are assuming they are discrete. If the features were discrete (for example, "Tall", "3", etc.) this probably wouldn't have been the case and we would see a much nicer probability distribution. + +# %% +dataset = iris + +target_vals = dataset.values[dataset.target] +target_dist = CountingProbDist(target_vals) +attr_dists = {(gv, attr): CountingProbDist(dataset.values[attr]) + for gv in target_vals + for attr in dataset.inputs} +for example in dataset.examples: + targetval = example[dataset.target] + target_dist.add(targetval) + for attr in dataset.inputs: + attr_dists[targetval, attr].add(example[attr]) + + +print(target_dist['setosa']) +print(attr_dists['setosa', 0][5.0]) + + +# %% [markdown] +# First we found the different values for the classes (called targets here) and calculated their distribution. Next we initialized a dictionary of `CountingProbDist` objects, one for each class and feature. Finally, we iterated through the examples in the dataset and calculated the needed probabilites. +# +# Having calculated the different probabilities, we will move on to the predicting function. It will receive as input an item and output the most likely class. Using the above formula, it will multiply the probability of the class appearing, with the probability of each feature value appearing in the class. It will return the max result. + +# %% +def predict(example): + def class_probability(targetval): + return (target_dist[targetval] * + product(attr_dists[targetval, attr][example[attr]] + for attr in dataset.inputs)) + return argmax(target_vals, key=class_probability) + + +print(predict([5, 3, 1, 0.1])) + +# %% [markdown] +# You can view the complete code by executing the next line: + +# %% +psource(NaiveBayesDiscrete) + +# %% [markdown] +# #### Continuous +# +# In the implementation we use the Gaussian/Normal distribution function. To make it work, we need to find the means and standard deviations of features for each class. We make use of the `find_means_and_deviations` Dataset function. On top of that, we will also calculate the class probabilities as we did with the Discrete approach. + +# %% +means, deviations = dataset.find_means_and_deviations() + +target_vals = dataset.values[dataset.target] +target_dist = CountingProbDist(target_vals) + + +print(means["setosa"]) +print(deviations["versicolor"]) + + +# %% [markdown] +# You can see the means of the features for the "Setosa" class and the deviations for "Versicolor". +# +# The prediction function will work similarly to the Discrete algorithm. It will multiply the probability of the class occurring with the conditional probabilities of the feature values for the class. +# +# Since we are using the Gaussian distribution, we will input the value for each feature into the Gaussian function, together with the mean and deviation of the feature. This will return the probability of the particular feature value for the given class. We will repeat for each class and pick the max value. + +# %% +def predict(example): + def class_probability(targetval): + prob = target_dist[targetval] + for attr in dataset.inputs: + prob *= gaussian(means[targetval][attr], deviations[targetval][attr], example[attr]) + return prob + + return argmax(target_vals, key=class_probability) + + +print(predict([5, 3, 1, 0.1])) + +# %% [markdown] +# The complete code of the continuous algorithm: + +# %% +psource(NaiveBayesContinuous) + +# %% [markdown] +# #### Simple +# +# The simple classifier (chosen with the argument `simple`) does not learn from a dataset, instead it takes as input a dictionary of already calculated `CountingProbDist` objects and returns a predictor function. The dictionary is in the following form: `(Class Name, Class Probability): CountingProbDist Object`. +# +# Each class has its own probability distribution. The classifier given a list of features calculates the probability of the input for each class and returns the max. The only pre-processing work is to create dictionaries for the distribution of classes (named `targets`) and attributes/features. +# +# The complete code for the simple classifier: + +# %% +psource(NaiveBayesSimple) + +# %% [markdown] +# This classifier is useful when you already have calculated the distributions and you need to predict future items. + +# %% [markdown] +# ### Examples +# +# We will now use the Naive Bayes Classifier (Discrete and Continuous) to classify items: + +# %% +nBD = NaiveBayesLearner(iris, continuous=False) +print("Discrete Classifier") +print(nBD([5, 3, 1, 0.1])) +print(nBD([6, 5, 3, 1.5])) +print(nBD([7, 3, 6.5, 2])) + + +nBC = NaiveBayesLearner(iris, continuous=True) +print("\nContinuous Classifier") +print(nBC([5, 3, 1, 0.1])) +print(nBC([6, 5, 3, 1.5])) +print(nBC([7, 3, 6.5, 2])) + +# %% [markdown] +# Notice how the Discrete Classifier misclassified the second item, while the Continuous one had no problem. +# +# Let's now take a look at the simple classifier. First we will come up with a sample problem to solve. Say we are given three bags. Each bag contains three letters ('a', 'b' and 'c') of different quantities. We are given a string of letters and we are tasked with finding from which bag the string of letters came. +# +# Since we know the probability distribution of the letters for each bag, we can use the naive bayes classifier to make our prediction. + +# %% +bag1 = 'a'*50 + 'b'*30 + 'c'*15 +dist1 = CountingProbDist(bag1) +bag2 = 'a'*30 + 'b'*45 + 'c'*20 +dist2 = CountingProbDist(bag2) +bag3 = 'a'*20 + 'b'*20 + 'c'*35 +dist3 = CountingProbDist(bag3) + +# %% [markdown] +# Now that we have the `CountingProbDist` objects for each bag/class, we will create the dictionary. We assume that it is equally probable that we will pick from any bag. + +# %% +dist = {('First', 0.5): dist1, ('Second', 0.3): dist2, ('Third', 0.2): dist3} +nBS = NaiveBayesLearner(dist, simple=True) + +# %% [markdown] +# Now we can start making predictions: + +# %% +print(nBS('aab')) # We can handle strings +print(nBS(['b', 'b'])) # And lists! +print(nBS('ccbcc')) + +# %% [markdown] +# The results make intuitive sence. The first bag has a high amount of 'a's, the second has a high amount of 'b's and the third has a high amount of 'c's. The classifier seems to confirm this intuition. +# +# Note that the simple classifier doesn't distinguish between discrete and continuous values. It just takes whatever it is given. Also, the `simple` option on the `NaiveBayesLearner` overrides the `continuous` argument. `NaiveBayesLearner(d, simple=True, continuous=False)` just creates a simple classifier. + +# %% [markdown] +# ## PERCEPTRON CLASSIFIER +# +# ### Overview +# +# The Perceptron is a linear classifier. It works the same way as a neural network with no hidden layers (just input and output). First it trains its weights given a dataset and then it can classify a new item by running it through the network. +# +# Its input layer consists of the the item features, while the output layer consists of nodes (also called neurons). Each node in the output layer has *n* synapses (for every item feature), each with its own weight. Then, the nodes find the dot product of the item features and the synapse weights. These values then pass through an activation function (usually a sigmoid). Finally, we pick the largest of the values and we return its index. +# +# Note that in classification problems each node represents a class. The final classification is the class/node with the max output value. +# +# Below you can see a single node/neuron in the outer layer. With *f* we denote the item features, with *w* the synapse weights, then inside the node we have the dot product and the activation function, *g*. + +# %% [markdown] +# ![perceptron](images/perceptron.png) + +# %% [markdown] +# ### Implementation +# +# First, we train (calculate) the weights given a dataset, using the `BackPropagationLearner` function of `learning.py`. We then return a function, `predict`, which we will use in the future to classify a new item. The function computes the (algebraic) dot product of the item with the calculated weights for each node in the outer layer. Then it picks the greatest value and classifies the item in the corresponding class. + +# %% +psource(PerceptronLearner) + +# %% [markdown] +# Note that the Perceptron is a one-layer neural network, without any hidden layers. So, in `BackPropagationLearner`, we will pass no hidden layers. From that function we get our network, which is just one layer, with the weights calculated. +# +# That function `predict` passes the input/example through the network, calculating the dot product of the input and the weights for each node and returns the class with the max dot product. + +# %% [markdown] +# ### Example +# +# We will train the Perceptron on the iris dataset. Because though the `BackPropagationLearner` works with integer indexes and not strings, we need to convert class names to integers. Then, we will try and classify the item/flower with measurements of 5, 3, 1, 0.1. + +# %% +iris = DataSet(name="iris") +iris.classes_to_numbers() + +perceptron = PerceptronLearner(iris) +print(perceptron([5, 3, 1, 0.1])) + +# %% [markdown] +# The correct output is 0, which means the item belongs in the first class, "setosa". Note that the Perceptron algorithm is not perfect and may produce false classifications. + +# %% [markdown] +# ## LINEAR LEARNER +# +# ### Overview +# +# Linear Learner is a model that assumes a linear relationship between the input variables x and the single output variable y. More specifically, that y can be calculated from a linear combination of the input variables x. Linear learner is a quite simple model as the representation of this model is a linear equation. +# +# The linear equation assigns one scaler factor to each input value or column, called a coefficients or weights. One additional coefficient is also added, giving additional degree of freedom and is often called the intercept or the bias coefficient. +# For example : y = ax1 + bx2 + c . +# +# ### Implementation +# +# Below mentioned is the implementation of Linear Learner. + +# %% +psource(LinearLearner) + +# %% [markdown] +# This algorithm first assigns some random weights to the input variables and then based on the error calculated updates the weight for each variable. Finally the prediction is made with the updated weights. +# +# ### Implementation +# +# We will now use the Linear Learner to classify a sample with values: 5.1, 3.0, 1.1, 0.1. + +# %% +iris = DataSet(name="iris") +iris.classes_to_numbers() + +linear_learner = LinearLearner(iris) +print(linear_learner([5, 3, 1, 0.1])) + +# %% [markdown] +# ## ENSEMBLE LEARNER +# +# ### Overview +# +# Ensemble Learning improves the performance of our model by combining several learners. It improvise the stability and predictive power of the model. Ensemble methods are meta-algorithms that combine several machine learning techniques into one predictive model in order to decrease variance, bias, or improve predictions. +# +# +# +# ![ensemble_learner.jpg](images/ensemble_learner.jpg) +# +# +# Some commonly used Ensemble Learning techniques are : +# +# 1. Bagging : Bagging tries to implement similar learners on small sample populations and then takes a mean of all the predictions. It helps us to reduce variance error. +# +# 2. Boosting : Boosting is an iterative technique which adjust the weight of an observation based on the last classification. If an observation was classified incorrectly, it tries to increase the weight of this observation and vice versa. It helps us to reduce bias error. +# +# 3. Stacking : This is a very interesting way of combining models. Here we use a learner to combine output from different learners. It can either decrease bias or variance error depending on the learners we use. +# +# ### Implementation +# +# Below mentioned is the implementation of Ensemble Learner. + +# %% +psource(EnsembleLearner) + +# %% [markdown] +# This algorithm takes input as a list of learning algorithms, have them vote and then finally returns the predicted result. + +# %% [markdown] +# ## LEARNER EVALUATION +# +# In this section we will evaluate and compare algorithm performance. The dataset we will use will again be the iris one. + +# %% +iris = DataSet(name="iris") + +# %% [markdown] +# ### Naive Bayes +# +# First up we have the Naive Bayes algorithm. First we will test how well the Discrete Naive Bayes works, and then how the Continuous fares. + +# %% +nBD = NaiveBayesLearner(iris, continuous=False) +print("Error ratio for Discrete:", err_ratio(nBD, iris)) + +nBC = NaiveBayesLearner(iris, continuous=True) +print("Error ratio for Continuous:", err_ratio(nBC, iris)) + +# %% [markdown] +# The error for the Naive Bayes algorithm is very, very low; close to 0. There is also very little difference between the discrete and continuous version of the algorithm. + +# %% [markdown] +# ## k-Nearest Neighbors +# +# Now we will take a look at kNN, for different values of *k*. Note that *k* should have odd values, to break any ties between two classes. + +# %% +kNN_1 = NearestNeighborLearner(iris, k=1) +kNN_3 = NearestNeighborLearner(iris, k=3) +kNN_5 = NearestNeighborLearner(iris, k=5) +kNN_7 = NearestNeighborLearner(iris, k=7) + +print("Error ratio for k=1:", err_ratio(kNN_1, iris)) +print("Error ratio for k=3:", err_ratio(kNN_3, iris)) +print("Error ratio for k=5:", err_ratio(kNN_5, iris)) +print("Error ratio for k=7:", err_ratio(kNN_7, iris)) + +# %% [markdown] +# Notice how the error became larger and larger as *k* increased. This is generally the case with datasets where classes are spaced out, as is the case with the iris dataset. If items from different classes were closer together, classification would be more difficult. Usually a value of 1, 3 or 5 for *k* suffices. +# +# Also note that since the training set is also the testing set, for *k* equal to 1 we get a perfect score, since the item we want to classify each time is already in the dataset and its closest neighbor is itself. + +# %% [markdown] +# ### Perceptron +# +# For the Perceptron, we first need to convert class names to integers. Let's see how it performs in the dataset. + +# %% +iris2 = DataSet(name="iris") +iris2.classes_to_numbers() + +perceptron = PerceptronLearner(iris2) +print("Error ratio for Perceptron:", err_ratio(perceptron, iris2)) + +# %% [markdown] +# The Perceptron didn't fare very well mainly because the dataset is not linearly separated. On simpler datasets the algorithm performs much better, but unfortunately such datasets are rare in real life scenarios. + +# %% [markdown] +# ## AdaBoost +# +# ### Overview +# +# **AdaBoost** is an algorithm which uses **ensemble learning**. In ensemble learning the hypotheses in the collection, or ensemble, vote for what the output should be and the output with the majority votes is selected as the final answer. +# +# AdaBoost algorithm, as mentioned in the book, works with a **weighted training set** and **weak learners** (classifiers that have about 50%+epsilon accuracy i.e slightly better than random guessing). It manipulates the weights attached to the the examples that are showed to it. Importance is given to the examples with higher weights. +# +# All the examples start with equal weights and a hypothesis is generated using these examples. Examples which are incorrectly classified, their weights are increased so that they can be classified correctly by the next hypothesis. The examples that are correctly classified, their weights are reduced. This process is repeated *K* times (here *K* is an input to the algorithm) and hence, *K* hypotheses are generated. +# +# These *K* hypotheses are also assigned weights according to their performance on the weighted training set. The final ensemble hypothesis is the weighted-majority combination of these *K* hypotheses. +# +# The speciality of AdaBoost is that by using weak learners and a sufficiently large *K*, a highly accurate classifier can be learned irrespective of the complexity of the function being learned or the dullness of the hypothesis space. + +# %% [markdown] +# ### Implementation +# +# As seen in the previous section, the `PerceptronLearner` does not perform that well on the iris dataset. We'll use perceptron as the learner for the AdaBoost algorithm and try to increase the accuracy. +# +# Let's first see what AdaBoost is exactly: + +# %% +psource(ada_boost) + +# %% [markdown] +# AdaBoost takes as inputs: **L** and *K* where **L** is the learner and *K* is the number of hypotheses to be generated. The learner **L** takes in as inputs: a dataset and the weights associated with the examples in the dataset. But the `PerceptronLearner` doesnot handle weights and only takes a dataset as its input. +# To remedy that we will give as input to the PerceptronLearner a modified dataset in which the examples will be repeated according to the weights associated to them. Intuitively, what this will do is force the learner to repeatedly learn the same example again and again until it can classify it correctly. +# +# To convert `PerceptronLearner` so that it can take weights as input too, we will have to pass it through the **`WeightedLearner`** function. + +# %% +psource(WeightedLearner) + +# %% [markdown] +# The `WeightedLearner` function will then call the `PerceptronLearner`, during each iteration, with the modified dataset which contains the examples according to the weights associated with them. + +# %% [markdown] +# ### Example +# +# We will pass the `PerceptronLearner` through `WeightedLearner` function. Then we will create an `AdaboostLearner` classifier with number of hypotheses or *K* equal to 5. + +# %% +WeightedPerceptron = WeightedLearner(PerceptronLearner) + +# %% +iris2 = DataSet(name="iris") +iris2.classes_to_numbers() + +adaboost = ada_boost(iris2, WeightedPerceptron, 5) + +adaboost([5, 3, 1, 0.1]) + +# %% [markdown] +# That is the correct answer. Let's check the error rate of adaboost with perceptron. + +# %% +print("Error ratio for adaboost: ", err_ratio(adaboost, iris2)) + +# %% [markdown] +# It reduced the error rate considerably. Unlike the `PerceptronLearner`, `AdaBoost` was able to learn the complexity in the iris dataset. + +# %% [markdown] +# ## CROSS-VALIDATION +# +# When we evaluate a learner we want an estimate of how well it will do on *unseen* data, not how well it memorizes the training set. **k-fold cross-validation** splits the examples into `k` equal folds; each fold is held out once as a validation set while the learner trains on the remaining `k-1` folds, and the training and validation errors are averaged over the `k` runs (optionally over several random shuffles via `trials`). This is implemented by `cross_validation` in `learning.py`. + +# %% [markdown] +# ### Implementation + +# %% +psource(cross_validation) + +# %% [markdown] +# ### Example + +# %% +# cross-validate a k-NN learner on the iris dataset. +# cross_validation calls learner(dataset, size), so we let `size` be the +# number of neighbours k: +iris = DataSet(name='iris') + +def knn(dataset, size): + return NearestNeighborLearner(dataset, k=size or 1) + +err_train, err_val = cross_validation(knn, iris, size=3, k=5) +print('training error: {:.3f}'.format(err_train)) +print('validation error: {:.3f}'.format(err_val)) diff --git a/learning_apps.ipynb b/notebooks/learning_apps.ipynb similarity index 99% rename from learning_apps.ipynb rename to notebooks/learning_apps.ipynb index dd45b11b5..202db28d3 100644 --- a/learning_apps.ipynb +++ b/notebooks/learning_apps.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -15,9 +24,9 @@ "metadata": {}, "outputs": [], "source": [ - "from learning import *\n", - "from probabilistic_learning import *\n", - "from notebook import *" + "from aima.learning import *\n", + "from aima.probabilistic_learning import *\n", + "from aima.notebook_utils import *" ] }, { @@ -985,4 +994,4 @@ }, "nbformat": 4, "nbformat_minor": 2 -} \ No newline at end of file +} diff --git a/notebooks/learning_apps.py b/notebooks/learning_apps.py new file mode 100644 index 000000000..edc8ce8d4 --- /dev/null +++ b/notebooks/learning_apps.py @@ -0,0 +1,294 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # LEARNING APPLICATIONS +# +# In this notebook we will take a look at some indicative applications of machine learning techniques. We will cover content from [`learning.py`](https://github.com/aimacode/aima-python/blob/master/learning.py), for chapter 18 from Stuart Russel's and Peter Norvig's book [*Artificial Intelligence: A Modern Approach*](http://aima.cs.berkeley.edu/). Execute the cell below to get started: + +# %% +from aima.learning import * +from aima.probabilistic_learning import * +from aima.notebook_utils import * + +# %% [markdown] +# ## CONTENTS +# +# * MNIST Handwritten Digits +# * Loading and Visualising +# * Testing +# * MNIST Fashion + +# %% [markdown] +# ## MNIST HANDWRITTEN DIGITS CLASSIFICATION +# +# The MNIST Digits database, available from [this page](http://yann.lecun.com/exdb/mnist/), is a large database of handwritten digits that is commonly used for training and testing/validating in Machine learning. +# +# The dataset has **60,000 training images** each of size 28x28 pixels with labels and **10,000 testing images** of size 28x28 pixels with labels. +# +# In this section, we will use this database to compare performances of different learning algorithms. +# +# It is estimated that humans have an error rate of about **0.2%** on this problem. Let's see how our algorithms perform! +# +# NOTE: We will be using external libraries to load and visualize the dataset smoothly ([numpy](http://www.numpy.org/) for loading and [matplotlib](http://matplotlib.org/) for visualization). You do not need previous experience of the libraries to follow along. + +# %% [markdown] +# ### Loading MNIST Digits Data +# +# Let's start by loading MNIST data into numpy arrays. +# +# The function `load_MNIST()` loads MNIST data from files saved in `aima-data/MNIST`. It returns four numpy arrays that we are going to use to train and classify hand-written digits in various learning approaches. + +# %% +train_img, train_lbl, test_img, test_lbl = load_MNIST() + +# %% [markdown] +# Check the shape of these NumPy arrays to make sure we have loaded the database correctly. +# +# Each 28x28 pixel image is flattened to a 784x1 array and we should have 60,000 of them in training data. Similarly, we should have 10,000 of those 784x1 arrays in testing data. + +# %% +print("Training images size:", train_img.shape) +print("Training labels size:", train_lbl.shape) +print("Testing images size:", test_img.shape) +print("Testing labels size:", test_lbl.shape) + +# %% [markdown] +# ### Visualizing Data +# +# To get a better understanding of the dataset, let's visualize some random images for each class from training and testing datasets. + +# %% +# takes 5-10 seconds to execute this +show_MNIST(train_lbl, train_img) + +# %% +# takes 5-10 seconds to execute this +show_MNIST(test_lbl, test_img) + +# %% [markdown] +# Let's have a look at the average of all the images of training and testing data. + +# %% +print("Average of all images in training dataset.") +show_ave_MNIST(train_lbl, train_img) + +print("Average of all images in testing dataset.") +show_ave_MNIST(test_lbl, test_img) + +# %% [markdown] +# ## Testing +# +# Now, let us convert this raw data into `DataSet.examples` to run our algorithms defined in `learning.py`. Every image is represented by 784 numbers (28x28 pixels) and we append them with its label or class to make them work with our implementations in learning module. + +# %% +print(train_img.shape, train_lbl.shape) +temp_train_lbl = train_lbl.reshape((60000,1)) +training_examples = np.hstack((train_img, temp_train_lbl)) +print(training_examples.shape) + +# %% [markdown] +# Now, we will initialize a DataSet with our training examples, so we can use it in our algorithms. + +# %% +# takes ~10 seconds to execute this +MNIST_DataSet = DataSet(examples=training_examples, distance=manhattan_distance) + +# %% [markdown] +# Moving forward we can use `MNIST_DataSet` to test our algorithms. + +# %% [markdown] +# ### Plurality Learner +# +# The Plurality Learner always returns the class with the most training samples. In this case, `1`. + +# %% +pL = PluralityLearner(MNIST_DataSet) +print(pL(177)) + +# %% +# %matplotlib inline + +print("Actual class of test image:", test_lbl[177]) +plt.imshow(test_img[177].reshape((28,28))) + +# %% [markdown] +# It is obvious that this Learner is not very efficient. In fact, it will guess correctly in only 1135/10000 of the samples, roughly 10%. It is very fast though, so it might have its use as a quick first guess. + +# %% [markdown] +# ### Naive-Bayes +# +# The Naive-Bayes classifier is an improvement over the Plurality Learner. It is much more accurate, but a lot slower. + +# %% +# takes ~45 Secs. to execute this + +nBD = NaiveBayesLearner(MNIST_DataSet, continuous = False) +print(nBD(test_img[0])) + +# %% [markdown] +# To make sure that the output we got is correct, let's plot that image along with its label. + +# %% +# %matplotlib inline + +print("Actual class of test image:", test_lbl[0]) +plt.imshow(test_img[0].reshape((28,28))) + +# %% [markdown] +# ### k-Nearest Neighbors +# +# We will now try to classify a random image from the dataset using the kNN classifier. + +# %% +# takes ~20 Secs. to execute this +kNN = NearestNeighborLearner(MNIST_DataSet, k=3) +print(kNN(test_img[211])) + +# %% [markdown] +# To make sure that the output we got is correct, let's plot that image along with its label. + +# %% +# %matplotlib inline + +print("Actual class of test image:", test_lbl[211]) +plt.imshow(test_img[211].reshape((28,28))) + +# %% [markdown] +# Hurray! We've got it correct. Don't worry if our algorithm predicted a wrong class. With this techinique we have only ~97% accuracy on this dataset. + +# %% [markdown] +# ## MNIST FASHION +# +# Another dataset in the same format is [MNIST Fashion](https://github.com/zalandoresearch/fashion-mnist/blob/master/README.md). This dataset, instead of digits contains types of apparel (t-shirts, trousers and others). As with the Digits dataset, it is split into training and testing images, with labels from 0 to 9 for each of the ten types of apparel present in the dataset. The below table shows what each label means: +# +# | Label | Description | +# | ----- | ----------- | +# | 0 | T-shirt/top | +# | 1 | Trouser | +# | 2 | Pullover | +# | 3 | Dress | +# | 4 | Coat | +# | 5 | Sandal | +# | 6 | Shirt | +# | 7 | Sneaker | +# | 8 | Bag | +# | 9 | Ankle boot | + +# %% [markdown] +# Since both the MNIST datasets follow the same format, the code we wrote for loading and visualizing the Digits dataset will work for Fashion too! The only difference is that we have to let the functions know which dataset we're using, with the `fashion` argument. Let's start by loading the training and testing images: + +# %% +train_img, train_lbl, test_img, test_lbl = load_MNIST(fashion=True) + +# %% [markdown] +# ### Visualizing Data +# +# Let's visualize some random images for each class, both for the training and testing sections: + +# %% +# takes 5-10 seconds to execute this +show_MNIST(train_lbl, train_img, fashion=True) + +# %% +# takes 5-10 seconds to execute this +show_MNIST(test_lbl, test_img, fashion=True) + +# %% [markdown] +# Let's now see how many times each class appears in the training and testing data: + +# %% +print("Average of all images in training dataset.") +show_ave_MNIST(train_lbl, train_img, fashion=True) + +print("Average of all images in testing dataset.") +show_ave_MNIST(test_lbl, test_img, fashion=True) + +# %% [markdown] +# Unlike Digits, in Fashion all items appear the same number of times. + +# %% [markdown] +# ## Testing +# +# We will now begin testing our algorithms on Fashion. +# +# First, we need to convert the dataset into the `learning`-compatible `Dataset` class: + +# %% +temp_train_lbl = train_lbl.reshape((60000,1)) +training_examples = np.hstack((train_img, temp_train_lbl)) + +# %% +# takes ~10 seconds to execute this +MNIST_DataSet = DataSet(examples=training_examples, distance=manhattan_distance) + +# %% [markdown] +# ### Plurality Learner +# +# The Plurality Learner always returns the class with the most training samples. In this case, `9`. + +# %% +pL = PluralityLearner(MNIST_DataSet) +print(pL(177)) + +# %% +# %matplotlib inline + +print("Actual class of test image:", test_lbl[177]) +plt.imshow(test_img[177].reshape((28,28))) + +# %% [markdown] +# ### Naive-Bayes +# +# The Naive-Bayes classifier is an improvement over the Plurality Learner. It is much more accurate, but a lot slower. + +# %% +# takes ~45 Secs. to execute this + +nBD = NaiveBayesLearner(MNIST_DataSet, continuous = False) +print(nBD(test_img[24])) + +# %% [markdown] +# Let's check if we got the right output. + +# %% +# %matplotlib inline + +print("Actual class of test image:", test_lbl[24]) +plt.imshow(test_img[24].reshape((28,28))) + +# %% [markdown] +# ### K-Nearest Neighbors +# +# With the dataset in hand, we will first test how the kNN algorithm performs: + +# %% +# takes ~20 Secs. to execute this +kNN = NearestNeighborLearner(MNIST_DataSet, k=3) +print(kNN(test_img[211])) + +# %% [markdown] +# The output is 1, which means the item at index 211 is a trouser. Let's see if the prediction is correct: + +# %% +# %matplotlib inline + +print("Actual class of test image:", test_lbl[211]) +plt.imshow(test_img[211].reshape((28,28))) + +# %% [markdown] +# Indeed, the item was a trouser! The algorithm classified the item correctly. diff --git a/logic.ipynb b/notebooks/logic.ipynb similarity index 96% rename from logic.ipynb rename to notebooks/logic.ipynb index 062ffede2..f5013aa76 100644 --- a/logic.ipynb +++ b/notebooks/logic.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": { @@ -26,9 +35,9 @@ }, "outputs": [], "source": [ - "from utils import *\n", - "from logic import *\n", - "from notebook import psource" + "from aima.utils import *\n", + "from aima.logic import *\n", + "from aima.notebook_utils import psource" ] }, { @@ -587,133 +596,11 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - "\n", - "\n", - " \n", - " \n", - " \n", - "\n", - "\n", - "

\n", - "\n", - "
def KB_AgentProgram(KB):\n",
-       "    """A generic logical knowledge-based agent program. [Figure 7.1]"""\n",
-       "    steps = itertools.count()\n",
-       "\n",
-       "    def program(percept):\n",
-       "        t = next(steps)\n",
-       "        KB.tell(make_percept_sentence(percept, t))\n",
-       "        action = KB.ask(make_action_query(t))\n",
-       "        KB.tell(make_action_sentence(action, t))\n",
-       "        return action\n",
-       "\n",
-       "    def make_percept_sentence(percept, t):\n",
-       "        return Expr("Percept")(percept, t)\n",
-       "\n",
-       "    def make_action_query(t):\n",
-       "        return expr("ShouldDo(action, {})".format(t))\n",
-       "\n",
-       "    def make_action_sentence(action, t):\n",
-       "        return Expr("Did")(action[expr('action')], t)\n",
-       "\n",
-       "    return program\n",
-       "
\n", - "\n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ - "psource(KB_AgentProgram)" + "psource(KBAgentProgram)" ] }, { @@ -4963,7 +4850,7 @@ } ], "source": [ - "from notebook import Canvas_fol_bc_ask\n", + "from aima.notebook_utils import Canvas_fol_bc_ask\n", "canvas_bc_ask = Canvas_fol_bc_ask('canvas_bc_ask', crime_kb, expr('Criminal(x)'))" ] }, diff --git a/notebooks/logic.py b/notebooks/logic.py new file mode 100644 index 000000000..d801df994 --- /dev/null +++ b/notebooks/logic.py @@ -0,0 +1,1035 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Logic + +# %% [markdown] +# This Jupyter notebook acts as supporting material for topics covered in __Chapter 6 Logical Agents__, __Chapter 7 First-Order Logic__ and __Chapter 8 Inference in First-Order Logic__ of the book *[Artificial Intelligence: A Modern Approach](http://aima.cs.berkeley.edu)*. We make use of the implementations in the [logic.py](https://github.com/aimacode/aima-python/blob/master/logic.py) module. See the [intro notebook](https://github.com/aimacode/aima-python/blob/master/intro.ipynb) for instructions. +# +# Let's first import everything from the `logic` module. + +# %% +from aima.utils import * +from aima.logic import * +from aima.notebook_utils import psource + +# %% [markdown] +# ## CONTENTS +# - Logical sentences +# - Expr +# - PropKB +# - Knowledge-based agents +# - Inference in propositional knowledge base +# - Truth table enumeration +# - Proof by resolution +# - Forward and backward chaining +# - DPLL +# - WalkSAT +# - SATPlan +# - FolKB +# - Inference in first order knowledge base +# - Unification +# - Forward chaining algorithm +# - Backward chaining algorithm + +# %% [markdown] +# ## Logical Sentences + +# %% [markdown] +# The `Expr` class is designed to represent any kind of mathematical expression. The simplest type of `Expr` is a symbol, which can be defined with the function `Symbol`: + +# %% +Symbol('x') + +# %% [markdown] +# Or we can define multiple symbols at the same time with the function `symbols`: + +# %% +(x, y, P, Q, f) = symbols('x, y, P, Q, f') + +# %% [markdown] +# We can combine `Expr`s with the regular Python infix and prefix operators. Here's how we would form the logical sentence "P and not Q": + +# %% +P & ~Q + +# %% [markdown] +# This works because the `Expr` class overloads the `&` operator with this definition: +# +# ```python +# def __and__(self, other): return Expr('&', self, other)``` +# +# and does similar overloads for the other operators. An `Expr` has two fields: `op` for the operator, which is always a string, and `args` for the arguments, which is a tuple of 0 or more expressions. By "expression," I mean either an instance of `Expr`, or a number. Let's take a look at the fields for some `Expr` examples: + +# %% +sentence = P & ~Q + +sentence.op + +# %% +sentence.args + +# %% +P.op + +# %% +P.args + +# %% +Pxy = P(x, y) + +Pxy.op + +# %% +Pxy.args + +# %% [markdown] +# It is important to note that the `Expr` class does not define the *logic* of Propositional Logic sentences; it just gives you a way to *represent* expressions. Think of an `Expr` as an [abstract syntax tree](https://en.wikipedia.org/wiki/Abstract_syntax_tree). Each of the `args` in an `Expr` can be either a symbol, a number, or a nested `Expr`. We can nest these trees to any depth. Here is a deply nested `Expr`: + +# %% +3 * f(x, y) + P(y) / 2 + 1 + +# %% [markdown] +# ## Operators for Constructing Logical Sentences +# +# Here is a table of the operators that can be used to form sentences. Note that we have a problem: we want to use Python operators to make sentences, so that our programs (and our interactive sessions like the one here) will show simple code. But Python does not allow implication arrows as operators, so for now we have to use a more verbose notation that Python does allow: `|'==>'|` instead of just `==>`. Alternately, you can always use the more verbose `Expr` constructor forms: +# +# | Operation | Book | Python Infix Input | Python Output | Python `Expr` Input +# |--------------------------|----------------------|-------------------------|---|---| +# | Negation | ¬ P | `~P` | `~P` | `Expr('~', P)` +# | And | P ∧ Q | `P & Q` | `P & Q` | `Expr('&', P, Q)` +# | Or | P ∨ Q | `P` | `Q`| `P` | `Q` | `Expr('`|`', P, Q)` +# | Inequality (Xor) | P ≠ Q | `P ^ Q` | `P ^ Q` | `Expr('^', P, Q)` +# | Implication | P → Q | `P` |`'==>'`| `Q` | `P ==> Q` | `Expr('==>', P, Q)` +# | Reverse Implication | Q ← P | `Q` |`'<=='`| `P` |`Q <== P` | `Expr('<==', Q, P)` +# | Equivalence | P ↔ Q | `P` |`'<=>'`| `Q` |`P <=> Q` | `Expr('<=>', P, Q)` +# +# Here's an example of defining a sentence with an implication arrow: + +# %% +~(P & Q) |'==>'| (~P | ~Q) + +# %% [markdown] +# ## `expr`: a Shortcut for Constructing Sentences +# +# If the `|'==>'|` notation looks ugly to you, you can use the function `expr` instead: + +# %% +expr('~(P & Q) ==> (~P | ~Q)') + +# %% [markdown] +# `expr` takes a string as input, and parses it into an `Expr`. The string can contain arrow operators: `==>`, `<==`, or `<=>`, which are handled as if they were regular Python infix operators. And `expr` automatically defines any symbols, so you don't need to pre-define them: + +# %% +expr('sqrt(b ** 2 - 4 * a * c)') + +# %% [markdown] +# For now that's all you need to know about `expr`. If you are interested, we explain the messy details of how `expr` is implemented and how `|'==>'|` is handled in the appendix. + +# %% [markdown] +# ## Propositional Knowledge Bases: `PropKB` +# +# The class `PropKB` can be used to represent a knowledge base of propositional logic sentences. +# +# We see that the class `KB` has four methods, apart from `__init__`. A point to note here: the `ask` method simply calls the `ask_generator` method. Thus, this one has already been implemented, and what you'll have to actually implement when you create your own knowledge base class (though you'll probably never need to, considering the ones we've created for you) will be the `ask_generator` function and not the `ask` function itself. +# +# The class `PropKB` now. +# * `__init__(self, sentence=None)` : The constructor `__init__` creates a single field `clauses` which will be a list of all the sentences of the knowledge base. Note that each one of these sentences will be a 'clause' i.e. a sentence which is made up of only literals and `or`s. +# * `tell(self, sentence)` : When you want to add a sentence to the KB, you use the `tell` method. This method takes a sentence, converts it to its CNF, extracts all the clauses, and adds all these clauses to the `clauses` field. So, you need not worry about `tell`ing only clauses to the knowledge base. You can `tell` the knowledge base a sentence in any form that you wish; converting it to CNF and adding the resulting clauses will be handled by the `tell` method. +# * `ask_generator(self, query)` : The `ask_generator` function is used by the `ask` function. It calls the `tt_entails` function, which in turn returns `True` if the knowledge base entails query and `False` otherwise. The `ask_generator` itself returns an empty dict `{}` if the knowledge base entails query and `None` otherwise. This might seem a little bit weird to you. After all, it makes more sense just to return a `True` or a `False` instead of the `{}` or `None` But this is done to maintain consistency with the way things are in First-Order Logic, where an `ask_generator` function is supposed to return all the substitutions that make the query true. Hence the dict, to return all these substitutions. I will be mostly be using the `ask` function which returns a `{}` or a `False`, but if you don't like this, you can always use the `ask_if_true` function which returns a `True` or a `False`. +# * `retract(self, sentence)` : This function removes all the clauses of the sentence given, from the knowledge base. Like the `tell` function, you don't have to pass clauses to remove them from the knowledge base; any sentence will do fine. The function will take care of converting that sentence to clauses and then remove those. + +# %% [markdown] +# ## Wumpus World KB +# Let us create a `PropKB` for the wumpus world with the sentences mentioned in `section 7.4.3`. + +# %% +wumpus_kb = PropKB() + +# %% [markdown] +# We define the symbols we use in our clauses.
+# $P_{x, y}$ is true if there is a pit in `[x, y]`.
+# $B_{x, y}$ is true if the agent senses breeze in `[x, y]`.
+ +# %% +P11, P12, P21, P22, P31, B11, B21 = expr('P11, P12, P21, P22, P31, B11, B21') + +# %% [markdown] +# Now we tell sentences based on `section 7.4.3`.
+# There is no pit in `[1,1]`. + +# %% +wumpus_kb.tell(~P11) + +# %% [markdown] +# A square is breezy if and only if there is a pit in a neighboring square. This has to be stated for each square but for now, we include just the relevant squares. + +# %% +wumpus_kb.tell(B11 | '<=>' | ((P12 | P21))) +wumpus_kb.tell(B21 | '<=>' | ((P11 | P22 | P31))) + +# %% [markdown] +# Now we include the breeze percepts for the first two squares leading up to the situation in `Figure 7.3(b)` + +# %% +wumpus_kb.tell(~B11) +wumpus_kb.tell(B21) + +# %% [markdown] +# We can check the clauses stored in a `KB` by accessing its `clauses` variable + +# %% +wumpus_kb.clauses + +# %% [markdown] +# We see that the equivalence $B_{1, 1} \iff (P_{1, 2} \lor P_{2, 1})$ was automatically converted to two implications which were inturn converted to CNF which is stored in the `KB`.
+# $B_{1, 1} \iff (P_{1, 2} \lor P_{2, 1})$ was split into $B_{1, 1} \implies (P_{1, 2} \lor P_{2, 1})$ and $B_{1, 1} \Longleftarrow (P_{1, 2} \lor P_{2, 1})$.
+# $B_{1, 1} \implies (P_{1, 2} \lor P_{2, 1})$ was converted to $P_{1, 2} \lor P_{2, 1} \lor \neg B_{1, 1}$.
+# $B_{1, 1} \Longleftarrow (P_{1, 2} \lor P_{2, 1})$ was converted to $\neg (P_{1, 2} \lor P_{2, 1}) \lor B_{1, 1}$ which becomes $(\neg P_{1, 2} \lor B_{1, 1}) \land (\neg P_{2, 1} \lor B_{1, 1})$ after applying De Morgan's laws and distributing the disjunction.
+# $B_{2, 1} \iff (P_{1, 1} \lor P_{2, 2} \lor P_{3, 2})$ is converted in similar manner. + +# %% [markdown] +# ## Knowledge based agents + +# %% [markdown] +# A knowledge-based agent is a simple generic agent that maintains and handles a knowledge base. +# The knowledge base may initially contain some background knowledge. +#
+# The purpose of a KB agent is to provide a level of abstraction over knowledge-base manipulation and is to be used as a base class for agents that work on a knowledge base. +#
+# Given a percept, the KB agent adds the percept to its knowledge base, asks the knowledge base for the best action, and tells the knowledge base that it has in fact taken that action. +#
+# Our implementation of `KB-Agent` is encapsulated in a class `KB_AgentProgram` which inherits from the `KB` class. +#
+# Let's have a look. + +# %% +psource(KBAgentProgram) + +# %% [markdown] +# The helper functions `make_percept_sentence`, `make_action_query` and `make_action_sentence` are all aptly named and as expected, +# `make_percept_sentence` makes first-order logic sentences about percepts we want our agent to receive, +# `make_action_query` asks the underlying `KB` about the action that should be taken and +# `make_action_sentence` tells the underlying `KB` about the action it has just taken. + +# %% [markdown] +# ## Inference in Propositional Knowledge Base +# In this section we will look at two algorithms to check if a sentence is entailed by the `KB`. Our goal is to decide whether $\text{KB} \vDash \alpha$ for some sentence $\alpha$. +# ### Truth Table Enumeration +# It is a model-checking approach which, as the name suggests, enumerates all possible models in which the `KB` is true and checks if $\alpha$ is also true in these models. We list the $n$ symbols in the `KB` and enumerate the $2^{n}$ models in a depth-first manner and check the truth of `KB` and $\alpha$. + +# %% +psource(tt_check_all) + +# %% [markdown] +# The algorithm basically computes every line of the truth table $KB\implies \alpha$ and checks if it is true everywhere. +#
+# If symbols are defined, the routine recursively constructs every combination of truth values for the symbols and then, +# it checks whether `model` is consistent with `kb`. +# The given models correspond to the lines in the truth table, +# which have a `true` in the KB column, +# and for these lines it checks whether the query evaluates to true +#
+# `result = pl_true(alpha, model)`. +#
+#
+# In short, `tt_check_all` evaluates this logical expression for each `model` +#
+# `pl_true(kb, model) => pl_true(alpha, model)` +#
+# which is logically equivalent to +#
+# `pl_true(kb, model) & ~pl_true(alpha, model)` +#
+# that is, the knowledge base and the negation of the query are logically inconsistent. +#
+#
+# `tt_entails()` just extracts the symbols from the query and calls `tt_check_all()` with the proper parameters. +# + +# %% +psource(tt_entails) + +# %% [markdown] +# Keep in mind that for two symbols P and Q, P => Q is false only when P is `True` and Q is `False`. +# Example usage of `tt_entails()`: + +# %% +tt_entails(P & Q, Q) + +# %% [markdown] +# P & Q is True only when both P and Q are True. Hence, (P & Q) => Q is True + +# %% +tt_entails(P | Q, Q) + +# %% +tt_entails(P | Q, P) + +# %% [markdown] +# If we know that P | Q is true, we cannot infer the truth values of P and Q. +# Hence (P | Q) => Q is False and so is (P | Q) => P. + +# %% +(A, B, C, D, E, F, G) = symbols('A, B, C, D, E, F, G') +tt_entails(A & (B | C) & D & E & ~(F | G), A & D & E & ~F & ~G) + +# %% [markdown] +# We can see that for the KB to be true, A, D, E have to be True and F and G have to be False. +# Nothing can be said about B or C. + +# %% [markdown] +# Coming back to our problem, note that `tt_entails()` takes an `Expr` which is a conjunction of clauses as the input instead of the `KB` itself. +# You can use the `ask_if_true()` method of `PropKB` which does all the required conversions. +# Let's check what `wumpus_kb` tells us about $P_{1, 1}$. + +# %% +wumpus_kb.ask_if_true(~P11), wumpus_kb.ask_if_true(P11) + +# %% [markdown] +# Looking at Figure 7.9 we see that in all models in which the knowledge base is `True`, $P_{1, 1}$ is `False`. It makes sense that `ask_if_true()` returns `True` for $\alpha = \neg P_{1, 1}$ and `False` for $\alpha = P_{1, 1}$. This begs the question, what if $\alpha$ is `True` in only a portion of all models. Do we return `True` or `False`? This doesn't rule out the possibility of $\alpha$ being `True` but it is not entailed by the `KB` so we return `False` in such cases. We can see this is the case for $P_{2, 2}$ and $P_{3, 1}$. + +# %% +wumpus_kb.ask_if_true(~P22), wumpus_kb.ask_if_true(P22) + +# %% [markdown] +# ### Proof by Resolution +# Recall that our goal is to check whether $\text{KB} \vDash \alpha$ i.e. is $\text{KB} \implies \alpha$ true in every model. Suppose we wanted to check if $P \implies Q$ is valid. We check the satisfiability of $\neg (P \implies Q)$, which can be rewritten as $P \land \neg Q$. If $P \land \neg Q$ is unsatisfiable, then $P \implies Q$ must be true in all models. This gives us the result "$\text{KB} \vDash \alpha$ if and only if $\text{KB} \land \neg \alpha$ is unsatisfiable".
+# This technique corresponds to proof by contradiction, a standard mathematical proof technique. We assume $\alpha$ to be false and show that this leads to a contradiction with known axioms in $\text{KB}$. We obtain a contradiction by making valid inferences using inference rules. In this proof we use a single inference rule, resolution which states $(l_1 \lor \dots \lor l_k) \land (m_1 \lor \dots \lor m_n) \land (l_i \iff \neg m_j) \implies l_1 \lor \dots \lor l_{i - 1} \lor l_{i + 1} \lor \dots \lor l_k \lor m_1 \lor \dots \lor m_{j - 1} \lor m_{j + 1} \lor \dots \lor m_n$. Applying the resolution yields us a clause which we add to the KB. We keep doing this until: +# +# * There are no new clauses that can be added, in which case $\text{KB} \nvDash \alpha$. +# * Two clauses resolve to yield the empty clause, in which case $\text{KB} \vDash \alpha$. +# +# The empty clause is equivalent to False because it arises only from resolving two complementary +# unit clauses such as $P$ and $\neg P$ which is a contradiction as both $P$ and $\neg P$ can't be True at the same time. + +# %% [markdown] +# There is one catch however, the algorithm that implements proof by resolution cannot handle complex sentences. +# Implications and bi-implications have to be simplified into simpler clauses. +# We already know that *every sentence of a propositional logic is logically equivalent to a conjunction of clauses*. +# We will use this fact to our advantage and simplify the input sentence into the **conjunctive normal form** (CNF) which is a conjunction of disjunctions of literals. +# For eg: +#
+# $$(A\lor B)\land (\neg B\lor C\lor\neg D)\land (D\lor\neg E)$$ +# This is equivalent to the POS (Product of sums) form in digital electronics. +#
+# Here's an outline of how the conversion is done: +# 1. Convert bi-implications to implications +#
+# $\alpha\iff\beta$ can be written as $(\alpha\implies\beta)\land(\beta\implies\alpha)$ +#
+# This also applies to compound sentences +#
+# $\alpha\iff(\beta\lor\gamma)$ can be written as $(\alpha\implies(\beta\lor\gamma))\land((\beta\lor\gamma)\implies\alpha)$ +#
+# 2. Convert implications to their logical equivalents +#
+# $\alpha\implies\beta$ can be written as $\neg\alpha\lor\beta$ +#
+# 3. Move negation inwards +#
+# CNF requires atomic literals. Hence, negation cannot appear on a compound statement. +# De Morgan's laws will be helpful here. +#
+# $\neg(\alpha\land\beta)\equiv(\neg\alpha\lor\neg\beta)$ +#
+# $\neg(\alpha\lor\beta)\equiv(\neg\alpha\land\neg\beta)$ +#
+# 4. Distribute disjunction over conjunction +#
+# Disjunction and conjunction are distributive over each other. +# Now that we only have conjunctions, disjunctions and negations in our expression, +# we will distribute disjunctions over conjunctions wherever possible as this will give us a sentence which is a conjunction of simpler clauses, +# which is what we wanted in the first place. +#
+# We need a term of the form +#
+# $(\alpha_{1}\lor\alpha_{2}\lor\alpha_{3}...)\land(\beta_{1}\lor\beta_{2}\lor\beta_{3}...)\land(\gamma_{1}\lor\gamma_{2}\lor\gamma_{3}...)\land...$ +#
+#
+# The `to_cnf` function executes this conversion using helper subroutines. + +# %% +psource(to_cnf) + +# %% [markdown] +# `to_cnf` calls three subroutines. +#
+# `eliminate_implications` converts bi-implications and implications to their logical equivalents. +#
+# `move_not_inwards` removes negations from compound statements and moves them inwards using De Morgan's laws. +#
+# `distribute_and_over_or` distributes disjunctions over conjunctions. +#
+# Run the cell below for implementation details. + +# %% +psource(eliminate_implications) +psource(move_not_inwards) +psource(distribute_and_over_or) + +# %% [markdown] +# Let's convert some sentences to see how it works +# + +# %% +A, B, C, D = expr('A, B, C, D') +to_cnf(A |'<=>'| B) + +# %% +to_cnf(A |'<=>'| (B & C)) + +# %% +to_cnf(A & (B | (C & D))) + +# %% +to_cnf((A |'<=>'| ~B) |'==>'| (C | ~D)) + +# %% [markdown] +# Coming back to our resolution problem, we can see how the `to_cnf` function is utilized here + +# %% +psource(pl_resolution) + +# %% +pl_resolution(wumpus_kb, ~P11), pl_resolution(wumpus_kb, P11) + +# %% +pl_resolution(wumpus_kb, ~P22), pl_resolution(wumpus_kb, P22) + +# %% [markdown] +# ### Forward and backward chaining +# Previously, we said we will look at two algorithms to check if a sentence is entailed by the `KB`. Here's a third one. +# The difference here is that our goal now is to determine if a knowledge base of definite clauses entails a single proposition symbol *q* - the query. +# There is a catch however - the knowledge base can only contain **Horn clauses**. +#
+# #### Horn Clauses +# Horn clauses can be defined as a *disjunction* of *literals* with **at most** one positive literal. +#
+# A Horn clause with exactly one positive literal is called a *definite clause*. +#
+# A Horn clause might look like +#
+# $\neg a\lor\neg b\lor\neg c\lor\neg d... \lor z$ +#
+# This, coincidentally, is also a definite clause. +#
+# Using De Morgan's laws, the example above can be simplified to +#
+# $a\land b\land c\land d ... \implies z$ +#
+# This seems like a logical representation of how humans process known data and facts. +# Assuming percepts `a`, `b`, `c`, `d` ... to be true simultaneously, we can infer `z` to also be true at that point in time. +# There are some interesting aspects of Horn clauses that make algorithmic inference or *resolution* easier. +# - Definite clauses can be written as implications: +#
+# The most important simplification a definite clause provides is that it can be written as an implication. +# The premise (or the knowledge that leads to the implication) is a conjunction of positive literals. +# The conclusion (the implied statement) is also a positive literal. +# The sentence thus becomes easier to understand. +# The premise and the conclusion are conventionally called the *body* and the *head* respectively. +# A single positive literal is called a *fact*. +# - Forward chaining and backward chaining can be used for inference from Horn clauses: +#
+# Forward chaining is semantically identical to `AND-OR-Graph-Search` from the chapter on search algorithms. +# Implementational details will be explained shortly. +# - Deciding entailment with Horn clauses is linear in size of the knowledge base: +#
+# Surprisingly, the forward and backward chaining algorithms traverse each element of the knowledge base at most once, greatly simplifying the problem. +#
+#
+# The function `pl_fc_entails` implements forward chaining to see if a knowledge base `KB` entails a symbol `q`. +#
+# Before we proceed further, note that `pl_fc_entails` doesn't use an ordinary `KB` instance. +# The knowledge base here is an instance of the `PropDefiniteKB` class, derived from the `PropKB` class, +# but modified to store definite clauses. +#
+# The main point of difference arises in the inclusion of a helper method to `PropDefiniteKB` that returns a list of clauses in KB that have a given symbol `p` in their premise. + +# %% +psource(PropDefiniteKB.clauses_with_premise) + +# %% [markdown] +# Let's now have a look at the `pl_fc_entails` algorithm. + +# %% +psource(pl_fc_entails) + +# %% [markdown] +# The function accepts a knowledge base `KB` (an instance of `PropDefiniteKB`) and a query `q` as inputs. +#
+#
+# `count` initially stores the number of symbols in the premise of each sentence in the knowledge base. +#
+# The `conjuncts` helper function separates a given sentence at conjunctions. +#
+# `inferred` is initialized as a *boolean* defaultdict. +# This will be used later to check if we have inferred all premises of each clause of the agenda. +#
+# `agenda` initially stores a list of clauses that the knowledge base knows to be true. +# The `is_prop_symbol` helper function checks if the given symbol is a valid propositional logic symbol. +#
+#
+# We now iterate through `agenda`, popping a symbol `p` on each iteration. +# If the query `q` is the same as `p`, we know that entailment holds. +#
+# The agenda is processed, reducing `count` by one for each implication with a premise `p`. +# A conclusion is added to the agenda when `count` reaches zero. This means we know all the premises of that particular implication to be true. +#
+# `clauses_with_premise` is a helpful method of the `PropKB` class. +# It returns a list of clauses in the knowledge base that have `p` in their premise. +#
+#
+# Now that we have an idea of how this function works, let's see a few examples of its usage, but we first need to define our knowledge base. We assume we know the following clauses to be true. + +# %% +clauses = ['(B & F)==>E', + '(A & E & F)==>G', + '(B & C)==>F', + '(A & B)==>D', + '(E & F)==>H', + '(H & I)==>J', + 'A', + 'B', + 'C'] + +# %% [markdown] +# We will now `tell` this information to our knowledge base. + +# %% +definite_clauses_KB = PropDefiniteKB() +for clause in clauses: + definite_clauses_KB.tell(expr(clause)) + +# %% [markdown] +# We can now check if our knowledge base entails the following queries. + +# %% +pl_fc_entails(definite_clauses_KB, expr('G')) + +# %% +pl_fc_entails(definite_clauses_KB, expr('H')) + +# %% +pl_fc_entails(definite_clauses_KB, expr('I')) + +# %% +pl_fc_entails(definite_clauses_KB, expr('J')) + +# %% [markdown] +# ### Effective Propositional Model Checking +# +# The previous segments elucidate the algorithmic procedure for model checking. +# In this segment, we look at ways of making them computationally efficient. +#
+# The problem we are trying to solve is conventionally called the _propositional satisfiability problem_, abbreviated as the _SAT_ problem. +# In layman terms, if there exists a model that satisfies a given Boolean formula, the formula is called satisfiable. +#
+# The SAT problem was the first problem to be proven _NP-complete_. +# The main characteristics of an NP-complete problem are: +# - Given a solution to such a problem, it is easy to verify if the solution solves the problem. +# - The time required to actually solve the problem using any known algorithm increases exponentially with respect to the size of the problem. +#
+#
+# Due to these properties, heuristic and approximational methods are often applied to find solutions to these problems. +#
+# It is extremely important to be able to solve large scale SAT problems efficiently because +# many combinatorial problems in computer science can be conveniently reduced to checking the satisfiability of a propositional sentence under some constraints. +#
+# We will introduce two new algorithms that perform propositional model checking in a computationally effective way. +#
+# + +# %% [markdown] +# ### 1. DPLL (Davis-Putnam-Logeman-Loveland) algorithm +# This algorithm is very similar to Backtracking-Search. +# It recursively enumerates possible models in a depth-first fashion with the following improvements over algorithms like `tt_entails`: +# 1. Early termination: +#
+# In certain cases, the algorithm can detect the truth value of a statement using just a partially completed model. +# For example, $(P\lor Q)\land(P\lor R)$ is true if P is true, regardless of other variables. +# This reduces the search space significantly. +# 2. Pure symbol heuristic: +#
+# A symbol that has the same sign (positive or negative) in all clauses is called a _pure symbol_. +# It isn't difficult to see that any satisfiable model will have the pure symbols assigned such that its parent clause becomes _true_. +# For example, $(P\lor\neg Q)\land(\neg Q\lor\neg R)\land(R\lor P)$ has P and Q as pure symbols +# and for the sentence to be true, P _has_ to be true and Q _has_ to be false. +# The pure symbol heuristic thus simplifies the problem a bit. +# 3. Unit clause heuristic: +#
+# In the context of DPLL, clauses with just one literal and clauses with all but one _false_ literals are called unit clauses. +# If a clause is a unit clause, it can only be satisfied by assigning the necessary value to make the last literal true. +# We have no other choice. +#
+# Assigning one unit clause can create another unit clause. +# For example, when P is false, $(P\lor Q)$ becomes a unit clause, causing _true_ to be assigned to Q. +# A series of forced assignments derived from previous unit clauses is called _unit propagation_. +# In this way, this heuristic simplifies the problem further. +#
+# The algorithm often employs other tricks to scale up to large problems. +# However, these tricks are currently out of the scope of this notebook. Refer to section 7.6 of the book for more details. +#
+#
+# Let's have a look at the algorithm. + +# %% +psource(dpll) + +# %% [markdown] +# The algorithm uses the ideas described above to check satisfiability of a sentence in propositional logic. +# It recursively calls itself, simplifying the problem at each step. It also uses helper functions `find_pure_symbol` and `find_unit_clause` to carry out steps 2 and 3 above. +#
+# The `dpll_satisfiable` helper function converts the input clauses to _conjunctive normal form_ and calls the `dpll` function with the correct parameters. + +# %% +psource(dpll_satisfiable) + +# %% [markdown] +# Let's see a few examples of usage. + +# %% +A, B, C, D = expr('A, B, C, D') + +# %% +dpll_satisfiable(A & B & ~C & D) + +# %% [markdown] +# This is a simple case to highlight that the algorithm actually works. + +# %% +dpll_satisfiable((A & B) | (C & ~A) | (B & ~D)) + +# %% [markdown] +# If a particular symbol isn't present in the solution, +# it means that the solution is independent of the value of that symbol. +# In this case, the solution is independent of A. + +# %% +dpll_satisfiable(A |'<=>'| B) + +# %% +dpll_satisfiable((A |'<=>'| B) |'==>'| (C & ~A)) + +# %% +dpll_satisfiable((A | (B & C)) |'<=>'| ((A | B) & (A | C))) + +# %% [markdown] +# ### 2. WalkSAT algorithm +# This algorithm is very similar to Hill climbing. +# On every iteration, the algorithm picks an unsatisfied clause and flips a symbol in the clause. +# This is similar to finding a neighboring state in the `hill_climbing` algorithm. +#
+# The symbol to be flipped is decided by an evaluation function that counts the number of unsatisfied clauses. +# Sometimes, symbols are also flipped randomly to avoid local optima. A subtle balance between greediness and randomness is required. Alternatively, some versions of the algorithm restart with a completely new random assignment if no solution has been found for too long as a way of getting out of local minima of numbers of unsatisfied clauses. +#
+#
+# Let's have a look at the algorithm. + +# %% +psource(WalkSAT) + +# %% [markdown] +# The function takes three arguments: +#
+# 1. The `clauses` we want to satisfy. +#
+# 2. The probability `p` of randomly changing a symbol. +#
+# 3. The maximum number of flips (`max_flips`) the algorithm will run for. If the clauses are still unsatisfied, the algorithm returns `None` to denote failure. +#
+# The algorithm is identical in concept to Hill climbing and the code isn't difficult to understand. +#
+#
+# Let's see a few examples of usage. + +# %% +A, B, C, D = expr('A, B, C, D') + +# %% +WalkSAT([A, B, ~C, D], 0.5, 100) + +# %% [markdown] +# This is a simple case to show that the algorithm converges. + +# %% +WalkSAT([A & B, A & C], 0.5, 100) + +# %% +WalkSAT([A & B, C & D, C & B], 0.5, 100) + +# %% +WalkSAT([A & B, C | D, ~(D | B)], 0.5, 1000) + + +# %% [markdown] +# This one doesn't give any output because WalkSAT did not find any model where these clauses hold. We can solve these clauses to see that they together form a contradiction and hence, it isn't supposed to have a solution. + +# %% [markdown] +# One point of difference between this algorithm and the `dpll_satisfiable` algorithms is that both these algorithms take inputs differently. +# For WalkSAT to take complete sentences as input, +# we can write a helper function that converts the input sentence into conjunctive normal form and then calls WalkSAT with the list of conjuncts of the CNF form of the sentence. + +# %% +def WalkSAT_CNF(sentence, p=0.5, max_flips=10000): + return WalkSAT(conjuncts(to_cnf(sentence)), 0, max_flips) + + +# %% [markdown] +# Now we can call `WalkSAT_CNF` and `DPLL_Satisfiable` with the same arguments. + +# %% +WalkSAT_CNF((A & B) | (C & ~A) | (B & ~D), 0.5, 1000) + +# %% [markdown] +# It works! +#
+# Notice that the solution generated by WalkSAT doesn't omit variables that the sentence doesn't depend upon. +# If the sentence is independent of a particular variable, the solution contains a random value for that variable because of the stochastic nature of the algorithm. +#
+#
+# Let's compare the runtime of WalkSAT and DPLL for a few cases. We will use the `%%timeit` magic to do this. + +# %% +sentence_1 = A |'<=>'| B +sentence_2 = (A & B) | (C & ~A) | (B & ~D) +sentence_3 = (A | (B & C)) |'<=>'| ((A | B) & (A | C)) + +# %% +# %%timeit +dpll_satisfiable(sentence_1) +dpll_satisfiable(sentence_2) +dpll_satisfiable(sentence_3) + +# %% +# %%timeit +WalkSAT_CNF(sentence_1) +WalkSAT_CNF(sentence_2) +WalkSAT_CNF(sentence_3) + +# %% [markdown] +# On an average, for solvable cases, `WalkSAT` is quite faster than `dpll` because, for a small number of variables, +# `WalkSAT` can reduce the search space significantly. +# Results can be different for sentences with more symbols though. +# Feel free to play around with this to understand the trade-offs of these algorithms better. + +# %% [markdown] +# ### SATPlan + +# %% [markdown] +# In this section we show how to make plans by logical inference. The basic idea is very simple. It includes the following three steps: +# 1. Constuct a sentence that includes: +# 1. A colection of assertions about the initial state. +# 2. The successor-state axioms for all the possible actions at each time up to some maximum time t. +# 3. The assertion that the goal is achieved at time t. +# 2. Present the whole sentence to a SAT solver. +# 3. Assuming a model is found, extract from the model those variables that represent actions and are assigned true. Together they represent a plan to achieve the goals. +# +# +# Lets have a look at the algorithm + +# %% +psource(SAT_plan) + +# %% [markdown] +# Let's see few examples of its usage. First we define a transition and then call `SAT_plan`. + +# %% +transition = {'A': {'Left': 'A', 'Right': 'B'}, + 'B': {'Left': 'A', 'Right': 'C'}, + 'C': {'Left': 'B', 'Right': 'C'}} + + +print(SAT_plan('A', transition, 'C', 2)) +print(SAT_plan('A', transition, 'B', 3)) +print(SAT_plan('C', transition, 'A', 3)) + +# %% [markdown] +# Let us do the same for another transition. + +# %% +transition = {(0, 0): {'Right': (0, 1), 'Down': (1, 0)}, + (0, 1): {'Left': (1, 0), 'Down': (1, 1)}, + (1, 0): {'Right': (1, 0), 'Up': (1, 0), 'Left': (1, 0), 'Down': (1, 0)}, + (1, 1): {'Left': (1, 0), 'Up': (0, 1)}} + + +print(SAT_plan((0, 0), transition, (1, 1), 4)) + +# %% [markdown] +# ## First-Order Logic Knowledge Bases: `FolKB` +# +# The class `FolKB` can be used to represent a knowledge base of First-order logic sentences. You would initialize and use it the same way as you would for `PropKB` except that the clauses are first-order definite clauses. We will see how to write such clauses to create a database and query them in the following sections. + +# %% [markdown] +# ## Criminal KB +# In this section we create a `FolKB` based on the following paragraph.
+# The law says that it is a crime for an American to sell weapons to hostile nations. The country Nono, an enemy of America, has some missiles, and all of its missiles were sold to it by Colonel West, who is American.
+# The first step is to extract the facts and convert them into first-order definite clauses. Extracting the facts from data alone is a challenging task. Fortunately, we have a small paragraph and can do extraction and conversion manually. We'll store the clauses in list aptly named `clauses`. + +# %% +clauses = [] + +# %% [markdown] +# “... it is a crime for an American to sell weapons to hostile nations”
+# The keywords to look for here are 'crime', 'American', 'sell', 'weapon' and 'hostile'. We use predicate symbols to make meaning of them. +# +# * `Criminal(x)`: `x` is a criminal +# * `American(x)`: `x` is an American +# * `Sells(x ,y, z)`: `x` sells `y` to `z` +# * `Weapon(x)`: `x` is a weapon +# * `Hostile(x)`: `x` is a hostile nation +# +# Let us now combine them with appropriate variable naming to depict the meaning of the sentence. The criminal `x` is also the American `x` who sells weapon `y` to `z`, which is a hostile nation. +# +# $\text{American}(x) \land \text{Weapon}(y) \land \text{Sells}(x, y, z) \land \text{Hostile}(z) \implies \text{Criminal} (x)$ + +# %% +clauses.append(expr("(American(x) & Weapon(y) & Sells(x, y, z) & Hostile(z)) ==> Criminal(x)")) + +# %% [markdown] +# "The country Nono, an enemy of America"
+# We now know that Nono is an enemy of America. We represent these nations using the constant symbols `Nono` and `America`. the enemy relation is show using the predicate symbol `Enemy`. +# +# $\text{Enemy}(\text{Nono}, \text{America})$ + +# %% +clauses.append(expr("Enemy(Nono, America)")) + +# %% [markdown] +# "Nono ... has some missiles"
+# This states the existence of some missile which is owned by Nono. $\exists x \text{Owns}(\text{Nono}, x) \land \text{Missile}(x)$. We invoke existential instantiation to introduce a new constant `M1` which is the missile owned by Nono. +# +# $\text{Owns}(\text{Nono}, \text{M1}), \text{Missile}(\text{M1})$ + +# %% +clauses.append(expr("Owns(Nono, M1)")) +clauses.append(expr("Missile(M1)")) + +# %% [markdown] +# "All of its missiles were sold to it by Colonel West"
+# If Nono owns something and it classifies as a missile, then it was sold to Nono by West. +# +# $\text{Missile}(x) \land \text{Owns}(\text{Nono}, x) \implies \text{Sells}(\text{West}, x, \text{Nono})$ + +# %% +clauses.append(expr("(Missile(x) & Owns(Nono, x)) ==> Sells(West, x, Nono)")) + +# %% [markdown] +# "West, who is American"
+# West is an American. +# +# $\text{American}(\text{West})$ + +# %% +clauses.append(expr("American(West)")) + +# %% [markdown] +# We also know, from our understanding of language, that missiles are weapons and that an enemy of America counts as “hostile”. +# +# $\text{Missile}(x) \implies \text{Weapon}(x), \text{Enemy}(x, \text{America}) \implies \text{Hostile}(x)$ + +# %% +clauses.append(expr("Missile(x) ==> Weapon(x)")) +clauses.append(expr("Enemy(x, America) ==> Hostile(x)")) + +# %% [markdown] +# Now that we have converted the information into first-order definite clauses we can create our first-order logic knowledge base. + +# %% +crime_kb = FolKB(clauses) + +# %% [markdown] +# The `subst` helper function substitutes variables with given values in first-order logic statements. +# This will be useful in later algorithms. +# It's implementation is quite simple and self-explanatory. + +# %% +psource(subst) + +# %% [markdown] +# Here's an example of how `subst` can be used. + +# %% +subst({x: expr('Nono'), y: expr('M1')}, expr('Owns(x, y)')) + +# %% [markdown] +# ## Inference in First-Order Logic +# In this section we look at a forward chaining and a backward chaining algorithm for `FolKB`. Both aforementioned algorithms rely on a process called unification, a key component of all first-order inference algorithms. + +# %% [markdown] +# ### Unification +# We sometimes require finding substitutions that make different logical expressions look identical. This process, called unification, is done by the `unify` algorithm. It takes as input two sentences and returns a unifier for them if one exists. A unifier is a dictionary which stores the substitutions required to make the two sentences identical. It does so by recursively unifying the components of a sentence, where the unification of a variable symbol `var` with a constant symbol `Const` is the mapping `{var: Const}`. Let's look at a few examples. + +# %% +unify(expr('x'), 3) + +# %% +unify(expr('A(x)'), expr('A(B)')) + +# %% +unify(expr('Cat(x) & Dog(Dobby)'), expr('Cat(Bella) & Dog(y)')) + +# %% [markdown] +# In cases where there is no possible substitution that unifies the two sentences the function return `None`. + +# %% +print(unify(expr('Cat(x)'), expr('Dog(Dobby)'))) + +# %% [markdown] +# We also need to take care we do not unintentionally use the same variable name. Unify treats them as a single variable which prevents it from taking multiple value. + +# %% +print(unify(expr('Cat(x) & Dog(Dobby)'), expr('Cat(Bella) & Dog(x)'))) + +# %% [markdown] +# ### Forward Chaining Algorithm +# We consider the simple forward-chaining algorithm presented in Figure 9.3. We look at each rule in the knowledge base and see if the premises can be satisfied. This is done by finding a substitution which unifies each of the premise with a clause in the `KB`. If we are able to unify the premises, the conclusion (with the corresponding substitution) is added to the `KB`. This inferencing process is repeated until either the query can be answered or till no new sentences can be added. We test if the newly added clause unifies with the query in which case the substitution yielded by `unify` is an answer to the query. If we run out of sentences to infer, this means the query was a failure. +# +# The function `fol_fc_ask` is a generator which yields all substitutions which validate the query. + +# %% +psource(fol_fc_ask) + +# %% [markdown] +# Let's find out all the hostile nations. Note that we only told the `KB` that Nono was an enemy of America, not that it was hostile. + +# %% +answer = fol_fc_ask(crime_kb, expr('Hostile(x)')) +print(list(answer)) + +# %% [markdown] +# The generator returned a single substitution which says that Nono is a hostile nation. See how after adding another enemy nation the generator returns two substitutions. + +# %% +crime_kb.tell(expr('Enemy(JaJa, America)')) +answer = fol_fc_ask(crime_kb, expr('Hostile(x)')) +print(list(answer)) + +# %% [markdown] +# Note: `fol_fc_ask` makes changes to the `KB` by adding sentences to it. + +# %% [markdown] +# ### Backward Chaining Algorithm +# This algorithm works backward from the goal, chaining through rules to find known facts that support the proof. Suppose `goal` is the query we want to find the substitution for. We find rules of the form $\text{lhs} \implies \text{goal}$ in the `KB` and try to prove `lhs`. There may be multiple clauses in the `KB` which give multiple `lhs`. It is sufficient to prove only one of these. But to prove a `lhs` all the conjuncts in the `lhs` of the clause must be proved. This makes it similar to And/Or search. + +# %% [markdown] +# #### OR +# The OR part of the algorithm comes from our choice to select any clause of the form $\text{lhs} \implies \text{goal}$. Looking at all rules's `lhs` whose `rhs` unify with the `goal`, we yield a substitution which proves all the conjuncts in the `lhs`. We use `parse_definite_clause` to attain `lhs` and `rhs` from a clause of the form $\text{lhs} \implies \text{rhs}$. For atomic facts the `lhs` is an empty list. + +# %% +psource(fol_bc_or) + +# %% [markdown] +# #### AND +# The AND corresponds to proving all the conjuncts in the `lhs`. We need to find a substitution which proves each and every clause in the list of conjuncts. + +# %% +psource(fol_bc_and) + +# %% [markdown] +# Now the main function `fl_bc_ask` calls `fol_bc_or` with substitution initialized as empty. The `ask` method of `FolKB` uses `fol_bc_ask` and fetches the first substitution returned by the generator to answer query. Let's query the knowledge base we created from `clauses` to find hostile nations. + +# %% +# Rebuild KB because running fol_fc_ask would add new facts to the KB +crime_kb = FolKB(clauses) + +# %% +crime_kb.ask(expr('Hostile(x)')) + +# %% [markdown] +# You may notice some new variables in the substitution. They are introduced to standardize the variable names to prevent naming problems as discussed in the [Unification section](#Unification) + +# %% [markdown] +# ## Appendix: The Implementation of `|'==>'|` +# +# Consider the `Expr` formed by this syntax: + +# %% +P |'==>'| ~Q + +# %% [markdown] +# What is the funny `|'==>'|` syntax? The trick is that "`|`" is just the regular Python or-operator, and so is exactly equivalent to this: + +# %% +(P | '==>') | ~Q + +# %% [markdown] +# In other words, there are two applications of or-operators. Here's the first one: + +# %% +P | '==>' + +# %% [markdown] +# What is going on here is that the `__or__` method of `Expr` serves a dual purpose. If the right-hand-side is another `Expr` (or a number), then the result is an `Expr`, as in `(P | Q)`. But if the right-hand-side is a string, then the string is taken to be an operator, and we create a node in the abstract syntax tree corresponding to a partially-filled `Expr`, one where we know the left-hand-side is `P` and the operator is `==>`, but we don't yet know the right-hand-side. +# +# The `PartialExpr` class has an `__or__` method that says to create an `Expr` node with the right-hand-side filled in. Here we can see the combination of the `PartialExpr` with `Q` to create a complete `Expr`: + +# %% +partial = PartialExpr('==>', P) +partial | ~Q + +# %% [markdown] +# This [trick](http://code.activestate.com/recipes/384122-infix-operators/) is due to [Ferdinand Jamitzky](http://code.activestate.com/recipes/users/98863/), with a modification by [C. G. Vedant](https://github.com/Chipe1), +# who suggested using a string inside the or-bars. +# +# ## Appendix: The Implementation of `expr` +# +# How does `expr` parse a string into an `Expr`? It turns out there are two tricks (besides the Jamitzky/Vedant trick): +# +# 1. We do a string substitution, replacing "`==>`" with "`|'==>'|`" (and likewise for other operators). +# 2. We `eval` the resulting string in an environment in which every identifier +# is bound to a symbol with that identifier as the `op`. +# +# In other words, + +# %% +expr('~(P & Q) ==> (~P | ~Q)') + +# %% [markdown] +# is equivalent to doing: + +# %% +P, Q = symbols('P, Q') +~(P & Q) |'==>'| (~P | ~Q) + +# %% [markdown] +# One thing to beware of: this puts `==>` at the same precedence level as `"|"`, which is not quite right. For example, we get this: + +# %% +P & Q |'==>'| P | Q + +# %% [markdown] +# which is probably not what we meant; when in doubt, put in extra parens: + +# %% +(P & Q) |'==>'| (P | Q) + +# %% [markdown] +# ## Examples + +# %% +from aima.notebook_utils import Canvas_fol_bc_ask +canvas_bc_ask = Canvas_fol_bc_ask('canvas_bc_ask', crime_kb, expr('Criminal(x)')) + +# %% [markdown] +# # Authors +# +# This notebook by [Chirag Vartak](https://github.com/chiragvartak) and [Peter Norvig](https://github.com/norvig). +# +# diff --git a/mdp.ipynb b/notebooks/mdp.ipynb similarity index 99% rename from mdp.ipynb rename to notebooks/mdp.ipynb index b9952f528..f99150e02 100644 --- a/mdp.ipynb +++ b/notebooks/mdp.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -16,8 +25,8 @@ "metadata": {}, "outputs": [], "source": [ - "from mdp import *\n", - "from notebook import psource, pseudocode, plot_pomdp_utility" + "from aima.mdp import *\n", + "from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility" ] }, { @@ -288,7 +297,7 @@ "metadata": {}, "source": [ "Now let us implement the simple MDP in the image below. States A, B have actions X, Y available in them. Their probabilities are shown just above the arrows. We start with using MDP as base class for our CustomMDP. Obviously we need to make a few changes to suit our case. We make use of a transition matrix as our transitions are not very simple.\n", - "" + "" ] }, { @@ -929,7 +938,7 @@ "outputs": [], "source": [ "%matplotlib inline\n", - "from notebook import make_plot_grid_step_function\n", + "from aima.notebook_utils import make_plot_grid_step_function\n", "\n", "plot_grid_step = make_plot_grid_step_function(columns, rows, U_over_time)" ] @@ -971,7 +980,7 @@ "source": [ "import ipywidgets as widgets\n", "from IPython.display import display\n", - "from notebook import make_visualize\n", + "from aima.notebook_utils import make_visualize\n", "\n", "iteration_slider = widgets.IntSlider(min=1, max=15, step=1, value=0)\n", "w=widgets.interactive(plot_grid_step,iteration=iteration_slider)\n", @@ -981,7 +990,7 @@ "\n", "visualize_button = widgets.ToggleButton(description = \"Visualize\", value = False)\n", "time_select = widgets.ToggleButtons(description='Extra Delay:',options=['0', '0.1', '0.2', '0.5', '0.7', '1.0'])\n", - "a = widgets.interactive(visualize_callback, Visualize = visualize_button, time_step=time_select)\n", + "a = widgets.interactive(visualize_callback, visualize=visualize_button, time_step=time_select)\n", "display(a)" ] }, @@ -1978,7 +1987,7 @@ } ], "source": [ - "from utils import print_table\n", + "from aima.utils import print_table\n", "print_table(sequential_decision_environment.to_arrows(pi))" ] }, @@ -2034,7 +2043,7 @@ ], "source": [ "pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001))\n", - "from utils import print_table\n", + "from aima.utils import print_table\n", "print_table(sequential_decision_environment.to_arrows(pi))" ] }, @@ -2094,7 +2103,7 @@ ], "source": [ "pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001))\n", - "from utils import print_table\n", + "from aima.utils import print_table\n", "print_table(sequential_decision_environment.to_arrows(pi))" ] }, @@ -2153,7 +2162,7 @@ ], "source": [ "pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001))\n", - "from utils import print_table\n", + "from aima.utils import print_table\n", "print_table(sequential_decision_environment.to_arrows(pi))" ] }, diff --git a/notebooks/mdp.py b/notebooks/mdp.py new file mode 100644 index 000000000..86241db32 --- /dev/null +++ b/notebooks/mdp.py @@ -0,0 +1,839 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Making Complex Decisions +# --- +# +# This Jupyter notebook acts as supporting material for topics covered in **Chapter 17 Making Complex Decisions** of the book* Artificial Intelligence: A Modern Approach*. We make use of the implementations in mdp.py module. This notebook also includes a brief summary of the main topics as a review. Let us import everything from the mdp module to get started. + +# %% +from aima.mdp import * +from aima.notebook_utils import psource, pseudocode, plot_pomdp_utility + +# %% [markdown] +# ## CONTENTS +# +# * Overview +# * MDP +# * Grid MDP +# * Value Iteration +# * Value Iteration Visualization +# * Policy Iteration +# * POMDPs +# * POMDP Value Iteration +# - Value Iteration Visualization + +# %% [markdown] +# ## OVERVIEW +# +# Before we start playing with the actual implementations let us review a couple of things about MDPs. +# +# - A stochastic process has the **Markov property** if the conditional probability distribution of future states of the process (conditional on both past and present states) depends only upon the present state, not on the sequence of events that preceded it. +# +# -- Source: [Wikipedia](https://en.wikipedia.org/wiki/Markov_property) +# +# Often it is possible to model many different phenomena as a Markov process by being flexible with our definition of state. +# +# +# - MDPs help us deal with fully-observable and non-deterministic/stochastic environments. For dealing with partially-observable and stochastic cases we make use of generalization of MDPs named POMDPs (partially observable Markov decision process). +# +# Our overall goal to solve a MDP is to come up with a policy which guides us to select the best action in each state so as to maximize the expected sum of future rewards. + +# %% [markdown] +# ## MDP +# +# To begin with let us look at the implementation of MDP class defined in mdp.py The docstring tells us what all is required to define a MDP namely - set of states, actions, initial state, transition model, and a reward function. Each of these are implemented as methods. Do not close the popup so that you can follow along the description of code below. + +# %% +psource(MDP) + +# %% [markdown] +# The **_ _init_ _** method takes in the following parameters: +# +# - init: the initial state. +# - actlist: List of actions possible in each state. +# - terminals: List of terminal states where only possible action is exit +# - gamma: Discounting factor. This makes sure that delayed rewards have less value compared to immediate ones. +# +# **R** method returns the reward for each state by using the self.reward dict. +# +# **T** method is not implemented and is somewhat different from the text. Here we return (probability, s') pairs where s' belongs to list of possible state by taking action a in state s. +# +# **actions** method returns list of actions possible in each state. By default it returns all actions for states other than terminal states. +# + +# %% [markdown] +# Now let us implement the simple MDP in the image below. States A, B have actions X, Y available in them. Their probabilities are shown just above the arrows. We start with using MDP as base class for our CustomMDP. Obviously we need to make a few changes to suit our case. We make use of a transition matrix as our transitions are not very simple. +# + +# %% +# Transition Matrix as nested dict. State -> Actions in state -> List of (Probability, State) tuples +t = { + "A": { + "X": [(0.3, "A"), (0.7, "B")], + "Y": [(1.0, "A")] + }, + "B": { + "X": {(0.8, "End"), (0.2, "B")}, + "Y": {(1.0, "A")} + }, + "End": {} +} + +init = "A" + +terminals = ["End"] + +rewards = { + "A": 5, + "B": -10, + "End": 100 +} + + +# %% +class CustomMDP(MDP): + def __init__(self, init, terminals, transition_matrix, reward = None, gamma=.9): + # All possible actions. + actlist = [] + for state in transition_matrix.keys(): + actlist.extend(transition_matrix[state]) + actlist = list(set(actlist)) + MDP.__init__(self, init, actlist, terminals, transition_matrix, reward, gamma=gamma) + + def T(self, state, action): + if action is None: + return [(0.0, state)] + else: + return self.t[state][action] + + +# %% [markdown] +# Finally we instantize the class with the parameters for our MDP in the picture. + +# %% +our_mdp = CustomMDP(init, terminals, t, rewards, gamma=.9) + +# %% [markdown] +# With this we have successfully represented our MDP. Later we will look at ways to solve this MDP. + +# %% [markdown] +# ## GRID MDP +# +# Now we look at a concrete implementation that makes use of the MDP as base class. The GridMDP class in the mdp module is used to represent a grid world MDP like the one shown in in **Fig 17.1** of the AIMA Book. We assume for now that the environment is _fully observable_, so that the agent always knows where it is. The code should be easy to understand if you have gone through the CustomMDP example. + +# %% +psource(GridMDP) + +# %% [markdown] +# The **_ _init_ _** method takes **grid** as an extra parameter compared to the MDP class. The grid is a nested list of rewards in states. +# +# **go** method returns the state by going in particular direction by using vector_add. +# +# **T** method is not implemented and is somewhat different from the text. Here we return (probability, s') pairs where s' belongs to list of possible state by taking action a in state s. +# +# **actions** method returns list of actions possible in each state. By default it returns all actions for states other than terminal states. +# +# **to_arrows** are used for representing the policy in a grid like format. + +# %% [markdown] +# We can create a GridMDP like the one in **Fig 17.1** as follows: +# +# GridMDP([[-0.04, -0.04, -0.04, +1], +# [-0.04, None, -0.04, -1], +# [-0.04, -0.04, -0.04, -0.04]], +# terminals=[(3, 2), (3, 1)]) +# +# In fact the **sequential_decision_environment** in mdp module has been instantized using the exact same code. + +# %% +sequential_decision_environment + +# %% [markdown] +# # VALUE ITERATION +# +# Now that we have looked how to represent MDPs. Let's aim at solving them. Our ultimate goal is to obtain an optimal policy. We start with looking at Value Iteration and a visualisation that should help us understanding it better. +# +# We start by calculating Value/Utility for each of the states. The Value of each state is the expected sum of discounted future rewards given we start in that state and follow a particular policy $\pi$. The value or the utility of a state is given by +# +# $$U(s)=R(s)+\gamma\max_{a\epsilon A(s)}\sum_{s'} P(s'\ |\ s,a)U(s')$$ +# +# This is called the Bellman equation. The algorithm Value Iteration (**Fig. 17.4** in the book) relies on finding solutions of this Equation. The intuition Value Iteration works is because values propagate through the state space by means of local updates. This point will we more clear after we encounter the visualisation. For more information you can refer to **Section 17.2** of the book. +# + +# %% +psource(value_iteration) + +# %% [markdown] +# It takes as inputs two parameters, an MDP to solve and epsilon, the maximum error allowed in the utility of any state. It returns a dictionary containing utilities where the keys are the states and values represent utilities.
Value Iteration starts with arbitrary initial values for the utilities, calculates the right side of the Bellman equation and plugs it into the left hand side, thereby updating the utility of each state from the utilities of its neighbors. +# This is repeated until equilibrium is reached. +# It works on the principle of _Dynamic Programming_ - using precomputed information to simplify the subsequent computation. +# If $U_i(s)$ is the utility value for state $s$ at the $i$ th iteration, the iteration step, called Bellman update, looks like this: +# +# $$ U_{i+1}(s) \leftarrow R(s) + \gamma \max_{a \epsilon A(s)} \sum_{s'} P(s'\ |\ s,a)U_{i}(s') $$ +# +# As you might have noticed, `value_iteration` has an infinite loop. How do we decide when to stop iterating? +# The concept of _contraction_ successfully explains the convergence of value iteration. +# Refer to **Section 17.2.3** of the book for a detailed explanation. +# In the algorithm, we calculate a value $delta$ that measures the difference in the utilities of the current time step and the previous time step. +# +# $$\delta = \max{(\delta, \begin{vmatrix}U_{i + 1}(s) - U_i(s)\end{vmatrix})}$$ +# +# This value of delta decreases as the values of $U_i$ converge. +# We terminate the algorithm if the $\delta$ value is less than a threshold value determined by the hyperparameter _epsilon_. +# +# $$\delta \lt \epsilon \frac{(1 - \gamma)}{\gamma}$$ +# +# To summarize, the Bellman update is a _contraction_ by a factor of $gamma$ on the space of utility vectors. +# Hence, from the properties of contractions in general, it follows that `value_iteration` always converges to a unique solution of the Bellman equations whenever $gamma$ is less than 1. +# We then terminate the algorithm when a reasonable approximation is achieved. +# In practice, it often occurs that the policy $pi$ becomes optimal long before the utility function converges. For the given 4 x 3 environment with $gamma = 0.9$, the policy $pi$ is optimal when $i = 4$ (at the 4th iteration), even though the maximum error in the utility function is stil 0.46. This can be clarified from **figure 17.6** in the book. Hence, to increase computational efficiency, we often use another method to solve MDPs called Policy Iteration which we will see in the later part of this notebook. +#
For now, let us solve the **sequential_decision_environment** GridMDP using `value_iteration`. + +# %% +value_iteration(sequential_decision_environment) + +# %% [markdown] +# The pseudocode for the algorithm: + +# %% +pseudocode("Value-Iteration") + + +# %% [markdown] +# ### AIMA3e +# __function__ VALUE-ITERATION(_mdp_, _ε_) __returns__ a utility function +#  __inputs__: _mdp_, an MDP with states _S_, actions _A_(_s_), transition model _P_(_s′_ | _s_, _a_), +#       rewards _R_(_s_), discount _γ_ +#    _ε_, the maximum error allowed in the utility of any state +#  __local variables__: _U_, _U′_, vectors of utilities for states in _S_, initially zero +#         _δ_, the maximum change in the utility of any state in an iteration +# +#  __repeat__ +#    _U_ ← _U′_; _δ_ ← 0 +#    __for each__ state _s_ in _S_ __do__ +#      _U′_\[_s_\] ← _R_(_s_) + _γ_ max_a_ ∈ _A_(_s_) Σ _P_(_s′_ | _s_, _a_) _U_\[_s′_\] +#      __if__ | _U′_\[_s_\] − _U_\[_s_\] | > _δ_ __then__ _δ_ ← | _U′_\[_s_\] − _U_\[_s_\] | +#  __until__ _δ_ < _ε_(1 − _γ_)/_γ_ +#  __return__ _U_ +# +# --- +# __Figure ??__ The value iteration algorithm for calculating utilities of states. The termination condition is from Equation (__??__). + +# %% [markdown] +# ## VALUE ITERATION VISUALIZATION +# +# To illustrate that values propagate out of states let us create a simple visualisation. We will be using a modified version of the value_iteration function which will store U over time. We will also remove the parameter epsilon and instead add the number of iterations we want. + +# %% +def value_iteration_instru(mdp, iterations=20): + U_over_time = [] + U1 = {s: 0 for s in mdp.states} + R, T, gamma = mdp.R, mdp.T, mdp.gamma + for _ in range(iterations): + U = U1.copy() + for s in mdp.states: + U1[s] = R(s) + gamma * max([sum([p * U[s1] for (p, s1) in T(s, a)]) + for a in mdp.actions(s)]) + U_over_time.append(U) + return U_over_time + + +# %% [markdown] +# Next, we define a function to create the visualisation from the utilities returned by **value_iteration_instru**. The reader need not concern himself with the code that immediately follows as it is the usage of Matplotib with IPython Widgets. If you are interested in reading more about these visit [ipywidgets.readthedocs.io](http://ipywidgets.readthedocs.io) + +# %% +columns = 4 +rows = 3 +U_over_time = value_iteration_instru(sequential_decision_environment) + +# %% +# %matplotlib inline +from aima.notebook_utils import make_plot_grid_step_function + +plot_grid_step = make_plot_grid_step_function(columns, rows, U_over_time) + +# %% +import ipywidgets as widgets +from IPython.display import display +from aima.notebook_utils import make_visualize + +iteration_slider = widgets.IntSlider(min=1, max=15, step=1, value=0) +w=widgets.interactive(plot_grid_step,iteration=iteration_slider) +display(w) + +visualize_callback = make_visualize(iteration_slider) + +visualize_button = widgets.ToggleButton(description = "Visualize", value = False) +time_select = widgets.ToggleButtons(description='Extra Delay:',options=['0', '0.1', '0.2', '0.5', '0.7', '1.0']) +a = widgets.interactive(visualize_callback, visualize=visualize_button, time_step=time_select) +display(a) + +# %% [markdown] +# Move the slider above to observe how the utility changes across iterations. It is also possible to move the slider using arrow keys or to jump to the value by directly editing the number with a double click. The **Visualize Button** will automatically animate the slider for you. The **Extra Delay Box** allows you to set time delay in seconds upto one second for each time step. There is also an interactive editor for grid-world problems `grid_mdp.py` in the gui folder for you to play around with. + +# %% [markdown] +# # POLICY ITERATION +# +# We have already seen that value iteration converges to the optimal policy long before it accurately estimates the utility function. +# If one action is clearly better than all the others, then the exact magnitude of the utilities in the states involved need not be precise. +# The policy iteration algorithm works on this insight. +# The algorithm executes two fundamental steps: +# * **Policy evaluation**: Given a policy _πᵢ_, calculate _Uᵢ = U(πᵢ)_, the utility of each state if _πᵢ_ were to be executed. +# * **Policy improvement**: Calculate a new policy _πᵢ₊₁_ using one-step look-ahead based on the utility values calculated. +# +# The algorithm terminates when the policy improvement step yields no change in the utilities. +# Refer to **Figure 17.6** in the book to see how this is an improvement over value iteration. +# We now have a simplified version of the Bellman equation +# +# $$U_i(s) = R(s) + \gamma \sum_{s'}P(s'\ |\ s, \pi_i(s))U_i(s')$$ +# +# An important observation in this equation is that this equation doesn't have the `max` operator, which makes it linear. +# For _n_ states, we have _n_ linear equations with _n_ unknowns, which can be solved exactly in time _**O(n³)**_. +# For more implementational details, have a look at **Section 17.3**. +# Let us now look at how the expected utility is found and how `policy_iteration` is implemented. + +# %% +psource(expected_utility) + +# %% +psource(policy_iteration) + +# %% [markdown] +#
Fortunately, it is not necessary to do _exact_ policy evaluation. +# The utilities can instead be reasonably approximated by performing some number of simplified value iteration steps. +# The simplified Bellman update equation for the process is +# +# $$U_{i+1}(s) \leftarrow R(s) + \gamma\sum_{s'}P(s'\ |\ s,\pi_i(s))U_{i}(s')$$ +# +# and this is repeated _k_ times to produce the next utility estimate. This is called _modified policy iteration_. + +# %% +psource(policy_evaluation) + +# %% [markdown] +# Let us now solve **`sequential_decision_environment`** using `policy_iteration`. + +# %% +policy_iteration(sequential_decision_environment) + +# %% +pseudocode('Policy-Iteration') + +# %% [markdown] +# ### AIMA3e +# __function__ POLICY-ITERATION(_mdp_) __returns__ a policy +#  __inputs__: _mdp_, an MDP with states _S_, actions _A_(_s_), transition model _P_(_s′_ | _s_, _a_) +#  __local variables__: _U_, a vector of utilities for states in _S_, initially zero +#         _π_, a policy vector indexed by state, initially random +# +#  __repeat__ +#    _U_ ← POLICY\-EVALUATION(_π_, _U_, _mdp_) +#    _unchanged?_ ← true +#    __for each__ state _s_ __in__ _S_ __do__ +#      __if__ max_a_ ∈ _A_(_s_) Σ_s′_ _P_(_s′_ | _s_, _a_) _U_\[_s′_\] > Σ_s′_ _P_(_s′_ | _s_, _π_\[_s_\]) _U_\[_s′_\] __then do__ +#        _π_\[_s_\] ← argmax_a_ ∈ _A_(_s_) Σ_s′_ _P_(_s′_ | _s_, _a_) _U_\[_s′_\] +#        _unchanged?_ ← false +#  __until__ _unchanged?_ +#  __return__ _π_ +# +# --- +# __Figure ??__ The policy iteration algorithm for calculating an optimal policy. + +# %% [markdown] +# ## Sequential Decision Problems +# +# Now that we have the tools required to solve MDPs, let us see how Sequential Decision Problems can be solved step by step and how a few built-in tools in the GridMDP class help us better analyse the problem at hand. +# As always, we will work with the grid world from **Figure 17.1** from the book. +# ![title](images/grid_mdp.jpg) +#
This is the environment for our agent. +# We assume for now that the environment is _fully observable_, so that the agent always knows where it is. +# We also assume that the transitions are **Markovian**, that is, the probability of reaching state $s'$ from state $s$ depends only on $s$ and not on the history of earlier states. +# Almost all stochastic decision problems can be reframed as a Markov Decision Process just by tweaking the definition of a _state_ for that particular problem. +#
+# However, the actions of our agent in this environment are unreliable. In other words, the motion of our agent is stochastic. +#

+# More specifically, the agent may - +# * move correctly in the intended direction with a probability of _0.8_, +# * move $90^\circ$ to the right of the intended direction with a probability 0.1 +# * move $90^\circ$ to the left of the intended direction with a probability 0.1 +#

+# The agent stays put if it bumps into a wall. +# ![title](images/grid_mdp_agent.jpg) + +# %% [markdown] +# These properties of the agent are called the transition properties and are hardcoded into the GridMDP class as you can see below. + +# %% +psource(GridMDP.T) + +# %% [markdown] +# To completely define our task environment, we need to specify the utility function for the agent. +# This is the function that gives the agent a rough estimate of how good being in a particular state is, or how much _reward_ an agent receives by being in that state. +# The agent then tries to maximize the reward it gets. +# As the decision problem is sequential, the utility function will depend on a sequence of states rather than on a single state. +# For now, we simply stipulate that in each state $s$, the agent receives a finite reward $R(s)$. +# +# For any given state, the actions the agent can take are encoded as given below: +# - Move Up: (0, 1) +# - Move Down: (0, -1) +# - Move Left: (-1, 0) +# - Move Right: (1, 0) +# - Do nothing: `None` +# +# We now wonder what a valid solution to the problem might look like. +# We cannot have fixed action sequences as the environment is stochastic and we can eventually end up in an undesirable state. +# Therefore, a solution must specify what the agent shoulddo for _any_ state the agent might reach. +#
+# Such a solution is known as a **policy** and is usually denoted by $\pi$. +#
+# The **optimal policy** is the policy that yields the highest expected utility an is usually denoted by $\pi^*$. +#
+# The `GridMDP` class has a useful method `to_arrows` that outputs a grid showing the direction the agent should move, given a policy. +# We will use this later to better understand the properties of the environment. + +# %% +psource(GridMDP.to_arrows) + +# %% [markdown] +# This method directly encodes the actions that the agent can take (described above) to characters representing arrows and shows it in a grid format for human visalization purposes. +# It converts the received policy from a `dictionary` to a grid using the `to_grid` method. + +# %% +psource(GridMDP.to_grid) + +# %% [markdown] +# Now that we have all the tools required and a good understanding of the agent and the environment, we consider some cases and see how the agent should behave for each case. + +# %% [markdown] +# ### Case 1 +# --- +# R(s) = -0.04 in all states except terminal states + +# %% +# Note that this environment is also initialized in mdp.py by default +sequential_decision_environment = GridMDP([[-0.04, -0.04, -0.04, +1], + [-0.04, None, -0.04, -1], + [-0.04, -0.04, -0.04, -0.04]], + terminals=[(3, 2), (3, 1)]) + +# %% [markdown] +# We will use the `best_policy` function to find the best policy for this environment. +# But, as you can see, `best_policy` requires a utility function as well. +# We already know that the utility function can be found by `value_iteration`. +# Hence, our best policy is: + +# %% +pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001)) + +# %% [markdown] +# We can now use the `to_arrows` method to see how our agent should pick its actions in the environment. + +# %% +from aima.utils import print_table +print_table(sequential_decision_environment.to_arrows(pi)) + +# %% [markdown] +# This is exactly the output we expected +#
+# ![title](images/-0.04.jpg) +#
+# Notice that, because the cost of taking a step is fairly small compared with the penalty for ending up in `(4, 2)` by accident, the optimal policy is conservative. +# In state `(3, 1)` it recommends taking the long way round, rather than taking the shorter way and risking getting a large negative reward of -1 in `(4, 2)`. + +# %% [markdown] +# ### Case 2 +# --- +# R(s) = -0.4 in all states except in terminal states + +# %% +sequential_decision_environment = GridMDP([[-0.4, -0.4, -0.4, +1], + [-0.4, None, -0.4, -1], + [-0.4, -0.4, -0.4, -0.4]], + terminals=[(3, 2), (3, 1)]) + +# %% +pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001)) +from aima.utils import print_table +print_table(sequential_decision_environment.to_arrows(pi)) + +# %% [markdown] +# This is exactly the output we expected +# ![title](images/-0.4.jpg) + +# %% [markdown] +# As the reward for each state is now more negative, life is certainly more unpleasant. +# The agent takes the shortest route to the +1 state and is willing to risk falling into the -1 state by accident. + +# %% [markdown] +# ### Case 3 +# --- +# R(s) = -4 in all states except terminal states + +# %% +sequential_decision_environment = GridMDP([[-4, -4, -4, +1], + [-4, None, -4, -1], + [-4, -4, -4, -4]], + terminals=[(3, 2), (3, 1)]) + +# %% +pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001)) +from aima.utils import print_table +print_table(sequential_decision_environment.to_arrows(pi)) + +# %% [markdown] +# This is exactly the output we expected +# ![title](images/-4.jpg) + +# %% [markdown] +# The living reward for each state is now lower than the least rewarding terminal. Life is so _painful_ that the agent heads for the nearest exit as even the worst exit is less painful than any living state. + +# %% [markdown] +# ### Case 4 +# --- +# R(s) = 4 in all states except terminal states + +# %% +sequential_decision_environment = GridMDP([[4, 4, 4, +1], + [4, None, 4, -1], + [4, 4, 4, 4]], + terminals=[(3, 2), (3, 1)]) + +# %% +pi = best_policy(sequential_decision_environment, value_iteration(sequential_decision_environment, .001)) +from aima.utils import print_table +print_table(sequential_decision_environment.to_arrows(pi)) + +# %% [markdown] +# In this case, the output we expect is +# ![title](images/4.jpg) +#
+# As life is positively enjoyable and the agent avoids _both_ exits. +# Even though the output we get is not exactly what we want, it is definitely not wrong. +# The scenario here requires the agent to anything but reach a terminal state, as this is the only way the agent can maximize its reward (total reward tends to infinity), and the program does just that. +#
+# Currently, the GridMDP class doesn't support an explicit marker for a "do whatever you like" action or a "don't care" condition. +# You can however, extend the class to do so. +#
+# For in-depth knowledge about sequential decision problems, refer **Section 17.1** in the AIMA book. + +# %% [markdown] +# ## POMDP +# --- +# Partially Observable Markov Decision Problems +# +# In retrospect, a Markov decision process or MDP is defined as: +# - a sequential decision problem for a fully observable, stochastic environment with a Markovian transition model and additive rewards. +# +# An MDP consists of a set of states (with an initial state $s_0$); a set $A(s)$ of actions +# in each state; a transition model $P(s' | s, a)$; and a reward function $R(s)$. +# +# The MDP seeks to make sequential decisions to occupy states so as to maximise some combination of the reward function $R(s)$. +# +# The characteristic problem of the MDP is hence to identify the optimal policy function $\pi^*(s)$ that provides the _utility-maximising_ action $a$ to be taken when the current state is $s$. +# +# ### Belief vector +# +# **Note**: The book refers to the _belief vector_ as the _belief state_. We use the latter terminology here to retain our ability to refer to the belief vector as a _probability distribution over states_. +# +# The solution of an MDP is subject to certain properties of the problem which are assumed and justified in [Section 17.1]. One critical assumption is that the agent is **fully aware of its current state at all times**. +# +# A tedious (but rewarding, as we will see) way of expressing this is in terms of the **belief vector** $b$ of the agent. The belief vector is a function mapping states to probabilities or certainties of being in those states. +# +# Consider an agent that is fully aware that it is in state $s_i$ in the statespace $(s_1, s_2, ... s_n)$ at the current time. +# +# Its belief vector is the vector $(b(s_1), b(s_2), ... b(s_n))$ given by the function $b(s)$: +# \begin{align*} +# b(s) &= 0 \quad \text{if }s \neq s_i \\ &= 1 \quad \text{if } s = s_i +# \end{align*} +# +# Note that $b(s)$ is a probability distribution that necessarily sums to $1$ over all $s$. +# +# + +# %% [markdown] +# ### POMDPs - a conceptual outline +# +# The POMDP really has only two modifications to the **problem formulation** compared to the MDP. +# +# - **Belief state** - In the real world, the current state of an agent is often not known with complete certainty. This makes the concept of a belief vector extremely relevant. It allows the agent to represent different degrees of certainty with which it _believes_ it is in each state. +# +# - **Evidence percepts** - In the real world, agents often have certain kinds of evidence, collected from sensors. They can use the probability distribution of observed evidence, conditional on state, to consolidate their information. This is a known distribution $P(e\ |\ s)$ - $e$ being an evidence, and $s$ being the state it is conditional on. +# +# Consider the world we used for the MDP. +# +# ![title](images/grid_mdp.jpg) +# +# #### Using the belief vector +# An agent beginning at $(1, 1)$ may not be certain that it is indeed in $(1, 1)$. Consider a belief vector $b$ such that: +# \begin{align*} +# b((1,1)) &= 0.8 \\ +# b((2,1)) &= 0.1 \\ +# b((1,2)) &= 0.1 \\ +# b(s) &= 0 \quad \quad \forall \text{ other } s +# \end{align*} +# +# By horizontally catenating each row, we can represent this as an 11-dimensional vector (omitting $(2, 2)$). +# +# Thus, taking $s_1 = (1, 1)$, $s_2 = (1, 2)$, ... $s_{11} = (4,3)$, we have $b$: +# +# $b = (0.8, 0.1, 0, 0, 0.1, 0, 0, 0, 0, 0, 0)$ +# +# This fully represents the certainty to which the agent is aware of its state. +# +# #### Using evidence +# The evidence observed here could be the number of adjacent 'walls' or 'dead ends' observed by the agent. We assume that the agent cannot 'orient' the walls - only count them. +# +# In this case, $e$ can take only two values, 1 and 2. This gives $P(e\ |\ s)$ as: +# \begin{align*} +# P(e=2\ |\ s) &= \frac{1}{7} \quad \forall \quad s \in \{s_1, s_2, s_4, s_5, s_8, s_9, s_{11}\}\\ +# P(e=1\ |\ s) &= \frac{1}{4} \quad \forall \quad s \in \{s_3, s_6, s_7, s_{10}\} \\ +# P(e\ |\ s) &= 0 \quad \forall \quad \text{ other } s, e +# \end{align*} +# +# Note that the implications of the evidence on the state must be known **a priori** to the agent. Ways of reliably learning this distribution from percepts are beyond the scope of this notebook. + +# %% [markdown] +# ### POMDPs - a rigorous outline +# +# A POMDP is thus a sequential decision problem for for a *partially* observable, stochastic environment with a Markovian transition model, a known 'sensor model' for inferring state from observation, and additive rewards. +# +# Practically, a POMDP has the following, which an MDP also has: +# - a set of states, each denoted by $s$ +# - a set of actions available in each state, $A(s)$ +# - a reward accrued on attaining some state, $R(s)$ +# - a transition probability $P(s'\ |\ s, a)$ of action $a$ changing the state from $s$ to $s'$ +# +# And the following, which an MDP does not: +# - a sensor model $P(e\ |\ s)$ on evidence conditional on states +# +# Additionally, the POMDP is now uncertain of its current state hence has: +# - a belief vector $b$ representing the certainty of being in each state (as a probability distribution) +# +# +# #### New uncertainties +# +# It is useful to intuitively appreciate the new uncertainties that have arisen in the agent's awareness of its own state. +# +# - At any point, the agent has belief vector $b$, the distribution of its believed likelihood of being in each state $s$. +# - For each of these states $s$ that the agent may **actually** be in, it has some set of actions given by $A(s)$. +# - Each of these actions may transport it to some other state $s'$, assuming an initial state $s$, with probability $P(s'\ |\ s, a)$ +# - Once the action is performed, the agent receives a percept $e$. $P(e\ |\ s)$ now tells it the chances of having perceived $e$ for each state $s$. The agent must use this information to update its new belief state appropriately. +# +# #### Evolution of the belief vector - the `FORWARD` function +# +# The new belief vector $b'(s')$ after an action $a$ on the belief vector $b(s)$ and the noting of evidence $e$ is: +# $$ b'(s') = \alpha P(e\ |\ s') \sum_s P(s'\ | s, a) b(s)$$ +# +# where $\alpha$ is a normalising constant (to retain the interpretation of $b$ as a probability distribution. +# +# This equation is just counts the sum of likelihoods of going to a state $s'$ from every possible state $s$, times the initial likelihood of being in each $s$. This is multiplied by the likelihood that the known evidence actually implies the new state $s'$. +# +# This function is represented as `b' = FORWARD(b, a, e)` +# +# #### Probability distribution of the evolving belief vector +# +# The goal here is to find $P(b'\ |\ b, a)$ - the probability that action $a$ transforms belief vector $b$ into belief vector $b'$. The following steps illustrate this - +# +# The probability of observing evidence $e$ when action $a$ is enacted on belief vector $b$ can be distributed over each possible new state $s'$ resulting from it: +# \begin{align*} +# P(e\ |\ b, a) &= \sum_{s'} P(e\ |\ b, a, s') P(s'\ |\ b, a) \\ +# &= \sum_{s'} P(e\ |\ s') P(s'\ |\ b, a) \\ +# &= \sum_{s'} P(e\ |\ s') \sum_s P(s'\ |\ s, a) b(s) +# \end{align*} +# +# The probability of getting belief vector $b'$ from $b$ by application of action $a$ can thus be summed over all possible evidences $e$: +# \begin{align*} +# P(b'\ |\ b, a) &= \sum_{e} P(b'\ |\ b, a, e) P(e\ |\ b, a) \\ +# &= \sum_{e} P(b'\ |\ b, a, e) \sum_{s'} P(e\ |\ s') \sum_s P(s'\ |\ s, a) b(s) +# \end{align*} +# +# where $P(b'\ |\ b, a, e) = 1$ if $b' = $ `FORWARD(b, a, e)` and $= 0$ otherwise. +# +# Given initial and final belief states $b$ and $b'$, the transition probabilities still depend on the action $a$ and observed evidence $e$. Some belief states may be achievable by certain actions, but have non-zero probabilities for states prohibited by the evidence $e$. Thus, the above condition thus ensures that only valid combinations of $(b', b, a, e)$ are considered. +# +# #### A modified rewardspace +# +# For MDPs, the reward space was simple - one reward per available state. However, for a belief vector $b(s)$, the expected reward is now: +# $$\rho(b) = \sum_s b(s) R(s)$$ +# +# Thus, as the belief vector can take infinite values of the distribution over states, so can the reward for each belief vector vary over a hyperplane in the belief space, or space of states (planes in an $N$-dimensional space are formed by a linear combination of the axes). + +# %% [markdown] +# Now that we know the basics, let's have a look at the `POMDP` class. + +# %% +psource(POMDP) + +# %% [markdown] +# The `POMDP` class includes all variables of the `MDP` class and additionally also stores the sensor model in `e_prob`. +#
+#
+# `remove_dominated_plans`, `remove_dominated_plans_fast`, `generate_mapping` and `max_difference` are helper methods for `pomdp_value_iteration` which will be explained shortly. + +# %% [markdown] +# To understand how we can model a partially observable MDP, let's take a simple example. +# Let's consider a simple two state world. +# The states are labelled 0 and 1, with the reward at state 0 being 0 and at state 1 being 1. +#
+# There are two actions: +#
+# `Stay`: stays put with probability 0.9 and +# `Go`: switches to the other state with probability 0.9. +#
+# For now, let's assume the discount factor `gamma` to be 1. +#
+# The sensor reports the correct state with probability 0.6. +#
+# This is a simple problem with a trivial solution. +# Obviously the agent should `Stay` when it thinks it is in state 1 and `Go` when it thinks it is in state 0. +#
+# The belief space can be viewed as one-dimensional because the two probabilities must sum to 1. + +# %% [markdown] +# Let's model this POMDP using the `POMDP` class. + +# %% +# transition probability P(s'|s,a) +t_prob = [[[0.9, 0.1], [0.1, 0.9]], [[0.1, 0.9], [0.9, 0.1]]] +# evidence function P(e|s) +e_prob = [[[0.6, 0.4], [0.4, 0.6]], [[0.6, 0.4], [0.4, 0.6]]] +# reward function +rewards = [[0.0, 0.0], [1.0, 1.0]] +# discount factor +gamma = 0.95 +# actions +actions = ('0', '1') +# states +states = ('0', '1') + +# %% +pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma) + +# %% [markdown] +# We have defined our `POMDP` object. + +# %% [markdown] +# ## POMDP VALUE ITERATION +# Defining a POMDP is useless unless we can find a way to solve it. As POMDPs can have infinitely many belief states, we cannot calculate one utility value for each state as we did in `value_iteration` for MDPs. +#
+# Instead of thinking about policies, we should think about conditional plans and how the expected utility of executing a fixed conditional plan varies with the initial belief state. +#
+# If we bound the depth of the conditional plans, then there are only finitely many such plans and the continuous space of belief states will generally be divided inte _regions_, each corresponding to a particular conditional plan that is optimal in that region. The utility function, being the maximum of a collection of hyperplanes, will be piecewise linear and convex. +#
+# For the one-step plans `Stay` and `Go`, the utility values are as follows +#
+#
+# $$\alpha_{|Stay|}(0) = R(0) + \gamma(0.9R(0) + 0.1R(1)) = 0.1$$ +# $$\alpha_{|Stay|}(1) = R(1) + \gamma(0.9R(1) + 0.1R(0)) = 1.9$$ +# $$\alpha_{|Go|}(0) = R(0) + \gamma(0.9R(1) + 0.1R(0)) = 0.9$$ +# $$\alpha_{|Go|}(1) = R(1) + \gamma(0.9R(0) + 0.1R(1)) = 1.1$$ + +# %% [markdown] +# The utility function can be found by `pomdp_value_iteration`. +#
+# To summarize, it generates a set of all plans consisting of an action and, for each possible next percept, a plan in U with computed utility vectors. +# The dominated plans are then removed from this set and the process is repeated till the maximum difference between the utility functions of two consecutive iterations reaches a value less than a threshold value. + +# %% +pseudocode('POMDP-Value-Iteration') + +# %% [markdown] +# Let's have a look at the `pomdp_value_iteration` function. + +# %% +psource(pomdp_value_iteration) + +# %% [markdown] +# This function uses two aptly named helper methods from the `POMDP` class, `remove_dominated_plans` and `max_difference`. + +# %% [markdown] +# Let's try solving a simple one-dimensional POMDP using value-iteration. +#
+# Consider the problem of a user listening to voicemails. +# At the end of each message, they can either _save_ or _delete_ a message. +# This forms the unobservable state _S = {save, delete}_. +# It is the task of the POMDP solver to guess which goal the user has. +#
+# The belief space has two elements, _b(s = save)_ and _b(s = delete)_. +# For example, for the belief state _b = (1, 0)_, the left end of the line segment indicates _b(s = save) = 1_ and _b(s = delete) = 0_. +# The intermediate points represent varying degrees of certainty in the user's goal. +#
+# The machine has three available actions: it can _ask_ what the user wishes to do in order to infer his or her current goal, or it can _doSave_ or _doDelete_ and move to the next message. +# If the user says _save_, then an error may occur with probability 0.2, whereas if the user says _delete_, an error may occur with a probability 0.3. +#
+# The machine receives a large positive reward (+5) for getting the user's goal correct, a very large negative reward (-20) for taking the action _doDelete_ when the user wanted _save_, and a smaller but still significant negative reward (-10) for taking the action _doSave_ when the user wanted _delete_. +# There is also a small negative reward for taking the _ask_ action (-1). +# The discount factor is set to 0.95 for this example. +#
+# Let's define the POMDP. + +# %% +# transition function P(s'|s,a) +t_prob = [[[0.65, 0.35], [0.65, 0.35]], [[0.65, 0.35], [0.65, 0.35]], [[1.0, 0.0], [0.0, 1.0]]] +# evidence function P(e|s) +e_prob = [[[0.5, 0.5], [0.5, 0.5]], [[0.5, 0.5], [0.5, 0.5]], [[0.8, 0.2], [0.3, 0.7]]] +# reward function +rewards = [[5, -10], [-20, 5], [-1, -1]] + +gamma = 0.95 +actions = ('0', '1', '2') +states = ('0', '1') + +pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma) + +# %% [markdown] +# We have defined the `POMDP` object. +# Let's run `pomdp_value_iteration` to find the utility function. + +# %% +utility = pomdp_value_iteration(pomdp, epsilon=0.1) + +# %% +# %matplotlib inline +plot_pomdp_utility(utility) + +# %% [markdown] +# --- +# ## Appendix +# +# Surprisingly, it turns out that there are six other optimal policies for various ranges of R(s). +# You can try to find them out for yourself. +# See **Exercise 17.5**. +# To help you with this, we have a GridMDP editor in `grid_mdp.py` in the GUI folder. +#
+# Here's a brief tutorial about how to use it +#
+# Let us use it to solve `Case 2` above +# 1. Run `python gui/grid_mdp.py` from the master directory. +# 2. Enter the dimensions of the grid (3 x 4 in this case), and click on `'Build a GridMDP'` +# 3. Click on `Initialize` in the `Edit` menu. +# 4. Set the reward as -0.4 and click `Apply`. Exit the dialog. +# ![title](images/ge0.jpg) +#
+# 5. Select cell (1, 1) and check the `Wall` radio button. `Apply` and exit the dialog. +# ![title](images/ge1.jpg) +#
+# 6. Select cells (4, 1) and (4, 2) and check the `Terminal` radio button for both. Set the rewards appropriately and click on `Apply`. Exit the dialog. Your window should look something like this. +# ![title](images/ge2.jpg) +#
+# 7. You are all set up now. Click on `Build and Run` in the `Build` menu and watch the heatmap calculate the utility function. +# ![title](images/ge4.jpg) +#
+# Green shades indicate positive utilities and brown shades indicate negative utilities. +# The values of the utility function and arrow diagram will pop up in separate dialogs after the algorithm converges. diff --git a/notebooks/mdp_apps.ipynb b/notebooks/mdp_apps.ipynb new file mode 100644 index 000000000..cefc9c7d2 --- /dev/null +++ b/notebooks/mdp_apps.ipynb @@ -0,0 +1,2139 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# APPLICATIONS OF MARKOV DECISION PROCESSES\n", + "---\n", + "In this notebook we will take a look at some indicative applications of markov decision processes. \n", + "We will cover content from [`mdp.py`](https://github.com/aimacode/aima-python/blob/master/mdp.py), for **Chapter 17 Making Complex Decisions** of Stuart Russel's and Peter Norvig's book [*Artificial Intelligence: A Modern Approach*](http://aima.cs.berkeley.edu/).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:47.018726Z", + "iopub.status.busy": "2026-06-27T00:34:47.018501Z", + "iopub.status.idle": "2026-06-27T00:34:48.282844Z", + "shell.execute_reply": "2026-06-27T00:34:48.281517Z" + } + }, + "outputs": [], + "source": [ + "from aima.mdp import *\n", + "from aima.notebook_utils import psource, pseudocode" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## CONTENTS\n", + "- Simple MDP\n", + " - State dependent reward function\n", + " - State and action dependent reward function\n", + " - State, action and next state dependent reward function\n", + "- Grid MDP\n", + " - Pathfinding problem\n", + "- POMDP\n", + " - Two state POMDP" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## SIMPLE MDP\n", + "---\n", + "### State dependent reward function\n", + "\n", + "Markov Decision Processes are formally described as processes that follow the Markov property which states that \"The future is independent of the past given the present\". \n", + "MDPs formally describe environments for reinforcement learning and we assume that the environment is *fully observable*. \n", + "Let us take a toy example MDP and solve it using the functions in `mdp.py`.\n", + "This is a simple example adapted from a [similar problem](http://www0.cs.ucl.ac.uk/staff/D.Silver/web/Teaching_files/MDP.pdf) by Dr. David Silver, tweaked to fit the limitations of the current functions.\n", + "![title](images/mdp-b.png)\n", + "\n", + "Let's say you're a student attending lectures in a university.\n", + "There are three lectures you need to attend on a given day.\n", + "
\n", + "Attending the first lecture gives you 4 points of reward.\n", + "After the first lecture, you have a 0.6 probability to continue into the second one, yielding 6 more points of reward.\n", + "
\n", + "But, with a probability of 0.4, you get distracted and start using Facebook instead and get a reward of -1.\n", + "From then onwards, you really can't let go of Facebook and there's just a 0.1 probability that you will concentrate back on the lecture.\n", + "
\n", + "After the second lecture, you have an equal chance of attending the next lecture or just falling asleep.\n", + "Falling asleep is the terminal state and yields you no reward, but continuing on to the final lecture gives you a big reward of 10 points.\n", + "
\n", + "From there on, you have a 40% chance of going to study and reach the terminal state, \n", + "but a 60% chance of going to the pub with your friends instead. \n", + "You end up drunk and don't know which lecture to attend, so you go to one of the lectures according to the probabilities given above.\n", + "
\n", + "We now have an outline of our stochastic environment and we need to maximize our reward by solving this MDP.\n", + "
\n", + "
\n", + "We first have to define our Transition Matrix as a nested dictionary to fit the requirements of the MDP class." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.286343Z", + "iopub.status.busy": "2026-06-27T00:34:48.285817Z", + "iopub.status.idle": "2026-06-27T00:34:48.293491Z", + "shell.execute_reply": "2026-06-27T00:34:48.292237Z" + } + }, + "outputs": [], + "source": [ + "t = {\n", + " 'leisure': {\n", + " 'facebook': {'leisure':0.9, 'class1':0.1},\n", + " 'quit': {'leisure':0.1, 'class1':0.9},\n", + " 'study': {},\n", + " 'sleep': {},\n", + " 'pub': {}\n", + " },\n", + " 'class1': {\n", + " 'study': {'class2':0.6, 'leisure':0.4},\n", + " 'facebook': {'class2':0.4, 'leisure':0.6},\n", + " 'quit': {},\n", + " 'sleep': {},\n", + " 'pub': {}\n", + " },\n", + " 'class2': {\n", + " 'study': {'class3':0.5, 'end':0.5},\n", + " 'sleep': {'end':0.5, 'class3':0.5},\n", + " 'facebook': {},\n", + " 'quit': {},\n", + " 'pub': {},\n", + " },\n", + " 'class3': {\n", + " 'study': {'end':0.6, 'class1':0.08, 'class2':0.16, 'class3':0.16},\n", + " 'pub': {'end':0.4, 'class1':0.12, 'class2':0.24, 'class3':0.24},\n", + " 'facebook': {},\n", + " 'quit': {},\n", + " 'sleep': {}\n", + " },\n", + " 'end': {}\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We now need to define the reward for each state." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.296144Z", + "iopub.status.busy": "2026-06-27T00:34:48.295926Z", + "iopub.status.idle": "2026-06-27T00:34:48.301460Z", + "shell.execute_reply": "2026-06-27T00:34:48.298612Z" + } + }, + "outputs": [], + "source": [ + "rewards = {\n", + " 'class1': 4,\n", + " 'class2': 6,\n", + " 'class3': 10,\n", + " 'leisure': -1,\n", + " 'end': 0\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This MDP has only one terminal state." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.305244Z", + "iopub.status.busy": "2026-06-27T00:34:48.304971Z", + "iopub.status.idle": "2026-06-27T00:34:48.308303Z", + "shell.execute_reply": "2026-06-27T00:34:48.307294Z" + } + }, + "outputs": [], + "source": [ + "terminals = ['end']" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's now set the initial state to Class 1." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.311226Z", + "iopub.status.busy": "2026-06-27T00:34:48.310927Z", + "iopub.status.idle": "2026-06-27T00:34:48.314698Z", + "shell.execute_reply": "2026-06-27T00:34:48.313408Z" + } + }, + "outputs": [], + "source": [ + "init = 'class1'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will write a CustomMDP class to extend the MDP class for the problem at hand. \n", + "This class will implement the `T` method to implement the transition model. This is the exact same class as given in [`mdp.ipynb`](https://github.com/aimacode/aima-python/blob/master/mdp.ipynb#MDP)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.318997Z", + "iopub.status.busy": "2026-06-27T00:34:48.318668Z", + "iopub.status.idle": "2026-06-27T00:34:48.324594Z", + "shell.execute_reply": "2026-06-27T00:34:48.323677Z" + } + }, + "outputs": [], + "source": [ + "class CustomMDP(MDP):\n", + "\n", + " def __init__(self, transition_matrix, rewards, terminals, init, gamma=.9):\n", + " # All possible actions.\n", + " actlist = []\n", + " for state in transition_matrix.keys():\n", + " actlist.extend(transition_matrix[state])\n", + " actlist = list(set(actlist))\n", + " print(actlist)\n", + "\n", + " MDP.__init__(self, init, actlist, terminals=terminals,\n", + " states=set(transition_matrix.keys()), gamma=gamma)\n", + " self.t = transition_matrix\n", + " self.reward = rewards\n", + " for state in self.t:\n", + " self.states.add(state)\n", + "\n", + " def T(self, state, action):\n", + " if action is None:\n", + " return [(0.0, state)]\n", + " else: \n", + " return [(prob, new_state) for new_state, prob in self.t[state][action].items()]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We now need an instance of this class." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.327128Z", + "iopub.status.busy": "2026-06-27T00:34:48.326923Z", + "iopub.status.idle": "2026-06-27T00:34:48.331043Z", + "shell.execute_reply": "2026-06-27T00:34:48.329731Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['pub', 'quit', 'study', 'facebook', 'sleep']\n", + "Warning: Transition table is empty.\n" + ] + } + ], + "source": [ + "mdp = CustomMDP(t, rewards, terminals, init, gamma=.9)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The utility of each state can be found by `value_iteration`." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.377142Z", + "iopub.status.busy": "2026-06-27T00:34:48.376927Z", + "iopub.status.idle": "2026-06-27T00:34:48.385292Z", + "shell.execute_reply": "2026-06-27T00:34:48.383915Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{'end': 0.0,\n", + " 'class3': 19.10533144728953,\n", + " 'leisure': 13.946891353066082,\n", + " 'class2': 14.597383430869879,\n", + " 'class1': 16.90340650279542}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "value_iteration(mdp)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that we can compute the utility values, we can find the best policy." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.387862Z", + "iopub.status.busy": "2026-06-27T00:34:48.387595Z", + "iopub.status.idle": "2026-06-27T00:34:48.391742Z", + "shell.execute_reply": "2026-06-27T00:34:48.390711Z" + } + }, + "outputs": [], + "source": [ + "pi = best_policy(mdp, value_iteration(mdp, .01))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`pi` stores the best action for each state." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.394329Z", + "iopub.status.busy": "2026-06-27T00:34:48.394102Z", + "iopub.status.idle": "2026-06-27T00:34:48.397884Z", + "shell.execute_reply": "2026-06-27T00:34:48.396743Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'end': None, 'class3': 'pub', 'leisure': 'quit', 'class2': 'study', 'class1': 'study'}\n" + ] + } + ], + "source": [ + "print(pi)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can confirm that this is the best policy by verifying this result against `policy_iteration`." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.400858Z", + "iopub.status.busy": "2026-06-27T00:34:48.400562Z", + "iopub.status.idle": "2026-06-27T00:34:48.406207Z", + "shell.execute_reply": "2026-06-27T00:34:48.405296Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{'end': None,\n", + " 'class3': 'pub',\n", + " 'leisure': 'quit',\n", + " 'class2': 'study',\n", + " 'class1': 'study'}" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "policy_iteration(mdp)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "collapsed": true + }, + "source": [ + "Everything looks perfect, but let us look at another possibility for an MDP.\n", + "
\n", + "Till now we have only dealt with rewards that the agent gets while it is **on** a particular state.\n", + "What if we want to have different rewards for a state depending on the action that the agent takes next. \n", + "The agent gets the reward _during its transition_ to the next state.\n", + "
\n", + "For the sake of clarity, we will call this the _transition reward_ and we will call this kind of MDP a _dynamic_ MDP. \n", + "This is not a conventional term, we just use it to minimize confusion between the two.\n", + "
\n", + "This next section deals with how to create and solve a dynamic MDP." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### State and action dependent reward function\n", + "Let us consider a very similar problem, but this time, we do not have rewards _on_ states, \n", + "instead, we have rewards on the transitions between states. \n", + "This state diagram will make it clearer.\n", + "![title](images/mdp-c.png)\n", + "\n", + "A very similar scenario as the previous problem, but we have different rewards for the same state depending on the action taken.\n", + "
\n", + "To deal with this, we just need to change the `R` method of the `MDP` class, but to prevent confusion, we will write a new similar class `DMDP`." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.408819Z", + "iopub.status.busy": "2026-06-27T00:34:48.408573Z", + "iopub.status.idle": "2026-06-27T00:34:48.416517Z", + "shell.execute_reply": "2026-06-27T00:34:48.415315Z" + } + }, + "outputs": [], + "source": [ + "class DMDP:\n", + "\n", + " \"\"\"A Markov Decision Process, defined by an initial state, transition model,\n", + " and reward model. We also keep track of a gamma value, for use by\n", + " algorithms. The transition model is represented somewhat differently from\n", + " the text. Instead of P(s' | s, a) being a probability number for each\n", + " state/state/action triplet, we instead have T(s, a) return a\n", + " list of (p, s') pairs. The reward function is very similar.\n", + " We also keep track of the possible states,\n", + " terminal states, and actions for each state.\"\"\"\n", + "\n", + " def __init__(self, init, actlist, terminals, transitions={}, rewards={}, states=None, gamma=.9):\n", + " if not (0 < gamma <= 1):\n", + " raise ValueError(\"An MDP must have 0 < gamma <= 1\")\n", + "\n", + " if states:\n", + " self.states = states\n", + " else:\n", + " self.states = set()\n", + " self.init = init\n", + " self.actlist = actlist\n", + " self.terminals = terminals\n", + " self.transitions = transitions\n", + " self.rewards = rewards\n", + " self.gamma = gamma\n", + "\n", + " def R(self, state, action):\n", + " \"\"\"Return a numeric reward for this state and this action.\"\"\"\n", + " if (self.rewards == {}):\n", + " raise ValueError('Reward model is missing')\n", + " else:\n", + " return self.rewards[state][action]\n", + "\n", + " def T(self, state, action):\n", + " \"\"\"Transition model. From a state and an action, return a list\n", + " of (probability, result-state) pairs.\"\"\"\n", + " if(self.transitions == {}):\n", + " raise ValueError(\"Transition model is missing\")\n", + " else:\n", + " return self.transitions[state][action]\n", + "\n", + " def actions(self, state):\n", + " \"\"\"Set of actions that can be performed in this state. By default, a\n", + " fixed list of actions, except for terminal states. Override this\n", + " method if you need to specialize by state.\"\"\"\n", + " if state in self.terminals:\n", + " return [None]\n", + " else:\n", + " return self.actlist" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The transition model will be the same" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.419375Z", + "iopub.status.busy": "2026-06-27T00:34:48.419161Z", + "iopub.status.idle": "2026-06-27T00:34:48.423849Z", + "shell.execute_reply": "2026-06-27T00:34:48.422954Z" + } + }, + "outputs": [], + "source": [ + "t = {\n", + " 'leisure': {\n", + " 'facebook': {'leisure':0.9, 'class1':0.1},\n", + " 'quit': {'leisure':0.1, 'class1':0.9},\n", + " 'study': {},\n", + " 'sleep': {},\n", + " 'pub': {}\n", + " },\n", + " 'class1': {\n", + " 'study': {'class2':0.6, 'leisure':0.4},\n", + " 'facebook': {'class2':0.4, 'leisure':0.6},\n", + " 'quit': {},\n", + " 'sleep': {},\n", + " 'pub': {}\n", + " },\n", + " 'class2': {\n", + " 'study': {'class3':0.5, 'end':0.5},\n", + " 'sleep': {'end':0.5, 'class3':0.5},\n", + " 'facebook': {},\n", + " 'quit': {},\n", + " 'pub': {},\n", + " },\n", + " 'class3': {\n", + " 'study': {'end':0.6, 'class1':0.08, 'class2':0.16, 'class3':0.16},\n", + " 'pub': {'end':0.4, 'class1':0.12, 'class2':0.24, 'class3':0.24},\n", + " 'facebook': {},\n", + " 'quit': {},\n", + " 'sleep': {}\n", + " },\n", + " 'end': {}\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The reward model will be a dictionary very similar to the transition dictionary with a reward for every action for every state." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.426114Z", + "iopub.status.busy": "2026-06-27T00:34:48.425907Z", + "iopub.status.idle": "2026-06-27T00:34:48.430343Z", + "shell.execute_reply": "2026-06-27T00:34:48.429208Z" + } + }, + "outputs": [], + "source": [ + "r = {\n", + " 'leisure': {\n", + " 'facebook':-1,\n", + " 'quit':0,\n", + " 'study':0,\n", + " 'sleep':0,\n", + " 'pub':0\n", + " },\n", + " 'class1': {\n", + " 'study':-2,\n", + " 'facebook':-1,\n", + " 'quit':0,\n", + " 'sleep':0,\n", + " 'pub':0\n", + " },\n", + " 'class2': {\n", + " 'study':-2,\n", + " 'sleep':0,\n", + " 'facebook':0,\n", + " 'quit':0,\n", + " 'pub':0\n", + " },\n", + " 'class3': {\n", + " 'study':10,\n", + " 'pub':1,\n", + " 'facebook':0,\n", + " 'quit':0,\n", + " 'sleep':0\n", + " },\n", + " 'end': {\n", + " 'study':0,\n", + " 'pub':0,\n", + " 'facebook':0,\n", + " 'quit':0,\n", + " 'sleep':0\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The MDP has only one terminal state" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.433160Z", + "iopub.status.busy": "2026-06-27T00:34:48.432868Z", + "iopub.status.idle": "2026-06-27T00:34:48.436716Z", + "shell.execute_reply": "2026-06-27T00:34:48.435652Z" + } + }, + "outputs": [], + "source": [ + "terminals = ['end']" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's now set the initial state to Class 1." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.438934Z", + "iopub.status.busy": "2026-06-27T00:34:48.438739Z", + "iopub.status.idle": "2026-06-27T00:34:48.442072Z", + "shell.execute_reply": "2026-06-27T00:34:48.441060Z" + } + }, + "outputs": [], + "source": [ + "init = 'class1'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will write a CustomDMDP class to extend the DMDP class for the problem at hand.\n", + "This class will implement everything that the previous CustomMDP class implements along with a new reward model." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.444914Z", + "iopub.status.busy": "2026-06-27T00:34:48.444639Z", + "iopub.status.idle": "2026-06-27T00:34:48.452578Z", + "shell.execute_reply": "2026-06-27T00:34:48.451422Z" + } + }, + "outputs": [], + "source": [ + "class CustomDMDP(DMDP):\n", + " \n", + " def __init__(self, transition_matrix, rewards, terminals, init, gamma=.9):\n", + " actlist = []\n", + " for state in transition_matrix.keys():\n", + " actlist.extend(transition_matrix[state])\n", + " actlist = list(set(actlist))\n", + " print(actlist)\n", + " \n", + " DMDP.__init__(self, init, actlist, terminals=terminals, gamma=gamma)\n", + " self.t = transition_matrix\n", + " self.rewards = rewards\n", + " for state in self.t:\n", + " self.states.add(state)\n", + " \n", + " \n", + " def T(self, state, action):\n", + " if action is None:\n", + " return [(0.0, state)]\n", + " else:\n", + " return [(prob, new_state) for new_state, prob in self.t[state][action].items()]\n", + " \n", + " def R(self, state, action):\n", + " if action is None:\n", + " return 0\n", + " else:\n", + " return self.rewards[state][action]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "One thing we haven't thought about yet is that the `value_iteration` algorithm won't work now that the reward model is changed.\n", + "It will be quite similar to the one we currently have nonetheless." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The Bellman update equation now is defined as follows\n", + "\n", + "$$U(s)=\\max_{a\\epsilon A(s)}\\bigg[R(s, a) + \\gamma\\sum_{s'}P(s'\\ |\\ s,a)U(s')\\bigg]$$\n", + "\n", + "It is not difficult to see that the update equation we have been using till now is just a special case of this more generalized equation. \n", + "We also need to max over the reward function now as the reward function is action dependent as well.\n", + "
\n", + "We will use this to write a function to carry out value iteration, very similar to the one we are familiar with." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.455151Z", + "iopub.status.busy": "2026-06-27T00:34:48.454876Z", + "iopub.status.idle": "2026-06-27T00:34:48.460510Z", + "shell.execute_reply": "2026-06-27T00:34:48.459510Z" + } + }, + "outputs": [], + "source": [ + "def value_iteration_dmdp(dmdp, epsilon=0.001):\n", + " U1 = {s: 0 for s in dmdp.states}\n", + " R, T, gamma = dmdp.R, dmdp.T, dmdp.gamma\n", + " while True:\n", + " U = U1.copy()\n", + " delta = 0\n", + " for s in dmdp.states:\n", + " U1[s] = max([(R(s, a) + gamma*sum([(p*U[s1]) for (p, s1) in T(s, a)])) for a in dmdp.actions(s)])\n", + " delta = max(delta, abs(U1[s] - U[s]))\n", + " if delta < epsilon * (1 - gamma) / gamma:\n", + " return U" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We're all set.\n", + "Let's instantiate our class." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.462879Z", + "iopub.status.busy": "2026-06-27T00:34:48.462663Z", + "iopub.status.idle": "2026-06-27T00:34:48.466680Z", + "shell.execute_reply": "2026-06-27T00:34:48.465858Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['pub', 'quit', 'study', 'facebook', 'sleep']\n" + ] + } + ], + "source": [ + "dmdp = CustomDMDP(t, r, terminals, init, gamma=.9)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Calculate utility values by calling `value_iteration_dmdp`." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.469244Z", + "iopub.status.busy": "2026-06-27T00:34:48.468995Z", + "iopub.status.idle": "2026-06-27T00:34:48.474369Z", + "shell.execute_reply": "2026-06-27T00:34:48.473398Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{'end': 0.0,\n", + " 'class3': 12.827904448229472,\n", + " 'leisure': 1.8474896554396596,\n", + " 'class2': 5.772550326127298,\n", + " 'class1': 2.0756895004431364}" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "value_iteration_dmdp(dmdp)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "These are the expected utility values for our new MDP.\n", + "
\n", + "As you might have guessed, we cannot use the old `best_policy` function to find the best policy.\n", + "So we will write our own.\n", + "But, before that we need a helper function to calculate the expected utility value given a state and an action." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.476726Z", + "iopub.status.busy": "2026-06-27T00:34:48.476530Z", + "iopub.status.idle": "2026-06-27T00:34:48.479822Z", + "shell.execute_reply": "2026-06-27T00:34:48.478924Z" + } + }, + "outputs": [], + "source": [ + "def expected_utility_dmdp(a, s, U, dmdp):\n", + " return dmdp.R(s, a) + dmdp.gamma*sum([(p*U[s1]) for (p, s1) in dmdp.T(s, a)])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we write our modified `best_policy` function." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.482742Z", + "iopub.status.busy": "2026-06-27T00:34:48.482379Z", + "iopub.status.idle": "2026-06-27T00:34:48.486866Z", + "shell.execute_reply": "2026-06-27T00:34:48.485753Z" + } + }, + "outputs": [], + "source": [ + "from aima.utils import argmax_random_tie as argmax\n", + "def best_policy_dmdp(dmdp, U):\n", + " pi = {}\n", + " for s in dmdp.states:\n", + " pi[s] = argmax(dmdp.actions(s), key=lambda a: expected_utility_dmdp(a, s, U, dmdp))\n", + " return pi" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Find the best policy." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.489138Z", + "iopub.status.busy": "2026-06-27T00:34:48.488913Z", + "iopub.status.idle": "2026-06-27T00:34:48.493344Z", + "shell.execute_reply": "2026-06-27T00:34:48.492222Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'end': None, 'class3': 'study', 'leisure': 'quit', 'class2': 'sleep', 'class1': 'facebook'}\n" + ] + } + ], + "source": [ + "pi = best_policy_dmdp(dmdp, value_iteration_dmdp(dmdp, .01))\n", + "print(pi)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "From this, we can infer that `value_iteration_dmdp` tries to minimize the negative reward. \n", + "Since we don't have rewards for states now, the algorithm takes the action that would try to avoid getting negative rewards and take the lesser of two evils if all rewards are negative.\n", + "You might also want to have state rewards alongside transition rewards. \n", + "Perhaps you can do that yourself now that the difficult part has been done.\n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### State, action and next-state dependent reward function\n", + "\n", + "For truly stochastic environments, \n", + "we have noticed that taking an action from a particular state doesn't always do what we want it to. \n", + "Instead, for every action taken from a particular state, \n", + "it might be possible to reach a different state each time depending on the transition probabilities. \n", + "What if we want different rewards for each state, action and next-state triplet? \n", + "Mathematically, we now want a reward function of the form R(s, a, s') for our MDP. \n", + "This section shows how we can tweak the MDP class to achieve this.\n", + "
\n", + "\n", + "Let's now take a different problem statement. \n", + "The one we are working with is a bit too simple.\n", + "Consider a taxi that serves three adjacent towns A, B, and C.\n", + "Each time the taxi discharges a passenger, the driver must choose from three possible actions:\n", + "1. Cruise the streets looking for a passenger.\n", + "2. Go to the nearest taxi stand.\n", + "3. Wait for a radio call from the dispatcher with instructions.\n", + "
\n", + "Subject to the constraint that the taxi driver cannot do the third action in town B because of distance and poor reception.\n", + "\n", + "Let's model our MDP.\n", + "
\n", + "The MDP has three states, namely A, B and C.\n", + "
\n", + "It has three actions, namely 1, 2 and 3.\n", + "
\n", + "Action sets:\n", + "
\n", + "$K_{a}$ = {1, 2, 3}\n", + "
\n", + "$K_{b}$ = {1, 2}\n", + "
\n", + "$K_{c}$ = {1, 2, 3}\n", + "
\n", + "\n", + "We have the following transition probability matrices:\n", + "
\n", + "
\n", + "Action 1: Cruising streets \n", + "
\n", + "$\\\\\n", + " P^{1} = \n", + " \\left[ {\\begin{array}{ccc}\n", + " \\frac{1}{2} & \\frac{1}{4} & \\frac{1}{4} \\\\\n", + " \\frac{1}{2} & 0 & \\frac{1}{2} \\\\\n", + " \\frac{1}{4} & \\frac{1}{4} & \\frac{1}{2} \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + "
\n", + "
\n", + "Action 2: Waiting at the taxi stand \n", + "
\n", + "$\\\\\n", + " P^{2} = \n", + " \\left[ {\\begin{array}{ccc}\n", + " \\frac{1}{16} & \\frac{3}{4} & \\frac{3}{16} \\\\\n", + " \\frac{1}{16} & \\frac{7}{8} & \\frac{1}{16} \\\\\n", + " \\frac{1}{8} & \\frac{3}{4} & \\frac{1}{8} \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + "
\n", + "
\n", + "Action 3: Waiting for dispatch \n", + "
\n", + "$\\\\\n", + " P^{3} =\n", + " \\left[ {\\begin{array}{ccc}\n", + " \\frac{1}{4} & \\frac{1}{8} & \\frac{5}{8} \\\\\n", + " 0 & 1 & 0 \\\\\n", + " \\frac{3}{4} & \\frac{1}{16} & \\frac{3}{16} \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + "
\n", + "
\n", + "For the sake of readability, we will call the states A, B and C and the actions 'cruise', 'stand' and 'dispatch'.\n", + "We will now build the transition model as a dictionary using these matrices." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.495628Z", + "iopub.status.busy": "2026-06-27T00:34:48.495418Z", + "iopub.status.idle": "2026-06-27T00:34:48.501471Z", + "shell.execute_reply": "2026-06-27T00:34:48.500308Z" + } + }, + "outputs": [], + "source": [ + "t = {\n", + " 'A': {\n", + " 'cruise': {'A':0.5, 'B':0.25, 'C':0.25},\n", + " 'stand': {'A':0.0625, 'B':0.75, 'C':0.1875},\n", + " 'dispatch': {'A':0.25, 'B':0.125, 'C':0.625}\n", + " },\n", + " 'B': {\n", + " 'cruise': {'A':0.5, 'B':0, 'C':0.5},\n", + " 'stand': {'A':0.0625, 'B':0.875, 'C':0.0625},\n", + " 'dispatch': {'A':0, 'B':1, 'C':0}\n", + " },\n", + " 'C': {\n", + " 'cruise': {'A':0.25, 'B':0.25, 'C':0.5},\n", + " 'stand': {'A':0.125, 'B':0.75, 'C':0.125},\n", + " 'dispatch': {'A':0.75, 'B':0.0625, 'C':0.1875}\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The reward matrices for the problem are as follows:\n", + "
\n", + "
\n", + "Action 1: Cruising streets \n", + "
\n", + "$\\\\\n", + " R^{1} = \n", + " \\left[ {\\begin{array}{ccc}\n", + " 10 & 4 & 8 \\\\\n", + " 14 & 0 & 18 \\\\\n", + " 10 & 2 & 8 \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + "
\n", + "
\n", + "Action 2: Waiting at the taxi stand \n", + "
\n", + "$\\\\\n", + " R^{2} = \n", + " \\left[ {\\begin{array}{ccc}\n", + " 8 & 2 & 4 \\\\\n", + " 8 & 16 & 8 \\\\\n", + " 6 & 4 & 2\\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + "
\n", + "
\n", + "Action 3: Waiting for dispatch \n", + "
\n", + "$\\\\\n", + " R^{3} = \n", + " \\left[ {\\begin{array}{ccc}\n", + " 4 & 6 & 4 \\\\\n", + " 0 & 0 & 0 \\\\\n", + " 4 & 0 & 8\\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + "
\n", + "
\n", + "We now build the reward model as a dictionary using these matrices." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.504226Z", + "iopub.status.busy": "2026-06-27T00:34:48.503975Z", + "iopub.status.idle": "2026-06-27T00:34:48.508255Z", + "shell.execute_reply": "2026-06-27T00:34:48.507287Z" + } + }, + "outputs": [], + "source": [ + "r = {\n", + " 'A': {\n", + " 'cruise': {'A':10, 'B':4, 'C':8},\n", + " 'stand': {'A':8, 'B':2, 'C':4},\n", + " 'dispatch': {'A':4, 'B':6, 'C':4}\n", + " },\n", + " 'B': {\n", + " 'cruise': {'A':14, 'B':0, 'C':18},\n", + " 'stand': {'A':8, 'B':16, 'C':8},\n", + " 'dispatch': {'A':0, 'B':0, 'C':0}\n", + " },\n", + " 'C': {\n", + " 'cruise': {'A':10, 'B':2, 'C':18},\n", + " 'stand': {'A':6, 'B':4, 'C':2},\n", + " 'dispatch': {'A':4, 'B':0, 'C':8}\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "collapsed": true + }, + "source": [ + "The Bellman update equation now is defined as follows\n", + "\n", + "$$U(s)=\\max_{a\\epsilon A(s)}\\sum_{s'}P(s'\\ |\\ s,a)(R(s'\\ |\\ s,a) + \\gamma U(s'))$$\n", + "\n", + "It is not difficult to see that all the update equations we have used till now is just a special case of this more generalized equation. \n", + "If we did not have next-state-dependent rewards, the first term inside the summation exactly sums up to R(s, a) or the state-reward for a particular action and we would get the update equation used in the previous problem.\n", + "If we did not have action dependent rewards, the first term inside the summation sums up to R(s) or the state-reward and we would get the first update equation used in `mdp.ipynb`.\n", + "
\n", + "For example, as we have the same reward regardless of the action, let's consider a reward of **r** units for a particular state and let's assume the transition probabilities to be 0.1, 0.2, 0.3 and 0.4 for 4 possible actions for that state.\n", + "We will further assume that a particular action in a state leads to the same state every time we take that action.\n", + "The first term inside the summation for this case will evaluate to (0.1 + 0.2 + 0.3 + 0.4)r = r which is equal to R(s) in the first update equation.\n", + "
\n", + "There are many ways to write value iteration for this situation, but we will go with the most intuitive method.\n", + "One that can be implemented with minor alterations to the existing `value_iteration` algorithm.\n", + "
\n", + "Our `DMDP` class will be slightly different.\n", + "More specifically, the `R` method will have one more index to go through now that we have three levels of nesting in the reward model.\n", + "We will call the new class `DMDP2` as I have run out of creative names." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.510994Z", + "iopub.status.busy": "2026-06-27T00:34:48.510770Z", + "iopub.status.idle": "2026-06-27T00:34:48.518562Z", + "shell.execute_reply": "2026-06-27T00:34:48.517319Z" + } + }, + "outputs": [], + "source": [ + "class DMDP2:\n", + "\n", + " \"\"\"A Markov Decision Process, defined by an initial state, transition model,\n", + " and reward model. We also keep track of a gamma value, for use by\n", + " algorithms. The transition model is represented somewhat differently from\n", + " the text. Instead of P(s' | s, a) being a probability number for each\n", + " state/state/action triplet, we instead have T(s, a) return a\n", + " list of (p, s') pairs. The reward function is very similar.\n", + " We also keep track of the possible states,\n", + " terminal states, and actions for each state.\"\"\"\n", + "\n", + " def __init__(self, init, actlist, terminals, transitions={}, rewards={}, states=None, gamma=.9):\n", + " if not (0 < gamma <= 1):\n", + " raise ValueError(\"An MDP must have 0 < gamma <= 1\")\n", + "\n", + " if states:\n", + " self.states = states\n", + " else:\n", + " self.states = set()\n", + " self.init = init\n", + " self.actlist = actlist\n", + " self.terminals = terminals\n", + " self.transitions = transitions\n", + " self.rewards = rewards\n", + " self.gamma = gamma\n", + "\n", + " def R(self, state, action, state_):\n", + " \"\"\"Return a numeric reward for this state, this action and the next state_\"\"\"\n", + " if (self.rewards == {}):\n", + " raise ValueError('Reward model is missing')\n", + " else:\n", + " return self.rewards[state][action][state_]\n", + "\n", + " def T(self, state, action):\n", + " \"\"\"Transition model. From a state and an action, return a list\n", + " of (probability, result-state) pairs.\"\"\"\n", + " if(self.transitions == {}):\n", + " raise ValueError(\"Transition model is missing\")\n", + " else:\n", + " return self.transitions[state][action]\n", + "\n", + " def actions(self, state):\n", + " \"\"\"Set of actions that can be performed in this state. By default, a\n", + " fixed list of actions, except for terminal states. Override this\n", + " method if you need to specialize by state.\"\"\"\n", + " if state in self.terminals:\n", + " return [None]\n", + " else:\n", + " return self.actlist\n", + " \n", + " def actions(self, state):\n", + " \"\"\"Set of actions that can be performed in this state. By default, a\n", + " fixed list of actions, except for terminal states. Override this\n", + " method if you need to specialize by state.\"\"\"\n", + " if state in self.terminals:\n", + " return [None]\n", + " else:\n", + " return self.actlist" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Only the `R` method is different from the previous `DMDP` class.\n", + "
\n", + "Our traditional custom class will be required to implement the transition model and the reward model.\n", + "
\n", + "We call this class `CustomDMDP2`." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.520781Z", + "iopub.status.busy": "2026-06-27T00:34:48.520588Z", + "iopub.status.idle": "2026-06-27T00:34:48.525998Z", + "shell.execute_reply": "2026-06-27T00:34:48.524877Z" + } + }, + "outputs": [], + "source": [ + "class CustomDMDP2(DMDP2):\n", + " \n", + " def __init__(self, transition_matrix, rewards, terminals, init, gamma=.9):\n", + " actlist = []\n", + " for state in transition_matrix.keys():\n", + " actlist.extend(transition_matrix[state])\n", + " actlist = list(set(actlist))\n", + " print(actlist)\n", + " \n", + " DMDP2.__init__(self, init, actlist, terminals=terminals, gamma=gamma)\n", + " self.t = transition_matrix\n", + " self.rewards = rewards\n", + " for state in self.t:\n", + " self.states.add(state)\n", + " \n", + " def T(self, state, action):\n", + " if action is None:\n", + " return [(0.0, state)]\n", + " else:\n", + " return [(prob, new_state) for new_state, prob in self.t[state][action].items()]\n", + " \n", + " def R(self, state, action, state_):\n", + " if action is None:\n", + " return 0\n", + " else:\n", + " return self.rewards[state][action][state_]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can finally write value iteration for this problem.\n", + "The latest update equation will be used." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.528388Z", + "iopub.status.busy": "2026-06-27T00:34:48.528109Z", + "iopub.status.idle": "2026-06-27T00:34:48.534855Z", + "shell.execute_reply": "2026-06-27T00:34:48.533521Z" + } + }, + "outputs": [], + "source": [ + "def value_iteration_taxi_mdp(dmdp2, epsilon=0.001):\n", + " U1 = {s: 0 for s in dmdp2.states}\n", + " R, T, gamma = dmdp2.R, dmdp2.T, dmdp2.gamma\n", + " while True:\n", + " U = U1.copy()\n", + " delta = 0\n", + " for s in dmdp2.states:\n", + " U1[s] = max([sum([(p*(R(s, a, s1) + gamma*U[s1])) for (p, s1) in T(s, a)]) for a in dmdp2.actions(s)])\n", + " delta = max(delta, abs(U1[s] - U[s]))\n", + " if delta < epsilon * (1 - gamma) / gamma:\n", + " return U" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "These algorithms can be made more pythonic by using cleverer list comprehensions.\n", + "We can also write the variants of value iteration in such a way that all problems are solved using the same base class, regardless of the reward function and the number of arguments it takes.\n", + "Quite a few things can be done to refactor the code and reduce repetition, but we have done it this way for the sake of clarity.\n", + "Perhaps you can try this as an exercise.\n", + "
\n", + "We now need to define terminals and initial state." + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.537139Z", + "iopub.status.busy": "2026-06-27T00:34:48.536870Z", + "iopub.status.idle": "2026-06-27T00:34:48.540034Z", + "shell.execute_reply": "2026-06-27T00:34:48.539423Z" + } + }, + "outputs": [], + "source": [ + "terminals = ['end']\n", + "init = 'A'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's instantiate our class." + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.542568Z", + "iopub.status.busy": "2026-06-27T00:34:48.542300Z", + "iopub.status.idle": "2026-06-27T00:34:48.546233Z", + "shell.execute_reply": "2026-06-27T00:34:48.545202Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['dispatch', 'stand', 'cruise']\n" + ] + } + ], + "source": [ + "dmdp2 = CustomDMDP2(t, r, terminals, init, gamma=.9)" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.549041Z", + "iopub.status.busy": "2026-06-27T00:34:48.548645Z", + "iopub.status.idle": "2026-06-27T00:34:48.556593Z", + "shell.execute_reply": "2026-06-27T00:34:48.555538Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{'C': 129.08041190693112, 'A': 124.48815435737677, 'B': 137.70885410461636}" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "value_iteration_taxi_mdp(dmdp2)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "These are the expected utility values for the states of our MDP.\n", + "Let's proceed to write a helper function to find the expected utility and another to find the best policy." + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.559847Z", + "iopub.status.busy": "2026-06-27T00:34:48.559505Z", + "iopub.status.idle": "2026-06-27T00:34:48.564362Z", + "shell.execute_reply": "2026-06-27T00:34:48.563123Z" + } + }, + "outputs": [], + "source": [ + "def expected_utility_dmdp2(a, s, U, dmdp2):\n", + " return sum([(p*(dmdp2.R(s, a, s1) + dmdp2.gamma*U[s1])) for (p, s1) in dmdp2.T(s, a)])" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.567858Z", + "iopub.status.busy": "2026-06-27T00:34:48.567540Z", + "iopub.status.idle": "2026-06-27T00:34:48.572163Z", + "shell.execute_reply": "2026-06-27T00:34:48.571142Z" + } + }, + "outputs": [], + "source": [ + "from aima.utils import argmax_random_tie as argmax\n", + "def best_policy_dmdp2(dmdp2, U):\n", + " pi = {}\n", + " for s in dmdp2.states:\n", + " pi[s] = argmax(dmdp2.actions(s), key=lambda a: expected_utility_dmdp2(a, s, U, dmdp2))\n", + " return pi" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Find the best policy." + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.574665Z", + "iopub.status.busy": "2026-06-27T00:34:48.574470Z", + "iopub.status.idle": "2026-06-27T00:34:48.579707Z", + "shell.execute_reply": "2026-06-27T00:34:48.578614Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'C': 'cruise', 'A': 'stand', 'B': 'stand'}\n" + ] + } + ], + "source": [ + "pi = best_policy_dmdp2(dmdp2, value_iteration_taxi_mdp(dmdp2, .01))\n", + "print(pi)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We have successfully adapted the existing code to a different scenario yet again.\n", + "The takeaway from this section is that you can convert the vast majority of reinforcement learning problems into MDPs and solve for the best policy using simple yet efficient tools." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## GRID MDP\n", + "---\n", + "### Pathfinding Problem\n", + "Markov Decision Processes can be used to find the best path through a maze. Let us consider this simple maze.\n", + "![title](images/maze.png)\n", + "\n", + "This environment can be formulated as a GridMDP.\n", + "
\n", + "To make the grid matrix, we will consider the state-reward to be -0.1 for every state.\n", + "
\n", + "State (1, 1) will have a reward of -5 to signify that this state is to be prohibited.\n", + "
\n", + "State (9, 9) will have a reward of +5.\n", + "This will be the terminal state.\n", + "
\n", + "The matrix can be generated using the GridMDP editor or we can write it ourselves." + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.583066Z", + "iopub.status.busy": "2026-06-27T00:34:48.582785Z", + "iopub.status.idle": "2026-06-27T00:34:48.589716Z", + "shell.execute_reply": "2026-06-27T00:34:48.588704Z" + } + }, + "outputs": [], + "source": [ + "grid = [\n", + " [None, None, None, None, None, None, None, None, None, None, None], \n", + " [None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None, +5.0, None], \n", + " [None, -0.1, None, None, None, None, None, None, None, -0.1, None], \n", + " [None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None], \n", + " [None, -0.1, None, None, None, None, None, None, None, None, None], \n", + " [None, -0.1, None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None], \n", + " [None, -0.1, None, None, None, None, None, -0.1, None, -0.1, None], \n", + " [None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None, -0.1, None], \n", + " [None, None, None, None, None, -0.1, None, -0.1, None, -0.1, None], \n", + " [None, -5.0, -0.1, -0.1, -0.1, -0.1, None, -0.1, None, -0.1, None], \n", + " [None, None, None, None, None, None, None, None, None, None, None]\n", + "]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We have only one terminal state, (9, 9)" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.593043Z", + "iopub.status.busy": "2026-06-27T00:34:48.592589Z", + "iopub.status.idle": "2026-06-27T00:34:48.600578Z", + "shell.execute_reply": "2026-06-27T00:34:48.598448Z" + } + }, + "outputs": [], + "source": [ + "terminals = [(9, 9)]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We define our maze environment below" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.604020Z", + "iopub.status.busy": "2026-06-27T00:34:48.603702Z", + "iopub.status.idle": "2026-06-27T00:34:48.609545Z", + "shell.execute_reply": "2026-06-27T00:34:48.608309Z" + } + }, + "outputs": [], + "source": [ + "maze = GridMDP(grid, terminals)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To solve the maze, we can use the `best_policy` function along with `value_iteration`." + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "metadata": { + "collapsed": true, + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.612997Z", + "iopub.status.busy": "2026-06-27T00:34:48.612671Z", + "iopub.status.idle": "2026-06-27T00:34:48.646184Z", + "shell.execute_reply": "2026-06-27T00:34:48.644567Z" + } + }, + "outputs": [], + "source": [ + "pi = best_policy(maze, value_iteration(maze))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is the heatmap generated by the GridMDP editor using `value_iteration` on this environment\n", + "
\n", + "![title](images/mdp-d.png)\n", + "
\n", + "Let's print out the best policy" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.650401Z", + "iopub.status.busy": "2026-06-27T00:34:48.650025Z", + "iopub.status.idle": "2026-06-27T00:34:48.655319Z", + "shell.execute_reply": "2026-06-27T00:34:48.654273Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "None None None None None None None None None None None\n", + "None v < < < < < < None . None\n", + "None v None None None None None None None ^ None\n", + "None > > > > > > > > ^ None\n", + "None ^ None None None None None None None None None\n", + "None ^ None > > > > v < < None\n", + "None ^ None None None None None v None ^ None\n", + "None ^ < < < < < < None ^ None\n", + "None None None None None ^ None ^ None ^ None\n", + "None > > > > ^ None ^ None ^ None\n", + "None None None None None None None None None None None\n" + ] + } + ], + "source": [ + "from aima.utils import print_table\n", + "print_table(maze.to_arrows(pi))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As you can infer, we can find the path to the terminal state starting from any given state using this policy.\n", + "All maze problems can be solved by formulating it as a MDP." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## POMDP\n", + "### Two state POMDP\n", + "Let's consider a problem where we have two doors, one to our left and one to our right.\n", + "One of these doors opens to a room with a tiger in it, and the other one opens to an empty hall.\n", + "
\n", + "We will call our two states `0` and `1` for `left` and `right` respectively.\n", + "
\n", + "The possible actions we can take are as follows:\n", + "
\n", + "1. __Open-left__: Open the left door.\n", + "Represented by `0`.\n", + "2. __Open-right__: Open the right door.\n", + "Represented by `1`.\n", + "3. __Listen__: Listen carefully to one side and possibly hear the tiger breathing.\n", + "Represented by `2`.\n", + "\n", + "
\n", + "The possible observations we can get are as follows:\n", + "
\n", + "1. __TL__: Tiger seems to be at the left door.\n", + "2. __TR__: Tiger seems to be at the right door.\n", + "\n", + "
\n", + "The reward function is as follows:\n", + "
\n", + "We get +10 reward for opening the door to the empty hall and we get -100 reward for opening the other door and setting the tiger free.\n", + "
\n", + "Listening costs us -1 reward.\n", + "
\n", + "We want to minimize our chances of setting the tiger free.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our transition probabilities can be defined as:\n", + "
\n", + "
\n", + "Action `0` (Open left door)\n", + "$\\\\\n", + " P(0) = \n", + " \\left[ {\\begin{array}{cc}\n", + " 0.5 & 0.5 \\\\\n", + " 0.5 & 0.5 \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + " \n", + "Action `1` (Open right door)\n", + "$\\\\\n", + " P(1) = \n", + " \\left[ {\\begin{array}{cc}\n", + " 0.5 & 0.5 \\\\\n", + " 0.5 & 0.5 \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + " \n", + "Action `2` (Listen)\n", + "$\\\\\n", + " P(2) = \n", + " \\left[ {\\begin{array}{cc}\n", + " 1.0 & 0.0 \\\\\n", + " 0.0 & 1.0 \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + " \n", + "
\n", + "
\n", + "Our observation probabilities can be defined as:\n", + "
\n", + "
\n", + "$\\\\\n", + " O(0) = \n", + " \\left[ {\\begin{array}{ccc}\n", + " Open left & TL & TR \\\\\n", + " Tiger: left & 0.5 & 0.5 \\\\\n", + " Tiger: right & 0.5 & 0.5 \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + "\n", + "$\\\\\n", + " O(1) = \n", + " \\left[ {\\begin{array}{ccc}\n", + " Open right & TL & TR \\\\\n", + " Tiger: left & 0.5 & 0.5 \\\\\n", + " Tiger: right & 0.5 & 0.5 \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + "\n", + "$\\\\\n", + " O(2) = \n", + " \\left[ {\\begin{array}{ccc}\n", + " Listen & TL & TR \\\\\n", + " Tiger: left & 0.85 & 0.15 \\\\\n", + " Tiger: right & 0.15 & 0.85 \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + "\n", + "
\n", + "
\n", + "The rewards of this POMDP are defined as:\n", + "
\n", + "
\n", + "$\\\\\n", + " R(0) = \n", + " \\left[ {\\begin{array}{cc}\n", + " Openleft & Reward \\\\\n", + " Tiger: left & -100 \\\\\n", + " Tiger: right & +10 \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + " \n", + "$\\\\\n", + " R(1) = \n", + " \\left[ {\\begin{array}{cc}\n", + " Openright & Reward \\\\\n", + " Tiger: left & +10 \\\\\n", + " Tiger: right & -100 \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + " \n", + "$\\\\\n", + " R(2) = \n", + " \\left[ {\\begin{array}{cc}\n", + " Listen & Reward \\\\\n", + " Tiger: left & -1 \\\\\n", + " Tiger: right & -1 \\\\\n", + " \\end{array}}\\right] \\\\\n", + " \\\\\n", + " $\n", + " \n", + "
\n", + "Based on these matrices, we will initialize our variables." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's first define our transition state." + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.658091Z", + "iopub.status.busy": "2026-06-27T00:34:48.657780Z", + "iopub.status.idle": "2026-06-27T00:34:48.662257Z", + "shell.execute_reply": "2026-06-27T00:34:48.661179Z" + } + }, + "outputs": [], + "source": [ + "t_prob = [[[0.5, 0.5], \n", + " [0.5, 0.5]], \n", + " \n", + " [[0.5, 0.5], \n", + " [0.5, 0.5]], \n", + " \n", + " [[1.0, 0.0], \n", + " [0.0, 1.0]]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Followed by the observation model." + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.667322Z", + "iopub.status.busy": "2026-06-27T00:34:48.666674Z", + "iopub.status.idle": "2026-06-27T00:34:48.671624Z", + "shell.execute_reply": "2026-06-27T00:34:48.670467Z" + } + }, + "outputs": [], + "source": [ + "e_prob = [[[0.5, 0.5], \n", + " [0.5, 0.5]], \n", + " \n", + " [[0.5, 0.5], \n", + " [0.5, 0.5]], \n", + " \n", + " [[0.85, 0.15], \n", + " [0.15, 0.85]]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And the reward model." + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.674960Z", + "iopub.status.busy": "2026-06-27T00:34:48.674706Z", + "iopub.status.idle": "2026-06-27T00:34:48.678669Z", + "shell.execute_reply": "2026-06-27T00:34:48.677532Z" + } + }, + "outputs": [], + "source": [ + "rewards = [[-100, 10], \n", + " [10, -100], \n", + " [-1, -1]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's now define our states, observations and actions.\n", + "
\n", + "We will use `gamma` = 0.95 for this example.\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.684026Z", + "iopub.status.busy": "2026-06-27T00:34:48.683676Z", + "iopub.status.idle": "2026-06-27T00:34:48.688197Z", + "shell.execute_reply": "2026-06-27T00:34:48.686967Z" + } + }, + "outputs": [], + "source": [ + "# 0: open-left, 1: open-right, 2: listen\n", + "actions = ('0', '1', '2')\n", + "# 0: left, 1: right\n", + "states = ('0', '1')\n", + "\n", + "gamma = 0.95" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We have all the required variables to instantiate an object of the `POMDP` class." + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.691879Z", + "iopub.status.busy": "2026-06-27T00:34:48.691581Z", + "iopub.status.idle": "2026-06-27T00:34:48.696695Z", + "shell.execute_reply": "2026-06-27T00:34:48.694441Z" + } + }, + "outputs": [], + "source": [ + "pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can now find the utility function by running `pomdp_value_iteration` on our `pomdp` object." + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:34:48.700711Z", + "iopub.status.busy": "2026-06-27T00:34:48.700364Z", + "iopub.status.idle": "2026-06-27T00:35:07.379148Z", + "shell.execute_reply": "2026-06-27T00:35:07.378290Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "defaultdict(list,\n", + " {'1': [array([ 26.94830804, -83.05169196])],\n", + " '2': [array([23.55049363, -0.76359097]),\n", + " array([23.55049363, -0.76359097]),\n", + " array([23.55049363, -0.76359097]),\n", + " array([23.55049363, -0.76359097]),\n", + " array([23.24120177, 1.56028929]),\n", + " array([23.24120177, 1.56028929]),\n", + " array([23.24120177, 1.56028929]),\n", + " array([20.0874279 , 15.03900771]),\n", + " array([20.0874279 , 15.03900771]),\n", + " array([20.0874279 , 15.03900771]),\n", + " array([20.0874279 , 15.03900771]),\n", + " array([17.91696135, 17.91696135]),\n", + " array([17.91696135, 17.91696135]),\n", + " array([17.91696135, 17.91696135]),\n", + " array([17.91696135, 17.91696135]),\n", + " array([17.91696135, 17.91696135]),\n", + " array([15.03900771, 20.0874279 ]),\n", + " array([15.03900771, 20.0874279 ]),\n", + " array([15.03900771, 20.0874279 ]),\n", + " array([15.03900771, 20.0874279 ]),\n", + " array([ 1.56028929, 23.24120177]),\n", + " array([ 1.56028929, 23.24120177]),\n", + " array([ 1.56028929, 23.24120177]),\n", + " array([-0.76359097, 23.55049363]),\n", + " array([-0.76359097, 23.55049363]),\n", + " array([-0.76359097, 23.55049363]),\n", + " array([-0.76359097, 23.55049363])],\n", + " '0': [array([-83.05169196, 26.94830804])]})" + ] + }, + "execution_count": 45, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "utility = pomdp_value_iteration(pomdp, epsilon=3)\n", + "utility" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:35:07.382198Z", + "iopub.status.busy": "2026-06-27T00:35:07.381960Z", + "iopub.status.idle": "2026-06-27T00:35:07.407355Z", + "shell.execute_reply": "2026-06-27T00:35:07.406428Z" + } + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "%matplotlib inline\n", + "\n", + "def plot_utility(utility):\n", + " open_left = utility['0'][0]\n", + " open_right = utility['1'][0]\n", + " listen_left = utility['2'][0]\n", + " listen_right = utility['2'][-1]\n", + " left = (open_left[0] - listen_left[0]) / (open_left[0] - listen_left[0] + listen_left[1] - open_left[1])\n", + " right = (open_right[0] - listen_right[0]) / (open_right[0] - listen_right[0] + listen_right[1] - open_right[1])\n", + " \n", + " colors = ['g', 'b', 'k']\n", + " for action in utility:\n", + " for value in utility[action]:\n", + " plt.plot(value, color=colors[int(action)])\n", + " plt.vlines([left, right], -10, 35, linestyles='dashed', colors='c')\n", + " plt.ylim(-10, 35)\n", + " plt.xlim(0, 1)\n", + " plt.text(left/2 - 0.35, 30, 'open-left')\n", + " plt.text((right + left)/2 - 0.04, 30, 'listen')\n", + " plt.text((right + 1)/2 + 0.22, 30, 'open-right')\n", + " plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 47, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T00:35:07.410446Z", + "iopub.status.busy": "2026-06-27T00:35:07.410217Z", + "iopub.status.idle": "2026-06-27T00:35:07.595625Z", + "shell.execute_reply": "2026-06-27T00:35:07.594233Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_utility(utility)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Hence, we get a piecewise-continuous utility function consistent with the given POMDP." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/mdp_apps.py b/notebooks/mdp_apps.py new file mode 100644 index 000000000..b50f76b0b --- /dev/null +++ b/notebooks/mdp_apps.py @@ -0,0 +1,1118 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # APPLICATIONS OF MARKOV DECISION PROCESSES +# --- +# In this notebook we will take a look at some indicative applications of markov decision processes. +# We will cover content from [`mdp.py`](https://github.com/aimacode/aima-python/blob/master/mdp.py), for **Chapter 17 Making Complex Decisions** of Stuart Russel's and Peter Norvig's book [*Artificial Intelligence: A Modern Approach*](http://aima.cs.berkeley.edu/). +# + +# %% +from aima.mdp import * +from aima.notebook_utils import psource, pseudocode + +# %% [markdown] +# ## CONTENTS +# - Simple MDP +# - State dependent reward function +# - State and action dependent reward function +# - State, action and next state dependent reward function +# - Grid MDP +# - Pathfinding problem +# - POMDP +# - Two state POMDP + +# %% [markdown] +# ## SIMPLE MDP +# --- +# ### State dependent reward function +# +# Markov Decision Processes are formally described as processes that follow the Markov property which states that "The future is independent of the past given the present". +# MDPs formally describe environments for reinforcement learning and we assume that the environment is *fully observable*. +# Let us take a toy example MDP and solve it using the functions in `mdp.py`. +# This is a simple example adapted from a [similar problem](http://www0.cs.ucl.ac.uk/staff/D.Silver/web/Teaching_files/MDP.pdf) by Dr. David Silver, tweaked to fit the limitations of the current functions. +# ![title](images/mdp-b.png) +# +# Let's say you're a student attending lectures in a university. +# There are three lectures you need to attend on a given day. +#
+# Attending the first lecture gives you 4 points of reward. +# After the first lecture, you have a 0.6 probability to continue into the second one, yielding 6 more points of reward. +#
+# But, with a probability of 0.4, you get distracted and start using Facebook instead and get a reward of -1. +# From then onwards, you really can't let go of Facebook and there's just a 0.1 probability that you will concentrate back on the lecture. +#
+# After the second lecture, you have an equal chance of attending the next lecture or just falling asleep. +# Falling asleep is the terminal state and yields you no reward, but continuing on to the final lecture gives you a big reward of 10 points. +#
+# From there on, you have a 40% chance of going to study and reach the terminal state, +# but a 60% chance of going to the pub with your friends instead. +# You end up drunk and don't know which lecture to attend, so you go to one of the lectures according to the probabilities given above. +#
+# We now have an outline of our stochastic environment and we need to maximize our reward by solving this MDP. +#
+#
+# We first have to define our Transition Matrix as a nested dictionary to fit the requirements of the MDP class. + +# %% +t = { + 'leisure': { + 'facebook': {'leisure':0.9, 'class1':0.1}, + 'quit': {'leisure':0.1, 'class1':0.9}, + 'study': {}, + 'sleep': {}, + 'pub': {} + }, + 'class1': { + 'study': {'class2':0.6, 'leisure':0.4}, + 'facebook': {'class2':0.4, 'leisure':0.6}, + 'quit': {}, + 'sleep': {}, + 'pub': {} + }, + 'class2': { + 'study': {'class3':0.5, 'end':0.5}, + 'sleep': {'end':0.5, 'class3':0.5}, + 'facebook': {}, + 'quit': {}, + 'pub': {}, + }, + 'class3': { + 'study': {'end':0.6, 'class1':0.08, 'class2':0.16, 'class3':0.16}, + 'pub': {'end':0.4, 'class1':0.12, 'class2':0.24, 'class3':0.24}, + 'facebook': {}, + 'quit': {}, + 'sleep': {} + }, + 'end': {} +} + +# %% [markdown] +# We now need to define the reward for each state. + +# %% +rewards = { + 'class1': 4, + 'class2': 6, + 'class3': 10, + 'leisure': -1, + 'end': 0 +} + +# %% [markdown] +# This MDP has only one terminal state. + +# %% +terminals = ['end'] + +# %% [markdown] +# Let's now set the initial state to Class 1. + +# %% +init = 'class1' + + +# %% [markdown] +# We will write a CustomMDP class to extend the MDP class for the problem at hand. +# This class will implement the `T` method to implement the transition model. This is the exact same class as given in [`mdp.ipynb`](https://github.com/aimacode/aima-python/blob/master/mdp.ipynb#MDP). + +# %% +class CustomMDP(MDP): + + def __init__(self, transition_matrix, rewards, terminals, init, gamma=.9): + # All possible actions. + actlist = [] + for state in transition_matrix.keys(): + actlist.extend(transition_matrix[state]) + actlist = list(set(actlist)) + print(actlist) + + MDP.__init__(self, init, actlist, terminals=terminals, + states=set(transition_matrix.keys()), gamma=gamma) + self.t = transition_matrix + self.reward = rewards + for state in self.t: + self.states.add(state) + + def T(self, state, action): + if action is None: + return [(0.0, state)] + else: + return [(prob, new_state) for new_state, prob in self.t[state][action].items()] + + +# %% [markdown] +# We now need an instance of this class. + +# %% +mdp = CustomMDP(t, rewards, terminals, init, gamma=.9) + +# %% [markdown] +# The utility of each state can be found by `value_iteration`. + +# %% +value_iteration(mdp) + +# %% [markdown] +# Now that we can compute the utility values, we can find the best policy. + +# %% +pi = best_policy(mdp, value_iteration(mdp, .01)) + +# %% [markdown] +# `pi` stores the best action for each state. + +# %% +print(pi) + +# %% [markdown] +# We can confirm that this is the best policy by verifying this result against `policy_iteration`. + +# %% +policy_iteration(mdp) + + +# %% [markdown] +# Everything looks perfect, but let us look at another possibility for an MDP. +#
+# Till now we have only dealt with rewards that the agent gets while it is **on** a particular state. +# What if we want to have different rewards for a state depending on the action that the agent takes next. +# The agent gets the reward _during its transition_ to the next state. +#
+# For the sake of clarity, we will call this the _transition reward_ and we will call this kind of MDP a _dynamic_ MDP. +# This is not a conventional term, we just use it to minimize confusion between the two. +#
+# This next section deals with how to create and solve a dynamic MDP. + +# %% [markdown] +# ### State and action dependent reward function +# Let us consider a very similar problem, but this time, we do not have rewards _on_ states, +# instead, we have rewards on the transitions between states. +# This state diagram will make it clearer. +# ![title](images/mdp-c.png) +# +# A very similar scenario as the previous problem, but we have different rewards for the same state depending on the action taken. +#
+# To deal with this, we just need to change the `R` method of the `MDP` class, but to prevent confusion, we will write a new similar class `DMDP`. + +# %% +class DMDP: + + """A Markov Decision Process, defined by an initial state, transition model, + and reward model. We also keep track of a gamma value, for use by + algorithms. The transition model is represented somewhat differently from + the text. Instead of P(s' | s, a) being a probability number for each + state/state/action triplet, we instead have T(s, a) return a + list of (p, s') pairs. The reward function is very similar. + We also keep track of the possible states, + terminal states, and actions for each state.""" + + def __init__(self, init, actlist, terminals, transitions={}, rewards={}, states=None, gamma=.9): + if not (0 < gamma <= 1): + raise ValueError("An MDP must have 0 < gamma <= 1") + + if states: + self.states = states + else: + self.states = set() + self.init = init + self.actlist = actlist + self.terminals = terminals + self.transitions = transitions + self.rewards = rewards + self.gamma = gamma + + def R(self, state, action): + """Return a numeric reward for this state and this action.""" + if (self.rewards == {}): + raise ValueError('Reward model is missing') + else: + return self.rewards[state][action] + + def T(self, state, action): + """Transition model. From a state and an action, return a list + of (probability, result-state) pairs.""" + if(self.transitions == {}): + raise ValueError("Transition model is missing") + else: + return self.transitions[state][action] + + def actions(self, state): + """Set of actions that can be performed in this state. By default, a + fixed list of actions, except for terminal states. Override this + method if you need to specialize by state.""" + if state in self.terminals: + return [None] + else: + return self.actlist + + +# %% [markdown] +# The transition model will be the same + +# %% +t = { + 'leisure': { + 'facebook': {'leisure':0.9, 'class1':0.1}, + 'quit': {'leisure':0.1, 'class1':0.9}, + 'study': {}, + 'sleep': {}, + 'pub': {} + }, + 'class1': { + 'study': {'class2':0.6, 'leisure':0.4}, + 'facebook': {'class2':0.4, 'leisure':0.6}, + 'quit': {}, + 'sleep': {}, + 'pub': {} + }, + 'class2': { + 'study': {'class3':0.5, 'end':0.5}, + 'sleep': {'end':0.5, 'class3':0.5}, + 'facebook': {}, + 'quit': {}, + 'pub': {}, + }, + 'class3': { + 'study': {'end':0.6, 'class1':0.08, 'class2':0.16, 'class3':0.16}, + 'pub': {'end':0.4, 'class1':0.12, 'class2':0.24, 'class3':0.24}, + 'facebook': {}, + 'quit': {}, + 'sleep': {} + }, + 'end': {} +} + +# %% [markdown] +# The reward model will be a dictionary very similar to the transition dictionary with a reward for every action for every state. + +# %% +r = { + 'leisure': { + 'facebook':-1, + 'quit':0, + 'study':0, + 'sleep':0, + 'pub':0 + }, + 'class1': { + 'study':-2, + 'facebook':-1, + 'quit':0, + 'sleep':0, + 'pub':0 + }, + 'class2': { + 'study':-2, + 'sleep':0, + 'facebook':0, + 'quit':0, + 'pub':0 + }, + 'class3': { + 'study':10, + 'pub':1, + 'facebook':0, + 'quit':0, + 'sleep':0 + }, + 'end': { + 'study':0, + 'pub':0, + 'facebook':0, + 'quit':0, + 'sleep':0 + } +} + +# %% [markdown] +# The MDP has only one terminal state + +# %% +terminals = ['end'] + +# %% [markdown] +# Let's now set the initial state to Class 1. + +# %% +init = 'class1' + + +# %% [markdown] +# We will write a CustomDMDP class to extend the DMDP class for the problem at hand. +# This class will implement everything that the previous CustomMDP class implements along with a new reward model. + +# %% +class CustomDMDP(DMDP): + + def __init__(self, transition_matrix, rewards, terminals, init, gamma=.9): + actlist = [] + for state in transition_matrix.keys(): + actlist.extend(transition_matrix[state]) + actlist = list(set(actlist)) + print(actlist) + + DMDP.__init__(self, init, actlist, terminals=terminals, gamma=gamma) + self.t = transition_matrix + self.rewards = rewards + for state in self.t: + self.states.add(state) + + + def T(self, state, action): + if action is None: + return [(0.0, state)] + else: + return [(prob, new_state) for new_state, prob in self.t[state][action].items()] + + def R(self, state, action): + if action is None: + return 0 + else: + return self.rewards[state][action] + + +# %% [markdown] +# One thing we haven't thought about yet is that the `value_iteration` algorithm won't work now that the reward model is changed. +# It will be quite similar to the one we currently have nonetheless. + +# %% [markdown] +# The Bellman update equation now is defined as follows +# +# $$U(s)=\max_{a\epsilon A(s)}\bigg[R(s, a) + \gamma\sum_{s'}P(s'\ |\ s,a)U(s')\bigg]$$ +# +# It is not difficult to see that the update equation we have been using till now is just a special case of this more generalized equation. +# We also need to max over the reward function now as the reward function is action dependent as well. +#
+# We will use this to write a function to carry out value iteration, very similar to the one we are familiar with. + +# %% +def value_iteration_dmdp(dmdp, epsilon=0.001): + U1 = {s: 0 for s in dmdp.states} + R, T, gamma = dmdp.R, dmdp.T, dmdp.gamma + while True: + U = U1.copy() + delta = 0 + for s in dmdp.states: + U1[s] = max([(R(s, a) + gamma*sum([(p*U[s1]) for (p, s1) in T(s, a)])) for a in dmdp.actions(s)]) + delta = max(delta, abs(U1[s] - U[s])) + if delta < epsilon * (1 - gamma) / gamma: + return U + + +# %% [markdown] +# We're all set. +# Let's instantiate our class. + +# %% +dmdp = CustomDMDP(t, r, terminals, init, gamma=.9) + +# %% [markdown] +# Calculate utility values by calling `value_iteration_dmdp`. + +# %% +value_iteration_dmdp(dmdp) + + +# %% [markdown] +# These are the expected utility values for our new MDP. +#
+# As you might have guessed, we cannot use the old `best_policy` function to find the best policy. +# So we will write our own. +# But, before that we need a helper function to calculate the expected utility value given a state and an action. + +# %% +def expected_utility_dmdp(a, s, U, dmdp): + return dmdp.R(s, a) + dmdp.gamma*sum([(p*U[s1]) for (p, s1) in dmdp.T(s, a)]) + + +# %% [markdown] +# Now we write our modified `best_policy` function. + +# %% +from aima.utils import argmax_random_tie as argmax +def best_policy_dmdp(dmdp, U): + pi = {} + for s in dmdp.states: + pi[s] = argmax(dmdp.actions(s), key=lambda a: expected_utility_dmdp(a, s, U, dmdp)) + return pi + + +# %% [markdown] +# Find the best policy. + +# %% +pi = best_policy_dmdp(dmdp, value_iteration_dmdp(dmdp, .01)) +print(pi) + +# %% [markdown] +# From this, we can infer that `value_iteration_dmdp` tries to minimize the negative reward. +# Since we don't have rewards for states now, the algorithm takes the action that would try to avoid getting negative rewards and take the lesser of two evils if all rewards are negative. +# You might also want to have state rewards alongside transition rewards. +# Perhaps you can do that yourself now that the difficult part has been done. +#
+ +# %% [markdown] +# ### State, action and next-state dependent reward function +# +# For truly stochastic environments, +# we have noticed that taking an action from a particular state doesn't always do what we want it to. +# Instead, for every action taken from a particular state, +# it might be possible to reach a different state each time depending on the transition probabilities. +# What if we want different rewards for each state, action and next-state triplet? +# Mathematically, we now want a reward function of the form R(s, a, s') for our MDP. +# This section shows how we can tweak the MDP class to achieve this. +#
+# +# Let's now take a different problem statement. +# The one we are working with is a bit too simple. +# Consider a taxi that serves three adjacent towns A, B, and C. +# Each time the taxi discharges a passenger, the driver must choose from three possible actions: +# 1. Cruise the streets looking for a passenger. +# 2. Go to the nearest taxi stand. +# 3. Wait for a radio call from the dispatcher with instructions. +#
+# Subject to the constraint that the taxi driver cannot do the third action in town B because of distance and poor reception. +# +# Let's model our MDP. +#
+# The MDP has three states, namely A, B and C. +#
+# It has three actions, namely 1, 2 and 3. +#
+# Action sets: +#
+# $K_{a}$ = {1, 2, 3} +#
+# $K_{b}$ = {1, 2} +#
+# $K_{c}$ = {1, 2, 3} +#
+# +# We have the following transition probability matrices: +#
+#
+# Action 1: Cruising streets +#
+# $\\ +# P^{1} = +# \left[ {\begin{array}{ccc} +# \frac{1}{2} & \frac{1}{4} & \frac{1}{4} \\ +# \frac{1}{2} & 0 & \frac{1}{2} \\ +# \frac{1}{4} & \frac{1}{4} & \frac{1}{2} \\ +# \end{array}}\right] \\ +# \\ +# $ +#
+#
+# Action 2: Waiting at the taxi stand +#
+# $\\ +# P^{2} = +# \left[ {\begin{array}{ccc} +# \frac{1}{16} & \frac{3}{4} & \frac{3}{16} \\ +# \frac{1}{16} & \frac{7}{8} & \frac{1}{16} \\ +# \frac{1}{8} & \frac{3}{4} & \frac{1}{8} \\ +# \end{array}}\right] \\ +# \\ +# $ +#
+#
+# Action 3: Waiting for dispatch +#
+# $\\ +# P^{3} = +# \left[ {\begin{array}{ccc} +# \frac{1}{4} & \frac{1}{8} & \frac{5}{8} \\ +# 0 & 1 & 0 \\ +# \frac{3}{4} & \frac{1}{16} & \frac{3}{16} \\ +# \end{array}}\right] \\ +# \\ +# $ +#
+#
+# For the sake of readability, we will call the states A, B and C and the actions 'cruise', 'stand' and 'dispatch'. +# We will now build the transition model as a dictionary using these matrices. + +# %% +t = { + 'A': { + 'cruise': {'A':0.5, 'B':0.25, 'C':0.25}, + 'stand': {'A':0.0625, 'B':0.75, 'C':0.1875}, + 'dispatch': {'A':0.25, 'B':0.125, 'C':0.625} + }, + 'B': { + 'cruise': {'A':0.5, 'B':0, 'C':0.5}, + 'stand': {'A':0.0625, 'B':0.875, 'C':0.0625}, + 'dispatch': {'A':0, 'B':1, 'C':0} + }, + 'C': { + 'cruise': {'A':0.25, 'B':0.25, 'C':0.5}, + 'stand': {'A':0.125, 'B':0.75, 'C':0.125}, + 'dispatch': {'A':0.75, 'B':0.0625, 'C':0.1875} + } +} + +# %% [markdown] +# The reward matrices for the problem are as follows: +#
+#
+# Action 1: Cruising streets +#
+# $\\ +# R^{1} = +# \left[ {\begin{array}{ccc} +# 10 & 4 & 8 \\ +# 14 & 0 & 18 \\ +# 10 & 2 & 8 \\ +# \end{array}}\right] \\ +# \\ +# $ +#
+#
+# Action 2: Waiting at the taxi stand +#
+# $\\ +# R^{2} = +# \left[ {\begin{array}{ccc} +# 8 & 2 & 4 \\ +# 8 & 16 & 8 \\ +# 6 & 4 & 2\\ +# \end{array}}\right] \\ +# \\ +# $ +#
+#
+# Action 3: Waiting for dispatch +#
+# $\\ +# R^{3} = +# \left[ {\begin{array}{ccc} +# 4 & 6 & 4 \\ +# 0 & 0 & 0 \\ +# 4 & 0 & 8\\ +# \end{array}}\right] \\ +# \\ +# $ +#
+#
+# We now build the reward model as a dictionary using these matrices. + +# %% +r = { + 'A': { + 'cruise': {'A':10, 'B':4, 'C':8}, + 'stand': {'A':8, 'B':2, 'C':4}, + 'dispatch': {'A':4, 'B':6, 'C':4} + }, + 'B': { + 'cruise': {'A':14, 'B':0, 'C':18}, + 'stand': {'A':8, 'B':16, 'C':8}, + 'dispatch': {'A':0, 'B':0, 'C':0} + }, + 'C': { + 'cruise': {'A':10, 'B':2, 'C':18}, + 'stand': {'A':6, 'B':4, 'C':2}, + 'dispatch': {'A':4, 'B':0, 'C':8} + } +} + + +# %% [markdown] +# The Bellman update equation now is defined as follows +# +# $$U(s)=\max_{a\epsilon A(s)}\sum_{s'}P(s'\ |\ s,a)(R(s'\ |\ s,a) + \gamma U(s'))$$ +# +# It is not difficult to see that all the update equations we have used till now is just a special case of this more generalized equation. +# If we did not have next-state-dependent rewards, the first term inside the summation exactly sums up to R(s, a) or the state-reward for a particular action and we would get the update equation used in the previous problem. +# If we did not have action dependent rewards, the first term inside the summation sums up to R(s) or the state-reward and we would get the first update equation used in `mdp.ipynb`. +#
+# For example, as we have the same reward regardless of the action, let's consider a reward of **r** units for a particular state and let's assume the transition probabilities to be 0.1, 0.2, 0.3 and 0.4 for 4 possible actions for that state. +# We will further assume that a particular action in a state leads to the same state every time we take that action. +# The first term inside the summation for this case will evaluate to (0.1 + 0.2 + 0.3 + 0.4)r = r which is equal to R(s) in the first update equation. +#
+# There are many ways to write value iteration for this situation, but we will go with the most intuitive method. +# One that can be implemented with minor alterations to the existing `value_iteration` algorithm. +#
+# Our `DMDP` class will be slightly different. +# More specifically, the `R` method will have one more index to go through now that we have three levels of nesting in the reward model. +# We will call the new class `DMDP2` as I have run out of creative names. + +# %% +class DMDP2: + + """A Markov Decision Process, defined by an initial state, transition model, + and reward model. We also keep track of a gamma value, for use by + algorithms. The transition model is represented somewhat differently from + the text. Instead of P(s' | s, a) being a probability number for each + state/state/action triplet, we instead have T(s, a) return a + list of (p, s') pairs. The reward function is very similar. + We also keep track of the possible states, + terminal states, and actions for each state.""" + + def __init__(self, init, actlist, terminals, transitions={}, rewards={}, states=None, gamma=.9): + if not (0 < gamma <= 1): + raise ValueError("An MDP must have 0 < gamma <= 1") + + if states: + self.states = states + else: + self.states = set() + self.init = init + self.actlist = actlist + self.terminals = terminals + self.transitions = transitions + self.rewards = rewards + self.gamma = gamma + + def R(self, state, action, state_): + """Return a numeric reward for this state, this action and the next state_""" + if (self.rewards == {}): + raise ValueError('Reward model is missing') + else: + return self.rewards[state][action][state_] + + def T(self, state, action): + """Transition model. From a state and an action, return a list + of (probability, result-state) pairs.""" + if(self.transitions == {}): + raise ValueError("Transition model is missing") + else: + return self.transitions[state][action] + + def actions(self, state): + """Set of actions that can be performed in this state. By default, a + fixed list of actions, except for terminal states. Override this + method if you need to specialize by state.""" + if state in self.terminals: + return [None] + else: + return self.actlist + + def actions(self, state): + """Set of actions that can be performed in this state. By default, a + fixed list of actions, except for terminal states. Override this + method if you need to specialize by state.""" + if state in self.terminals: + return [None] + else: + return self.actlist + + +# %% [markdown] +# Only the `R` method is different from the previous `DMDP` class. +#
+# Our traditional custom class will be required to implement the transition model and the reward model. +#
+# We call this class `CustomDMDP2`. + +# %% +class CustomDMDP2(DMDP2): + + def __init__(self, transition_matrix, rewards, terminals, init, gamma=.9): + actlist = [] + for state in transition_matrix.keys(): + actlist.extend(transition_matrix[state]) + actlist = list(set(actlist)) + print(actlist) + + DMDP2.__init__(self, init, actlist, terminals=terminals, gamma=gamma) + self.t = transition_matrix + self.rewards = rewards + for state in self.t: + self.states.add(state) + + def T(self, state, action): + if action is None: + return [(0.0, state)] + else: + return [(prob, new_state) for new_state, prob in self.t[state][action].items()] + + def R(self, state, action, state_): + if action is None: + return 0 + else: + return self.rewards[state][action][state_] + + +# %% [markdown] +# We can finally write value iteration for this problem. +# The latest update equation will be used. + +# %% +def value_iteration_taxi_mdp(dmdp2, epsilon=0.001): + U1 = {s: 0 for s in dmdp2.states} + R, T, gamma = dmdp2.R, dmdp2.T, dmdp2.gamma + while True: + U = U1.copy() + delta = 0 + for s in dmdp2.states: + U1[s] = max([sum([(p*(R(s, a, s1) + gamma*U[s1])) for (p, s1) in T(s, a)]) for a in dmdp2.actions(s)]) + delta = max(delta, abs(U1[s] - U[s])) + if delta < epsilon * (1 - gamma) / gamma: + return U + + +# %% [markdown] +# These algorithms can be made more pythonic by using cleverer list comprehensions. +# We can also write the variants of value iteration in such a way that all problems are solved using the same base class, regardless of the reward function and the number of arguments it takes. +# Quite a few things can be done to refactor the code and reduce repetition, but we have done it this way for the sake of clarity. +# Perhaps you can try this as an exercise. +#
+# We now need to define terminals and initial state. + +# %% +terminals = ['end'] +init = 'A' + +# %% [markdown] +# Let's instantiate our class. + +# %% +dmdp2 = CustomDMDP2(t, r, terminals, init, gamma=.9) + +# %% +value_iteration_taxi_mdp(dmdp2) + + +# %% [markdown] +# These are the expected utility values for the states of our MDP. +# Let's proceed to write a helper function to find the expected utility and another to find the best policy. + +# %% +def expected_utility_dmdp2(a, s, U, dmdp2): + return sum([(p*(dmdp2.R(s, a, s1) + dmdp2.gamma*U[s1])) for (p, s1) in dmdp2.T(s, a)]) + + +# %% +from aima.utils import argmax_random_tie as argmax +def best_policy_dmdp2(dmdp2, U): + pi = {} + for s in dmdp2.states: + pi[s] = argmax(dmdp2.actions(s), key=lambda a: expected_utility_dmdp2(a, s, U, dmdp2)) + return pi + + +# %% [markdown] +# Find the best policy. + +# %% +pi = best_policy_dmdp2(dmdp2, value_iteration_taxi_mdp(dmdp2, .01)) +print(pi) + +# %% [markdown] +# We have successfully adapted the existing code to a different scenario yet again. +# The takeaway from this section is that you can convert the vast majority of reinforcement learning problems into MDPs and solve for the best policy using simple yet efficient tools. + +# %% [markdown] +# ## GRID MDP +# --- +# ### Pathfinding Problem +# Markov Decision Processes can be used to find the best path through a maze. Let us consider this simple maze. +# ![title](images/maze.png) +# +# This environment can be formulated as a GridMDP. +#
+# To make the grid matrix, we will consider the state-reward to be -0.1 for every state. +#
+# State (1, 1) will have a reward of -5 to signify that this state is to be prohibited. +#
+# State (9, 9) will have a reward of +5. +# This will be the terminal state. +#
+# The matrix can be generated using the GridMDP editor or we can write it ourselves. + +# %% +grid = [ + [None, None, None, None, None, None, None, None, None, None, None], + [None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None, +5.0, None], + [None, -0.1, None, None, None, None, None, None, None, -0.1, None], + [None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None], + [None, -0.1, None, None, None, None, None, None, None, None, None], + [None, -0.1, None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None], + [None, -0.1, None, None, None, None, None, -0.1, None, -0.1, None], + [None, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, None, -0.1, None], + [None, None, None, None, None, -0.1, None, -0.1, None, -0.1, None], + [None, -5.0, -0.1, -0.1, -0.1, -0.1, None, -0.1, None, -0.1, None], + [None, None, None, None, None, None, None, None, None, None, None] +] + +# %% [markdown] +# We have only one terminal state, (9, 9) + +# %% +terminals = [(9, 9)] + +# %% [markdown] +# We define our maze environment below + +# %% +maze = GridMDP(grid, terminals) + +# %% [markdown] +# To solve the maze, we can use the `best_policy` function along with `value_iteration`. + +# %% +pi = best_policy(maze, value_iteration(maze)) + +# %% [markdown] +# This is the heatmap generated by the GridMDP editor using `value_iteration` on this environment +#
+# ![title](images/mdp-d.png) +#
+# Let's print out the best policy + +# %% +from aima.utils import print_table +print_table(maze.to_arrows(pi)) + +# %% [markdown] +# As you can infer, we can find the path to the terminal state starting from any given state using this policy. +# All maze problems can be solved by formulating it as a MDP. + +# %% [markdown] +# ## POMDP +# ### Two state POMDP +# Let's consider a problem where we have two doors, one to our left and one to our right. +# One of these doors opens to a room with a tiger in it, and the other one opens to an empty hall. +#
+# We will call our two states `0` and `1` for `left` and `right` respectively. +#
+# The possible actions we can take are as follows: +#
+# 1. __Open-left__: Open the left door. +# Represented by `0`. +# 2. __Open-right__: Open the right door. +# Represented by `1`. +# 3. __Listen__: Listen carefully to one side and possibly hear the tiger breathing. +# Represented by `2`. +# +#
+# The possible observations we can get are as follows: +#
+# 1. __TL__: Tiger seems to be at the left door. +# 2. __TR__: Tiger seems to be at the right door. +# +#
+# The reward function is as follows: +#
+# We get +10 reward for opening the door to the empty hall and we get -100 reward for opening the other door and setting the tiger free. +#
+# Listening costs us -1 reward. +#
+# We want to minimize our chances of setting the tiger free. +# + +# %% [markdown] +# Our transition probabilities can be defined as: +#
+#
+# Action `0` (Open left door) +# $\\ +# P(0) = +# \left[ {\begin{array}{cc} +# 0.5 & 0.5 \\ +# 0.5 & 0.5 \\ +# \end{array}}\right] \\ +# \\ +# $ +# +# Action `1` (Open right door) +# $\\ +# P(1) = +# \left[ {\begin{array}{cc} +# 0.5 & 0.5 \\ +# 0.5 & 0.5 \\ +# \end{array}}\right] \\ +# \\ +# $ +# +# Action `2` (Listen) +# $\\ +# P(2) = +# \left[ {\begin{array}{cc} +# 1.0 & 0.0 \\ +# 0.0 & 1.0 \\ +# \end{array}}\right] \\ +# \\ +# $ +# +#
+#
+# Our observation probabilities can be defined as: +#
+#
+# $\\ +# O(0) = +# \left[ {\begin{array}{ccc} +# Open left & TL & TR \\ +# Tiger: left & 0.5 & 0.5 \\ +# Tiger: right & 0.5 & 0.5 \\ +# \end{array}}\right] \\ +# \\ +# $ +# +# $\\ +# O(1) = +# \left[ {\begin{array}{ccc} +# Open right & TL & TR \\ +# Tiger: left & 0.5 & 0.5 \\ +# Tiger: right & 0.5 & 0.5 \\ +# \end{array}}\right] \\ +# \\ +# $ +# +# $\\ +# O(2) = +# \left[ {\begin{array}{ccc} +# Listen & TL & TR \\ +# Tiger: left & 0.85 & 0.15 \\ +# Tiger: right & 0.15 & 0.85 \\ +# \end{array}}\right] \\ +# \\ +# $ +# +#
+#
+# The rewards of this POMDP are defined as: +#
+#
+# $\\ +# R(0) = +# \left[ {\begin{array}{cc} +# Openleft & Reward \\ +# Tiger: left & -100 \\ +# Tiger: right & +10 \\ +# \end{array}}\right] \\ +# \\ +# $ +# +# $\\ +# R(1) = +# \left[ {\begin{array}{cc} +# Openright & Reward \\ +# Tiger: left & +10 \\ +# Tiger: right & -100 \\ +# \end{array}}\right] \\ +# \\ +# $ +# +# $\\ +# R(2) = +# \left[ {\begin{array}{cc} +# Listen & Reward \\ +# Tiger: left & -1 \\ +# Tiger: right & -1 \\ +# \end{array}}\right] \\ +# \\ +# $ +# +#
+# Based on these matrices, we will initialize our variables. + +# %% [markdown] +# Let's first define our transition state. + +# %% +t_prob = [[[0.5, 0.5], + [0.5, 0.5]], + + [[0.5, 0.5], + [0.5, 0.5]], + + [[1.0, 0.0], + [0.0, 1.0]]] + +# %% [markdown] +# Followed by the observation model. + +# %% +e_prob = [[[0.5, 0.5], + [0.5, 0.5]], + + [[0.5, 0.5], + [0.5, 0.5]], + + [[0.85, 0.15], + [0.15, 0.85]]] + +# %% [markdown] +# And the reward model. + +# %% +rewards = [[-100, 10], + [10, -100], + [-1, -1]] + +# %% [markdown] +# Let's now define our states, observations and actions. +#
+# We will use `gamma` = 0.95 for this example. +#
+ +# %% +# 0: open-left, 1: open-right, 2: listen +actions = ('0', '1', '2') +# 0: left, 1: right +states = ('0', '1') + +gamma = 0.95 + +# %% [markdown] +# We have all the required variables to instantiate an object of the `POMDP` class. + +# %% +pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma) + +# %% [markdown] +# We can now find the utility function by running `pomdp_value_iteration` on our `pomdp` object. + +# %% +utility = pomdp_value_iteration(pomdp, epsilon=3) +utility + +# %% +import matplotlib.pyplot as plt +# %matplotlib inline + +def plot_utility(utility): + open_left = utility['0'][0] + open_right = utility['1'][0] + listen_left = utility['2'][0] + listen_right = utility['2'][-1] + left = (open_left[0] - listen_left[0]) / (open_left[0] - listen_left[0] + listen_left[1] - open_left[1]) + right = (open_right[0] - listen_right[0]) / (open_right[0] - listen_right[0] + listen_right[1] - open_right[1]) + + colors = ['g', 'b', 'k'] + for action in utility: + for value in utility[action]: + plt.plot(value, color=colors[int(action)]) + plt.vlines([left, right], -10, 35, linestyles='dashed', colors='c') + plt.ylim(-10, 35) + plt.xlim(0, 1) + plt.text(left/2 - 0.35, 30, 'open-left') + plt.text((right + left)/2 - 0.04, 30, 'listen') + plt.text((right + 1)/2 + 0.22, 30, 'open-right') + plt.show() + + +# %% +plot_utility(utility) + +# %% [markdown] +# Hence, we get a piecewise-continuous utility function consistent with the given POMDP. diff --git a/neural_nets.ipynb b/notebooks/neural_nets.ipynb similarity index 99% rename from neural_nets.ipynb rename to notebooks/neural_nets.ipynb index 1291da547..67be5ca7b 100644 --- a/neural_nets.ipynb +++ b/notebooks/neural_nets.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -17,9 +26,9 @@ "metadata": {}, "outputs": [], "source": [ - "from learning import *\n", + "from aima.learning import *\n", "\n", - "from notebook import psource, pseudocode" + "from aima.notebook_utils import psource, pseudocode" ] }, { @@ -567,4 +576,4 @@ }, "nbformat": 4, "nbformat_minor": 2 -} \ No newline at end of file +} diff --git a/notebooks/neural_nets.py b/notebooks/neural_nets.py new file mode 100644 index 000000000..accd5b27e --- /dev/null +++ b/notebooks/neural_nets.py @@ -0,0 +1,113 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # NEURAL NETWORKS +# +# This notebook covers the neural network algorithms from chapter 18 of the book *Artificial Intelligence: A Modern Approach*, by Stuart Russel and Peter Norvig. The code in the notebook can be found in [learning.py](https://github.com/aimacode/aima-python/blob/master/learning.py). +# +# Execute the below cell to get started: + +# %% +from aima.learning import * + +from aima.notebook_utils import psource, pseudocode + +# %% [markdown] +# ## NEURAL NETWORK ALGORITHM +# +# ### Overview +# +# Although the Perceptron may seem like a good way to make classifications, it is a linear classifier (which, roughly, means it can only draw straight lines to divide spaces) and therefore it can be stumped by more complex problems. To solve this issue we can extend Perceptron by employing multiple layers of its functionality. The construct we are left with is called a Neural Network, or a Multi-Layer Perceptron, and it is a non-linear classifier. It achieves that by combining the results of linear functions on each layer of the network. +# +# Similar to the Perceptron, this network also has an input and output layer; however, it can also have a number of hidden layers. These hidden layers are responsible for the non-linearity of the network. The layers are comprised of nodes. Each node in a layer (excluding the input one), holds some values, called *weights*, and takes as input the output values of the previous layer. The node then calculates the dot product of its inputs and its weights and then activates it with an *activation function* (e.g. sigmoid activation function). Its output is then fed to the nodes of the next layer. Note that sometimes the output layer does not use an activation function, or uses a different one from the rest of the network. The process of passing the outputs down the layer is called *feed-forward*. +# +# After the input values are fed-forward into the network, the resulting output can be used for classification. The problem at hand now is how to train the network (i.e. adjust the weights in the nodes). To accomplish that we utilize the *Backpropagation* algorithm. In short, it does the opposite of what we were doing up to this point. Instead of feeding the input forward, it will track the error backwards. So, after we make a classification, we check whether it is correct or not, and how far off we were. We then take this error and propagate it backwards in the network, adjusting the weights of the nodes accordingly. We will run the algorithm on the given input/dataset for a fixed amount of time, or until we are satisfied with the results. The number of times we will iterate over the dataset is called *epochs*. In a later section we take a detailed look at how this algorithm works. +# +# NOTE: Sometimes we add another node to the input of each layer, called *bias*. This is a constant value that will be fed to the next layer, usually set to 1. The bias generally helps us "shift" the computed function to the left or right. + +# %% [markdown] +# ![neural_net](images/neural_net.png) + +# %% [markdown] +# ### Implementation +# +# The `NeuralNetLearner` function takes as input a dataset to train upon, the learning rate (in (0, 1]), the number of epochs and finally the size of the hidden layers. This last argument is a list, with each element corresponding to one hidden layer. +# +# After that we will create our neural network in the `network` function. This function will make the necessary connections between the input layer, hidden layer and output layer. With the network ready, we will use the `BackPropagationLearner` to train the weights of our network for the examples provided in the dataset. +# +# The NeuralNetLearner returns the `predict` function which, in short, can receive an example and feed-forward it into our network to generate a prediction. +# +# In more detail, the example values are first passed to the input layer and then they are passed through the rest of the layers. Each node calculates the dot product of its inputs and its weights, activates it and pushes it to the next layer. The final prediction is the node in the output layer with the maximum value. + +# %% +psource(NeuralNetLearner) + +# %% [markdown] +# ## BACKPROPAGATION +# +# ### Overview +# +# In both the Perceptron and the Neural Network, we are using the Backpropagation algorithm to train our model by updating the weights. This is achieved by propagating the errors from our last layer (output layer) back to our first layer (input layer), this is why it is called Backpropagation. In order to use Backpropagation, we need a cost function. This function is responsible for indicating how good our neural network is for a given example. One common cost function is the *Mean Squared Error* (MSE). This cost function has the following format: +# +# $$MSE=\frac{1}{n} \sum_{i=1}^{n}(y - \hat{y})^{2}$$ +# +# Where `n` is the number of training examples, $\hat{y}$ is our prediction and $y$ is the correct prediction for the example. +# +# The algorithm combines the concept of partial derivatives and the chain rule to generate the gradient for each weight in the network based on the cost function. +# +# For example, if we are using a Neural Network with three layers, the sigmoid function as our activation function and the MSE cost function, we want to find the gradient for the a given weight $w_{j}$, we can compute it like this: +# +# $$\frac{\partial MSE(\hat{y}, y)}{\partial w_{j}} = \frac{\partial MSE(\hat{y}, y)}{\partial \hat{y}}\times\frac{\partial\hat{y}(in_{j})}{\partial in_{j}}\times\frac{\partial in_{j}}{\partial w_{j}}$$ +# +# Solving this equation, we have: +# +# $$\frac{\partial MSE(\hat{y}, y)}{\partial w_{j}} = (\hat{y} - y)\times{\hat{y}}'(in_{j})\times a_{j}$$ +# +# Remember that $\hat{y}$ is the activation function applied to a neuron in our hidden layer, therefore $$\hat{y} = sigmoid(\sum_{i=1}^{num\_neurons}w_{i}\times a_{i})$$ +# +# Also $a$ is the input generated by feeding the input layer variables into the hidden layer. +# +# We can use the same technique for the weights in the input layer as well. After we have the gradients for both weights, we use gradient descent to update the weights of the network. + +# %% [markdown] +# ### Pseudocode + +# %% +pseudocode('Back-Prop-Learning') + +# %% [markdown] +# ### Implementation +# +# First, we feed-forward the examples in our neural network. After that, we calculate the gradient for each layers' weights by using the chain rule. Once that is complete, we update all the weights using gradient descent. After running these for a given number of epochs, the function returns the trained Neural Network. + +# %% +psource(BackPropagationLearner) + +# %% +iris = DataSet(name="iris") +iris.classes_to_numbers() + +nNL = NeuralNetLearner(iris) +print(nNL([5, 3, 1, 0.1])) + +# %% [markdown] pycharm={"name": "#%% md\n"} +# The output should be 0, which means the item should get classified in the first class, "setosa". Note that since the algorithm is non-deterministic (because of the random initial weights) the classification might be wrong. Usually though, it should be correct. +# +# To increase accuracy, you can (most of the time) add more layers and nodes. Unfortunately, increasing the number of layers or nodes also increases the computation cost and might result in overfitting. +# +# diff --git a/nlp.ipynb b/notebooks/nlp.ipynb similarity index 98% rename from nlp.ipynb rename to notebooks/nlp.ipynb index 9656c1ea0..c517ac28b 100644 --- a/nlp.ipynb +++ b/notebooks/nlp.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -17,12 +26,12 @@ "metadata": {}, "outputs": [], "source": [ - "import nlp\n", - "from nlp import Page, HITS\n", - "from nlp import Lexicon, Rules, Grammar, ProbLexicon, ProbRules, ProbGrammar\n", - "from nlp import CYK_parse, Chart\n", + "from aima import nlp\n", + "from aima.nlp import Page, HITS\n", + "from aima.nlp import Lexicon, Rules, Grammar, ProbLexicon, ProbRules, ProbGrammar\n", + "from aima.nlp import CYK_parse, Chart\n", "\n", - "from notebook import psource" + "from aima.notebook_utils import psource" ] }, { @@ -586,7 +595,7 @@ "\n", "A quick overview of the helper functions functions we use:\n", "\n", - "* `relevant_pages`: Returns relevant pages from `pagesIndex` given a query.\n", + "* `relevant_pages`: Returns relevant pages from `pages_index` given a query.\n", "\n", "* `expand_pages`: Adds to the collection pages linked to and from the given `pages`.\n", "\n", @@ -607,7 +616,7 @@ "\n", "Before we begin we need to define a list of sample pages to work on. The pages are `pA`, `pB` and so on and their text is given by `testHTML` and `testHTML2`. The `Page` class takes as arguments the in-links and out-links as lists. For page \"A\", the in-links are \"B\", \"C\" and \"E\" while the sole out-link is \"D\".\n", "\n", - "We also need to set the `nlp` global variables `pageDict`, `pagesIndex` and `pagesContent`." + "We also need to set the `nlp` global variables `pageDict`, `pages_index` and `pages_content`." ] }, { @@ -632,9 +641,9 @@ "nlp.pageDict = {pA.address: pA, pB.address: pB, pC.address: pC,\n", " pD.address: pD, pE.address: pE, pF.address: pF}\n", "\n", - "nlp.pagesIndex = nlp.pageDict\n", + "nlp.pages_index = nlp.pageDict\n", "\n", - "nlp.pagesContent ={pA.address: testHTML, pB.address: testHTML2,\n", + "nlp.pages_content ={pA.address: testHTML, pB.address: testHTML2,\n", " pC.address: testHTML, pD.address: testHTML2,\n", " pE.address: testHTML, pF.address: testHTML2}" ] diff --git a/notebooks/nlp.py b/notebooks/nlp.py new file mode 100644 index 000000000..41cb344a5 --- /dev/null +++ b/notebooks/nlp.py @@ -0,0 +1,568 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # NATURAL LANGUAGE PROCESSING +# +# This notebook covers chapters 22 and 23 from the book *Artificial Intelligence: A Modern Approach*, 3rd Edition. The implementations of the algorithms can be found in [nlp.py](https://github.com/aimacode/aima-python/blob/master/nlp.py). +# +# Run the below cell to import the code from the module and get started! + +# %% +from aima import nlp +from aima.nlp import Page, HITS +from aima.nlp import Lexicon, Rules, Grammar, ProbLexicon, ProbRules, ProbGrammar +from aima.nlp import CYK_parse, Chart + +from aima.notebook_utils import psource + +# %% [markdown] +# ## CONTENTS +# +# * Overview +# * Languages +# * HITS +# * Question Answering +# * CYK Parse +# * Chart Parsing + +# %% [markdown] +# ## OVERVIEW +# +# **Natural Language Processing (NLP)** is a field of AI concerned with understanding, analyzing and using natural languages. This field is considered a difficult yet intriguing field of study, since it is connected to how humans and their languages work. +# +# Applications of the field include translation, speech recognition, topic segmentation, information extraction and retrieval, and a lot more. +# +# Below we take a look at some algorithms in the field. Before we get right into it though, we will take a look at a very useful form of language, **context-free** languages. Even though they are a bit restrictive, they have been used a lot in research in natural language processing. + +# %% [markdown] +# ## LANGUAGES +# +# Languages can be represented by a set of grammar rules over a lexicon of words. Different languages can be represented by different types of grammar, but in Natural Language Processing we are mainly interested in context-free grammars. +# +# ### Context-Free Grammars +# +# A lot of natural and programming languages can be represented by a **Context-Free Grammar (CFG)**. A CFG is a grammar that has a single non-terminal symbol on the left-hand side. That means a non-terminal can be replaced by the right-hand side of the rule regardless of context. An example of a CFG: +# +# ``` +# S -> aSb | ε +# ``` +# +# That means `S` can be replaced by either `aSb` or `ε` (with `ε` we denote the empty string). The lexicon of the language is comprised of the terminals `a` and `b`, while with `S` we denote the non-terminal symbol. In general, non-terminals are capitalized while terminals are not, and we usually name the starting non-terminal `S`. The language generated by the above grammar is the language anbn for n greater or equal than 1. + +# %% [markdown] +# ### Probabilistic Context-Free Grammar +# +# While a simple CFG can be very useful, we might want to know the chance of each rule occurring. Above, we do not know if `S` is more likely to be replaced by `aSb` or `ε`. **Probabilistic Context-Free Grammars (PCFG)** are built to fill exactly that need. Each rule has a probability, given in brackets, and the probabilities of a rule sum up to 1: +# +# ``` +# S -> aSb [0.7] | ε [0.3] +# ``` +# +# Now we know it is more likely for `S` to be replaced by `aSb` than by `ε`. +# +# An issue with *PCFGs* is how we will assign the various probabilities to the rules. We could use our knowledge as humans to assign the probabilities, but that is a laborious and prone to error task. Instead, we can *learn* the probabilities from data. Data is categorized as labeled (with correctly parsed sentences, usually called a **treebank**) or unlabeled (given only lexical and syntactic category names). +# +# With labeled data, we can simply count the occurrences. For the above grammar, if we have 100 `S` rules and 30 of them are of the form `S -> ε`, we assign a probability of 0.3 to the transformation. +# +# With unlabeled data we have to learn both the grammar rules and the probability of each rule. We can go with many approaches, one of them the **inside-outside** algorithm. It uses a dynamic programming approach, that first finds the probability of a substring being generated by each rule, and then estimates the probability of each rule. + +# %% [markdown] +# ### Chomsky Normal Form +# +# A grammar is in Chomsky Normal Form (or **CNF**, not to be confused with *Conjunctive Normal Form*) if its rules are one of the three: +# +# * `X -> Y Z` +# * `A -> a` +# * `S -> ε` +# +# Where *X*, *Y*, *Z*, *A* are non-terminals, *a* is a terminal, *ε* is the empty string and *S* is the start symbol (the start symbol should not be appearing on the right hand side of rules). Note that there can be multiple rules for each left hand side non-terminal, as long they follow the above. For example, a rule for *X* might be: `X -> Y Z | A B | a | b`. +# +# Of course, we can also have a *CNF* with probabilities. +# +# This type of grammar may seem restrictive, but it can be proven that any context-free grammar can be converted to CNF. + +# %% [markdown] +# ### Lexicon +# +# The lexicon of a language is defined as a list of allowable words. These words are grouped into the usual classes: `verbs`, `nouns`, `adjectives`, `adverbs`, `pronouns`, `names`, `articles`, `prepositions` and `conjunctions`. For the first five classes it is impossible to list all words, since words are continuously being added in the classes. Recently "google" was added to the list of verbs, and words like that will continue to pop up and get added to the lists. For that reason, these first five categories are called **open classes**. The rest of the categories have much fewer words and much less development. While words like "thou" were commonly used in the past but have declined almost completely in usage, most changes take many decades or centuries to manifest, so we can safely assume the categories will remain static for the foreseeable future. Thus, these categories are called **closed classes**. +# +# An example lexicon for a PCFG (note that other classes can also be used according to the language, like `digits`, or `RelPro` for relative pronoun): +# +# ``` +# Verb -> is [0.3] | say [0.1] | are [0.1] | ... +# Noun -> robot [0.1] | sheep [0.05] | fence [0.05] | ... +# Adjective -> good [0.1] | new [0.1] | sad [0.05] | ... +# Adverb -> here [0.1] | lightly [0.05] | now [0.05] | ... +# Pronoun -> me [0.1] | you [0.1] | he [0.05] | ... +# RelPro -> that [0.4] | who [0.2] | which [0.2] | ... +# Name -> john [0.05] | mary [0.05] | peter [0.01] | ... +# Article -> the [0.35] | a [0.25] | an [0.025] | ... +# Preposition -> to [0.25] | in [0.2] | at [0.1] | ... +# Conjunction -> and [0.5] | or [0.2] | but [0.2] | ... +# Digit -> 1 [0.3] | 2 [0.2] | 0 [0.2] | ... +# ``` + +# %% [markdown] +# ### Grammar +# +# With grammars we combine words from the lexicon into valid phrases. A grammar is comprised of **grammar rules**. Each rule transforms the left-hand side of the rule into the right-hand side. For example, `A -> B` means that `A` transforms into `B`. Let's build a grammar for the language we started building with the lexicon. We will use a PCFG. +# +# ``` +# S -> NP VP [0.9] | S Conjunction S [0.1] +# +# NP -> Pronoun [0.3] | Name [0.1] | Noun [0.1] | Article Noun [0.25] | +# Article Adjs Noun [0.05] | Digit [0.05] | NP PP [0.1] | +# NP RelClause [0.05] +# +# VP -> Verb [0.4] | VP NP [0.35] | VP Adjective [0.05] | VP PP [0.1] +# VP Adverb [0.1] +# +# Adjs -> Adjective [0.8] | Adjective Adjs [0.2] +# +# PP -> Preposition NP [1.0] +# +# RelClause -> RelPro VP [1.0] +# ``` +# +# Some valid phrases the grammar produces: "`mary is sad`", "`you are a robot`" and "`she likes mary and a good fence`". +# +# What if we wanted to check if the phrase "`mary is sad`" is actually a valid sentence? We can use a **parse tree** to constructively prove that a string of words is a valid phrase in the given language and even calculate the probability of the generation of the sentence. +# +# ![parse_tree](images/parse_tree.png) +# +# The probability of the whole tree can be calculated by multiplying the probabilities of each individual rule transormation: `0.9 * 0.1 * 0.05 * 0.05 * 0.4 * 0.05 * 0.3 = 0.00000135`. +# +# To conserve space, we can also write the tree in linear form: +# +# [S [NP [Name **mary**]] [VP [VP [Verb **is**]] [Adjective **sad**]]] +# +# Unfortunately, the current grammar **overgenerates**, that is, it creates sentences that are not grammatically correct (according to the English language), like "`the fence are john which say`". It also **undergenerates**, which means there are valid sentences it does not generate, like "`he believes mary is sad`". + +# %% [markdown] +# ### Implementation +# +# In the module we have implementation both for probabilistic and non-probabilistic grammars. Both these implementation follow the same format. There are functions for the lexicon and the rules which can be combined to create a grammar object. +# +# #### Non-Probabilistic +# +# Execute the cell below to view the implemenations: + +# %% +psource(Lexicon, Rules, Grammar) + +# %% [markdown] +# Let's build a lexicon and a grammar for the above language: + +# %% +lexicon = Lexicon( + Verb = "is | say | are", + Noun = "robot | sheep | fence", + Adjective = "good | new | sad", + Adverb = "here | lightly | now", + Pronoun = "me | you | he", + RelPro = "that | who | which", + Name = "john | mary | peter", + Article = "the | a | an", + Preposition = "to | in | at", + Conjunction = "and | or | but", + Digit = "1 | 2 | 0" +) + +print("Lexicon", lexicon) + +rules = Rules( + S = "NP VP | S Conjunction S", + NP = "Pronoun | Name | Noun | Article Noun \ + | Article Adjs Noun | Digit | NP PP | NP RelClause", + VP = "Verb | VP NP | VP Adjective | VP PP | VP Adverb", + Adjs = "Adjective | Adjective Adjs", + PP = "Preposition NP", + RelClause = "RelPro VP" +) + +print("\nRules:", rules) + +# %% [markdown] +# Both the functions return a dictionary with keys the left-hand side of the rules. For the lexicon, the values are the terminals for each left-hand side non-terminal, while for the rules the values are the right-hand sides as lists. +# +# We can now use the variables `lexicon` and `rules` to build a grammar. After we've done so, we can find the transformations of a non-terminal (the `Noun`, `Verb` and the other basic classes do **not** count as proper non-terminals in the implementation). We can also check if a word is in a particular class. + +# %% +grammar = Grammar("A Simple Grammar", rules, lexicon) + +print("How can we rewrite 'VP'?", grammar.rewrites_for('VP')) +print("Is 'the' an article?", grammar.isa('the', 'Article')) +print("Is 'here' a noun?", grammar.isa('here', 'Noun')) + +# %% [markdown] +# If the grammar is in Chomsky Normal Form, we can call the class function `cnf_rules` to get all the rules in the form of `(X, Y, Z)` for each `X -> Y Z` rule. Since the above grammar is not in *CNF* though, we have to create a new one. + +# %% +E_Chomsky = Grammar("E_Prob_Chomsky", # A Grammar in Chomsky Normal Form + Rules( + S = "NP VP", + NP = "Article Noun | Adjective Noun", + VP = "Verb NP | Verb Adjective", + ), + Lexicon( + Article = "the | a | an", + Noun = "robot | sheep | fence", + Adjective = "good | new | sad", + Verb = "is | say | are" + )) + +# %% +print(E_Chomsky.cnf_rules()) + +# %% [markdown] +# Finally, we can generate random phrases using our grammar. Most of them will be complete gibberish, falling under the overgenerated phrases of the grammar. That goes to show that in the grammar the valid phrases are much fewer than the overgenerated ones. + +# %% +grammar.generate_random('S') + +# %% [markdown] +# #### Probabilistic +# +# The probabilistic grammars follow the same approach. They take as input a string, are assembled from a grammar and a lexicon and can generate random sentences (giving the probability of the sentence). The main difference is that in the lexicon we have tuples (terminal, probability) instead of strings and for the rules we have a list of tuples (list of non-terminals, probability) instead of list of lists of non-terminals. +# +# Execute the cells to read the code: + +# %% +psource(ProbLexicon, ProbRules, ProbGrammar) + +# %% [markdown] +# Let's build a lexicon and rules for the probabilistic grammar: + +# %% +lexicon = ProbLexicon( + Verb = "is [0.5] | say [0.3] | are [0.2]", + Noun = "robot [0.4] | sheep [0.4] | fence [0.2]", + Adjective = "good [0.5] | new [0.2] | sad [0.3]", + Adverb = "here [0.6] | lightly [0.1] | now [0.3]", + Pronoun = "me [0.3] | you [0.4] | he [0.3]", + RelPro = "that [0.5] | who [0.3] | which [0.2]", + Name = "john [0.4] | mary [0.4] | peter [0.2]", + Article = "the [0.5] | a [0.25] | an [0.25]", + Preposition = "to [0.4] | in [0.3] | at [0.3]", + Conjunction = "and [0.5] | or [0.2] | but [0.3]", + Digit = "0 [0.35] | 1 [0.35] | 2 [0.3]" +) + +print("Lexicon", lexicon) + +rules = ProbRules( + S = "NP VP [0.6] | S Conjunction S [0.4]", + NP = "Pronoun [0.2] | Name [0.05] | Noun [0.2] | Article Noun [0.15] \ + | Article Adjs Noun [0.1] | Digit [0.05] | NP PP [0.15] | NP RelClause [0.1]", + VP = "Verb [0.3] | VP NP [0.2] | VP Adjective [0.25] | VP PP [0.15] | VP Adverb [0.1]", + Adjs = "Adjective [0.5] | Adjective Adjs [0.5]", + PP = "Preposition NP [1]", + RelClause = "RelPro VP [1]" +) + +print("\nRules:", rules) + +# %% [markdown] +# Let's use the above to assemble our probabilistic grammar and run some simple queries: + +# %% +grammar = ProbGrammar("A Simple Probabilistic Grammar", rules, lexicon) + +print("How can we rewrite 'VP'?", grammar.rewrites_for('VP')) +print("Is 'the' an article?", grammar.isa('the', 'Article')) +print("Is 'here' a noun?", grammar.isa('here', 'Noun')) + +# %% [markdown] +# If we have a grammar in *CNF*, we can get a list of all the rules. Let's create a grammar in the form and print the *CNF* rules: + +# %% +E_Prob_Chomsky = ProbGrammar("E_Prob_Chomsky", # A Probabilistic Grammar in CNF + ProbRules( + S = "NP VP [1]", + NP = "Article Noun [0.6] | Adjective Noun [0.4]", + VP = "Verb NP [0.5] | Verb Adjective [0.5]", + ), + ProbLexicon( + Article = "the [0.5] | a [0.25] | an [0.25]", + Noun = "robot [0.4] | sheep [0.4] | fence [0.2]", + Adjective = "good [0.5] | new [0.2] | sad [0.3]", + Verb = "is [0.5] | say [0.3] | are [0.2]" + )) + +# %% +print(E_Prob_Chomsky.cnf_rules()) + +# %% [markdown] +# Lastly, we can generate random sentences from this grammar. The function `prob_generation` returns a tuple (sentence, probability). + +# %% +sentence, prob = grammar.generate_random('S') +print(sentence) +print(prob) + +# %% [markdown] +# As with the non-probabilistic grammars, this one mostly overgenerates. You can also see that the probability is very, very low, which means there are a ton of generateable sentences (in this case infinite, since we have recursion; notice how `VP` can produce another `VP`, for example). + +# %% [markdown] +# ## HITS +# +# ### Overview +# +# **Hyperlink-Induced Topic Search** (or HITS for short) is an algorithm for information retrieval and page ranking. You can read more on information retrieval in the [text notebook](https://github.com/aimacode/aima-python/blob/master/text.ipynb). Essentially, given a collection of documents and a user's query, such systems return to the user the documents most relevant to what the user needs. The HITS algorithm differs from a lot of other similar ranking algorithms (like Google's *Pagerank*) as the page ratings in this algorithm are dependent on the given query. This means that for each new query the result pages must be computed anew. This cost might be prohibitive for many modern search engines, so a lot steer away from this approach. +# +# HITS first finds a list of relevant pages to the query and then adds pages that link to or are linked from these pages. Once the set is built, we define two values for each page. **Authority** on the query, the degree of pages from the relevant set linking to it and **hub** of the query, the degree that it points to authoritative pages in the set. Since we do not want to simply count the number of links from a page to other pages, but we also want to take into account the quality of the linked pages, we update the hub and authority values of a page in the following manner, until convergence: +# +# * Hub score = The sum of the authority scores of the pages it links to. +# +# * Authority score = The sum of hub scores of the pages it is linked from. +# +# So the higher quality the pages a page is linked to and from, the higher its scores. +# +# We then normalize the scores by dividing each score by the sum of the squares of the respective scores of all pages. When the values converge, we return the top-valued pages. Note that because we normalize the values, the algorithm is guaranteed to converge. + +# %% [markdown] +# ### Implementation +# +# The source code for the algorithm is given below: + +# %% +psource(HITS) + +# %% [markdown] +# First we compile the collection of pages as mentioned above. Then, we initialize the authority and hub scores for each page and finally we update and normalize the values until convergence. +# +# A quick overview of the helper functions functions we use: +# +# * `relevant_pages`: Returns relevant pages from `pages_index` given a query. +# +# * `expand_pages`: Adds to the collection pages linked to and from the given `pages`. +# +# * `normalize`: Normalizes authority and hub scores. +# +# * `ConvergenceDetector`: A class that checks for convergence, by keeping a history of the pages' scores and checking if they change or not. +# +# * `Page`: The template for pages. Stores the address, authority/hub scores and in-links/out-links. + +# %% [markdown] +# ### Example +# +# Before we begin we need to define a list of sample pages to work on. The pages are `pA`, `pB` and so on and their text is given by `testHTML` and `testHTML2`. The `Page` class takes as arguments the in-links and out-links as lists. For page "A", the in-links are "B", "C" and "E" while the sole out-link is "D". +# +# We also need to set the `nlp` global variables `pageDict`, `pages_index` and `pages_content`. + +# %% +testHTML = """Like most other male mammals, a man inherits an + X from his mom and a Y from his dad.""" +testHTML2 = "a mom and a dad" + +pA = Page('A', ['B', 'C', 'E'], ['D']) +pB = Page('B', ['E'], ['A', 'C', 'D']) +pC = Page('C', ['B', 'E'], ['A', 'D']) +pD = Page('D', ['A', 'B', 'C', 'E'], []) +pE = Page('E', [], ['A', 'B', 'C', 'D', 'F']) +pF = Page('F', ['E'], []) + +nlp.pageDict = {pA.address: pA, pB.address: pB, pC.address: pC, + pD.address: pD, pE.address: pE, pF.address: pF} + +nlp.pages_index = nlp.pageDict + +nlp.pages_content ={pA.address: testHTML, pB.address: testHTML2, + pC.address: testHTML, pD.address: testHTML2, + pE.address: testHTML, pF.address: testHTML2} + +# %% [markdown] +# We can now run the HITS algorithm. Our query will be 'mammals' (note that while the content of the HTML doesn't matter, it should include the query words or else no page will be picked at the first step). + +# %% +HITS('mammals') +page_list = ['A', 'B', 'C', 'D', 'E', 'F'] +auth_list = [pA.authority, pB.authority, pC.authority, pD.authority, pE.authority, pF.authority] +hub_list = [pA.hub, pB.hub, pC.hub, pD.hub, pE.hub, pF.hub] + +# %% [markdown] +# Let's see how the pages were scored: + +# %% +for i in range(6): + p = page_list[i] + a = auth_list[i] + h = hub_list[i] + + print("{}: total={}, auth={}, hub={}".format(p, a + h, a, h)) + +# %% [markdown] +# The top score is 0.82 by "C". This is the most relevant page according to the algorithm. You can see that the pages it links to, "A" and "D", have the two highest authority scores (therefore "C" has a high hub score) and the pages it is linked from, "B" and "E", have the highest hub scores (so "C" has a high authority score). By combining these two facts, we get that "C" is the most relevant page. It is worth noting that it does not matter if the given page contains the query words, just that it links and is linked from high-quality pages. + +# %% [markdown] +# ## QUESTION ANSWERING +# +# **Question Answering** is a type of Information Retrieval system, where we have a question instead of a query and instead of relevant documents we want the computer to return a short sentence, phrase or word that answers our question. To better understand the concept of question answering systems, you can first read the "Text Models" and "Information Retrieval" section from the [text notebook](https://github.com/aimacode/aima-python/blob/master/text.ipynb). +# +# A typical example of such a system is `AskMSR` (Banko *et al.*, 2002), a system for question answering that performed admirably against more sophisticated algorithms. The basic idea behind it is that a lot of questions have already been answered in the web numerous times. The system doesn't know a lot about verbs, or concepts or even what a noun is. It knows about 15 different types of questions and how they can be written as queries. It can rewrite [Who was George Washington's second in command?] as the query [\* was George Washington's second in command] or [George Washington's second in command was \*]. +# +# After rewriting the questions, it issues these queries and retrieves the short text around the query terms. It then breaks the result into 1, 2 or 3-grams. Filters are also applied to increase the chances of a correct answer. If the query starts with "who", we filter for names, if it starts with "how many" we filter for numbers and so on. We can also filter out the words appearing in the query. For the above query, the answer "George Washington" is wrong, even though it is quite possible the 2-gram would appear a lot around the query terms. +# +# Finally, the different results are weighted by the generality of the queries. The result from the general boolean query [George Washington OR second in command] weighs less that the more specific query [George Washington's second in command was \*]. As an answer we return the most highly-ranked n-gram. + +# %% [markdown] +# ## CYK PARSE +# +# ### Overview +# +# Syntactic analysis (or **parsing**) of a sentence is the process of uncovering the phrase structure of the sentence according to the rules of a grammar. There are two main approaches to parsing. *Top-down*, start with the starting symbol and build a parse tree with the given words as its leaves, and *bottom-up*, where we start from the given words and build a tree that has the starting symbol as its root. Both approaches involve "guessing" ahead, so it is very possible it will take long to parse a sentence (wrong guess mean a lot of backtracking). Thankfully, a lot of effort is spent in analyzing already analyzed substrings, so we can follow a dynamic programming approach to store and reuse these parses instead of recomputing them. The *CYK Parsing Algorithm* (named after its inventors, Cocke, Younger and Kasami) utilizes this technique to parse sentences of a grammar in *Chomsky Normal Form*. +# +# The CYK algorithm returns an *M x N x N* array (named *P*), where *N* is the number of words in the sentence and *M* the number of non-terminal symbols in the grammar. Each element in this array shows the probability of a substring being transformed from a particular non-terminal. To find the most probable parse of the sentence, a search in the resulting array is required. Search heuristic algorithms work well in this space, and we can derive the heuristics from the properties of the grammar. +# +# The algorithm in short works like this: There is an external loop that determines the length of the substring. Then the algorithm loops through the words in the sentence. For each word, it again loops through all the words to its right up to the first-loop length. The substring it will work on in this iteration is the words from the second-loop word with first-loop length. Finally, it loops through all the rules in the grammar and updates the substring's probability for each right-hand side non-terminal. + +# %% [markdown] +# ### Implementation +# +# The implementation takes as input a list of words and a probabilistic grammar (from the `ProbGrammar` class detailed above) in CNF and returns the table/dictionary *P*. An item's key in *P* is a tuple in the form `(Non-terminal, start of substring, length of substring)`, and the value is a probability. For example, for the sentence "the monkey is dancing" and the substring "the monkey" an item can be `('NP', 0, 2): 0.5`, which means the first two words (the substring from index 0 and length 2) have a 0.5 probablity of coming from the `NP` terminal. +# +# Before we continue, you can take a look at the source code by running the cell below: + +# %% +psource(CYK_parse) + +# %% [markdown] +# When updating the probability of a substring, we pick the max of its current one and the probability of the substring broken into two parts: one from the second-loop word with third-loop length, and the other from the first part's end to the remainer of the first-loop length. + +# %% [markdown] +# ### Example +# +# Let's build a probabilistic grammar in CNF: + +# %% +E_Prob_Chomsky = ProbGrammar("E_Prob_Chomsky", # A Probabilistic Grammar in CNF + ProbRules( + S = "NP VP [1]", + NP = "Article Noun [0.6] | Adjective Noun [0.4]", + VP = "Verb NP [0.5] | Verb Adjective [0.5]", + ), + ProbLexicon( + Article = "the [0.5] | a [0.25] | an [0.25]", + Noun = "robot [0.4] | sheep [0.4] | fence [0.2]", + Adjective = "good [0.5] | new [0.2] | sad [0.3]", + Verb = "is [0.5] | say [0.3] | are [0.2]" + )) + +# %% [markdown] +# Now let's see the probabilities table for the sentence "the robot is good": + +# %% +words = ['the', 'robot', 'is', 'good'] +grammar = E_Prob_Chomsky + +P = CYK_parse(words, grammar) +print(P) + +# %% [markdown] +# A `defaultdict` object is returned (`defaultdict` is basically a dictionary but with a default value/type). Keys are tuples in the form mentioned above and the values are the corresponding probabilities. Most of the items/parses have a probability of 0. Let's filter those out to take a better look at the parses that matter. + +# %% +parses = {k: p for k, p in P.items() if p >0} + +print(parses) + +# %% [markdown] +# The item `('Article', 0, 1): 0.5` means that the first item came from the `Article` non-terminal with a chance of 0.5. A more complicated item, one with two words, is `('NP', 0, 2): 0.12` which covers the first two words. The probability of the substring "the robot" coming from the `NP` non-terminal is 0.12. Let's try and follow the transformations from `NP` to the given words (top-down) to make sure this is indeed the case: +# +# 1. The probability of `NP` transforming to `Article Noun` is 0.6. +# +# 2. The probability of `Article` transforming to "the" is 0.5 (total probability = 0.6*0.5 = 0.3). +# +# 3. The probability of `Noun` transforming to "robot" is 0.4 (total = 0.3*0.4 = 0.12). +# +# Thus, the total probability of the transformation is 0.12. +# +# Notice how the probability for the whole string (given by the key `('S', 0, 4)`) is 0.015. This means the most probable parsing of the sentence has a probability of 0.015. + +# %% [markdown] +# ## CHART PARSING +# +# ### Overview +# +# Let's now take a look at a more general chart parsing algorithm. Given a non-probabilistic grammar and a sentence, this algorithm builds a parse tree in a top-down manner, with the words of the sentence as the leaves. It works with a dynamic programming approach, building a chart to store parses for substrings so that it doesn't have to analyze them again (just like the CYK algorithm). Each non-terminal, starting from S, gets replaced by its right-hand side rules in the chart, until we end up with the correct parses. +# +# ### Implementation +# +# A parse is in the form `[start, end, non-terminal, sub-tree, expected-transformation]`, where `sub-tree` is a tree with the corresponding `non-terminal` as its root and `expected-transformation` is a right-hand side rule of the `non-terminal`. +# +# The chart parsing is implemented in a class, `Chart`. It is initialized with a grammar and can return the list of all the parses of a sentence with the `parses` function. +# +# The chart is a list of lists. The lists correspond to the lengths of substrings (including the empty string), from start to finish. When we say 'a point in the chart', we refer to a list of a certain length. +# +# A quick rundown of the class functions: + +# %% [markdown] +# * `parses`: Returns a list of parses for a given sentence. If the sentence can't be parsed, it will return an empty list. Initializes the process by calling `parse` from the starting symbol. +# +# +# * `parse`: Parses the list of words and builds the chart. +# +# +# * `add_edge`: Adds another edge to the chart at a given point. Also, examines whether the edge extends or predicts another edge. If the edge itself is not expecting a transformation, it will extend other edges and it will predict edges otherwise. +# +# +# * `scanner`: Given a word and a point in the chart, it extends edges that were expecting a transformation that can result in the given word. For example, if the word 'the' is an 'Article' and we are examining two edges at a chart's point, with one expecting an 'Article' and the other a 'Verb', the first one will be extended while the second one will not. +# +# +# * `predictor`: If an edge can't extend other edges (because it is expecting a transformation itself), we will add to the chart rules/transformations that can help extend the edge. The new edges come from the right-hand side of the expected transformation's rules. For example, if an edge is expecting the transformation 'Adjective Noun', we will add to the chart an edge for each right-hand side rule of the non-terminal 'Adjective'. +# +# +# * `extender`: Extends edges given an edge (called `E`). If `E`'s non-terminal is the same as the expected transformation of another edge (let's call it `A`), add to the chart a new edge with the non-terminal of `A` and the transformations of `A` minus the non-terminal that matched with `E`'s non-terminal. For example, if an edge `E` has 'Article' as its non-terminal and is expecting no transformation, we need to see what edges it can extend. Let's examine the edge `N`. This expects a transformation of 'Noun Verb'. 'Noun' does not match with 'Article', so we move on. Another edge, `A`, expects a transformation of 'Article Noun' and has a non-terminal of 'NP'. We have a match! A new edge will be added with 'NP' as its non-terminal (the non-terminal of `A`) and 'Noun' as the expected transformation (the rest of the expected transformation of `A`). + +# %% [markdown] +# You can view the source code by running the cell below: + +# %% +psource(Chart) + +# %% [markdown] +# ### Example +# +# We will use the grammar `E0` to parse the sentence "the stench is in 2 2". +# +# First we need to build a `Chart` object: + +# %% +chart = Chart(nlp.E0) + +# %% [markdown] +# And then we simply call the `parses` function: + +# %% +print(chart.parses('the stench is in 2 2')) + +# %% [markdown] +# You can see which edges get added by setting the optional initialization argument `trace` to true. + +# %% +chart_trace = Chart(nlp.E0, trace=True) +chart_trace.parses('the stench is in 2 2') + +# %% [markdown] +# Let's try and parse a sentence that is not recognized by the grammar: + +# %% +print(chart.parses('the stench 2 2')) + +# %% [markdown] +# An empty list was returned. diff --git a/nlp_apps.ipynb b/notebooks/nlp_apps.ipynb similarity index 98% rename from nlp_apps.ipynb rename to notebooks/nlp_apps.ipynb index 2f4796b7a..7e3a68508 100644 --- a/nlp_apps.ipynb +++ b/notebooks/nlp_apps.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -42,8 +51,8 @@ "metadata": {}, "outputs": [], "source": [ - "from utils import open_data\n", - "from text import *\n", + "from aima.utils import open_data\n", + "from aima.text import *\n", "\n", "flatland = open_data(\"EN-text/flatland.txt\").read()\n", "wordseq = words(flatland)\n", @@ -71,7 +80,7 @@ "metadata": {}, "outputs": [], "source": [ - "from learning import NaiveBayesLearner\n", + "from aima.learning import NaiveBayesLearner\n", "\n", "dist = {('English', 1): P_flatland, ('German', 1): P_faust}\n", "\n", @@ -253,8 +262,8 @@ "metadata": {}, "outputs": [], "source": [ - "from utils import open_data\n", - "from text import *\n", + "from aima.utils import open_data\n", + "from aima.text import *\n", "\n", "flatland = open_data(\"EN-text/flatland.txt\").read()\n", "wordseq = words(flatland)\n", @@ -282,7 +291,7 @@ "metadata": {}, "outputs": [], "source": [ - "from learning import NaiveBayesLearner\n", + "from aima.learning import NaiveBayesLearner\n", "\n", "dist = {('Abbott', 1): P_Abbott, ('Austen', 1): P_Austen}\n", "\n", @@ -395,8 +404,8 @@ "metadata": {}, "outputs": [], "source": [ - "from utils import open_data\n", - "from text import *\n", + "from aima.utils import open_data\n", + "from aima.text import *\n", "\n", "federalist = open_data(\"EN-text/federalist.txt\").read()" ] @@ -957,7 +966,7 @@ "metadata": {}, "outputs": [], "source": [ - "from learning import NaiveBayesLearner\n", + "from aima.learning import NaiveBayesLearner\n", "\n", "dist = {('company', 1): model_words_0, ('fruit', 1): model_words_1}\n", "\n", diff --git a/notebooks/nlp_apps.py b/notebooks/nlp_apps.py new file mode 100644 index 000000000..f2a1b17aa --- /dev/null +++ b/notebooks/nlp_apps.py @@ -0,0 +1,532 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # NATURAL LANGUAGE PROCESSING APPLICATIONS +# +# In this notebook we will take a look at some indicative applications of natural language processing. We will cover content from [`nlp.py`](https://github.com/aimacode/aima-python/blob/master/nlp.py) and [`text.py`](https://github.com/aimacode/aima-python/blob/master/text.py), for chapters 22 and 23 of Stuart Russel's and Peter Norvig's book [*Artificial Intelligence: A Modern Approach*](http://aima.cs.berkeley.edu/). + +# %% [markdown] +# ## CONTENTS +# +# * Language Recognition +# * Author Recognition +# * The Federalist Papers +# * Text Classification + +# %% [markdown] +# # LANGUAGE RECOGNITION +# +# A very useful application of text models (you can read more on them on the [`text notebook`](https://github.com/aimacode/aima-python/blob/master/text.ipynb)) is categorizing text into a language. In fact, with enough data we can categorize correctly mostly any text. That is because different languages have certain characteristics that set them apart. For example, in German it is very usual for 'c' to be followed by 'h' while in English we see 't' followed by 'h' a lot. +# +# Here we will build an application to categorize sentences in either English or German. +# +# First we need to build our dataset. We will take as input text in English and in German and we will extract n-gram character models (in this case, *bigrams* for n=2). For English, we will use *Flatland* by Edwin Abbott and for German *Faust* by Goethe. +# +# Let's build our text models for each language, which will hold the probability of each bigram occuring in the text. + +# %% +from aima.utils import open_data +from aima.text import * + +flatland = open_data("EN-text/flatland.txt").read() +wordseq = words(flatland) + +P_flatland = NgramCharModel(2, wordseq) + +faust = open_data("GE-text/faust.txt").read() +wordseq = words(faust) + +P_faust = NgramCharModel(2, wordseq) + +# %% [markdown] +# We can use this information to build a *Naive Bayes Classifier* that will be used to categorize sentences (you can read more on Naive Bayes on the [`learning notebook`](https://github.com/aimacode/aima-python/blob/master/learning.ipynb)). The classifier will take as input the probability distribution of bigrams and given a list of bigrams (extracted from the sentence to be classified), it will calculate the probability of the example/sentence coming from each language and pick the maximum. +# +# Let's build our classifier, with the assumption that English is as probable as German (the input is a dictionary with values the text models and keys the tuple `language, probability`): + +# %% +from aima.learning import NaiveBayesLearner + +dist = {('English', 1): P_flatland, ('German', 1): P_faust} + +nBS = NaiveBayesLearner(dist, simple=True) + + +# %% [markdown] +# Now we need to write a function that takes as input a sentence, breaks it into a list of bigrams and classifies it with the naive bayes classifier from above. +# +# Once we get the text model for the sentence, we need to unravel it. The text models show the probability of each bigram, but the classifier can't handle that extra data. It requires a simple *list* of bigrams. So, if the text model shows that a bigram appears three times, we need to add it three times in the list. Since the text model stores the n-gram information in a dictionary (with the key being the n-gram and the value the number of times the n-gram appears) we need to iterate through the items of the dictionary and manually add them to the list of n-grams. + +# %% +def recognize(sentence, nBS, n): + sentence = sentence.lower() + wordseq = words(sentence) + + P_sentence = NgramCharModel(n, wordseq) + + ngrams = [] + for b, p in P_sentence.dictionary.items(): + ngrams += [b]*p + + print(ngrams) + + return nBS(ngrams) + + +# %% [markdown] +# Now we can start categorizing sentences. + +# %% +recognize("Ich bin ein platz", nBS, 2) + +# %% +recognize("Turtles fly high", nBS, 2) + +# %% +recognize("Der pelikan ist hier", nBS, 2) + +# %% +recognize("And thus the wizard spoke", nBS, 2) + +# %% [markdown] +# You can add more languages if you want, the algorithm works for as many as you like! Also, you can play around with *n*. Here we used 2, but other numbers work too (even though 2 suffices). The algorithm is not perfect, but it has high accuracy even for small samples like the ones we used. That is because English and German are very different languages. The closer together languages are (for example, Norwegian and Swedish share a lot of common ground) the lower the accuracy of the classifier. + +# %% [markdown] +# ## AUTHOR RECOGNITION +# +# Another similar application to language recognition is recognizing who is more likely to have written a sentence, given text written by them. Here we will try and predict text from Edwin Abbott and Jane Austen. They wrote *Flatland* and *Pride and Prejudice* respectively. +# +# We are optimistic we can determine who wrote what based on the fact that Abbott wrote his novella on much later date than Austen, which means there will be linguistic differences between the two works. Indeed, *Flatland* uses more modern and direct language while *Pride and Prejudice* is written in a more archaic tone containing more sophisticated wording. +# +# Similarly with Language Recognition, we will first import the two datasets. This time though we are not looking for connections between characters, since that wouldn't give that great results. Why? Because both authors use English and English follows a set of patterns, as we show earlier. Trying to determine authorship based on this patterns would not be very efficient. +# +# Instead, we will abstract our querying to a higher level. We will use words instead of characters. That way we can more accurately pick at the differences between their writing style and thus have a better chance at guessing the correct author. +# +# Let's go right ahead and import our data: + +# %% +from aima.utils import open_data +from aima.text import * + +flatland = open_data("EN-text/flatland.txt").read() +wordseq = words(flatland) + +P_Abbott = UnigramWordModel(wordseq, 5) + +pride = open_data("EN-text/pride.txt").read() +wordseq = words(pride) + +P_Austen = UnigramWordModel(wordseq, 5) + +# %% [markdown] +# This time we set the `default` parameter of the model to 5, instead of 0. If we leave it at 0, then when we get a sentence containing a word we have not seen from that particular author, the chance of that sentence coming from that author is exactly 0 (since to get the probability, we multiply all the separate probabilities; if one is 0 then the result is also 0). To avoid that, we tell the model to add 5 to the count of all the words that appear. +# +# Next we will build the Naive Bayes Classifier: + +# %% +from aima.learning import NaiveBayesLearner + +dist = {('Abbott', 1): P_Abbott, ('Austen', 1): P_Austen} + +nBS = NaiveBayesLearner(dist, simple=True) + + +# %% [markdown] +# Now that we have build our classifier, we will start classifying. First, we need to convert the given sentence to the format the classifier needs. That is, a list of words. + +# %% +def recognize(sentence, nBS): + sentence = sentence.lower() + sentence_words = words(sentence) + + return nBS(sentence_words) + + +# %% [markdown] +# First we will input a sentence that is something Abbott would write. Note the use of square and the simpler language. + +# %% +recognize("the square is mad", nBS) + +# %% [markdown] +# The classifier correctly guessed Abbott. +# +# Next we will input a more sophisticated sentence, similar to the style of Austen. + +# %% +recognize("a most peculiar acquaintance", nBS) + +# %% [markdown] +# The classifier guessed correctly again. +# +# You can try more sentences on your own. Unfortunately though, since the datasets are pretty small, chances are the guesses will not always be correct. + +# %% [markdown] +# ## THE FEDERALIST PAPERS +# +# Let's now take a look at a harder problem, classifying the authors of the [Federalist Papers](https://en.wikipedia.org/wiki/The_Federalist_Papers). The *Federalist Papers* are a series of papers written by Alexander Hamilton, James Madison and John Jay towards establishing the United States Constitution. +# +# What is interesting about these papers is that they were all written under a pseudonym, "Publius", to keep the identity of the authors a secret. Only after Hamilton's death, when a list was found written by him detailing the authorship of the papers, did the rest of the world learn what papers each of the authors wrote. After the list was published, Madison chimed in to make a couple of corrections: Hamilton, Madison said, hastily wrote down the list and assigned some papers to the wrong author! +# +# Here we will try and find out who really wrote these mysterious papers. +# +# To solve this we will learn from the undisputed papers to predict the disputed ones. First, let's read the texts from the file: + +# %% +from aima.utils import open_data +from aima.text import * + +federalist = open_data("EN-text/federalist.txt").read() + +# %% [markdown] +# Let's see how the text looks. We will print the first 500 characters: + +# %% +federalist[:500] + +# %% [markdown] +# It seems that the text file opens with a license agreement, hardly useful in our case. In fact, the license spans 113 words, while there is also a licensing agreement at the end of the file, which spans 3098 words. We need to remove them. To do so, we will first convert the text into words, to make our lives easier. + +# %% +wordseq = words(federalist) +wordseq = wordseq[114:-3098] + +# %% [markdown] +# Let's now take a look at the first 100 words: + +# %% +' '.join(wordseq[:100]) + +# %% [markdown] +# Much better. +# +# As with any Natural Language Processing problem, it is prudent to do some text pre-processing and clean our data before we start building our model. Remember that all the papers are signed as 'Publius', so we can safely remove that word, since it doesn't give us any information as to the real author. +# +# NOTE: Since we are only removing a single word from each paper, this step can be skipped. We add it here to show that processing the data in our hands is something we should always be considering. Oftentimes pre-processing the data in just the right way is the difference between a robust model and a flimsy one. + +# %% +wordseq = [w for w in wordseq if w != 'publius'] + +# %% [markdown] +# Now we have to separate the text from a block of words into papers and assign them to their authors. We can see that each paper starts with the word 'federalist', so we will split the text on that word. +# +# The disputed papers are the papers from 49 to 58, from 18 to 20 and paper 64. We want to leave these papers unassigned. Also, note that there are two versions of paper 70; both from Hamilton. +# +# Finally, to keep the implementation intuitive, we add a `None` object at the start of the `papers` list to make the list index match up with the paper numbering (for example, `papers[5]` now corresponds to paper no. 5 instead of the paper no.6 in the 0-indexed Python). + +# %% +import re + +papers = re.split(r'federalist\s', ' '.join(wordseq)) +papers = [p for p in papers if p not in ['', ' ']] +papers = [None] + papers + +disputed = list(range(49, 58+1)) + [18, 19, 20, 64] +jay, madison, hamilton = [], [], [] +for i, p in enumerate(papers): + if i in disputed or i == 0: + continue + + if 'jay' in p: + jay.append(p) + elif 'madison' in p: + madison.append(p) + else: + hamilton.append(p) + +len(jay), len(madison), len(hamilton) + +# %% [markdown] +# As we can see, from the undisputed papers Jay wrote 4, Madison 17 and Hamilton 51 (+1 duplicate). Let's now build our word models. The Unigram Word Model again will come in handy. + +# %% +hamilton = ''.join(hamilton) +hamilton_words = words(hamilton) +P_hamilton = UnigramWordModel(hamilton_words, default=1) + +madison = ''.join(madison) +madison_words = words(madison) +P_madison = UnigramWordModel(madison_words, default=1) + +jay = ''.join(jay) +jay_words = words(jay) +P_jay = UnigramWordModel(jay_words, default=1) + +# %% [markdown] +# Now it is time to build our new Naive Bayes Learner. It is very similar to the one found in `learning.py`, but with an important difference: it doesn't classify an example, but instead returns the probability of the example belonging to each class. This will allow us to not only see to whom a paper belongs to, but also the probability of authorship as well. +# We will build two versions of Learners, one will multiply probabilities as is and other will add the logarithms of them. +# +# Finally, since we are dealing with long text and the string of probability multiplications is long, we will end up with the results being rounded to 0 due to floating point underflow. To work around this problem we will use the built-in Python library `decimal`, which allows as to set decimal precision to much larger than normal. +# +# Note that the logarithmic learner will compute a negative likelihood since the logarithm of values less than 1 will be negative. +# Thus, the author with the lesser magnitude of proportion is more likely to have written that paper. +# +# + +# %% +import random +import decimal +import math +from decimal import Decimal + +decimal.getcontext().prec = 100 + +def precise_product(numbers): + result = 1 + for x in numbers: + result *= Decimal(x) + return result + +def log_product(numbers): + result = 0.0 + for x in numbers: + result += math.log(x) + return result + +def NaiveBayesLearner(dist): + """A simple naive bayes classifier that takes as input a dictionary of + Counter distributions and can then be used to find the probability + of a given item belonging to each class. + The input dictionary is in the following form: + ClassName: Counter""" + attr_dist = {c_name: count_prob for c_name, count_prob in dist.items()} + + def predict(example): + """Predict the probabilities for each class.""" + def class_prob(target, e): + attr = attr_dist[target] + return precise_product([attr[a] for a in e]) + + pred = {t: class_prob(t, example) for t in dist.keys()} + + total = sum(pred.values()) + for k, v in pred.items(): + pred[k] = v / total + + return pred + + return predict + +def NaiveBayesLearnerLog(dist): + """A simple naive bayes classifier that takes as input a dictionary of + Counter distributions and can then be used to find the probability + of a given item belonging to each class. It will compute the likelihood by adding the logarithms of probabilities. + The input dictionary is in the following form: + ClassName: Counter""" + attr_dist = {c_name: count_prob for c_name, count_prob in dist.items()} + + def predict(example): + """Predict the probabilities for each class.""" + def class_prob(target, e): + attr = attr_dist[target] + return log_product([attr[a] for a in e]) + + pred = {t: class_prob(t, example) for t in dist.keys()} + + total = -sum(pred.values()) + for k, v in pred.items(): + pred[k] = v/total + + return pred + + return predict + + + +# %% [markdown] +# Next we will build our Learner. Note that even though Hamilton wrote the most papers, that doesn't make it more probable that he wrote the rest, so all the class probabilities will be equal. We can change them if we have some external knowledge, which for this tutorial we do not have. + +# %% +dist = {('Madison', 1): P_madison, ('Hamilton', 1): P_hamilton, ('Jay', 1): P_jay} +nBS = NaiveBayesLearner(dist) +nBSL = NaiveBayesLearnerLog(dist) + + +# %% [markdown] +# As usual, the `recognize` function will take as input a string and after removing capitalization and splitting it into words, will feed it into the Naive Bayes Classifier. + +# %% +def recognize(sentence, nBS): + return nBS(words(sentence.lower())) + + +# %% [markdown] +# Now we can start predicting the disputed papers: + +# %% +print('\nStraightforward Naive Bayes Learner\n') +for d in disputed: + probs = recognize(papers[d], nBS) + results = ['{}: {:.4f}'.format(name, probs[(name, 1)]) for name in 'Hamilton Madison Jay'.split()] + print('Paper No. {}: {}'.format(d, ' '.join(results))) + +print('\nLogarithmic Naive Bayes Learner\n') +for d in disputed: + probs = recognize(papers[d], nBSL) + results = ['{}: {:.6f}'.format(name, probs[(name, 1)]) for name in 'Hamilton Madison Jay'.split()] + print('Paper No. {}: {}'.format(d, ' '.join(results))) + + + +# %% [markdown] +# We can see that both learners classify the papers identically. Because of underflow in the straightforward learner, only one author remains with a positive value. The log learner is more accurate with marginal differences between all the authors. +# +# This is a simple approach to the problem and thankfully researchers are fairly certain that papers 49-58 were all written by Madison, while 18-20 were written in collaboration between Hamilton and Madison, with Madison being credited for most of the work. Our classifier is not that far off. It correctly identifies the papers written by Madison, even the ones in collaboration with Hamilton. +# +# Unfortunately, it misses paper 64. Consensus is that the paper was written by John Jay, while our classifier believes it was written by Hamilton. The classifier is wrong there because it does not have much information on Jay's writing; only 4 papers. This is one of the problems with using unbalanced datasets such as this one, where information on some classes is sparser than information on the rest. To avoid this, we can add more writings for Jay and Madison to end up with an equal amount of data for each author. + +# %% [markdown] +# ## Text Classification + +# %% [markdown] +# **Text Classification** is assigning a category to a document based on the content of the document. Text Classification is one of the most popular and fundamental tasks of Natural Language Processing. Text classification can be applied on a variety of texts like *Short Documents* (like tweets, customer reviews, etc.) and *Long Document* (like emails, media articles, etc.). +# +# We already have seen an example of Text Classification in the above tasks like Language Identification, Author Recognition and Federalist Paper Identification. +# +# ### Applications +# Some of the broad applications of Text Classification are:- +# - Language Identification +# - Author Recognition +# - Sentiment Analysis +# - Spam Mail Detection +# - Topic Labelling +# - Word Sense Disambiguation +# +# ### Use Cases +# Some of the use cases of Text classification are:- +# - Social Media Monitoring +# - Brand Monitoring +# - Auto-tagging of user queries +# +# For Text Classification, we would be using the Naive Bayes Classifier. The reasons for using Naive Bayes Classifier are:- +# - Being a probabilistic classifier, therefore, will calculate the probability of each category +# - It is fast, reliable and accurate +# - Naive Bayes Classifiers have already been used to solve many Natural Language Processing (NLP) applications. +# +# Here we would here be covering an example of **Word Sense Disambiguation** as an application of Text Classification. It is used to remove the ambiguity of a given word if the word has two different meanings. +# +# As we know that we would be working on determining whether the word *apple* in a sentence refers to `fruit` or to a `company`. + +# %% [markdown] +# **Step 1:- Defining the dataset** +# +# The dataset has been defined here so that everything is clear and can be tested with other things as well. + +# %% +train_data = [ + "Apple targets big business with new iOS 7 features. Finally... A corp iTunes account!", + "apple inc is searching for people to help and try out all their upcoming tablet within our own net page No.", + "Microsoft to bring Xbox and PC games to Apple, Android phones: Report: Microsoft Corp", + "When did green skittles change from lime to green apple?", + "Myra Oltman is the best. I told her I wanted to learn how to make apple pie, so she made me a kit!", + "Surreal Sat in a sewing room, surrounded by crap, listening to beautiful music eating apple pie." +] + +train_target = [ + "company", + "company", + "company", + "fruit", + "fruit", + "fruit", +] + +class_0 = "company" +class_1 = "fruit" + +test_data = [ + "Apple Inc. supplier Foxconn demos its own iPhone-compatible smartwatch", + "I now know how to make a delicious apple pie thanks to the best teachers ever" +] + +# %% [markdown] +# **Step 2:- Preprocessing the dataset** +# +# In this step, we would be doing some preprocessing on the dataset like breaking the sentence into words and converting to lower case. +# +# We already have a `words(sent)` function defined in `text.py` which does the task of splitting the sentence into words. + +# %% +train_data_processed = [words(i) for i in train_data] + +# %% [markdown] +# **Step 3:- Feature Extraction from the text** +# +# Now we would be extracting features from the text like extracting the set of words used in both the categories i.e. `company` and `fruit`. +# +# The frequency of a word would help in calculating the probability of that word being in a particular class. + +# %% +words_0 = [] +words_1 = [] + +for sent, tag in zip(train_data_processed, train_target): + if(tag == class_0): + words_0 += sent + elif(tag == class_1): + words_1 += sent + +print("Number of words in `{}` class: {}".format(class_0, len(words_0))) +print("Number of words in `{}` class: {}".format(class_1, len(words_1))) + +# %% [markdown] +# As you might have observed, that our dataset is equally balanced, i.e. we have an equal number of words in both the classes. + +# %% [markdown] +# **Step 4:- Building the Naive Bayes Model** +# +# Using the Naive Bayes classifier we can calculate the probability of a word in `company` and `fruit` class and then multiplying all of them to get the probability of that sentence belonging each of the given classes. But if a word is not in our dictionary then this leads to the probability of that word belonging to that class becoming zero. For example:- the word *Foxconn* is not in the dictionary of any of the classes. Due to this, the probability of word *Foxconn* being in any of these classes becomes zero, and since all the probabilities are multiplied, this leads to the probability of that sentence belonging to any of the classes becoming zero. +# +# To solve the problem we need to use **smoothing**, i.e. providing a minimum non-zero threshold probability to every word that we come across. +# +# The `UnigramWordModel` class has implemented smoothing by taking an additional argument from the user, i.e. the minimum frequency that we would be giving to every word even if it is new to the dictionary. + +# %% +model_words_0 = UnigramWordModel(words_0, 1) +model_words_1 = UnigramWordModel(words_1, 1) + +# %% [markdown] +# Now we would be building the Naive Bayes model. For that, we would be making `dist` as we had done earlier in the Authorship Recognition Task. + +# %% +from aima.learning import NaiveBayesLearner + +dist = {('company', 1): model_words_0, ('fruit', 1): model_words_1} + +nBS = NaiveBayesLearner(dist, simple=True) + + +# %% [markdown] +# **Step 5:- Predict the class of a sentence** +# +# Now we will be writing a function that does pre-process of the sentences which we have taken for testing. And then predicting the class of every sentence in the document. + +# %% +def recognize(sentence, nBS): + sentence_words = words(sentence) + return nBS(sentence_words) + + +# %% +# predicting the class of sentences in the test set +for i in test_data: + print(i + "\t-" + recognize(i, nBS)) + +# %% [markdown] +# You might have observed that the predictions made by the model are correct and we are able to differentiate between sentences of different classes. You can try more sentences on your own. Unfortunately though, since the datasets are pretty small, chances are the guesses will not always be correct. +# +# As you might have observed, the above method is very much similar to the Author Recognition, which is also a type of Text Classification. Like this most of Text Classification have the same underlying structure and follow a similar procedure. diff --git a/planning.ipynb b/notebooks/planning.ipynb similarity index 99% rename from planning.ipynb rename to notebooks/planning.ipynb index 7b05b3c20..f83b48bda 100644 --- a/planning.ipynb +++ b/notebooks/planning.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": { @@ -32,8 +41,8 @@ "metadata": {}, "outputs": [], "source": [ - "from planning import *\n", - "from notebook import psource" + "from aima.planning import *\n", + "from aima.notebook_utils import psource" ] }, { @@ -507,7 +516,7 @@ "metadata": {}, "outputs": [], "source": [ - "from utils import *\n", + "from aima.utils import *\n", "# this imports the required expr so we can create our knowledge base\n", "\n", "knowledge_base = [\n", @@ -814,7 +823,7 @@ " given the starting location and airplanes.\n", "\n", " Example:\n", - " >>> from planning import *\n", + " >>> from aima.planning import *\n", " >>> ac = air_cargo()\n", " >>> ac.goal_test()\n", " False\n", @@ -1098,7 +1107,7 @@ " with a spare tire from the trunk.\n", "\n", " Example:\n", - " >>> from planning import *\n", + " >>> from aima.planning import *\n", " >>> st = spare_tire()\n", " >>> st.goal_test()\n", " False\n", @@ -1420,7 +1429,7 @@ " also known as the Sussman Anomaly.\n", "\n", " Example:\n", - " >>> from planning import *\n", + " >>> from aima.planning import *\n", " >>> tbt = three_block_tower()\n", " >>> tbt.goal_test()\n", " False\n", @@ -1678,7 +1687,7 @@ " A simplified definition of the Sussman Anomaly problem.\n", "\n", " Example:\n", - " >>> from planning import *\n", + " >>> from aima.planning import *\n", " >>> sbw = simple_blocks_world()\n", " >>> sbw.goal_test()\n", " False\n", @@ -1948,7 +1957,7 @@ " A problem of acquiring some items given their availability at certain stores.\n", "\n", " Example:\n", - " >>> from planning import *\n", + " >>> from aima.planning import *\n", " >>> sp = shopping_problem()\n", " >>> sp.goal_test()\n", " False\n", @@ -2220,7 +2229,7 @@ " A task of wearing socks and shoes on both feet\n", "\n", " Example:\n", - " >>> from planning import *\n", + " >>> from aima.planning import *\n", " >>> ss = socks_and_shoes()\n", " >>> ss.goal_test()\n", " False\n", @@ -2482,7 +2491,7 @@ " The possible actions include baking a cake and eating a cake.\n", "\n", " Example:\n", - " >>> from planning import *\n", + " >>> from aima.planning import *\n", " >>> cp = have_cake_and_eat_cake_too()\n", " >>> cp.goal_test()\n", " False\n", @@ -2655,8 +2664,8 @@ "source": [ "cakeProblem = have_cake_and_eat_cake_too()\n", "\n", - "solution = [expr('Bake(Cake)'),\n", - " expr('Eat(Cake)')]\n", + "solution = [expr('Eat(Cake)'),\n", + " expr('Bake(Cake)')]\n", "\n", "for action in solution:\n", " cakeProblem.act(action)" @@ -2898,23 +2907,23 @@ " return state\n", " \n", "\n", - " def angelic_search(problem, hierarchy, initialPlan):\n", + " def angelic_search(problem, hierarchy, initial_plan):\n", " """\n", "\t[Figure 11.8] A hierarchical planning algorithm that uses angelic semantics to identify and\n", "\tcommit to high-level plans that work while avoiding high-level plans that don’t. \n", "\tThe predicate MAKING-PROGRESS checks to make sure that we aren’t stuck in an infinite regression\n", "\tof refinements. \n", - "\tAt top level, call ANGELIC -SEARCH with [Act ] as the initialPlan .\n", + "\tAt top level, call ANGELIC -SEARCH with [Act ] as the initial_plan .\n", "\n", - " initialPlan contains a sequence of HLA's with angelic semantics \n", + " initial_plan contains a sequence of HLA's with angelic semantics \n", "\n", - " The possible effects of an angelic HLA in initialPlan are : \n", + " The possible effects of an angelic HLA in initial_plan are : \n", " ~ : effect remove\n", " $+: effect possibly add\n", " $-: effect possibly remove\n", " $$: possibly add or remove\n", "\t"""\n", - " frontier = deque(initialPlan)\n", + " frontier = deque(initial_plan)\n", " while True: \n", " if not frontier:\n", " return None\n", @@ -3008,7 +3017,7 @@ " break\n", " return (hla, index)\n", "\t\n", - " def making_progress(plan, initialPlan):\n", + " def making_progress(plan, initial_plan):\n", " """ \n", " Not correct\n", "\n", @@ -3061,7 +3070,7 @@ } ], "source": [ - "psource(Problem)" + "psource(PlanningProblem)" ] }, { @@ -3393,7 +3402,7 @@ " with resource and ordering constraints.\n", "\n", " Example:\n", - " >>> from planning import *\n", + " >>> from aima.planning import *\n", " >>> p = job_shop_problem()\n", " >>> p.goal_test()\n", " False\n", @@ -3687,7 +3696,7 @@ " trying to return an approaching ball and repositioning around in the court.\n", "\n", " Example:\n", - " >>> from planning import *\n", + " >>> from aima.planning import *\n", " >>> dtp = double_tennis_problem()\n", " >>> goal_test(dtp.goals, dtp.init)\n", " False\n", diff --git a/notebooks/planning.py b/notebooks/planning.py new file mode 100644 index 000000000..62b01c82c --- /dev/null +++ b/notebooks/planning.py @@ -0,0 +1,871 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Planning +# #### Chapters 10-11 +# ---- + +# %% [markdown] +# This notebook serves as supporting material for topics covered in **Chapter 10 - Classical Planning** and **Chapter 11 - Planning and Acting in the Real World** from the book *[Artificial Intelligence: A Modern Approach](http://aima.cs.berkeley.edu)*. +# This notebook uses implementations from the [planning.py](https://github.com/aimacode/aima-python/blob/master/planning.py) module. +# See the [intro notebook](https://github.com/aimacode/aima-python/blob/master/intro.ipynb) for instructions. +# +# We'll start by looking at `PlanningProblem` and `Action` data types for defining problems and actions. +# Then, we will see how to use them by trying to plan a trip from *Sibiu* to *Bucharest* across the familiar map of Romania, from [search.ipynb](https://github.com/aimacode/aima-python/blob/master/search.ipynb) +# followed by some common planning problems and methods of solving them. +# +# Let's start by importing everything from the planning module. + +# %% +from aima.planning import * +from aima.notebook_utils import psource + +# %% [markdown] +# ## CONTENTS +# +# **Classical Planning** +# - PlanningProblem +# - Action +# - Planning Problems +# * Air cargo problem +# * Spare tire problem +# * Three block tower problem +# * Shopping Problem +# * Socks and shoes problem +# * Cake problem +# - Solving Planning Problems +# * GraphPlan +# * Linearize +# * PartialOrderPlanner +#
+# +# **Planning in the real world** +# - Problem +# - HLA +# - Planning Problems +# * Job shop problem +# * Double tennis problem +# - Solving Planning Problems +# * Hierarchical Search +# * Angelic Search + +# %% [markdown] +# ## PlanningProblem +# +# PDDL stands for Planning Domain Definition Language. +# The `PlanningProblem` class is used to represent planning problems in this module. The following attributes are essential to be able to define a problem: +# * an initial state +# * a set of goals +# * a set of viable actions that can be executed in the search space of the problem +# +# View the source to see how the Python code tries to realise these. + +# %% +psource(PlanningProblem) + +# %% [markdown] +# The `init` attribute is an expression that forms the initial knowledge base for the problem. +#
+# The `goals` attribute is an expression that indicates the goals to be reached by the problem. +#
+# Lastly, `actions` contains a list of `Action` objects that may be executed in the search space of the problem. +#
+# The `goal_test` method checks if the goal has been reached. +#
+# The `act` method acts out the given action and updates the current state. +#
+# + +# %% [markdown] +# ## ACTION +# +# To be able to model a planning problem properly, it is essential to be able to represent an Action. Each action we model requires at least three things: +# * preconditions that the action must meet +# * the effects of executing the action +# * some expression that represents the action + +# %% [markdown] +# The module models actions using the `Action` class + +# %% +psource(Action) + +# %% [markdown] +# This class represents an action given the expression, the preconditions and its effects. +# A list `precond` stores the preconditions of the action and a list `effect` stores its effects. +# Negative preconditions and effects are input using a `~` symbol before the clause, which are internally prefixed with a `Not` to make it easier to work with. +# For example, the negation of `At(obj, loc)` will be input as `~At(obj, loc)` and internally represented as `NotAt(obj, loc)`. +# This equivalently creates a new clause for each negative literal, removing the hassle of maintaining two separate knowledge bases. +# This greatly simplifies algorithms like `GraphPlan` as we will see later. +# The `convert` method takes an input string, parses it, removes conjunctions if any and returns a list of `Expr` objects. +# The `check_precond` method checks if the preconditions for that action are valid, given a `kb`. +# The `act` method carries out the action on the given knowledge base. + +# %% [markdown] +# Now lets try to define a planning problem using these tools. Since we already know about the map of Romania, lets see if we can plan a trip across a simplified map of Romania. +# +# Here is our simplified map definition: + +# %% +from aima.utils import * +# this imports the required expr so we can create our knowledge base + +knowledge_base = [ + expr("Connected(Bucharest,Pitesti)"), + expr("Connected(Pitesti,Rimnicu)"), + expr("Connected(Rimnicu,Sibiu)"), + expr("Connected(Sibiu,Fagaras)"), + expr("Connected(Fagaras,Bucharest)"), + expr("Connected(Pitesti,Craiova)"), + expr("Connected(Craiova,Rimnicu)") + ] + +# %% [markdown] +# Let us add some logic propositions to complete our knowledge about travelling around the map. These are the typical symmetry and transitivity properties of connections on a map. We can now be sure that our `knowledge_base` understands what it truly means for two locations to be connected in the sense usually meant by humans when we use the term. +# +# Let's also add our starting location - *Sibiu* to the map. + +# %% +knowledge_base.extend([ + expr("Connected(x,y) ==> Connected(y,x)"), + expr("Connected(x,y) & Connected(y,z) ==> Connected(x,z)"), + expr("At(Sibiu)") + ]) + +# %% [markdown] +# We now have a complete knowledge base, which can be seen like this: + +# %% +knowledge_base + +# %% [markdown] +# We now define possible actions to our problem. We know that we can drive between any connected places. But, as is evident from [this](https://en.wikipedia.org/wiki/List_of_airports_in_Romania) list of Romanian airports, we can also fly directly between Sibiu, Bucharest, and Craiova. +# +# We can define these flight actions like this: + +# %% +#Sibiu to Bucharest +precond = 'At(Sibiu)' +effect = 'At(Bucharest) & ~At(Sibiu)' +fly_s_b = Action('Fly(Sibiu, Bucharest)', precond, effect) + +#Bucharest to Sibiu +precond = 'At(Bucharest)' +effect = 'At(Sibiu) & ~At(Bucharest)' +fly_b_s = Action('Fly(Bucharest, Sibiu)', precond, effect) + +#Sibiu to Craiova +precond = 'At(Sibiu)' +effect = 'At(Craiova) & ~At(Sibiu)' +fly_s_c = Action('Fly(Sibiu, Craiova)', precond, effect) + +#Craiova to Sibiu +precond = 'At(Craiova)' +effect = 'At(Sibiu) & ~At(Craiova)' +fly_c_s = Action('Fly(Craiova, Sibiu)', precond, effect) + +#Bucharest to Craiova +precond = 'At(Bucharest)' +effect = 'At(Craiova) & ~At(Bucharest)' +fly_b_c = Action('Fly(Bucharest, Craiova)', precond, effect) + +#Craiova to Bucharest +precond = 'At(Craiova)' +effect = 'At(Bucharest) & ~At(Craiova)' +fly_c_b = Action('Fly(Craiova, Bucharest)', precond, effect) + +# %% [markdown] +# And the drive actions like this. + +# %% +#Drive +precond = 'At(x)' +effect = 'At(y) & ~At(x)' +drive = Action('Drive(x, y)', precond, effect) + +# %% [markdown] +# Our goal is defined as + +# %% +goals = 'At(Bucharest)' + + +# %% [markdown] +# Finally, we can define a a function that will tell us when we have reached our destination, Bucharest. + +# %% +def goal_test(kb): + return kb.ask(expr('At(Bucharest)')) + + +# %% [markdown] +# Thus, with all the components in place, we can define the planning problem. + +# %% +prob = PlanningProblem(knowledge_base, goals, [fly_s_b, fly_b_s, fly_s_c, fly_c_s, fly_b_c, fly_c_b, drive]) + +# %% [markdown] +# ## PLANNING PROBLEMS +# --- +# +# ## Air Cargo Problem + +# %% [markdown] +# In the Air Cargo problem, we start with cargo at two airports, SFO and JFK. Our goal is to send each cargo to the other airport. We have two airplanes to help us accomplish the task. +# The problem can be defined with three actions: Load, Unload and Fly. +# Let us look how the `air_cargo` problem has been defined in the module. + +# %% +psource(air_cargo) + +# %% [markdown] +# **At(c, a):** The cargo **'c'** is at airport **'a'**. +# +# **~At(c, a):** The cargo **'c'** is _not_ at airport **'a'**. +# +# **In(c, p):** Cargo **'c'** is in plane **'p'**. +# +# **~In(c, p):** Cargo **'c'** is _not_ in plane **'p'**. +# +# **Cargo(c):** Declare **'c'** as cargo. +# +# **Plane(p):** Declare **'p'** as plane. +# +# **Airport(a):** Declare **'a'** as airport. +# +# +# +# In the `initial_state`, we have cargo C1, plane P1 at airport SFO and cargo C2, plane P2 at airport JFK. +# Our goal state is to have cargo C1 at airport JFK and cargo C2 at airport SFO. We will discuss on how to achieve this. Let us now define an object of the `air_cargo` problem: + +# %% +airCargo = air_cargo() + +# %% [markdown] +# Before taking any actions, we will check if `airCargo` has reached its goal: + +# %% +print(airCargo.goal_test()) + +# %% [markdown] +# It returns False because the goal state is not yet reached. Now, we define the sequence of actions that it should take in order to achieve the goal. +# The actions are then carried out on the `airCargo` PlanningProblem. +# +# The actions available to us are the following: Load, Unload, Fly +# +# **Load(c, p, a):** Load cargo **'c'** into plane **'p'** from airport **'a'**. +# +# **Fly(p, f, t):** Fly the plane **'p'** from airport **'f'** to airport **'t'**. +# +# **Unload(c, p, a):** Unload cargo **'c'** from plane **'p'** to airport **'a'**. +# +# This problem can have multiple valid solutions. +# One such solution is shown below. + +# %% +solution = [expr("Load(C1 , P1, SFO)"), + expr("Fly(P1, SFO, JFK)"), + expr("Unload(C1, P1, JFK)"), + expr("Load(C2, P2, JFK)"), + expr("Fly(P2, JFK, SFO)"), + expr("Unload (C2, P2, SFO)")] + +for action in solution: + airCargo.act(action) + +# %% [markdown] +# As the `airCargo` has taken all the steps it needed in order to achieve the goal, we can now check if it has acheived its goal: + +# %% +print(airCargo.goal_test()) + +# %% [markdown] +# It has now achieved its goal. + +# %% [markdown] +# ## The Spare Tire Problem + +# %% [markdown] +# Let's consider the problem of changing a flat tire of a car. +# The goal is to mount a spare tire onto the car's axle, given that we have a flat tire on the axle and a spare tire in the trunk. + +# %% +psource(spare_tire) + +# %% [markdown] +# **At(obj, loc):** object **'obj'** is at location **'loc'**. +# +# **~At(obj, loc):** object **'obj'** is _not_ at location **'loc'**. +# +# **Tire(t):** Declare a tire of type **'t'**. +# +# Let us now define an object of `spare_tire` problem: + +# %% +spareTire = spare_tire() + +# %% [markdown] +# Before taking any actions, we will check if `spare_tire` has reached its goal: + +# %% +print(spareTire.goal_test()) + +# %% [markdown] +# As we can see, it hasn't completed the goal. +# We now define a possible solution that can help us reach the goal of having a spare tire mounted onto the car's axle. +# The actions are then carried out on the `spareTire` PlanningProblem. +# +# The actions available to us are the following: Remove, PutOn +# +# **Remove(obj, loc):** Remove the tire **'obj'** from the location **'loc'**. +# +# **PutOn(t, Axle):** Attach the tire **'t'** on the Axle. +# +# **LeaveOvernight():** We live in a particularly bad neighborhood and all tires, flat or not, are stolen if we leave them overnight. +# +# + +# %% +solution = [expr("Remove(Flat, Axle)"), + expr("Remove(Spare, Trunk)"), + expr("PutOn(Spare, Axle)")] + +for action in solution: + spareTire.act(action) + +# %% +print(spareTire.goal_test()) + +# %% [markdown] +# This is a valid solution. +#
+# Another possible solution is + +# %% +spareTire = spare_tire() + +solution = [expr('Remove(Spare, Trunk)'), + expr('Remove(Flat, Axle)'), + expr('PutOn(Spare, Axle)')] + +for action in solution: + spareTire.act(action) + +# %% +print(spareTire.goal_test()) + +# %% [markdown] +# Notice that both solutions work, which means that the problem can be solved irrespective of the order in which the `Remove` actions take place, as long as both `Remove` actions take place before the `PutOn` action. + +# %% [markdown] +# We have successfully mounted a spare tire onto the axle. + +# %% [markdown] +# ## Three Block Tower Problem + +# %% [markdown] +# This problem's domain consists of a set of cube-shaped blocks sitting on a table. +# The blocks can be stacked, but only one block can fit directly on top of another. +# A robot arm can pick up a block and move it to another position, either on the table or on top of another block. +# The arm can pick up only one block at a time, so it cannot pick up a block that has another one on it. +# The goal will always be to build one or more stacks of blocks. +# In our case, we consider only three blocks. +# The particular configuration we will use is called the Sussman anomaly after Prof. Gerry Sussman. + +# %% [markdown] +# Let's take a look at the definition of `three_block_tower()` in the module. + +# %% +psource(three_block_tower) + +# %% [markdown] +# **On(b, x):** The block **'b'** is on **'x'**. **'x'** can be a table or a block. +# +# **~On(b, x):** The block **'b'** is _not_ on **'x'**. **'x'** can be a table or a block. +# +# **Block(b):** Declares **'b'** as a block. +# +# **Clear(x):** To indicate that there is nothing on **'x'** and it is free to be moved around. +# +# **~Clear(x):** To indicate that there is something on **'x'** and it cannot be moved. +# +# Let us now define an object of `three_block_tower` problem: + +# %% +threeBlockTower = three_block_tower() + +# %% [markdown] +# Before taking any actions, we will check if `threeBlockTower` has reached its goal: + +# %% +print(threeBlockTower.goal_test()) + +# %% [markdown] +# As we can see, it hasn't completed the goal. +# We now define a sequence of actions that can stack three blocks in the required order. +# The actions are then carried out on the `threeBlockTower` PlanningProblem. +# +# The actions available to us are the following: MoveToTable, Move +# +# **MoveToTable(b, x): ** Move box **'b'** stacked on **'x'** to the table, given that box **'b'** is clear. +# +# **Move(b, x, y): ** Move box **'b'** stacked on **'x'** to the top of **'y'**, given that both **'b'** and **'y'** are clear. +# + +# %% +solution = [expr("MoveToTable(C, A)"), + expr("Move(B, Table, C)"), + expr("Move(A, Table, B)")] + +for action in solution: + threeBlockTower.act(action) + +# %% [markdown] +# As the `three_block_tower` has taken all the steps it needed in order to achieve the goal, we can now check if it has acheived its goal. + +# %% +print(threeBlockTower.goal_test()) + +# %% [markdown] +# It has now successfully achieved its goal i.e, to build a stack of three blocks in the specified order. + +# %% [markdown] +# The `three_block_tower` problem can also be defined in simpler terms using just two actions `ToTable(x, y)` and `FromTable(x, y)`. +# The underlying problem remains the same however, stacking up three blocks in a certain configuration given a particular starting state. +# Let's have a look at the alternative definition. + +# %% +psource(simple_blocks_world) + +# %% [markdown] +# **On(x, y):** The block **'x'** is on **'y'**. Both **'x'** and **'y'** have to be blocks. +# +# **~On(x, y):** The block **'x'** is _not_ on **'y'**. Both **'x'** and **'y'** have to be blocks. +# +# **OnTable(x):** The block **'x'** is on the table. +# +# **~OnTable(x):** The block **'x'** is _not_ on the table. +# +# **Clear(x):** To indicate that there is nothing on **'x'** and it is free to be moved around. +# +# **~Clear(x):** To indicate that there is something on **'x'** and it cannot be moved. +# +# Let's now define a `simple_blocks_world` prolem. + +# %% +simpleBlocksWorld = simple_blocks_world() + +# %% [markdown] +# Before taking any actions, we will see if `simple_bw` has reached its goal. + +# %% +simpleBlocksWorld.goal_test() + +# %% [markdown] +# As we can see, it hasn't completed the goal. +# We now define a sequence of actions that can stack three blocks in the required order. +# The actions are then carried out on the `simple_bw` PlanningProblem. +# +# The actions available to us are the following: MoveToTable, Move +# +# **ToTable(x, y): ** Move box **'x'** stacked on **'y'** to the table, given that box **'y'** is clear. +# +# **FromTable(x, y): ** Move box **'x'** from wherever it is, to the top of **'y'**, given that both **'x'** and **'y'** are clear. +# + +# %% +solution = [expr('ToTable(A, B)'), + expr('FromTable(B, A)'), + expr('FromTable(C, B)')] + +for action in solution: + simpleBlocksWorld.act(action) + +# %% [markdown] +# As the `three_block_tower` has taken all the steps it needed in order to achieve the goal, we can now check if it has acheived its goal. + +# %% +print(simpleBlocksWorld.goal_test()) + +# %% [markdown] +# It has now successfully achieved its goal i.e, to build a stack of three blocks in the specified order. + +# %% [markdown] +# ## Shopping Problem + +# %% [markdown] +# This problem requires us to acquire a carton of milk, a banana and a drill. +# Initially, we start from home and it is known to us that milk and bananas are available in the supermarket and the hardware store sells drills. +# Let's take a look at the definition of the `shopping_problem` in the module. + +# %% +psource(shopping_problem) + +# %% [markdown] +# **At(x):** Indicates that we are currently at **'x'** where **'x'** can be Home, SM (supermarket) or HW (Hardware store). +# +# **~At(x):** Indicates that we are currently _not_ at **'x'**. +# +# **Sells(s, x):** Indicates that item **'x'** can be bought from store **'s'**. +# +# **Have(x):** Indicates that we possess the item **'x'**. + +# %% +shoppingProblem = shopping_problem() + +# %% [markdown] +# Let's first check whether the goal state Have(Milk), Have(Banana), Have(Drill) is reached or not. + +# %% +print(shoppingProblem.goal_test()) + +# %% [markdown] +# Let's look at the possible actions +# +# **Buy(x, store):** Buy an item **'x'** from a **'store'** given that the **'store'** sells **'x'**. +# +# **Go(x, y):** Go to destination **'y'** starting from source **'x'**. + +# %% [markdown] +# We now define a valid solution that will help us reach the goal. +# The sequence of actions will then be carried out onto the `shoppingProblem` PlanningProblem. + +# %% +solution = [expr('Go(Home, SM)'), + expr('Buy(Milk, SM)'), + expr('Buy(Banana, SM)'), + expr('Go(SM, HW)'), + expr('Buy(Drill, HW)')] + +for action in solution: + shoppingProblem.act(action) + +# %% [markdown] +# We have taken the steps required to acquire all the stuff we need. +# Let's see if we have reached our goal. + +# %% +shoppingProblem.goal_test() + +# %% [markdown] +# It has now successfully achieved the goal. + +# %% [markdown] +# ## Socks and Shoes + +# %% [markdown] +# This is a simple problem of putting on a pair of socks and shoes. +# The problem is defined in the module as given below. + +# %% +psource(socks_and_shoes) + +# %% [markdown] +# **LeftSockOn:** Indicates that we have already put on the left sock. +# +# **RightSockOn:** Indicates that we have already put on the right sock. +# +# **LeftShoeOn:** Indicates that we have already put on the left shoe. +# +# **RightShoeOn:** Indicates that we have already put on the right shoe. +# + +# %% +socksShoes = socks_and_shoes() + +# %% [markdown] +# Let's first check whether the goal state is reached or not. + +# %% +socksShoes.goal_test() + +# %% [markdown] +# As the goal state isn't reached, we will define a sequence of actions that might help us achieve the goal. +# These actions will then be acted upon the `socksShoes` PlanningProblem to check if the goal state is reached. + +# %% +solution = [expr('RightSock'), + expr('RightShoe'), + expr('LeftSock'), + expr('LeftShoe')] + +# %% +for action in solution: + socksShoes.act(action) + +socksShoes.goal_test() + +# %% [markdown] +# We have reached our goal. + +# %% [markdown] +# ## Cake Problem + +# %% [markdown] +# This problem requires us to reach the state of having a cake and having eaten a cake simlutaneously, given a single cake. +# Let's first take a look at the definition of the `have_cake_and_eat_cake_too` problem in the module. + +# %% +psource(have_cake_and_eat_cake_too) + +# %% [markdown] +# Since this problem doesn't involve variables, states can be considered similar to symbols in propositional logic. +# +# **Have(Cake):** Declares that we have a **'Cake'**. +# +# **~Have(Cake):** Declares that we _don't_ have a **'Cake'**. + +# %% +cakeProblem = have_cake_and_eat_cake_too() + +# %% [markdown] +# First let us check whether the goal state 'Have(Cake)' and 'Eaten(Cake)' are reached or not. + +# %% +print(cakeProblem.goal_test()) + +# %% [markdown] +# Let us look at the possible actions. +# +# **Bake(x):** To bake **' x '**. +# +# **Eat(x):** To eat **' x '**. + +# %% [markdown] +# We now define a valid solution that can help us reach the goal. +# The sequence of actions will then be acted upon the `cakeProblem` PlanningProblem. + +# %% +solution = [expr("Eat(Cake)"), + expr("Bake(Cake)")] + +for action in solution: + cakeProblem.act(action) + +# %% [markdown] +# Now we have made actions to bake the cake and eat the cake. Let us check if we have reached the goal. + +# %% +print(cakeProblem.goal_test()) + +# %% [markdown] +# It has now successfully achieved its goal i.e, to have and eat the cake. + +# %% [markdown] +# One might wonder if the order of the actions matters for this problem. +# Let's see for ourselves. + +# %% +cakeProblem = have_cake_and_eat_cake_too() + +solution = [expr('Eat(Cake)'), + expr('Bake(Cake)')] + +for action in solution: + cakeProblem.act(action) + +# %% [markdown] +# It raises an exception. +# Indeed, according to the problem, we cannot bake a cake if we already have one. +# In planning terms, '~Have(Cake)' is a precondition to the action 'Bake(Cake)'. +# Hence, this solution is invalid. + +# %% [markdown] +# ## PLANNING IN THE REAL WORLD +# --- +# ## PROBLEM +# The `Problem` class is a wrapper for `PlanningProblem` with some additional functionality and data-structures to handle real-world planning problems that involve time and resource constraints. +# The `Problem` class includes everything that the `PlanningProblem` class includes. +# Additionally, it also includes the following attributes essential to define a real-world planning problem: +# - a list of `jobs` to be done +# - a dictionary of `resources` +# +# It also overloads the `act` method to call the `do_action` method of the `HLA` class, +# and also includes a new method `refinements` that finds refinements or primitive actions for high level actions. +#
+# `hierarchical_search` and `angelic_search` are also built into the `Problem` class to solve such planning problems. + +# %% +psource(PlanningProblem) + +# %% [markdown] +# ## HLA +# To be able to model a real-world planning problem properly, it is essential to be able to represent a _high-level action (HLA)_ that can be hierarchically reduced to primitive actions. + +# %% +psource(HLA) + +# %% [markdown] +# In addition to preconditions and effects, an object of the `HLA` class also stores: +# - the `duration` of the HLA +# - the quantity of consumption of _consumable_ resources +# - the quantity of _reusable_ resources used +# - a bool `completed` denoting if the `HLA` has been completed +# +# The class also has some useful helper methods: +# - `do_action`: checks if required consumable and reusable resources are available and if so, executes the action. +# - `has_consumable_resource`: checks if there exists sufficient quantity of the required consumable resource. +# - `has_usable_resource`: checks if reusable resources are available and not already engaged. +# - `inorder`: ensures that all the jobs that had to be executed before the current one have been successfully executed. + +# %% [markdown] +# ## PLANNING PROBLEMS +# --- +# ## Job-shop Problem +# This is a simple problem involving the assembly of two cars simultaneously. +# The problem consists of two jobs, each of the form [`AddEngine`, `AddWheels`, `Inspect`] to be performed on two cars with different requirements and availability of resources. +#
+# Let's look at how the `job_shop_problem` has been defined on the module. + +# %% +psource(job_shop_problem) + +# %% [markdown] +# The states of this problem are: +#
+#
+# **Has(x, y)**: Car **'x'** _has_ **'y'** where **'y'** can be an Engine or a Wheel. +# +# **~Has(x, y)**: Car **'x'** does _not have_ **'y'** where **'y'** can be an Engine or a Wheel. +# +# **Inspected(c)**: Car **'c'** has been _inspected_. +# +# **~Inspected(c)**: Car **'c'** has _not_ been inspected. +# +# In the initial state, `C1` and `C2` are cars and neither have an engine or wheels and haven't been inspected. +# `E1` and `E2` are engines. +# `W1` and `W2` are wheels. +#
+# Our goal is to have engines and wheels on both cars and to get them inspected. We will discuss how to achieve this. +#
+# Let's define an object of the `job_shop_problem`. + +# %% +jobShopProblem = job_shop_problem() + +# %% [markdown] +# Before taking any actions, we will check if `jobShopProblem` has reached its goal. + +# %% +print(jobShopProblem.goal_test()) + +# %% [markdown] +# We now define a possible solution that can help us reach the goal. +# The actions are then carried out on the `jobShopProblem` object. + +# %% [markdown] +# The following actions are available to us: +# +# **AddEngine1**: Adds an engine to the car C1. Takes 30 minutes to complete and uses an engine hoist. +# +# **AddEngine2**: Adds an engine to the car C2. Takes 60 minutes to complete and uses an engine hoist. +# +# **AddWheels1**: Adds wheels to car C1. Takes 30 minutes to complete. Uses a wheel station and consumes 20 lug nuts. +# +# **AddWheels2**: Adds wheels to car C2. Takes 15 minutes to complete. Uses a wheel station and consumes 20 lug nuts as well. +# +# **Inspect1**: Gets car C1 inspected. Requires 10 minutes of inspection by one inspector. +# +# **Inspect2**: Gets car C2 inspected. Requires 10 minutes of inspection by one inspector. + +# %% +solution = [jobShopProblem.jobs[1][0], + jobShopProblem.jobs[1][1], + jobShopProblem.jobs[1][2], + jobShopProblem.jobs[0][0], + jobShopProblem.jobs[0][1], + jobShopProblem.jobs[0][2]] + +for action in solution: + jobShopProblem.act(action) + +# %% +print(jobShopProblem.goal_test()) + +# %% [markdown] +# This is a valid solution and one of many correct ways to solve this problem. + +# %% [markdown] +# ## Double tennis problem +# This problem is a simple case of a multiactor planning problem, where two agents act at once and can simultaneously change the current state of the problem. +# A correct plan is one that, if executed by the actors, achieves the goal. +# In the true multiagent setting, of course, the agents may not agree to execute any particular plan, but atleast they will know what plans _would_ work if they _did_ agree to execute them. +#
+# In the double tennis problem, two actors A and B are playing together and can be in one of four locations: `LeftBaseLine`, `RightBaseLine`, `LeftNet` and `RightNet`. +# The ball can be returned only if a player is in the right place. +# Each action must include the actor as an argument. +#
+# Let's first look at the definition of the `double_tennis_problem` in the module. + +# %% +psource(double_tennis_problem) + +# %% [markdown] +# The states of this problem are: +# +# **Approaching(Ball, loc)**: The `Ball` is approaching the location `loc`. +# +# **Returned(Ball)**: One of the actors successfully hit the approaching ball from the correct location which caused it to return to the other side. +# +# **At(actor, loc)**: `actor` is at location `loc`. +# +# **~At(actor, loc)**: `actor` is _not_ at location `loc`. +# +# Let's now define an object of `double_tennis_problem`. +# + +# %% +doubleTennisProblem = double_tennis_problem() + +# %% [markdown] +# Before taking any actions, we will check if `doubleTennisProblem` has reached the goal. + +# %% +print(doubleTennisProblem.goal_test()) + +# %% [markdown] +# As we can see, the goal hasn't been reached. +# We now define a possible solution that can help us reach the goal of having the ball returned. +# The actions will then be carried out on the `doubleTennisProblem` object. + +# %% [markdown] +# The actions available to us are the following: +# +# **Hit(actor, ball, loc)**: returns an approaching ball if `actor` is present at the `loc` that the ball is approaching. +# +# **Go(actor, to, loc)**: moves an `actor` from location `loc` to location `to`. +# +# We notice something different in this problem though, +# which is quite unlike any other problem we have seen so far. +# The goal state of the problem contains a variable `a`. +# This happens sometimes in multiagent planning problems +# and it means that it doesn't matter _which_ actor is at the `LeftNet` or the `RightNet`, as long as there is atleast one actor at either `LeftNet` or `RightNet`. + +# %% +solution = [expr('Go(A, RightBaseLine, LeftBaseLine)'), + expr('Hit(A, Ball, RightBaseLine)'), + expr('Go(A, LeftNet, RightBaseLine)')] + +for action in solution: + doubleTennisProblem.act(action) + +# %% +doubleTennisProblem.goal_test() + +# %% [markdown] +# It has now successfully reached its goal, ie, to return the approaching ball. diff --git a/planning_angelic_search.ipynb b/notebooks/planning_angelic_search.ipynb similarity index 93% rename from planning_angelic_search.ipynb rename to notebooks/planning_angelic_search.ipynb index 71408e1d9..afae6a932 100644 --- a/planning_angelic_search.ipynb +++ b/notebooks/planning_angelic_search.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -7,11 +16,11 @@ "# Angelic Search \n", "\n", "Search using angelic semantics (is a hierarchical search), where the agent chooses the implementation of the HLA's.
\n", - "The algorithms input is: problem, hierarchy and initialPlan\n", + "The algorithms input is: problem, hierarchy and initial_plan\n", "- problem is of type Problem \n", "- hierarchy is a dictionary consisting of all the actions. \n", - "- initialPlan is an approximate description(optimistic and pessimistic) of the agents choices for the implementation.
\n", - " initialPlan contains a sequence of HLA's with angelic semantics" + "- initial_plan is an approximate description(optimistic and pessimistic) of the agents choices for the implementation.
\n", + " initial_plan contains a sequence of HLA's with angelic semantics" ] }, { @@ -20,8 +29,8 @@ "metadata": {}, "outputs": [], "source": [ - "from planning import * \n", - "from notebook import psource" + "from aima.planning import * \n", + "from aima.notebook_utils import psource" ] }, { @@ -34,7 +43,7 @@ "- a search in the space of refinements, in a similar way with hierarchical search\n", "\n", "### Searching using angelic semantics\n", - "- Find the reachable set (optimistic and pessimistic) of the sequence of angelic HLA in initialPlan\n", + "- Find the reachable set (optimistic and pessimistic) of the sequence of angelic HLA in initial_plan\n", " - If the optimistic reachable set doesn't intersect the goal, then there is no solution\n", " - If the pessimistic reachable set intersects the goal, then we call decompose, in order to find the sequence of actions that lead us to the goal. \n", " - If the optimistic reachable set intersects the goal, but the pessimistic doesn't we do some further refinements, in order to see if there is a sequence of actions that achieves the goal. \n", @@ -141,23 +150,23 @@ "\n", "

\n", "\n", - "
    def angelic_search(problem, hierarchy, initialPlan):\n",
+       "
    def angelic_search(problem, hierarchy, initial_plan):\n",
        "        """\n",
        "\t[Figure 11.8] A hierarchical planning algorithm that uses angelic semantics to identify and\n",
        "\tcommit to high-level plans that work while avoiding high-level plans that don’t. \n",
        "\tThe predicate MAKING-PROGRESS checks to make sure that we aren’t stuck in an infinite regression\n",
        "\tof refinements. \n",
-       "\tAt top level, call ANGELIC -SEARCH with [Act ] as the initialPlan .\n",
+       "\tAt top level, call ANGELIC -SEARCH with [Act ] as the initial_plan .\n",
        "\n",
-       "        initialPlan contains a sequence of HLA's with angelic semantics \n",
+       "        initial_plan contains a sequence of HLA's with angelic semantics \n",
        "\n",
-       "        The possible effects of an angelic HLA in initialPlan are : \n",
+       "        The possible effects of an angelic HLA in initial_plan are : \n",
        "        ~ : effect remove\n",
        "        $+: effect possibly add\n",
        "        $-: effect possibly remove\n",
        "        $$: possibly add or remove\n",
        "\t"""\n",
-       "        frontier = deque(initialPlan)\n",
+       "        frontier = deque(initial_plan)\n",
        "        while True: \n",
        "            if not frontier:\n",
        "                return None\n",
@@ -168,7 +177,7 @@
        "                if Problem.is_primitive( plan, hierarchy ): \n",
        "                    return ([x for x in plan.action])\n",
        "                guaranteed = problem.intersects_goal(pes_reachable_set) \n",
-       "                if guaranteed and Problem.making_progress(plan, initialPlan):\n",
+       "                if guaranteed and Problem.making_progress(plan, initial_plan):\n",
        "                    final_state = guaranteed[0] # any element of guaranteed \n",
        "                    #print('decompose')\n",
        "                    return Problem.decompose(hierarchy, problem, plan, final_state, pes_reachable_set)\n",
@@ -191,7 +200,7 @@
     }
    ],
    "source": [
-    "psource(Problem.angelic_search)"
+    "psource(RealWorldPlanningProblem.angelic_search)"
    ]
   },
   {
@@ -331,7 +340,7 @@
     }
    ],
    "source": [
-    "psource(Problem.decompose)"
+    "psource(RealWorldPlanningProblem.decompose)"
    ]
   },
   {
@@ -397,7 +406,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "prob = Problem('At(Home) & Have(Cash) & Have(Car)', 'At(SFO) & Have(Cash)', [go_SFO, taxi_SFO, drive_SFOLongTermParking,shuttle_SFO])"
+    "prob = RealWorldPlanningProblem('At(Home) & Have(Cash) & Have(Car)', 'At(SFO) & Have(Cash)', [go_SFO, taxi_SFO, drive_SFOLongTermParking,shuttle_SFO])"
    ]
   },
   {
@@ -405,7 +414,7 @@
    "metadata": {},
    "source": [
     "An agent gives us some approximate information about the plan we will follow: 
\n", - "(initialPlan is an Angelic Node, where: \n", + "(initial_plan is an Angelic Node, where: \n", "- state is the initial state of the problem, \n", "- parent is None \n", "- action: is a list of actions (Angelic HLA's) with the optimistic estimators of effects and \n", @@ -419,17 +428,17 @@ "metadata": {}, "outputs": [], "source": [ - "angelic_opt_description = Angelic_HLA('Go(Home, SFO)', precond = 'At(Home)', effect ='$+At(SFO) & $-At(Home)' ) \n", - "angelic_pes_description = Angelic_HLA('Go(Home, SFO)', precond = 'At(Home)', effect ='$+At(SFO) & ~At(Home)' )\n", + "angelic_opt_description = AngelicHLA('Go(Home, SFO)', precond = 'At(Home)', effect ='$+At(SFO) & $-At(Home)' ) \n", + "angelic_pes_description = AngelicHLA('Go(Home, SFO)', precond = 'At(Home)', effect ='$+At(SFO) & ~At(Home)' )\n", "\n", - "initialPlan = [Angelic_Node(prob.init, None, [angelic_opt_description], [angelic_pes_description])] \n" + "initial_plan = [AngelicNode(prob.initial, None, [angelic_opt_description], [angelic_pes_description])] \n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "We want to find the optimistic and pessimistic reachable set of initialPlan when applied to the problem:\n", + "We want to find the optimistic and pessimistic reachable set of initial_plan when applied to the problem:\n", "##### Optimistic/Pessimistic reachable set" ] }, @@ -449,8 +458,8 @@ } ], "source": [ - "opt_reachable_set = Problem.reach_opt(prob.init, initialPlan[0])\n", - "pes_reachable_set = Problem.reach_pes(prob.init, initialPlan[0])\n", + "opt_reachable_set = RealWorldPlanningProblem.reach_opt(prob.initial, initial_plan[0])\n", + "pes_reachable_set = RealWorldPlanningProblem.reach_pes(prob.initial, initial_plan[0])\n", "print([x for y in opt_reachable_set.keys() for x in opt_reachable_set[y]], '\\n')\n", "print([x for y in pes_reachable_set.keys() for x in pes_reachable_set[y]])\n" ] @@ -481,7 +490,7 @@ } ], "source": [ - "for sequence in Problem.refinements(go_SFO, prob, library):\n", + "for sequence in RealWorldPlanningProblem.refinements(go_SFO, library):\n", " print (sequence)\n", " print([x.__dict__ for x in sequence ], '\\n')" ] @@ -510,7 +519,7 @@ } ], "source": [ - "plan= Problem.angelic_search(prob, library, initialPlan)\n", + "plan= RealWorldPlanningProblem.angelic_search(prob, library, initial_plan)\n", "print (plan, '\\n')\n", "print ([x.__dict__ for x in plan])" ] @@ -552,7 +561,7 @@ } ], "source": [ - "plan_2 = Problem.angelic_search(prob, library_2, initialPlan)\n", + "plan_2 = RealWorldPlanningProblem.angelic_search(prob, library_2, initial_plan)\n", "print(plan_2, '\\n')\n", "print([x.__dict__ for x in plan_2])" ] @@ -587,12 +596,12 @@ "outputs": [], "source": [ "shuttle_SFO = HLA('Shuttle(SFOLongTermParking, SFO)', 'Have(Cash) & At(SFOLongTermParking)', 'At(SFO)')\n", - "prob_3 = Problem('At(SFOLongTermParking) & Have(Cash)', 'At(SFO) & Have(Cash)', [shuttle_SFO])\n", + "prob_3 = RealWorldPlanningProblem('At(SFOLongTermParking) & Have(Cash)', 'At(SFO) & Have(Cash)', [shuttle_SFO])\n", "# optimistic/pessimistic descriptions\n", - "angelic_opt_description = Angelic_HLA('Shuttle(SFOLongTermParking, SFO)', precond = 'At(SFOLongTermParking)', effect ='$+At(SFO) & $-At(SFOLongTermParking)' ) \n", - "angelic_pes_description = Angelic_HLA('Shuttle(SFOLongTermParking, SFO)', precond = 'At(SFOLongTermParking)', effect ='$+At(SFO) & ~At(SFOLongTermParking)' ) \n", + "angelic_opt_description = AngelicHLA('Shuttle(SFOLongTermParking, SFO)', precond = 'At(SFOLongTermParking)', effect ='$+At(SFO) & $-At(SFOLongTermParking)' ) \n", + "angelic_pes_description = AngelicHLA('Shuttle(SFOLongTermParking, SFO)', precond = 'At(SFOLongTermParking)', effect ='$+At(SFO) & ~At(SFOLongTermParking)' ) \n", "# initial Plan\n", - "initialPlan_3 = [Angelic_Node(prob.init, None, [angelic_opt_description], [angelic_pes_description])] " + "initialPlan_3 = [AngelicNode(prob.initial, None, [angelic_opt_description], [angelic_pes_description])] " ] }, { diff --git a/notebooks/planning_angelic_search.py b/notebooks/planning_angelic_search.py new file mode 100644 index 000000000..b3f8442f7 --- /dev/null +++ b/notebooks/planning_angelic_search.py @@ -0,0 +1,189 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Angelic Search +# +# Search using angelic semantics (is a hierarchical search), where the agent chooses the implementation of the HLA's.
+# The algorithms input is: problem, hierarchy and initial_plan +# - problem is of type Problem +# - hierarchy is a dictionary consisting of all the actions. +# - initial_plan is an approximate description(optimistic and pessimistic) of the agents choices for the implementation.
+# initial_plan contains a sequence of HLA's with angelic semantics + +# %% +from aima.planning import * +from aima.notebook_utils import psource + +# %% [markdown] +# The Angelic search algorithm consists of three parts. +# - Search using angelic semantics +# - Decompose +# - a search in the space of refinements, in a similar way with hierarchical search +# +# ### Searching using angelic semantics +# - Find the reachable set (optimistic and pessimistic) of the sequence of angelic HLA in initial_plan +# - If the optimistic reachable set doesn't intersect the goal, then there is no solution +# - If the pessimistic reachable set intersects the goal, then we call decompose, in order to find the sequence of actions that lead us to the goal. +# - If the optimistic reachable set intersects the goal, but the pessimistic doesn't we do some further refinements, in order to see if there is a sequence of actions that achieves the goal. +# +# ### Search in space of refinements +# - Create a search tree, that has root the action and children it's refinements +# - Extend frontier by adding each refinement, so that we keep looping till we find all primitive actions +# - If we achieve that we return the path of the solution (search tree), else there is no solution and we return None. +# +# +# + +# %% +psource(RealWorldPlanningProblem.angelic_search) + +# %% [markdown] +# +# ### Decompose +# - Finds recursively the sequence of states and actions that lead us from initial state to goal. +# - For each of the above actions we find their refinements,if they are not primitive, by calling the angelic_search function. +# If there are not refinements return None +# +# + +# %% +psource(RealWorldPlanningProblem.decompose) + +# %% [markdown] +# ## Example +# +# Suppose that somebody wants to get to the airport. +# The possible ways to do so is either get a taxi, or drive to the airport.
+# Those two actions have some preconditions and some effects. +# If you get the taxi, you need to have cash, whereas if you drive you need to have a car.
+# Thus we define the following hierarchy of possible actions. +# +# ##### hierarchy + +# %% +library = { + 'HLA': ['Go(Home,SFO)', 'Go(Home,SFO)', 'Drive(Home, SFOLongTermParking)', 'Shuttle(SFOLongTermParking, SFO)', 'Taxi(Home, SFO)'], + 'steps': [['Drive(Home, SFOLongTermParking)', 'Shuttle(SFOLongTermParking, SFO)'], ['Taxi(Home, SFO)'], [], [], []], + 'precond': [['At(Home) & Have(Car)'], ['At(Home)'], ['At(Home) & Have(Car)'], ['At(SFOLongTermParking)'], ['At(Home)']], + 'effect': [['At(SFO) & ~At(Home)'], ['At(SFO) & ~At(Home) & ~Have(Cash)'], ['At(SFOLongTermParking) & ~At(Home)'], ['At(SFO) & ~At(LongTermParking)'], ['At(SFO) & ~At(Home) & ~Have(Cash)']] } + + + +# %% [markdown] +# +# the possible actions are the following: + +# %% +go_SFO = HLA('Go(Home,SFO)', precond='At(Home)', effect='At(SFO) & ~At(Home)') +taxi_SFO = HLA('Taxi(Home,SFO)', precond='At(Home)', effect='At(SFO) & ~At(Home) & ~Have(Cash)') +drive_SFOLongTermParking = HLA('Drive(Home, SFOLongTermParking)', 'At(Home) & Have(Car)','At(SFOLongTermParking) & ~At(Home)' ) +shuttle_SFO = HLA('Shuttle(SFOLongTermParking, SFO)', 'At(SFOLongTermParking)', 'At(SFO) & ~At(LongTermParking)') + +# %% [markdown] +# Suppose that (our preconditionds are that) we are Home and we have cash and car and our goal is to get to SFO and maintain our cash, and our possible actions are the above.
+# ##### Then our problem is: + +# %% +prob = RealWorldPlanningProblem('At(Home) & Have(Cash) & Have(Car)', 'At(SFO) & Have(Cash)', [go_SFO, taxi_SFO, drive_SFOLongTermParking,shuttle_SFO]) + +# %% [markdown] +# An agent gives us some approximate information about the plan we will follow:
+# (initial_plan is an Angelic Node, where: +# - state is the initial state of the problem, +# - parent is None +# - action: is a list of actions (Angelic HLA's) with the optimistic estimators of effects and +# - action_pes: is a list of actions (Angelic HLA's) with the pessimistic approximations of the effects +# ##### InitialPlan + +# %% +angelic_opt_description = AngelicHLA('Go(Home, SFO)', precond = 'At(Home)', effect ='$+At(SFO) & $-At(Home)' ) +angelic_pes_description = AngelicHLA('Go(Home, SFO)', precond = 'At(Home)', effect ='$+At(SFO) & ~At(Home)' ) + +initial_plan = [AngelicNode(prob.initial, None, [angelic_opt_description], [angelic_pes_description])] + + +# %% [markdown] +# We want to find the optimistic and pessimistic reachable set of initial_plan when applied to the problem: +# ##### Optimistic/Pessimistic reachable set + +# %% +opt_reachable_set = RealWorldPlanningProblem.reach_opt(prob.initial, initial_plan[0]) +pes_reachable_set = RealWorldPlanningProblem.reach_pes(prob.initial, initial_plan[0]) +print([x for y in opt_reachable_set.keys() for x in opt_reachable_set[y]], '\n') +print([x for y in pes_reachable_set.keys() for x in pes_reachable_set[y]]) + + +# %% [markdown] +# ##### Refinements + +# %% +for sequence in RealWorldPlanningProblem.refinements(go_SFO, library): + print (sequence) + print([x.__dict__ for x in sequence ], '\n') + +# %% [markdown] +# Run the angelic search +# ##### Top level call + +# %% +plan= RealWorldPlanningProblem.angelic_search(prob, library, initial_plan) +print (plan, '\n') +print ([x.__dict__ for x in plan]) + +# %% [markdown] +# ## Example 2 + +# %% +library_2 = { + 'HLA': ['Go(Home,SFO)', 'Go(Home,SFO)', 'Bus(Home, MetroStop)', 'Metro(MetroStop, SFO)' , 'Metro(MetroStop, SFO)', 'Metro1(MetroStop, SFO)', 'Metro2(MetroStop, SFO)' ,'Taxi(Home, SFO)'], + 'steps': [['Bus(Home, MetroStop)', 'Metro(MetroStop, SFO)'], ['Taxi(Home, SFO)'], [], ['Metro1(MetroStop, SFO)'], ['Metro2(MetroStop, SFO)'],[],[],[]], + 'precond': [['At(Home)'], ['At(Home)'], ['At(Home)'], ['At(MetroStop)'], ['At(MetroStop)'],['At(MetroStop)'], ['At(MetroStop)'] ,['At(Home) & Have(Cash)']], + 'effect': [['At(SFO) & ~At(Home)'], ['At(SFO) & ~At(Home) & ~Have(Cash)'], ['At(MetroStop) & ~At(Home)'], ['At(SFO) & ~At(MetroStop)'], ['At(SFO) & ~At(MetroStop)'], ['At(SFO) & ~At(MetroStop)'] , ['At(SFO) & ~At(MetroStop)'] ,['At(SFO) & ~At(Home) & ~Have(Cash)']] + } + +# %% +plan_2 = RealWorldPlanningProblem.angelic_search(prob, library_2, initial_plan) +print(plan_2, '\n') +print([x.__dict__ for x in plan_2]) + +# %% [markdown] +# ## Example 3 +# +# Sometimes there is no plan that achieves the goal! + +# %% +library_3 = { + 'HLA': ['Shuttle(SFOLongTermParking, SFO)', 'Go(Home, SFOLongTermParking)', 'Taxi(Home, SFOLongTermParking)', 'Drive(Home, SFOLongTermParking)', 'Drive(SFOLongTermParking, Home)', 'Get(Cash)', 'Go(Home, ATM)'], + 'steps': [['Get(Cash)', 'Go(Home, SFOLongTermParking)'], ['Taxi(Home, SFOLongTermParking)'], [], [], [], ['Drive(SFOLongTermParking, Home)', 'Go(Home, ATM)'], []], + 'precond': [['At(SFOLongTermParking)'], ['At(Home)'], ['At(Home) & Have(Cash)'], ['At(Home)'], ['At(SFOLongTermParking)'], ['At(SFOLongTermParking)'], ['At(Home)']], + 'effect': [['At(SFO)'], ['At(SFO)'], ['At(SFOLongTermParking) & ~Have(Cash)'], ['At(SFOLongTermParking)'] ,['At(Home) & ~At(SFOLongTermParking)'], ['At(Home) & Have(Cash)'], ['Have(Cash)'] ] + } + + +# %% +shuttle_SFO = HLA('Shuttle(SFOLongTermParking, SFO)', 'Have(Cash) & At(SFOLongTermParking)', 'At(SFO)') +prob_3 = RealWorldPlanningProblem('At(SFOLongTermParking) & Have(Cash)', 'At(SFO) & Have(Cash)', [shuttle_SFO]) +# optimistic/pessimistic descriptions +angelic_opt_description = AngelicHLA('Shuttle(SFOLongTermParking, SFO)', precond = 'At(SFOLongTermParking)', effect ='$+At(SFO) & $-At(SFOLongTermParking)' ) +angelic_pes_description = AngelicHLA('Shuttle(SFOLongTermParking, SFO)', precond = 'At(SFOLongTermParking)', effect ='$+At(SFO) & ~At(SFOLongTermParking)' ) +# initial Plan +initialPlan_3 = [AngelicNode(prob.initial, None, [angelic_opt_description], [angelic_pes_description])] + +# %% +plan_3 = prob_3.angelic_search(library_3, initialPlan_3) +print(plan_3) diff --git a/planning_graphPlan.ipynb b/notebooks/planning_graph_plan.ipynb similarity index 99% rename from planning_graphPlan.ipynb rename to notebooks/planning_graph_plan.ipynb index bffecb937..5e369f10e 100644 --- a/planning_graphPlan.ipynb +++ b/notebooks/planning_graph_plan.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -63,8 +72,8 @@ "metadata": {}, "outputs": [], "source": [ - "from planning import *\n", - "from notebook import psource" + "from aima.planning import *\n", + "from aima.notebook_utils import psource" ] }, { @@ -848,7 +857,7 @@ } ], "source": [ - "psource(air_cargo_graphplan)" + "psource(air_cargo_graph_plan)" ] }, { @@ -885,7 +894,7 @@ } ], "source": [ - "airCargoG = air_cargo_graphplan()\n", + "airCargoG = air_cargo_graph_plan()\n", "airCargoG" ] }, @@ -953,7 +962,7 @@ } ], "source": [ - "spareTireG = spare_tire_graphplan()\n", + "spareTireG = spare_tire_graph_plan()\n", "linearize(spareTireG)" ] }, @@ -981,7 +990,7 @@ } ], "source": [ - "cakeProblemG = have_cake_and_eat_cake_too_graphplan()\n", + "cakeProblemG = have_cake_and_eat_cake_too_graph_plan()\n", "linearize(cakeProblemG)" ] }, @@ -1009,7 +1018,7 @@ } ], "source": [ - "sussmanAnomalyG = three_block_tower_graphplan()\n", + "sussmanAnomalyG = three_block_tower_graph_plan()\n", "linearize(sussmanAnomalyG)" ] }, @@ -1037,7 +1046,7 @@ } ], "source": [ - "socksShoesG = socks_and_shoes_graphplan()\n", + "socksShoesG = socks_and_shoes_graph_plan()\n", "linearize(socksShoesG)" ] } diff --git a/notebooks/planning_graph_plan.py b/notebooks/planning_graph_plan.py new file mode 100644 index 000000000..19294be04 --- /dev/null +++ b/notebooks/planning_graph_plan.py @@ -0,0 +1,189 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# ## SOLVING PLANNING PROBLEMS +# ---- +# ### GRAPHPLAN +#
+# The GraphPlan algorithm is a popular method of solving classical planning problems. +# Before we get into the details of the algorithm, let's look at a special data structure called **planning graph**, used to give better heuristic estimates and plays a key role in the GraphPlan algorithm. + +# %% [markdown] +# ### Planning Graph +# A planning graph is a directed graph organized into levels. +# Each level contains information about the current state of the knowledge base and the possible state-action links to and from that level. +# The first level contains the initial state with nodes representing each fluent that holds in that level. +# This level has state-action links linking each state to valid actions in that state. +# Each action is linked to all its preconditions and its effect states. +# Based on these effects, the next level is constructed. +# The next level contains similarly structured information about the next state. +# In this way, the graph is expanded using state-action links till we reach a state where all the required goals hold true simultaneously. +# We can say that we have reached our goal if none of the goal states in the current level are mutually exclusive. +# This will be explained in detail later. +#
+# Planning graphs only work for propositional planning problems, hence we need to eliminate all variables by generating all possible substitutions. +#
+# For example, the planning graph of the `have_cake_and_eat_cake_too` problem might look like this +# ![title](images/cake_graph.jpg) +#
+# The black lines indicate links between states and actions. +#
+# In every planning problem, we are allowed to carry out the `no-op` action, ie, we can choose no action for a particular state. +# These are called 'Persistence' actions and are represented in the graph by the small square boxes. +# In technical terms, a persistence action has effects same as its preconditions. +# This enables us to carry a state to the next level. +#
+#
+# The gray lines indicate mutual exclusivity. +# This means that the actions connected bya gray line cannot be taken together. +# Mutual exclusivity (mutex) occurs in the following cases: +# 1. **Inconsistent effects**: One action negates the effect of the other. For example, _Eat(Cake)_ and the persistence of _Have(Cake)_ have inconsistent effects because they disagree on the effect _Have(Cake)_ +# 2. **Interference**: One of the effects of an action is the negation of a precondition of the other. For example, _Eat(Cake)_ interferes with the persistence of _Have(Cake)_ by negating its precondition. +# 3. **Competing needs**: One of the preconditions of one action is mutually exclusive with a precondition of the other. For example, _Bake(Cake)_ and _Eat(Cake)_ are mutex because they compete on the value of the _Have(Cake)_ precondition. + +# %% [markdown] +# In the module, planning graphs have been implemented using two classes, `Level` which stores data for a particular level and `Graph` which connects multiple levels together. +# Let's look at the `Level` class. + +# %% +from aima.planning import * +from aima.notebook_utils import psource + +# %% +psource(Level) + +# %% [markdown] +# Each level stores the following data +# 1. The current state of the level in `current_state` +# 2. Links from an action to its preconditions in `current_action_links` +# 3. Links from a state to the possible actions in that state in `current_state_links` +# 4. Links from each action to its effects in `next_action_links` +# 5. Links from each possible next state from each action in `next_state_links`. This stores the same information as the `current_action_links` of the next level. +# 6. Mutex links in `mutex`. +#
+#
+# The `find_mutex` method finds the mutex links according to the points given above. +#
+# The `build` method populates the data structures storing the state and action information. +# Persistence actions for each clause in the current state are also defined here. +# The newly created persistence action has the same name as its state, prefixed with a 'P'. + +# %% [markdown] +# Let's now look at the `Graph` class. + +# %% +psource(Graph) + +# %% [markdown] +# The class stores a problem definition in `pddl`, +# a knowledge base in `kb`, +# a list of `Level` objects in `levels` and +# all the possible arguments found in the initial state of the problem in `objects`. +#
+# The `expand_graph` method generates a new level of the graph. +# This method is invoked when the goal conditions haven't been met in the current level or the actions that lead to it are mutually exclusive. +# The `non_mutex_goals` method checks whether the goals in the current state are mutually exclusive. +#
+#
+# Using these two classes, we can define a planning graph which can either be used to provide reliable heuristics for planning problems or used in the `GraphPlan` algorithm. +#
+# Let's have a look at the `GraphPlan` class. + +# %% +psource(GraphPlan) + +# %% [markdown] +# Given a planning problem defined as a PlanningProblem, `GraphPlan` creates a planning graph stored in `graph` and expands it till it reaches a state where all its required goals are present simultaneously without mutual exclusivity. +#
+# Once a goal is found, `extract_solution` is called. +# This method recursively finds the path to a solution given a planning graph. +# In the case where `extract_solution` fails to find a solution for a set of goals as a given level, we record the `(level, goals)` pair as a **no-good**. +# Whenever `extract_solution` is called again with the same level and goals, we can find the recorded no-good and immediately return failure rather than searching again. +# No-goods are also used in the termination test. +#
+# The `check_leveloff` method checks if the planning graph for the problem has **levelled-off**, ie, it has the same states, actions and mutex pairs as the previous level. +# If the graph has already levelled off and we haven't found a solution, there is no point expanding the graph, as it won't lead to anything new. +# In such a case, we can declare that the planning problem is unsolvable with the given constraints. +#
+#
+# To summarize, the `GraphPlan` algorithm calls `expand_graph` and tests whether it has reached the goal and if the goals are non-mutex. +#
+# If so, `extract_solution` is invoked which recursively reconstructs the solution from the planning graph. +#
+# If not, then we check if our graph has levelled off and continue if it hasn't. + +# %% [markdown] +# Let's solve a few planning problems that we had defined earlier. + +# %% [markdown] +# #### Air cargo problem +# In accordance with the summary above, we have defined a helper function to carry out `GraphPlan` on the `air_cargo` problem. +# The function is pretty straightforward. +# Let's have a look. + +# %% +psource(air_cargo_graph_plan) + +# %% [markdown] +# Let's instantiate the problem and find a solution using this helper function. + +# %% +airCargoG = air_cargo_graph_plan() +airCargoG + +# %% [markdown] +# Each element in the solution is a valid action. +# The solution is separated into lists for each level. +# The actions prefixed with a 'P' are persistence actions and can be ignored. +# They simply carry certain states forward. +# We have another helper function `linearize` that presents the solution in a more readable format, much like a total-order planner, but it is _not_ a total-order planner. + +# %% +linearize(airCargoG) + +# %% [markdown] +# Indeed, this is a correct solution. +#
+# There are similar helper functions for some other planning problems. +#
+# Lets' try solving the spare tire problem. + +# %% +spareTireG = spare_tire_graph_plan() +linearize(spareTireG) + +# %% [markdown] +# Solution for the cake problem + +# %% +cakeProblemG = have_cake_and_eat_cake_too_graph_plan() +linearize(cakeProblemG) + +# %% [markdown] +# Solution for the Sussman's Anomaly configuration of three blocks. + +# %% +sussmanAnomalyG = three_block_tower_graph_plan() +linearize(sussmanAnomalyG) + +# %% [markdown] +# Solution of the socks and shoes problem + +# %% +socksShoesG = socks_and_shoes_graph_plan() +linearize(socksShoesG) diff --git a/planning_hierarchical_search.ipynb b/notebooks/planning_hierarchical_search.ipynb similarity index 97% rename from planning_hierarchical_search.ipynb rename to notebooks/planning_hierarchical_search.ipynb index 18e57b23b..8edfb84dc 100644 --- a/planning_hierarchical_search.ipynb +++ b/notebooks/planning_hierarchical_search.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -38,8 +47,8 @@ "metadata": {}, "outputs": [], "source": [ - "from planning import * \n", - "from notebook import psource" + "from aima.planning import * \n", + "from aima.notebook_utils import psource" ] }, { @@ -196,7 +205,7 @@ } ], "source": [ - "psource(Problem.refinements)" + "psource(RealWorldPlanningProblem.refinements)" ] }, { @@ -213,7 +222,7 @@ "- __hierarchy__: is a dictionary consisting of all the actions and the order in which they are performed. \n", "
\n", "\n", - "In top level call, initialPlan contains [act] (i.e. is the action to be performed) " + "In top level call, initial_plan contains [act] (i.e. is the action to be performed) " ] }, { @@ -347,7 +356,7 @@ } ], "source": [ - "psource(Problem.hierarchical_search)" + "psource(RealWorldPlanningProblem.hierarchical_search)" ] }, { @@ -413,7 +422,7 @@ "metadata": {}, "outputs": [], "source": [ - "prob = Problem('At(Home) & Have(Cash) & Have(Car)', 'At(SFO) & Have(Cash)', [go_SFO])" + "prob = RealWorldPlanningProblem('At(Home) & Have(Cash) & Have(Car)', 'At(SFO) & Have(Cash)', [go_SFO])" ] }, { @@ -445,7 +454,7 @@ } ], "source": [ - "for sequence in Problem.refinements(go_SFO, prob, library):\n", + "for sequence in RealWorldPlanningProblem.refinements(go_SFO, library):\n", " print (sequence)\n", " print([x.__dict__ for x in sequence ], '\\n')" ] @@ -474,7 +483,7 @@ } ], "source": [ - "plan= Problem.hierarchical_search(prob, library)\n", + "plan= RealWorldPlanningProblem.hierarchical_search(prob, library)\n", "print (plan, '\\n')\n", "print ([x.__dict__ for x in plan])" ] @@ -516,7 +525,7 @@ } ], "source": [ - "plan_2 = Problem.hierarchical_search(prob, library_2)\n", + "plan_2 = RealWorldPlanningProblem.hierarchical_search(prob, library_2)\n", "print(plan_2, '\\n')\n", "print([x.__dict__ for x in plan_2])" ] diff --git a/notebooks/planning_hierarchical_search.py b/notebooks/planning_hierarchical_search.py new file mode 100644 index 000000000..faeb5b3a9 --- /dev/null +++ b/notebooks/planning_hierarchical_search.py @@ -0,0 +1,141 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Hierarchical Search +# +# Hierarchical search is a a planning algorithm in high level of abstraction.
+# Instead of actions as in classical planning (chapter 10) (primitive actions) we now use high level actions (HLAs) (see planning.ipynb)
+# +# ## Refinements +# +# Each __HLA__ has one or more refinements into a sequence of actions, each of which may be an HLA or a primitive action (which has no refinements by definition).
+# For example: +# - (a) the high level action "Go to San Fransisco airport" (Go(Home, SFO)), might have two possible refinements, "Drive to San Fransisco airport" and "Taxi to San Fransisco airport". +#
+# - (b) A recursive refinement for navigation in the vacuum world would be: to get to a +# destination, take a step, and then go to the destination. +#
+# ![title](images/refinement.png) +#
+# - __implementation__: An HLA refinement that contains only primitive actions is called an implementation of the HLA +# - An implementation of a high-level plan (a sequence of HLAs) is the concatenation of implementations of each HLA in the sequence +# - A high-level plan __achieves the goal__ from a given state if at least one of its implementations achieves the goal from that state +#
+# +# The refinements function input is: +# - __hla__: the HLA of which we want to compute its refinements +# - __state__: the knoweledge base of the current problem (Problem.init) +# - __library__: the hierarchy of the actions in the planning problem +# +# + +# %% +from aima.planning import * +from aima.notebook_utils import psource + +# %% +psource(RealWorldPlanningProblem.refinements) + +# %% [markdown] +# ## Hierarchical search +# +# Hierarchical search is a breadth-first implementation of hierarchical forward planning search in the space of refinements. (i.e. repeatedly choose an HLA in the current plan and replace it with one of its refinements, until the plan achieves the goal.) +# +#
+# The algorithms input is: problem and hierarchy +# - __problem__: is of type Problem +# - __hierarchy__: is a dictionary consisting of all the actions and the order in which they are performed. +#
+# +# In top level call, initial_plan contains [act] (i.e. is the action to be performed) + +# %% +psource(RealWorldPlanningProblem.hierarchical_search) + +# %% [markdown] +# ## Example +# +# Suppose that somebody wants to get to the airport. +# The possible ways to do so is either get a taxi, or drive to the airport.
+# Those two actions have some preconditions and some effects. +# If you get the taxi, you need to have cash, whereas if you drive you need to have a car.
+# Thus we define the following hierarchy of possible actions. +# +# ##### hierarchy + +# %% +library = { + 'HLA': ['Go(Home,SFO)', 'Go(Home,SFO)', 'Drive(Home, SFOLongTermParking)', 'Shuttle(SFOLongTermParking, SFO)', 'Taxi(Home, SFO)'], + 'steps': [['Drive(Home, SFOLongTermParking)', 'Shuttle(SFOLongTermParking, SFO)'], ['Taxi(Home, SFO)'], [], [], []], + 'precond': [['At(Home) & Have(Car)'], ['At(Home)'], ['At(Home) & Have(Car)'], ['At(SFOLongTermParking)'], ['At(Home)']], + 'effect': [['At(SFO) & ~At(Home)'], ['At(SFO) & ~At(Home) & ~Have(Cash)'], ['At(SFOLongTermParking) & ~At(Home)'], ['At(SFO) & ~At(LongTermParking)'], ['At(SFO) & ~At(Home) & ~Have(Cash)']] } + + + +# %% [markdown] +# +# the possible actions are the following: + +# %% +go_SFO = HLA('Go(Home,SFO)', precond='At(Home)', effect='At(SFO) & ~At(Home)') +taxi_SFO = HLA('Taxi(Home,SFO)', precond='At(Home)', effect='At(SFO) & ~At(Home) & ~Have(Cash)') +drive_SFOLongTermParking = HLA('Drive(Home, SFOLongTermParking)', 'At(Home) & Have(Car)','At(SFOLongTermParking) & ~At(Home)' ) +shuttle_SFO = HLA('Shuttle(SFOLongTermParking, SFO)', 'At(SFOLongTermParking)', 'At(SFO) & ~At(LongTermParking)') + +# %% [markdown] +# Suppose that (our preconditionds are that) we are Home and we have cash and car and our goal is to get to SFO and maintain our cash, and our possible actions are the above.
+# ##### Then our problem is: + +# %% +prob = RealWorldPlanningProblem('At(Home) & Have(Cash) & Have(Car)', 'At(SFO) & Have(Cash)', [go_SFO]) + +# %% [markdown] +# ##### Refinements +# +# The refinements of the action Go(Home, SFO), are defined as:
+# ['Drive(Home,SFOLongTermParking)', 'Shuttle(SFOLongTermParking, SFO)'], ['Taxi(Home, SFO)'] + +# %% +for sequence in RealWorldPlanningProblem.refinements(go_SFO, library): + print (sequence) + print([x.__dict__ for x in sequence ], '\n') + +# %% [markdown] +# Run the hierarchical search +# ##### Top level call + +# %% +plan= RealWorldPlanningProblem.hierarchical_search(prob, library) +print (plan, '\n') +print ([x.__dict__ for x in plan]) + +# %% [markdown] +# ## Example 2 + +# %% +library_2 = { + 'HLA': ['Go(Home,SFO)', 'Go(Home,SFO)', 'Bus(Home, MetroStop)', 'Metro(MetroStop, SFO)' , 'Metro(MetroStop, SFO)', 'Metro1(MetroStop, SFO)', 'Metro2(MetroStop, SFO)' ,'Taxi(Home, SFO)'], + 'steps': [['Bus(Home, MetroStop)', 'Metro(MetroStop, SFO)'], ['Taxi(Home, SFO)'], [], ['Metro1(MetroStop, SFO)'], ['Metro2(MetroStop, SFO)'],[],[],[]], + 'precond': [['At(Home)'], ['At(Home)'], ['At(Home)'], ['At(MetroStop)'], ['At(MetroStop)'],['At(MetroStop)'], ['At(MetroStop)'] ,['At(Home) & Have(Cash)']], + 'effect': [['At(SFO) & ~At(Home)'], ['At(SFO) & ~At(Home) & ~Have(Cash)'], ['At(MetroStop) & ~At(Home)'], ['At(SFO) & ~At(MetroStop)'], ['At(SFO) & ~At(MetroStop)'], ['At(SFO) & ~At(MetroStop)'] , ['At(SFO) & ~At(MetroStop)'] ,['At(SFO) & ~At(Home) & ~Have(Cash)']] + } + +# %% +plan_2 = RealWorldPlanningProblem.hierarchical_search(prob, library_2) +print(plan_2, '\n') +print([x.__dict__ for x in plan_2]) diff --git a/planning_partial_order_planner.ipynb b/notebooks/planning_partial_order_planner.ipynb similarity index 50% rename from planning_partial_order_planner.ipynb rename to notebooks/planning_partial_order_planner.ipynb index 4b1a98bb3..7c8578604 100644 --- a/planning_partial_order_planner.ipynb +++ b/notebooks/planning_partial_order_planner.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -21,119 +30,168 @@ }, { "cell_type": "code", - "execution_count": 3, - "metadata": {}, + "execution_count": 1, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T10:32:40.235651Z", + "iopub.status.busy": "2026-06-27T10:32:40.235010Z", + "iopub.status.idle": "2026-06-27T10:32:48.601297Z", + "shell.execute_reply": "2026-06-27T10:32:48.586503Z" + } + }, "outputs": [], "source": [ - "from planning import *\n", - "from notebook import psource" + "from aima.planning import *\n", + "from aima.notebook_utils import psource" ] }, { "cell_type": "code", - "execution_count": 4, - "metadata": {}, + "execution_count": 2, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T10:32:48.621379Z", + "iopub.status.busy": "2026-06-27T10:32:48.615980Z", + "iopub.status.idle": "2026-06-27T10:32:49.681388Z", + "shell.execute_reply": "2026-06-27T10:32:49.670955Z" + } + }, "outputs": [ { "data": { "text/html": [ "\n", - "\n", + "\n", "\n", "\n", " \n", " \n", " \n", "\n", "\n", "

\n", "\n", - "
class PartialOrderPlanner:\n",
-       "\n",
-       "    def __init__(self, planningproblem):\n",
-       "        self.planningproblem = planningproblem\n",
-       "        self.initialize()\n",
-       "\n",
-       "    def initialize(self):\n",
-       "        """Initialize all variables"""\n",
+       "
class PartialOrderPlanner:\n",
+       "    """\n",
+       "    [Section 10.13] PARTIAL-ORDER-PLANNER\n",
+       "\n",
+       "    Partially ordered plans are created by a search through the space of plans\n",
+       "    rather than a search through the state space. It views planning as a refinement of partially ordered plans.\n",
+       "    A partially ordered plan is defined by a set of actions and a set of constraints of the form A < B,\n",
+       "    which denotes that action A has to be performed before action B.\n",
+       "    To summarize the working of a partial order planner,\n",
+       "    1. An open precondition is selected (a sub-goal that we want to achieve).\n",
+       "    2. An action that fulfils the open precondition is chosen.\n",
+       "    3. Temporal constraints are updated.\n",
+       "    4. Existing causal links are protected. Protection is a method that checks if the causal links conflict\n",
+       "       and if they do, temporal constraints are added to fix the threats.\n",
+       "    5. The set of open preconditions is updated.\n",
+       "    6. Temporal constraints of the selected action and the next action are established.\n",
+       "    7. A new causal link is added between the selected action and the owner of the open precondition.\n",
+       "    8. The set of new causal links is checked for threats and if found, the threat is removed by either promotion or\n",
+       "       demotion. If promotion or demotion is unable to solve the problem, the planning problem cannot be solved with\n",
+       "       the current sequence of actions or it may not be solvable at all.\n",
+       "    9. These steps are repeated until the set of open preconditions is empty.\n",
+       "    """\n",
+       "\n",
+       "    def __init__(self, planning_problem):\n",
+       "        self.tries = 1\n",
+       "        # safety bounds for the backtracking search in execute(): the maximum\n",
+       "        # number of actions a plan may contain (iterative-deepening target) and\n",
+       "        # the maximum number of node expansions per deepening level\n",
+       "        self._max_plan_actions = 12\n",
+       "        self._max_expansions = 20000\n",
+       "        self.planning_problem = planning_problem\n",
        "        self.causal_links = []\n",
-       "        self.start = Action('Start', [], self.planningproblem.init)\n",
-       "        self.finish = Action('Finish', self.planningproblem.goals, [])\n",
+       "        self.start = Action('Start', [], self.planning_problem.initial)\n",
+       "        self.finish = Action('Finish', self.planning_problem.goals, [])\n",
        "        self.actions = set()\n",
        "        self.actions.add(self.start)\n",
        "        self.actions.add(self.finish)\n",
@@ -142,93 +200,39 @@
        "        self.agenda = set()\n",
        "        for precond in self.finish.precond:\n",
        "            self.agenda.add((precond, self.finish))\n",
-       "        self.expanded_actions = self.expand_actions()\n",
-       "\n",
-       "    def expand_actions(self, name=None):\n",
-       "        """Generate all possible actions with variable bindings for precondition selection heuristic"""\n",
-       "\n",
-       "        objects = set(arg for clause in self.planningproblem.init for arg in clause.args)\n",
-       "        expansions = []\n",
-       "        action_list = []\n",
-       "        if name is not None:\n",
-       "            for action in self.planningproblem.actions:\n",
-       "                if str(action.name) == name:\n",
-       "                    action_list.append(action)\n",
-       "        else:\n",
-       "            action_list = self.planningproblem.actions\n",
-       "\n",
-       "        for action in action_list:\n",
-       "            for permutation in itertools.permutations(objects, len(action.args)):\n",
-       "                bindings = unify(Expr(action.name, *action.args), Expr(action.name, *permutation))\n",
-       "                if bindings is not None:\n",
-       "                    new_args = []\n",
-       "                    for arg in action.args:\n",
-       "                        if arg in bindings:\n",
-       "                            new_args.append(bindings[arg])\n",
-       "                        else:\n",
-       "                            new_args.append(arg)\n",
-       "                    new_expr = Expr(str(action.name), *new_args)\n",
-       "                    new_preconds = []\n",
-       "                    for precond in action.precond:\n",
-       "                        new_precond_args = []\n",
-       "                        for arg in precond.args:\n",
-       "                            if arg in bindings:\n",
-       "                                new_precond_args.append(bindings[arg])\n",
-       "                            else:\n",
-       "                                new_precond_args.append(arg)\n",
-       "                        new_precond = Expr(str(precond.op), *new_precond_args)\n",
-       "                        new_preconds.append(new_precond)\n",
-       "                    new_effects = []\n",
-       "                    for effect in action.effect:\n",
-       "                        new_effect_args = []\n",
-       "                        for arg in effect.args:\n",
-       "                            if arg in bindings:\n",
-       "                                new_effect_args.append(bindings[arg])\n",
-       "                            else:\n",
-       "                                new_effect_args.append(arg)\n",
-       "                        new_effect = Expr(str(effect.op), *new_effect_args)\n",
-       "                        new_effects.append(new_effect)\n",
-       "                    expansions.append(Action(new_expr, new_preconds, new_effects))\n",
-       "\n",
-       "        return expansions\n",
-       "\n",
-       "    def find_open_precondition(self):\n",
-       "        """Find open precondition with the least number of possible actions"""\n",
-       "\n",
+       "        self.expanded_actions = planning_problem.expand_actions()\n",
+       "\n",
+       "    def find_open_precondition(self):\n",
+       "        """\n",
+       "        Find the open precondition with the least number of achieving actions\n",
+       "        (a most-constrained-variable heuristic). Returns the triple\n",
+       "        (precondition, action_that_needs_it, [achieving_actions]). Iteration is\n",
+       "        ordered deterministically so the search does not depend on set/hash\n",
+       "        ordering. Returns (None, None, None) when some open precondition has no\n",
+       "        achiever at all, which is a dead end for the current partial plan.\n",
+       "        """\n",
+       "        possible_actions = list(self.actions) + self.expanded_actions\n",
        "        number_of_ways = dict()\n",
        "        actions_for_precondition = dict()\n",
-       "        for element in self.agenda:\n",
-       "            open_precondition = element[0]\n",
-       "            possible_actions = list(self.actions) + self.expanded_actions\n",
-       "            for action in possible_actions:\n",
-       "                for effect in action.effect:\n",
-       "                    if effect == open_precondition:\n",
-       "                        if open_precondition in number_of_ways:\n",
-       "                            number_of_ways[open_precondition] += 1\n",
-       "                            actions_for_precondition[open_precondition].append(action)\n",
-       "                        else:\n",
-       "                            number_of_ways[open_precondition] = 1\n",
-       "                            actions_for_precondition[open_precondition] = [action]\n",
-       "\n",
-       "        number = sorted(number_of_ways, key=number_of_ways.__getitem__)\n",
-       "        \n",
-       "        for k, v in number_of_ways.items():\n",
-       "            if v == 0:\n",
-       "                return None, None, None\n",
-       "\n",
-       "        act1 = None\n",
-       "        for element in self.agenda:\n",
-       "            if element[0] == number[0]:\n",
-       "                act1 = element[1]\n",
-       "                break\n",
-       "\n",
-       "        if number[0] in self.expanded_actions:\n",
-       "            self.expanded_actions.remove(number[0])\n",
-       "\n",
-       "        return number[0], act1, actions_for_precondition[number[0]]\n",
-       "\n",
-       "    def find_action_for_precondition(self, oprec):\n",
-       "        """Find action for a given precondition"""\n",
+       "        for open_precondition, act in sorted(self.agenda, key=str):\n",
+       "            if open_precondition in number_of_ways:\n",
+       "                continue\n",
+       "            achievers = [action for action in possible_actions\n",
+       "                         if any(effect == open_precondition for effect in action.effect)]\n",
+       "            if not achievers:\n",
+       "                return None, None, None\n",
+       "            number_of_ways[open_precondition] = len(achievers)\n",
+       "            actions_for_precondition[open_precondition] = achievers\n",
+       "\n",
+       "        if not number_of_ways:\n",
+       "            return None, None, None\n",
+       "\n",
+       "        chosen = min(number_of_ways, key=lambda p: (number_of_ways[p], str(p)))\n",
+       "        act1 = next(act for precond, act in sorted(self.agenda, key=str) if precond == chosen)\n",
+       "        return chosen, act1, actions_for_precondition[chosen]\n",
+       "\n",
+       "    def find_action_for_precondition(self, oprec):\n",
+       "        """Find action for a given precondition"""\n",
        "\n",
        "        # either\n",
        "        #   choose act0 E Actions such that act0 achieves G\n",
@@ -239,16 +243,16 @@
        "\n",
        "        # or\n",
        "        #   choose act0 E Actions such that act0 achieves G\n",
-       "        for action in self.planningproblem.actions:\n",
+       "        for action in self.planning_problem.actions:\n",
        "            for effect in action.effect:\n",
        "                if effect.op == oprec.op:\n",
-       "                    bindings = unify(effect, oprec)\n",
-       "                    if bindings is None:\n",
+       "                    bindings = unify_mm(effect, oprec)\n",
+       "                    if bindings is None:\n",
        "                        break\n",
        "                    return action, bindings\n",
        "\n",
-       "    def generate_expr(self, clause, bindings):\n",
-       "        """Generate atomic expression from generic expression given variable bindings"""\n",
+       "    def generate_expr(self, clause, bindings):\n",
+       "        """Generate atomic expression from generic expression given variable bindings"""\n",
        "\n",
        "        new_args = []\n",
        "        for arg in clause.args:\n",
@@ -261,9 +265,9 @@
        "            return Expr(str(clause.name), *new_args)\n",
        "        except:\n",
        "            return Expr(str(clause.op), *new_args)\n",
-       "        \n",
-       "    def generate_action_object(self, action, bindings):\n",
-       "        """Generate action object given a generic action andvariable bindings"""\n",
+       "\n",
+       "    def generate_action_object(self, action, bindings):\n",
+       "        """Generate action object given a generic action and variable bindings"""\n",
        "\n",
        "        # if bindings is 0, it means the action already exists in self.actions\n",
        "        if bindings == 0:\n",
@@ -282,8 +286,8 @@
        "                new_effects.append(new_effect)\n",
        "            return Action(new_expr, new_preconds, new_effects)\n",
        "\n",
-       "    def cyclic(self, graph):\n",
-       "        """Check cyclicity of a directed graph"""\n",
+       "    def cyclic(self, graph):\n",
+       "        """Check cyclicity of a directed graph"""\n",
        "\n",
        "        new_graph = dict()\n",
        "        for element in graph:\n",
@@ -294,19 +298,19 @@
        "\n",
        "        path = set()\n",
        "\n",
-       "        def visit(vertex):\n",
+       "        def visit(vertex):\n",
        "            path.add(vertex)\n",
        "            for neighbor in new_graph.get(vertex, ()):\n",
        "                if neighbor in path or visit(neighbor):\n",
-       "                    return True\n",
+       "                    return True\n",
        "            path.remove(vertex)\n",
-       "            return False\n",
+       "            return False\n",
        "\n",
        "        value = any(visit(v) for v in new_graph)\n",
        "        return value\n",
        "\n",
-       "    def add_const(self, constraint, constraints):\n",
-       "        """Add the constraint to constraints if the resulting graph is acyclic"""\n",
+       "    def add_const(self, constraint, constraints):\n",
+       "        """Add the constraint to constraints if the resulting graph is acyclic"""\n",
        "\n",
        "        if constraint[0] == self.finish or constraint[1] == self.start:\n",
        "            return constraints\n",
@@ -318,21 +322,21 @@
        "            return constraints\n",
        "        return new_constraints\n",
        "\n",
-       "    def is_a_threat(self, precondition, effect):\n",
-       "        """Check if effect is a threat to precondition"""\n",
+       "    def is_a_threat(self, precondition, effect):\n",
+       "        """Check if effect is a threat to precondition"""\n",
        "\n",
        "        if (str(effect.op) == 'Not' + str(precondition.op)) or ('Not' + str(effect.op) == str(precondition.op)):\n",
        "            if effect.args == precondition.args:\n",
-       "                return True\n",
-       "        return False\n",
+       "                return True\n",
+       "        return False\n",
        "\n",
-       "    def protect(self, causal_link, action, constraints):\n",
-       "        """Check and resolve threats by promotion or demotion"""\n",
+       "    def protect(self, causal_link, action, constraints):\n",
+       "        """Check and resolve threats by promotion or demotion"""\n",
        "\n",
-       "        threat = False\n",
+       "        threat = False\n",
        "        for effect in action.effect:\n",
        "            if self.is_a_threat(causal_link[1], effect):\n",
-       "                threat = True\n",
+       "                threat = True\n",
        "                break\n",
        "\n",
        "        if action != causal_link[0] and action != causal_link[2] and threat:\n",
@@ -349,12 +353,12 @@
        "                    constraints = self.add_const((causal_link[2], action), constraints)\n",
        "                else:\n",
        "                    # both promotion and demotion fail\n",
-       "                    print('Unable to resolve a threat caused by', action, 'onto', causal_link)\n",
+       "                    print('Unable to resolve a threat caused by', action, 'onto', causal_link)\n",
        "                    return\n",
        "        return constraints\n",
        "\n",
-       "    def convert(self, constraints):\n",
-       "        """Convert constraints into a dict of Action to set orderings"""\n",
+       "    def convert(self, constraints):\n",
+       "        """Convert constraints into a dict of Action to set orderings"""\n",
        "\n",
        "        graph = dict()\n",
        "        for constraint in constraints:\n",
@@ -365,8 +369,8 @@
        "                graph[constraint[0]].add(constraint[1])\n",
        "        return graph\n",
        "\n",
-       "    def toposort(self, graph):\n",
-       "        """Generate topological ordering of constraints"""\n",
+       "    def toposort(self, graph):\n",
+       "        """Generate topological ordering of constraints"""\n",
        "\n",
        "        if len(graph) == 0:\n",
        "            return\n",
@@ -378,87 +382,158 @@
        "\n",
        "        extra_elements_in_dependencies = _reduce(set.union, graph.values()) - set(graph.keys())\n",
        "\n",
-       "        graph.update({element:set() for element in extra_elements_in_dependencies})\n",
-       "        while True:\n",
+       "        graph.update({element: set() for element in extra_elements_in_dependencies})\n",
+       "        while True:\n",
        "            ordered = set(element for element, dependency in graph.items() if len(dependency) == 0)\n",
        "            if not ordered:\n",
        "                break\n",
        "            yield ordered\n",
-       "            graph = {element: (dependency - ordered) for element, dependency in graph.items() if element not in ordered}\n",
+       "            graph = {element: (dependency - ordered)\n",
+       "                     for element, dependency in graph.items()\n",
+       "                     if element not in ordered}\n",
        "        if len(graph) != 0:\n",
        "            raise ValueError('The graph is not acyclic and cannot be linearly ordered')\n",
        "\n",
-       "    def display_plan(self):\n",
-       "        """Display causal links, constraints and the plan"""\n",
+       "    def display_plan(self):\n",
+       "        """Display causal links, constraints and the plan"""\n",
        "\n",
-       "        print('Causal Links')\n",
+       "        print('Causal Links')\n",
        "        for causal_link in self.causal_links:\n",
-       "            print(causal_link)\n",
+       "            print(causal_link)\n",
        "\n",
-       "        print('\\nConstraints')\n",
+       "        print('\\n_constraints')\n",
        "        for constraint in self.constraints:\n",
-       "            print(constraint[0], '<', constraint[1])\n",
-       "\n",
-       "        print('\\nPartial Order Plan')\n",
-       "        print(list(reversed(list(self.toposort(self.convert(self.constraints))))))\n",
-       "\n",
-       "    def execute(self, display=True):\n",
-       "        """Execute the algorithm"""\n",
-       "\n",
-       "        step = 1\n",
-       "        self.tries = 1\n",
-       "        while len(self.agenda) > 0:\n",
-       "            step += 1\n",
-       "            # select <G, act1> from Agenda\n",
-       "            try:\n",
-       "                G, act1, possible_actions = self.find_open_precondition()\n",
-       "            except IndexError:\n",
-       "                print('Probably Wrong')\n",
-       "                break\n",
-       "\n",
-       "            act0 = possible_actions[0]\n",
-       "            # remove <G, act1> from Agenda\n",
-       "            self.agenda.remove((G, act1))\n",
-       "\n",
-       "            # For actions with variable number of arguments, use least commitment principle\n",
-       "            # act0_temp, bindings = self.find_action_for_precondition(G)\n",
-       "            # act0 = self.generate_action_object(act0_temp, bindings)\n",
-       "\n",
-       "            # Actions = Actions U {act0}\n",
+       "            print(constraint[0], '<', constraint[1])\n",
+       "\n",
+       "        print('\\n_partial Order Plan')\n",
+       "        print(list(reversed(list(self.toposort(self.convert(self.constraints))))))\n",
+       "\n",
+       "    def execute(self, display=True):\n",
+       "        """\n",
+       "        Execute the algorithm with backtracking, using iterative deepening on the\n",
+       "        number of actions in the plan. The original greedy version committed to\n",
+       "        the first achiever it happened to iterate over and could not recover when\n",
+       "        that action's own preconditions turned out to be unsatisfiable, so it\n",
+       "        depended on hash ordering and often printed 'Probably Wrong' / "Couldn't\n",
+       "        find a solution". Backtracking over both action choices and threat\n",
+       "        resolution (promotion vs demotion), together with the deterministic\n",
+       "        selection in find_open_precondition and a smallest-plan-first deepening\n",
+       "        bound, makes the planner solve the standard problems reproducibly and\n",
+       "        return a short, valid plan.\n",
+       "        """\n",
+       "        pristine = self._snapshot()\n",
+       "        for limit in range(1, self._max_plan_actions + 1):\n",
+       "            self._restore(pristine)\n",
+       "            if self._search([self._max_expansions], limit):\n",
+       "                if display:\n",
+       "                    self.display_plan()\n",
+       "                else:\n",
+       "                    return self.constraints, self.causal_links\n",
+       "                return\n",
+       "        print("Couldn't find a solution")\n",
+       "        if not display:\n",
+       "            return None, None\n",
+       "\n",
+       "    def _reachable(self, source, target):\n",
+       "        """True if target is forced to come after source by the ordering constraints"""\n",
+       "\n",
+       "        stack, seen = [source], set()\n",
+       "        while stack:\n",
+       "            node = stack.pop()\n",
+       "            if node == target:\n",
+       "                return True\n",
+       "            if node in seen:\n",
+       "                continue\n",
+       "            seen.add(node)\n",
+       "            stack.extend(b for a, b in self.constraints if a == node)\n",
+       "        return False\n",
+       "\n",
+       "    def _open_threat(self):\n",
+       "        """\n",
+       "        Return an (action, causal_link) threat that is not yet resolved by the\n",
+       "        ordering constraints, or None if every causal link is protected. A\n",
+       "        causal link (a0, p, a1) is threatened by an action whose effect negates p\n",
+       "        unless the action is already ordered before a0 (promotion) or after a1\n",
+       "        (demotion).\n",
+       "        """\n",
+       "        for a0, p, a1 in self.causal_links:\n",
+       "            for action in self.actions:\n",
+       "                if action == a0 or action == a1:\n",
+       "                    continue\n",
+       "                if any(self.is_a_threat(p, effect) for effect in action.effect):\n",
+       "                    if not (self._reachable(action, a0) or self._reachable(a1, action)):\n",
+       "                        return action, (a0, p, a1)\n",
+       "        return None\n",
+       "\n",
+       "    def _snapshot(self):\n",
+       "        return set(self.actions), set(self.constraints), list(self.causal_links), set(self.agenda)\n",
+       "\n",
+       "    def _restore(self, snapshot):\n",
+       "        self.actions, self.constraints, self.causal_links, self.agenda = (\n",
+       "            set(snapshot[0]), set(snapshot[1]), list(snapshot[2]), set(snapshot[3]))\n",
+       "\n",
+       "    def _search(self, budget, limit):\n",
+       "        """\n",
+       "        Recursively complete the partial plan, backtracking on failure. Three\n",
+       "        kinds of choice points are explored: which action satisfies an open\n",
+       "        precondition, how each threat is resolved (promotion vs demotion), and -\n",
+       "        bounded by 'limit' - whether to introduce a new action at all. Returns\n",
+       "        True and leaves the solution in self.* on success.\n",
+       "        """\n",
+       "        if budget[0] <= 0:\n",
+       "            return False\n",
+       "        budget[0] -= 1\n",
+       "\n",
+       "        # first, resolve any outstanding threat to a causal link (choice point)\n",
+       "        threat = self._open_threat()\n",
+       "        if threat is not None:\n",
+       "            action, (a0, p, a1) = threat\n",
+       "            snapshot = self._snapshot()\n",
+       "            for ordering in ((action, a0), (a1, action)):  # promotion, then demotion\n",
+       "                new_constraints = self.add_const(ordering, self.constraints)\n",
+       "                if ordering in new_constraints:  # ordering was consistent (acyclic and allowed)\n",
+       "                    self.constraints = new_constraints\n",
+       "                    if self._search(budget, limit):\n",
+       "                        return True\n",
+       "                self._restore(snapshot)\n",
+       "            return False\n",
+       "\n",
+       "        # no open threats: a plan with an empty agenda is a complete solution\n",
+       "        if not self.agenda:\n",
+       "            return True\n",
+       "\n",
+       "        # select <G, act1> from the agenda (most-constrained precondition first)\n",
+       "        G, act1, possible_actions = self.find_open_precondition()\n",
+       "        if G is None:  # an open precondition has no achiever -> dead end\n",
+       "            return False\n",
+       "\n",
+       "        # number of actions already introduced, excluding the dummy Start/Finish\n",
+       "        introduced = len(self.actions) - 2\n",
+       "        snapshot = self._snapshot()\n",
+       "        # try each achiever deterministically, reusing existing actions first\n",
+       "        for act0 in sorted(set(possible_actions), key=lambda a: (a not in self.actions, str(a))):\n",
+       "            is_new = act0 not in self.actions\n",
+       "            if is_new and introduced >= limit:  # deepening bound on plan size\n",
+       "                continue\n",
+       "            self.agenda.discard((G, act1))\n",
        "            self.actions.add(act0)\n",
-       "\n",
-       "            # Constraints = add_const(start < act0, Constraints)\n",
        "            self.constraints = self.add_const((self.start, act0), self.constraints)\n",
-       "\n",
-       "            # for each CL E CausalLinks do\n",
-       "            #   Constraints = protect(CL, act0, Constraints)\n",
-       "            for causal_link in self.causal_links:\n",
-       "                self.constraints = self.protect(causal_link, act0, self.constraints)\n",
-       "\n",
-       "            # Agenda = Agenda U {<P, act0>: P is a precondition of act0}\n",
-       "            for precondition in act0.precond:\n",
-       "                self.agenda.add((precondition, act0))\n",
-       "\n",
-       "            # Constraints = add_const(act0 < act1, Constraints)\n",
        "            self.constraints = self.add_const((act0, act1), self.constraints)\n",
-       "\n",
-       "            # CausalLinks U {<act0, G, act1>}\n",
-       "            if (act0, G, act1) not in self.causal_links:\n",
-       "                self.causal_links.append((act0, G, act1))\n",
-       "\n",
-       "            # for each A E Actions do\n",
-       "            #   Constraints = protect(<act0, G, act1>, A, Constraints)\n",
-       "            for action in self.actions:\n",
-       "                self.constraints = self.protect((act0, G, act1), action, self.constraints)\n",
-       "\n",
-       "            if step > 200:\n",
-       "                print('Couldn\\'t find a solution')\n",
-       "                return None, None\n",
-       "\n",
-       "        if display:\n",
-       "            self.display_plan()\n",
-       "        else:\n",
-       "            return self.constraints, self.causal_links                \n",
+       "            # the causal link act0 --G--> act1 requires act0 strictly before act1\n",
+       "            # (and after start); add_const drops an ordering that would create a\n",
+       "            # cycle, so reject the choice when the required ordering is not enforced\n",
+       "            if ((act0 == act1 or self._reachable(act0, act1)) and\n",
+       "                    (act0 == self.start or self._reachable(self.start, act0))):\n",
+       "                if (act0, G, act1) not in self.causal_links:\n",
+       "                    self.causal_links.append((act0, G, act1))\n",
+       "                if is_new:  # a freshly introduced action contributes its own preconditions\n",
+       "                    for precondition in act0.precond:\n",
+       "                        self.agenda.add((precondition, act0))\n",
+       "                if self._search(budget, limit):\n",
+       "                    return True\n",
+       "            # undo and try the next achiever\n",
+       "            self._restore(snapshot)\n",
+       "        return False\n",
        "
\n", "\n", "\n" @@ -591,34 +666,43 @@ }, { "cell_type": "code", - "execution_count": 5, - "metadata": {}, + "execution_count": 3, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T10:32:50.028987Z", + "iopub.status.busy": "2026-06-27T10:32:50.028030Z", + "iopub.status.idle": "2026-06-27T10:32:50.093174Z", + "shell.execute_reply": "2026-06-27T10:32:50.087372Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Causal Links\n", - "(Action(PutOn(Spare, Axle)), At(Spare, Axle), Action(Finish))\n", - "(Action(Start), Tire(Spare), Action(PutOn(Spare, Axle)))\n", - "(Action(Remove(Flat, Axle)), NotAt(Flat, Axle), Action(PutOn(Spare, Axle)))\n", - "(Action(Start), At(Flat, Axle), Action(Remove(Flat, Axle)))\n", - "(Action(Remove(Spare, Trunk)), At(Spare, Ground), Action(PutOn(Spare, Axle)))\n", - "(Action(Start), At(Spare, Trunk), Action(Remove(Spare, Trunk)))\n", - "(Action(Remove(Flat, Axle)), At(Flat, Ground), Action(Finish))\n", + "(PutOn(Spare, Axle), At(Spare, Axle), Finish)\n", + "(Start, Tire(Spare), PutOn(Spare, Axle))\n", + "(Remove(Flat, Axle), NotAt(Flat, Axle), PutOn(Spare, Axle))\n", + "(Start, Tire(Flat), Remove(Flat, Axle))\n", + "(Start, At(Flat, Axle), Remove(Flat, Axle))\n", + "(Remove(Spare, Trunk), At(Spare, Ground), PutOn(Spare, Axle))\n", + "(Start, At(Spare, Trunk), Remove(Spare, Trunk))\n", + "(Start, Tire(Spare), Remove(Spare, Trunk))\n", + "(Remove(Flat, Axle), At(Flat, Ground), Finish)\n", "\n", - "Constraints\n", - "Action(Remove(Flat, Axle)) < Action(PutOn(Spare, Axle))\n", - "Action(Start) < Action(Finish)\n", - "Action(Remove(Spare, Trunk)) < Action(PutOn(Spare, Axle))\n", - "Action(Start) < Action(Remove(Spare, Trunk))\n", - "Action(Start) < Action(Remove(Flat, Axle))\n", - "Action(Remove(Flat, Axle)) < Action(Finish)\n", - "Action(PutOn(Spare, Axle)) < Action(Finish)\n", - "Action(Start) < Action(PutOn(Spare, Axle))\n", + "_constraints\n", + "Start < Remove(Flat, Axle)\n", + "PutOn(Spare, Axle) < Finish\n", + "Remove(Flat, Axle) < Finish\n", + "Remove(Flat, Axle) < PutOn(Spare, Axle)\n", + "Start < PutOn(Spare, Axle)\n", + "Remove(Spare, Trunk) < PutOn(Spare, Axle)\n", + "Start < Remove(Spare, Trunk)\n", + "Start < Finish\n", "\n", - "Partial Order Plan\n", - "[{Action(Start)}, {Action(Remove(Flat, Axle)), Action(Remove(Spare, Trunk))}, {Action(PutOn(Spare, Axle))}, {Action(Finish)}]\n" + "_partial Order Plan\n", + "[{Start}, {Remove(Flat, Axle), Remove(Spare, Trunk)}, {PutOn(Spare, Axle)}, {Finish}]\n" ] } ], @@ -639,38 +723,45 @@ }, { "cell_type": "code", - "execution_count": 6, - "metadata": {}, + "execution_count": 4, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T10:32:50.102953Z", + "iopub.status.busy": "2026-06-27T10:32:50.102022Z", + "iopub.status.idle": "2026-06-27T10:32:50.144839Z", + "shell.execute_reply": "2026-06-27T10:32:50.140058Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Causal Links\n", - "(Action(FromTable(C, B)), On(C, B), Action(Finish))\n", - "(Action(FromTable(B, A)), On(B, A), Action(Finish))\n", - "(Action(Start), OnTable(B), Action(FromTable(B, A)))\n", - "(Action(Start), OnTable(C), Action(FromTable(C, B)))\n", - "(Action(Start), Clear(C), Action(FromTable(C, B)))\n", - "(Action(Start), Clear(A), Action(FromTable(B, A)))\n", - "(Action(ToTable(A, B)), Clear(B), Action(FromTable(C, B)))\n", - "(Action(Start), On(A, B), Action(ToTable(A, B)))\n", - "(Action(ToTable(A, B)), Clear(B), Action(FromTable(B, A)))\n", - "(Action(Start), Clear(A), Action(ToTable(A, B)))\n", + "(FromTable(B, A), On(B, A), Finish)\n", + "(FromTable(C, B), On(C, B), Finish)\n", + "(ToTable(A, B), Clear(B), FromTable(B, A))\n", + "(Start, On(A, B), ToTable(A, B))\n", + "(Start, Clear(A), FromTable(B, A))\n", + "(Start, Clear(A), ToTable(A, B))\n", + "(ToTable(A, B), Clear(B), FromTable(C, B))\n", + "(Start, Clear(C), FromTable(C, B))\n", + "(Start, OnTable(B), FromTable(B, A))\n", + "(Start, OnTable(C), FromTable(C, B))\n", "\n", - "Constraints\n", - "Action(Start) < Action(FromTable(C, B))\n", - "Action(FromTable(B, A)) < Action(FromTable(C, B))\n", - "Action(Start) < Action(FromTable(B, A))\n", - "Action(Start) < Action(ToTable(A, B))\n", - "Action(Start) < Action(Finish)\n", - "Action(FromTable(B, A)) < Action(Finish)\n", - "Action(FromTable(C, B)) < Action(Finish)\n", - "Action(ToTable(A, B)) < Action(FromTable(B, A))\n", - "Action(ToTable(A, B)) < Action(FromTable(C, B))\n", + "_constraints\n", + "Start < FromTable(B, A)\n", + "Start < FromTable(C, B)\n", + "Start < ToTable(A, B)\n", + "ToTable(A, B) < FromTable(B, A)\n", + "Start < Finish\n", + "FromTable(B, A) < FromTable(C, B)\n", + "FromTable(C, B) < Finish\n", + "ToTable(A, B) < FromTable(C, B)\n", + "FromTable(B, A) < Finish\n", "\n", - "Partial Order Plan\n", - "[{Action(Start)}, {Action(ToTable(A, B))}, {Action(FromTable(B, A))}, {Action(FromTable(C, B))}, {Action(Finish)}]\n" + "_partial Order Plan\n", + "[{Start}, {ToTable(A, B)}, {FromTable(B, A)}, {FromTable(C, B)}, {Finish}]\n" ] } ], @@ -691,32 +782,39 @@ }, { "cell_type": "code", - "execution_count": 7, - "metadata": {}, + "execution_count": 5, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T10:32:50.154083Z", + "iopub.status.busy": "2026-06-27T10:32:50.153332Z", + "iopub.status.idle": "2026-06-27T10:32:50.178378Z", + "shell.execute_reply": "2026-06-27T10:32:50.173844Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Causal Links\n", - "(Action(RightShoe), RightShoeOn, Action(Finish))\n", - "(Action(LeftShoe), LeftShoeOn, Action(Finish))\n", - "(Action(LeftSock), LeftSockOn, Action(LeftShoe))\n", - "(Action(RightSock), RightSockOn, Action(RightShoe))\n", + "(LeftShoe, LeftShoeOn, Finish)\n", + "(LeftSock, LeftSockOn, LeftShoe)\n", + "(RightShoe, RightShoeOn, Finish)\n", + "(RightSock, RightSockOn, RightShoe)\n", "\n", - "Constraints\n", - "Action(LeftSock) < Action(LeftShoe)\n", - "Action(RightSock) < Action(RightShoe)\n", - "Action(Start) < Action(RightShoe)\n", - "Action(Start) < Action(Finish)\n", - "Action(LeftShoe) < Action(Finish)\n", - "Action(Start) < Action(RightSock)\n", - "Action(Start) < Action(LeftShoe)\n", - "Action(Start) < Action(LeftSock)\n", - "Action(RightShoe) < Action(Finish)\n", + "_constraints\n", + "LeftSock < LeftShoe\n", + "Start < LeftSock\n", + "Start < RightSock\n", + "Start < RightShoe\n", + "RightSock < RightShoe\n", + "Start < Finish\n", + "LeftShoe < Finish\n", + "Start < LeftShoe\n", + "RightShoe < Finish\n", "\n", - "Partial Order Plan\n", - "[{Action(Start)}, {Action(LeftSock), Action(RightSock)}, {Action(LeftShoe), Action(RightShoe)}, {Action(Finish)}]\n" + "_partial Order Plan\n", + "[{Start}, {LeftSock, RightSock}, {RightShoe, LeftShoe}, {Finish}]\n" ] } ], @@ -751,8 +849,15 @@ }, { "cell_type": "code", - "execution_count": 8, - "metadata": {}, + "execution_count": 6, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T10:32:50.186142Z", + "iopub.status.busy": "2026-06-27T10:32:50.185311Z", + "iopub.status.idle": "2026-06-27T10:32:50.209152Z", + "shell.execute_reply": "2026-06-27T10:32:50.205378Z" + } + }, "outputs": [], "source": [ "ss = socks_and_shoes()" @@ -760,14 +865,21 @@ }, { "cell_type": "code", - "execution_count": 9, - "metadata": {}, + "execution_count": 7, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T10:32:50.217506Z", + "iopub.status.busy": "2026-06-27T10:32:50.216519Z", + "iopub.status.idle": "2026-06-27T10:32:54.755675Z", + "shell.execute_reply": "2026-06-27T10:32:54.754693Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "198 µs ± 3.53 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)\n" + "529 μs ± 70.2 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)\n" ] } ], @@ -778,14 +890,21 @@ }, { "cell_type": "code", - "execution_count": 10, - "metadata": {}, + "execution_count": 8, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T10:32:54.758067Z", + "iopub.status.busy": "2026-06-27T10:32:54.757858Z", + "iopub.status.idle": "2026-06-27T10:33:04.568670Z", + "shell.execute_reply": "2026-06-27T10:33:04.566335Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "844 µs ± 23.8 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)\n" + "1.17 ms ± 123 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)\n" ] } ], @@ -796,14 +915,21 @@ }, { "cell_type": "code", - "execution_count": 11, - "metadata": {}, + "execution_count": 9, + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-27T10:33:04.572932Z", + "iopub.status.busy": "2026-06-27T10:33:04.572557Z", + "iopub.status.idle": "2026-06-27T10:33:15.534624Z", + "shell.execute_reply": "2026-06-27T10:33:15.532043Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "258 µs ± 4.03 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)\n" + "1.24 ms ± 186 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)\n" ] } ], @@ -842,7 +968,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.5.3" + "version": "3.12.13" } }, "nbformat": 4, diff --git a/notebooks/planning_partial_order_planner.py b/notebooks/planning_partial_order_planner.py new file mode 100644 index 000000000..9979c7477 --- /dev/null +++ b/notebooks/planning_partial_order_planner.py @@ -0,0 +1,192 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# ### PARTIAL ORDER PLANNER +# A partial-order planning algorithm is significantly different from a total-order planner. +# The way a partial-order plan works enables it to take advantage of _problem decomposition_ and work on each subproblem separately. +# It works on several subgoals independently, solves them with several subplans, and then combines the plan. +#
+# A partial-order planner also follows the **least commitment** strategy, where it delays making choices for as long as possible. +# Variables are not bound unless it is absolutely necessary and new actions are chosen only if the existing actions cannot fulfil the required precondition. +#
+# Any planning algorithm that can place two actions into a plan without specifying which comes first is called a **partial-order planner**. +# A partial-order planner searches through the space of plans rather than the space of states, which makes it perform better for certain problems. +#
+#
+# Let's have a look at the `PartialOrderPlanner` class. + +# %% +from aima.planning import * +from aima.notebook_utils import psource + +# %% +psource(PartialOrderPlanner) + +# %% [markdown] +# We will first describe the data-structures and helper methods used, followed by the algorithm used to find a partial-order plan. + +# %% [markdown] +# Each plan has the following four components: +# +# 1. **`actions`**: a set of actions that make up the steps of the plan. +# `actions` is always a subset of `pddl.actions` the set of possible actions for the given planning problem. +# The `start` and `finish` actions are dummy actions defined to bring uniformity to the problem. The `start` action has no preconditions and its effects constitute the initial state of the planning problem. +# The `finish` action has no effects and its preconditions constitute the goal state of the planning problem. +# The empty plan consists of just these two dummy actions. +# 2. **`constraints`**: a set of temporal constraints that define the order of performing the actions relative to each other. +# `constraints` does not define a linear ordering, rather it usually represents a directed graph which is also acyclic if the plan is consistent. +# Each ordering is of the form A < B, which reads as "A before B" and means that action A _must_ be executed sometime before action B, but not necessarily immediately before. +# `constraints` stores these as a set of tuples `(Action(A), Action(B))` which is interpreted as given above. +# A constraint cannot be added to `constraints` if it breaks the acyclicity of the existing graph. +# 3. **`causal_links`**: a set of causal-links. +# A causal link between two actions _A_ and _B_ in the plan is written as _A_ --_p_--> _B_ and is read as "A achieves p for B". +# This imples that _p_ is an effect of _A_ and a precondition of _B_. +# It also asserts that _p_ must remain true from the time of action _A_ to the time of action _B_. +# Any violation of this rule is called a threat and must be resolved immediately by adding suitable ordering constraints. +# `causal_links` stores this information as tuples `(Action(A), precondition(p), Action(B))` which is interpreted as given above. +# Causal-links can also be called **protection-intervals**, because the link _A_ --_p_--> _B_ protects _p_ from being negated over the interval from _A_ to _B_. +# 4. **`agenda`**: a set of open-preconditions. +# A precondition is open if it is not achieved by some action in the plan. +# Planners will work to reduce the set of open preconditions to the empty set, without introducing a contradiction. +# `agenda` stored this information as tuples `(precondition(p), Action(A))` where p is a precondition of the action A. +# +# A **consistent plan** is a plan in which there are no cycles in the ordering constraints and no conflicts with the causal-links. +# A consistent plan with no open preconditions is a **solution**. +#
+#
+# Let's briefly glance over the helper functions before going into the actual algorithm. +#
+# **`expand_actions`**: generates all possible actions with variable bindings for use as a heuristic of selection of an open precondition. +#
+# **`find_open_precondition`**: finds a precondition from the agenda with the least number of actions that fulfil that precondition. +# This heuristic helps form mandatory ordering constraints and causal-links to further simplify the problem and reduce the probability of encountering a threat. +#
+# **`find_action_for_precondition`**: finds an action that fulfils the given precondition along with the absolutely necessary variable bindings in accordance with the principle of _least commitment_. +# In case of multiple possible actions, the action with the least number of effects is chosen to minimize the chances of encountering a threat. +#
+# **`cyclic`**: checks if a directed graph is cyclic. +#
+# **`add_const`**: adds `constraint` to `constraints` if the newly formed graph is acyclic and returns `constraints` otherwise. +#
+# **`is_a_threat`**: checks if the given `effect` negates the given `precondition`. +#
+# **`protect`**: checks if the given `action` poses a threat to the given `causal_link`. +# If so, the threat is resolved by either promotion or demotion, whichever generates acyclic temporal constraints. +# If neither promotion or demotion work, the chosen action is not the correct fit or the planning problem cannot be solved altogether. +#
+# **`convert`**: converts a graph from a list of edges to an `Action` : `set` mapping, for use in topological sorting. +#
+# **`toposort`**: a generator function that generates a topological ordering of a given graph as a list of sets. +# Each set contains an action or several actions. +# If a set has more that one action in it, it means that permutations between those actions also produce a valid plan. +#
+# **`display_plan`**: displays the `causal_links`, `constraints` and the partial order plan generated from `toposort`. +#
+ +# %% [markdown] +# The **`execute`** method executes the algorithm, which is summarized below: +#
+# 1. An open precondition is selected (a sub-goal that we want to achieve). +# 2. An action that fulfils the open precondition is chosen. +# 3. Temporal constraints are updated. +# 4. Existing causal links are protected. Protection is a method that checks if the causal links conflict +# and if they do, temporal constraints are added to fix the threats. +# 5. The set of open preconditions is updated. +# 6. Temporal constraints of the selected action and the next action are established. +# 7. A new causal link is added between the selected action and the owner of the open precondition. +# 8. The set of new causal links is checked for threats and if found, the threat is removed by either promotion or demotion. +# If promotion or demotion is unable to solve the problem, the planning problem cannot be solved with the current sequence of actions +# or it may not be solvable at all. +# 9. These steps are repeated until the set of open preconditions is empty. + +# %% [markdown] +# A partial-order plan can be used to generate different valid total-order plans. +# This step is called **linearization** of the partial-order plan. +# All possible linearizations of a partial-order plan for `socks_and_shoes` looks like this. +#
+# ![title](images/pop.jpg) +#
+# Linearization can be carried out in many ways, but the most efficient way is to represent the set of temporal constraints as a directed graph. +# We can easily realize that the graph should also be acyclic as cycles in constraints means that the constraints are inconsistent. +# This acyclicity is enforced by the `add_const` method, which adds a new constraint only if the acyclicity of the existing graph is not violated. +# The `protect` method also checks for acyclicity of the newly-added temporal constraints to make a decision between promotion and demotion in case of a threat. +# This property of a graph created from the temporal constraints of a valid partial-order plan allows us to use topological sort to order the constraints linearly. +# A topological sort may produce several different valid solutions for a given directed acyclic graph. + +# %% [markdown] +# Now that we know how `PartialOrderPlanner` works, let's solve a few problems using it. + +# %% +st = spare_tire() +pop = PartialOrderPlanner(st) +pop.execute() + +# %% [markdown] +# We observe that in the given partial order plan, Remove(Flat, Axle) and Remove(Spare, Trunk) are in the same set. +# This means that the order of performing these actions does not affect the final outcome. +# That aside, we also see that the PutOn(Spare, Axle) action has to be performed after both the Remove actions are complete, which seems logically consistent. + +# %% +sbw = simple_blocks_world() +pop = PartialOrderPlanner(sbw) +pop.execute() + +# %% [markdown] +# We see that this plan does not have flexibility in selecting actions, ie, actions should be performed in this order and this order only, to successfully reach the goal state. + +# %% +ss = socks_and_shoes() +pop = PartialOrderPlanner(ss) +pop.execute() + +# %% [markdown] +# This plan again doesn't have constraints in selecting socks or shoes. +# As long as both socks are worn before both shoes, we are fine. +# Notice however, there is one valid solution, +#
+# LeftSock -> LeftShoe -> RightSock -> RightShoe +#
+# that the algorithm could not find as it cannot be represented as a general partially-ordered plan but is a specific total-order solution. + +# %% [markdown] +# ### Runtime differences +# Let's briefly take a look at the running time of all the three algorithms on the `socks_and_shoes` problem. + +# %% +ss = socks_and_shoes() + +# %% +# %%timeit +GraphPlan(ss).execute() + +# %% +# %%timeit +Linearize(ss).execute() + +# %% +# %%timeit +PartialOrderPlanner(ss).execute(display=False) + +# %% [markdown] +# We observe that `GraphPlan` is about 4 times faster than `Linearize` because `Linearize` essentially runs a `GraphPlan` subroutine under the hood and then carries out some transformations on the solved planning-graph. +#
+# We also find that `GraphPlan` is slightly faster than `PartialOrderPlanner`, but this is mainly due to the `expand_actions` method in `PartialOrderPlanner` that slows it down as it generates all possible permutations of actions and variable bindings. +#
+# Without heuristic functions, `PartialOrderPlanner` will be atleast as fast as `GraphPlan`, if not faster, but will have a higher tendency to encounter threats and conflicts which might take additional time to resolve. +#
+# Different planning algorithms work differently for different problems. diff --git a/planning_total_order_planner.ipynb b/notebooks/planning_total_order_planner.ipynb similarity index 98% rename from planning_total_order_planner.ipynb rename to notebooks/planning_total_order_planner.ipynb index b94941ece..2e69046db 100644 --- a/planning_total_order_planner.ipynb +++ b/notebooks/planning_total_order_planner.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -30,8 +39,8 @@ "metadata": {}, "outputs": [], "source": [ - "from planning import *\n", - "from notebook import psource" + "from aima.planning import *\n", + "from aima.notebook_utils import psource" ] }, { diff --git a/notebooks/planning_total_order_planner.py b/notebooks/planning_total_order_planner.py new file mode 100644 index 000000000..1f0231893 --- /dev/null +++ b/notebooks/planning_total_order_planner.py @@ -0,0 +1,73 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# ### TOTAL ORDER PLANNER +# +# In mathematical terminology, **total order**, **linear order** or **simple order** refers to a set *X* which is said to be totally ordered under ≤ if the following statements hold for all *a*, *b* and *c* in *X*: +#
+# If *a* ≤ *b* and *b* ≤ *a*, then *a* = *b* (antisymmetry). +#
+# If *a* ≤ *b* and *b* ≤ *c*, then *a* ≤ *c* (transitivity). +#
+# *a* ≤ *b* or *b* ≤ *a* (connex relation). +# +#
+# In simpler terms, a total order plan is a linear ordering of actions to be taken to reach the goal state. +# There may be several different total-order plans for a particular goal depending on the problem. +#
+#
+# In the module, the `Linearize` class solves problems using this paradigm. +# At its core, the `Linearize` uses a solved planning graph from `GraphPlan` and finds a valid total-order solution for it. +# Let's have a look at the class. + +# %% +from aima.planning import * +from aima.notebook_utils import psource + +# %% +psource(Linearize) + +# %% [markdown] +# The `filter` method removes the persistence actions (if any) from the planning graph representation. +#
+# The `orderlevel` method finds a valid total-ordering of a specified level of the planning-graph, given the state of the graph after the previous level. +#
+# The `execute` method sequentially calls `orderlevel` for all the levels in the planning-graph and returns the final total-order solution. +#
+#
+# Let's look at some examples. + +# %% +# total-order solution for air_cargo problem +Linearize(air_cargo()).execute() + +# %% +# total-order solution for spare_tire problem +Linearize(spare_tire()).execute() + +# %% +# total-order solution for three_block_tower problem +Linearize(three_block_tower()).execute() + +# %% +# total-order solution for simple_blocks_world problem +Linearize(simple_blocks_world()).execute() + +# %% +# total-order solution for socks_and_shoes problem +Linearize(socks_and_shoes()).execute() diff --git a/probability.ipynb b/notebooks/probability.ipynb similarity index 98% rename from probability.ipynb rename to notebooks/probability.ipynb index fe9643a83..4afca7196 100644 --- a/probability.ipynb +++ b/notebooks/probability.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -15,9 +24,9 @@ "metadata": {}, "outputs": [], "source": [ - "from probability import *\n", - "from utils import print_table\n", - "from notebook import psource, pseudocode, heatmap" + "from aima.probability import *\n", + "from aima.utils import print_table\n", + "from aima.notebook_utils import psource, pseudocode, heatmap" ] }, { @@ -254,23 +263,12 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'?'" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "p = ProbDist(freqs={'low': 125, 'medium': 375, 'high': 500})\n", - "p.varname" + "p = ProbDist(freq={'low': 125, 'medium': 375, 'high': 500})\n", + "p.var_name" ] }, { @@ -1288,7 +1286,7 @@ "\n", "The example below where we implement the network shown in **Figure 14.3** of the book will make this more clear.\n", "\n", - "\n", + "\n", "\n", "The alarm node can be made as follows: " ] @@ -3018,6 +3016,163 @@ "Of course, for more complicated networks, variable elimination will be significantly faster and runtime will drop not just by a constant factor, but by a polynomial factor proportional to the number of nodes, due to the reduction in repeated calculations." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## MULTI-VALUED NETWORKS: THE CAR-INSURANCE CASE STUDY\n", + "\n", + "The Bayesian networks above all use **boolean** variables. Many real models have variables with more than two values. AIMA's car-insurance case study (Section 16) is the classic example: the *Insurance* network of Binder, Koller, Russell & Kanazawa (1997), with 27 discrete variables.\n", + "\n", + "aima-python represents such networks with `DiscreteBayesNode` / `DiscreteBayesNet`. These expose the same `node.p(value, event)` / `variable_values` interface that inference relies on, so the very same `enumeration_ask` and `elimination_ask` used above work **unchanged** on multi-valued networks. Here is a tiny one built by hand:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'heavy: 0.308, light: 0.462, none: 0.231'" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rain_net = DiscreteBayesNet([\n", + " ('Rain', '', ['none', 'light', 'heavy'], {(): [0.6, 0.3, 0.1]}),\n", + " ('Traffic', 'Rain', ['low', 'high'],\n", + " {('none',): [0.9, 0.1], ('light',): [0.6, 0.4], ('heavy',): [0.2, 0.8]}),\n", + "])\n", + "\n", + "enumeration_ask('Rain', {'Traffic': 'high'}, rain_net).show_approx()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Loading the Insurance network\n", + "\n", + "Networks in the standard **BIF** (Bayesian Interchange Format) of the [bnlearn Bayesian Network Repository](https://www.bnlearn.com/bnrepository/) can be read with `read_bif`. The Insurance network ships in `aima-data`, so `insurance()` returns it directly — these are the discrete conditional distributions the book refers to:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(27,\n", + " ['Age',\n", + " 'SocioEcon',\n", + " 'GoodStudent',\n", + " 'RiskAversion',\n", + " 'VehicleYear',\n", + " 'MakeModel',\n", + " 'Antilock',\n", + " 'Mileage'])" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "insurance_net = insurance()\n", + "len(insurance_net.variables), insurance_net.variables[:8]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Its variables are multi-valued, e.g. the driver's `Age` and the medical-cost outcome `MedCost`:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(['Adolescent', 'Adult', 'Senior'],\n", + " ['Thousand', 'TenThou', 'HundredThou', 'Million'])" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "insurance_net.variable_values('Age'), insurance_net.variable_values('MedCost')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Exact inference runs on the full 27-node network. The prior over the driver's age:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Adolescent: 0.2, Adult: 0.6, Senior: 0.2'" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "elimination_ask('Age', dict(), insurance_net).show_approx()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And a query with evidence — the good-student probability for a wealthy adolescent (both of `GoodStudent`'s parents are observed here, so this reads straight off its CPT):" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'False: 0.6, True: 0.4'" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "elimination_ask('GoodStudent', dict(Age='Adolescent', SocioEcon='Wealthy'), insurance_net).show_approx()" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -3275,7 +3430,7 @@ "source": [ "The function **prior_sample** implements the algorithm described in **Figure 14.13** of the book. Nodes are sampled in the topological order. The old value of the event is passed as evidence for parent values. We will use the Bayesian Network in **Figure 14.12** to try out the **prior_sample**\n", "\n", - "\n", + "\n", "\n", "Traversing the graph in topological order is important.\n", "There are two possible topological orderings for this particular directed acyclic graph.\n", @@ -4909,7 +5064,7 @@ ], "source": [ "umbrella_prior = [0.5, 0.5]\n", - "prob = forward_backward(hmm, ev=[T, T], prior=umbrella_prior)\n", + "prob = forward_backward(hmm, ev=[T, T])\n", "print ('The probability of raining in Day 0 is {:.2f} and in Day 1 is {:.2f}'.format(prob[0][0], prob[1][0]))" ] }, @@ -6528,7 +6683,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.9" + "version": "3.12.13" } }, "nbformat": 4, diff --git a/notebooks/probability.py b/notebooks/probability.py new file mode 100644 index 000000000..bb6e25b1b --- /dev/null +++ b/notebooks/probability.py @@ -0,0 +1,1295 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Probability +# +# This IPy notebook acts as supporting material for topics covered in **Chapter 13 Quantifying Uncertainty**, **Chapter 14 Probabilistic Reasoning**, **Chapter 15 Probabilistic Reasoning over Time**, **Chapter 16 Making Simple Decisions** and parts of **Chapter 25 Robotics** of the book* Artificial Intelligence: A Modern Approach*. This notebook makes use of the implementations in probability.py module. Let us import everything from the probability module. It might be helpful to view the source of some of our implementations. Please refer to the Introductory IPy file for more details on how to do so. + +# %% +from aima.probability import * +from aima.utils import print_table +from aima.notebook_utils import psource, pseudocode, heatmap + +# %% [markdown] +# ## CONTENTS +# - Probability Distribution +# - Joint probability distribution +# - Inference using full joint distributions +#
+# - Bayesian Networks +# - BayesNode +# - BayesNet +# - Exact Inference in Bayesian Networks +# - Enumeration +# - Variable elimination +# - Approximate Inference in Bayesian Networks +# - Prior sample +# - Rejection sampling +# - Likelihood weighting +# - Gibbs sampling +#
+# - Hidden Markov Models +# - Inference in Hidden Markov Models +# - Forward-backward +# - Fixed lag smoothing +# - Particle filtering +#
+#
+# - Monte Carlo Localization +# - Decision Theoretic Agent +# - Information Gathering Agent + +# %% [markdown] +# ## PROBABILITY DISTRIBUTION +# +# Let us begin by specifying discrete probability distributions. The class **ProbDist** defines a discrete probability distribution. We name our random variable and then assign probabilities to the different values of the random variable. Assigning probabilities to the values works similar to that of using a dictionary with keys being the Value and we assign to it the probability. This is possible because of the magic methods **_ _getitem_ _** and **_ _setitem_ _** which store the probabilities in the prob dict of the object. You can keep the source window open alongside while playing with the rest of the code to get a better understanding. + +# %% +psource(ProbDist) + +# %% +p = ProbDist('Flip') +p['H'], p['T'] = 0.25, 0.75 +p['T'] + +# %% [markdown] +# The first parameter of the constructor **varname** has a default value of '?'. So if the name is not passed it defaults to ?. The keyword argument **freqs** can be a dictionary of values of random variable: probability. These are then normalized such that the probability values sum upto 1 using the **normalize** method. + +# %% +p = ProbDist(freq={'low': 125, 'medium': 375, 'high': 500}) +p.var_name + +# %% +(p['low'], p['medium'], p['high']) + +# %% [markdown] +# Besides the **prob** and **varname** the object also separately keeps track of all the values of the distribution in a list called **values**. Every time a new value is assigned a probability it is appended to this list, This is done inside the **_ _setitem_ _** method. + +# %% +p.values + +# %% [markdown] +# The distribution by default is not normalized if values are added incrementally. We can still force normalization by invoking the **normalize** method. + +# %% +p = ProbDist('Y') +p['Cat'] = 50 +p['Dog'] = 114 +p['Mice'] = 64 +(p['Cat'], p['Dog'], p['Mice']) + +# %% +p.normalize() +(p['Cat'], p['Dog'], p['Mice']) + +# %% [markdown] +# It is also possible to display the approximate values upto decimals using the **show_approx** method. + +# %% +p.show_approx() + +# %% [markdown] +# ## Joint Probability Distribution +# +# The helper function **event_values** returns a tuple of the values of variables in event. An event is specified by a dict where the keys are the names of variables and the corresponding values are the value of the variable. Variables are specified with a list. The ordering of the returned tuple is same as those of the variables. +# +# +# Alternatively if the event is specified by a list or tuple of equal length of the variables. Then the events tuple is returned as it is. + +# %% +event = {'A': 10, 'B': 9, 'C': 8} +variables = ['C', 'A'] +event_values(event, variables) + +# %% [markdown] +# _A probability model is completely determined by the joint distribution for all of the random variables._ (**Section 13.3**) The probability module implements these as the class **JointProbDist** which inherits from the **ProbDist** class. This class specifies a discrete probability distribute over a set of variables. + +# %% +psource(JointProbDist) + +# %% [markdown] +# Values for a Joint Distribution is a an ordered tuple in which each item corresponds to the value associate with a particular variable. For Joint Distribution of X, Y where X, Y take integer values this can be something like (18, 19). +# +# To specify a Joint distribution we first need an ordered list of variables. + +# %% +variables = ['X', 'Y'] +j = JointProbDist(variables) +j + +# %% [markdown] +# Like the **ProbDist** class **JointProbDist** also employes magic methods to assign probability to different values. +# The probability can be assigned in either of the two formats for all possible values of the distribution. The **event_values** call inside **_ _getitem_ _** and **_ _setitem_ _** does the required processing to make this work. + +# %% +j[1,1] = 0.2 +j[dict(X=0, Y=1)] = 0.5 + +(j[1,1], j[0,1]) + +# %% [markdown] +# It is also possible to list all the values for a particular variable using the **values** method. + +# %% +j.values('X') + +# %% [markdown] +# ## Inference Using Full Joint Distributions +# +# In this section we use Full Joint Distributions to calculate the posterior distribution given some evidence. We represent evidence by using a python dictionary with variables as dict keys and dict values representing the values. +# +# This is illustrated in **Section 13.3** of the book. The functions **enumerate_joint** and **enumerate_joint_ask** implement this functionality. Under the hood they implement **Equation 13.9** from the book. +# +# $$\textbf{P}(X | \textbf{e}) = \alpha \textbf{P}(X, \textbf{e}) = \alpha \sum_{y} \textbf{P}(X, \textbf{e}, \textbf{y})$$ +# +# Here **α** is the normalizing factor. **X** is our query variable and **e** is the evidence. According to the equation we enumerate on the remaining variables **y** (not in evidence or query variable) i.e. all possible combinations of **y** +# +# We will be using the same example as the book. Let us create the full joint distribution from **Figure 13.3**. + +# %% +full_joint = JointProbDist(['Cavity', 'Toothache', 'Catch']) +full_joint[dict(Cavity=True, Toothache=True, Catch=True)] = 0.108 +full_joint[dict(Cavity=True, Toothache=True, Catch=False)] = 0.012 +full_joint[dict(Cavity=True, Toothache=False, Catch=True)] = 0.016 +full_joint[dict(Cavity=True, Toothache=False, Catch=False)] = 0.064 +full_joint[dict(Cavity=False, Toothache=True, Catch=True)] = 0.072 +full_joint[dict(Cavity=False, Toothache=False, Catch=True)] = 0.144 +full_joint[dict(Cavity=False, Toothache=True, Catch=False)] = 0.008 +full_joint[dict(Cavity=False, Toothache=False, Catch=False)] = 0.576 + +# %% [markdown] +# Let us now look at the **enumerate_joint** function returns the sum of those entries in P consistent with e,provided variables is P's remaining variables (the ones not in e). Here, P refers to the full joint distribution. The function uses a recursive call in its implementation. The first parameter **variables** refers to remaining variables. The function in each recursive call keeps on variable constant while varying others. + +# %% +psource(enumerate_joint) + +# %% [markdown] +# Let us assume we want to find **P(Toothache=True)**. This can be obtained by marginalization (**Equation 13.6**). We can use **enumerate_joint** to solve for this by taking Toothache=True as our evidence. **enumerate_joint** will return the sum of probabilities consistent with evidence i.e. Marginal Probability. + +# %% +evidence = dict(Toothache=True) +variables = ['Cavity', 'Catch'] # variables not part of evidence +ans1 = enumerate_joint(variables, evidence, full_joint) +ans1 + +# %% [markdown] +# You can verify the result from our definition of the full joint distribution. We can use the same function to find more complex probabilities like **P(Cavity=True and Toothache=True)** + +# %% +evidence = dict(Cavity=True, Toothache=True) +variables = ['Catch'] # variables not part of evidence +ans2 = enumerate_joint(variables, evidence, full_joint) +ans2 + +# %% [markdown] +# Being able to find sum of probabilities satisfying given evidence allows us to compute conditional probabilities like **P(Cavity=True | Toothache=True)** as we can rewrite this as $$P(Cavity=True | Toothache = True) = \frac{P(Cavity=True \ and \ Toothache=True)}{P(Toothache=True)}$$ +# +# We have already calculated both the numerator and denominator. + +# %% +ans2/ans1 + +# %% [markdown] +# We might be interested in the probability distribution of a particular variable conditioned on some evidence. This can involve doing calculations like above for each possible value of the variable. This has been implemented slightly differently using normalization in the function **enumerate_joint_ask** which returns a probability distribution over the values of the variable **X**, given the {var:val} observations **e**, in the **JointProbDist P**. The implementation of this function calls **enumerate_joint** for each value of the query variable and passes **extended evidence** with the new evidence having **X = xi**. This is followed by normalization of the obtained distribution. + +# %% +psource(enumerate_joint_ask) + +# %% [markdown] +# Let us find **P(Cavity | Toothache=True)** using **enumerate_joint_ask**. + +# %% +query_variable = 'Cavity' +evidence = dict(Toothache=True) +ans = enumerate_joint_ask(query_variable, evidence, full_joint) +(ans[True], ans[False]) + +# %% [markdown] +# You can verify that the first value is the same as we obtained earlier by manual calculation. + +# %% [markdown] +# ## BAYESIAN NETWORKS +# +# A Bayesian network is a representation of the joint probability distribution encoding a collection of conditional independence statements. +# +# A Bayes Network is implemented as the class **BayesNet**. It consisits of a collection of nodes implemented by the class **BayesNode**. The implementation in the above mentioned classes focuses only on boolean variables. Each node is associated with a variable and it contains a **conditional probabilty table (cpt)**. The **cpt** represents the probability distribution of the variable conditioned on its parents **P(X | parents)**. +# +# Let us dive into the **BayesNode** implementation. + +# %% +psource(BayesNode) + +# %% [markdown] +# The constructor takes in the name of **variable**, **parents** and **cpt**. Here **variable** is a the name of the variable like 'Earthquake'. **parents** should a list or space separate string with variable names of parents. The conditional probability table is a dict {(v1, v2, ...): p, ...}, the distribution P(X=true | parent1=v1, parent2=v2, ...) = p. Here the keys are combination of boolean values that the parents take. The length and order of the values in keys should be same as the supplied **parent** list/string. In all cases the probability of X being false is left implicit, since it follows from P(X=true). +# +# The example below where we implement the network shown in **Figure 14.3** of the book will make this more clear. +# +# +# +# The alarm node can be made as follows: + +# %% +alarm_node = BayesNode('Alarm', ['Burglary', 'Earthquake'], + {(True, True): 0.95,(True, False): 0.94, (False, True): 0.29, (False, False): 0.001}) + +# %% [markdown] +# It is possible to avoid using a tuple when there is only a single parent. So an alternative format for the **cpt** is + +# %% +john_node = BayesNode('JohnCalls', ['Alarm'], {True: 0.90, False: 0.05}) +mary_node = BayesNode('MaryCalls', 'Alarm', {(True, ): 0.70, (False, ): 0.01}) # Using string for parents. +# Equivalant to john_node definition. + +# %% [markdown] +# The general format used for the alarm node always holds. For nodes with no parents we can also use. + +# %% +burglary_node = BayesNode('Burglary', '', 0.001) +earthquake_node = BayesNode('Earthquake', '', 0.002) + +# %% [markdown] +# It is possible to use the node for lookup function using the **p** method. The method takes in two arguments **value** and **event**. Event must be a dict of the type {variable:values, ..} The value corresponds to the value of the variable we are interested in (False or True).The method returns the conditional probability **P(X=value | parents=parent_values)**, where parent_values are the values of parents in event. (event must assign each parent a value.) + +# %% +john_node.p(False, {'Alarm': True, 'Burglary': True}) # P(JohnCalls=False | Alarm=True) + +# %% [markdown] +# With all the information about nodes present it is possible to construct a Bayes Network using **BayesNet**. The **BayesNet** class does not take in nodes as input but instead takes a list of **node_specs**. An entry in **node_specs** is a tuple of the parameters we use to construct a **BayesNode** namely **(X, parents, cpt)**. **node_specs** must be ordered with parents before children. + +# %% +psource(BayesNet) + +# %% [markdown] +# The constructor of **BayesNet** takes each item in **node_specs** and adds a **BayesNode** to its **nodes** object variable by calling the **add** method. **add** in turn adds node to the net. Its parents must already be in the net, and its variable must not. Thus add allows us to grow a **BayesNet** given its parents are already present. +# +# **burglary** global is an instance of **BayesNet** corresponding to the above example. +# +# T, F = True, False +# +# burglary = BayesNet([ +# ('Burglary', '', 0.001), +# ('Earthquake', '', 0.002), +# ('Alarm', 'Burglary Earthquake', +# {(T, T): 0.95, (T, F): 0.94, (F, T): 0.29, (F, F): 0.001}), +# ('JohnCalls', 'Alarm', {T: 0.90, F: 0.05}), +# ('MaryCalls', 'Alarm', {T: 0.70, F: 0.01}) +# ]) + +# %% +burglary + +# %% [markdown] +# **BayesNet** method **variable_node** allows to reach **BayesNode** instances inside a Bayes Net. It is possible to modify the **cpt** of the nodes directly using this method. + +# %% +type(burglary.variable_node('Alarm')) + +# %% +burglary.variable_node('Alarm').cpt + +# %% [markdown] +# ## Exact Inference in Bayesian Networks +# +# A Bayes Network is a more compact representation of the full joint distribution and like full joint distributions allows us to do inference i.e. answer questions about probability distributions of random variables given some evidence. +# +# Exact algorithms don't scale well for larger networks. Approximate algorithms are explained in the next section. +# +# ### Inference by Enumeration +# +# We apply techniques similar to those used for **enumerate_joint_ask** and **enumerate_joint** to draw inference from Bayesian Networks. **enumeration_ask** and **enumerate_all** implement the algorithm described in **Figure 14.9** of the book. + +# %% +psource(enumerate_all) + +# %% [markdown] +# **enumerate_all** recursively evaluates a general form of the **Equation 14.4** in the book. +# +# $$\textbf{P}(X | \textbf{e}) = α \textbf{P}(X, \textbf{e}) = α \sum_{y} \textbf{P}(X, \textbf{e}, \textbf{y})$$ +# +# such that **P(X, e, y)** is written in the form of product of conditional probabilities **P(variable | parents(variable))** from the Bayesian Network. +# +# **enumeration_ask** calls **enumerate_all** on each value of query variable **X** and finally normalizes them. +# + +# %% +psource(enumeration_ask) + +# %% [markdown] +# Let us solve the problem of finding out **P(Burglary=True | JohnCalls=True, MaryCalls=True)** using the **burglary** network. **enumeration_ask** takes three arguments **X** = variable name, **e** = Evidence (in form a dict like previously explained), **bn** = The Bayes Net to do inference on. + +# %% +ans_dist = enumeration_ask('Burglary', {'JohnCalls': True, 'MaryCalls': True}, burglary) +ans_dist[True] + +# %% [markdown] +# ### Variable Elimination +# +# The enumeration algorithm can be improved substantially by eliminating repeated calculations. In enumeration we join the joint of all hidden variables. This is of exponential size for the number of hidden variables. Variable elimination employes interleaving join and marginalization. +# +# Before we look into the implementation of Variable Elimination we must first familiarize ourselves with Factors. +# +# In general we call a multidimensional array of type P(Y1 ... Yn | X1 ... Xm) a factor where some of Xs and Ys maybe assigned values. Factors are implemented in the probability module as the class **Factor**. They take as input **variables** and **cpt**. +# +# +# #### Helper Functions +# +# There are certain helper functions that help creating the **cpt** for the Factor given the evidence. Let us explore them one by one. + +# %% +psource(make_factor) + +# %% [markdown] +# **make_factor** is used to create the **cpt** and **variables** that will be passed to the constructor of **Factor**. We use **make_factor** for each variable. It takes in the arguments **var** the particular variable, **e** the evidence we want to do inference on, **bn** the bayes network. +# +# Here **variables** for each node refers to a list consisting of the variable itself and the parents minus any variables that are part of the evidence. This is created by finding the **node.parents** and filtering out those that are not part of the evidence. +# +# The **cpt** created is the one similar to the original **cpt** of the node with only rows that agree with the evidence. + +# %% +psource(all_events) + +# %% [markdown] +# The **all_events** function is a recursive generator function which yields a key for the orignal **cpt** which is part of the node. This works by extending evidence related to the node, thus all the output from **all_events** only includes events that support the evidence. Given **all_events** is a generator function one such event is returned on every call. +# +# We can try this out using the example on **Page 524** of the book. We will make **f**5(A) = P(m | A) + +# %% +f5 = make_factor('MaryCalls', {'JohnCalls': True, 'MaryCalls': True}, burglary) + +# %% +f5 + +# %% +f5.cpt + +# %% +f5.variables + +# %% [markdown] +# Here **f5.cpt** False key gives probability for **P(MaryCalls=True | Alarm = False)**. Due to our representation where we only store probabilities for only in cases where the node variable is True this is the same as the **cpt** of the BayesNode. Let us try a somewhat different example from the book where evidence is that the Alarm = True + +# %% +new_factor = make_factor('MaryCalls', {'Alarm': True}, burglary) + +# %% +new_factor.cpt + +# %% [markdown] +# Here the **cpt** is for **P(MaryCalls | Alarm = True)**. Therefore the probabilities for True and False sum up to one. Note the difference between both the cases. Again the only rows included are those consistent with the evidence. +# +# #### Operations on Factors +# +# We are interested in two kinds of operations on factors. **Pointwise Product** which is used to created joint distributions and **Summing Out** which is used for marginalization. + +# %% +psource(Factor.pointwise_product) + +# %% [markdown] +# **Factor.pointwise_product** implements a method of creating a joint via combining two factors. We take the union of **variables** of both the factors and then generate the **cpt** for the new factor using **all_events** function. Note that the given we have eliminated rows that are not consistent with the evidence. Pointwise product assigns new probabilities by multiplying rows similar to that in a database join. + +# %% +psource(pointwise_product) + +# %% [markdown] +# **pointwise_product** extends this operation to more than two operands where it is done sequentially in pairs of two. + +# %% +psource(Factor.sum_out) + +# %% [markdown] +# **Factor.sum_out** makes a factor eliminating a variable by summing over its values. Again **events_all** is used to generate combinations for the rest of the variables. + +# %% +psource(sum_out) + +# %% [markdown] +# **sum_out** uses both **Factor.sum_out** and **pointwise_product** to finally eliminate a particular variable from all factors by summing over its values. + +# %% [markdown] +# #### Elimination Ask +# +# The algorithm described in **Figure 14.11** of the book is implemented by the function **elimination_ask**. We use this for inference. The key idea is that we eliminate the hidden variables by interleaving joining and marginalization. It takes in 3 arguments **X** the query variable, **e** the evidence variable and **bn** the Bayes network. +# +# The algorithm creates factors out of Bayes Nodes in reverse order and eliminates hidden variables using **sum_out**. Finally it takes a point wise product of all factors and normalizes. Let us finally solve the problem of inferring +# +# **P(Burglary=True | JohnCalls=True, MaryCalls=True)** using variable elimination. + +# %% +psource(elimination_ask) + +# %% +elimination_ask('Burglary', dict(JohnCalls=True, MaryCalls=True), burglary).show_approx() + +# %% [markdown] +# #### Elimination Ask Optimizations +# +# `elimination_ask` has some critical point to consider and some optimizations could be performed: +# +# - **Operation on factors**: +# +# `sum_out` and `pointwise_product` function used in `elimination_ask` is where space and time complexity arise in the variable elimination algorithm (AIMA3e pg. 526). +# +# >The only trick is to notice that any factor that does not depend on the variable to be summed out can be moved outside the summation. +# +# - **Variable ordering**: +# +# Elimination ordering is important, every choice of ordering yields a valid algorithm, but different orderings cause different intermediate factors to be generated during the calculation (AIMA3e pg. 527). In this case the algorithm applies a reversed order. +# +# > In general, the time and space requirements of variable elimination are dominated by the size of the largest factor constructed during the operation of the algorithm. This in turn is determined by the order of elimination of variables and by the structure of the network. It turns out to be intractable to determine the optimal ordering, but several good heuristics are available. One fairly effective method is a greedy one: eliminate whichever variable minimizes the size of the next factor to be constructed. +# +# - **Variable relevance** +# +# Some variables could be irrelevant to resolve a query (i.e. sums to 1). A variable elimination algorithm can therefore remove all these variables before evaluating the query (AIMA3e pg. 528). +# +# > An optimization is to remove 'every variable that is not an ancestor of a query variable or evidence variable is irrelevant to the query'. + +# %% [markdown] +# #### Runtime comparison +# Let's see how the runtimes of these two algorithms compare. +# We expect variable elimination to outperform enumeration by a large margin as we reduce the number of repetitive calculations significantly. + +# %% +# %%timeit +enumeration_ask('Burglary', dict(JohnCalls=True, MaryCalls=True), burglary).show_approx() + +# %% +# %%timeit +elimination_ask('Burglary', dict(JohnCalls=True, MaryCalls=True), burglary).show_approx() + +# %% [markdown] +# In this test case we observe that variable elimination is slower than what we expected. It has something to do with number of threads, how Python tries to optimize things and this happens because the network is very small, with just 5 nodes. The `elimination_ask` has some critical point and some optimizations must be perfomed as seen above. +#
+# Of course, for more complicated networks, variable elimination will be significantly faster and runtime will drop not just by a constant factor, but by a polynomial factor proportional to the number of nodes, due to the reduction in repeated calculations. + +# %% [markdown] +# ## MULTI-VALUED NETWORKS: THE CAR-INSURANCE CASE STUDY +# +# The Bayesian networks above all use **boolean** variables. Many real models have variables with more than two values. AIMA's car-insurance case study (Section 16) is the classic example: the *Insurance* network of Binder, Koller, Russell & Kanazawa (1997), with 27 discrete variables. +# +# aima-python represents such networks with `DiscreteBayesNode` / `DiscreteBayesNet`. These expose the same `node.p(value, event)` / `variable_values` interface that inference relies on, so the very same `enumeration_ask` and `elimination_ask` used above work **unchanged** on multi-valued networks. Here is a tiny one built by hand: + +# %% +rain_net = DiscreteBayesNet([ + ('Rain', '', ['none', 'light', 'heavy'], {(): [0.6, 0.3, 0.1]}), + ('Traffic', 'Rain', ['low', 'high'], + {('none',): [0.9, 0.1], ('light',): [0.6, 0.4], ('heavy',): [0.2, 0.8]}), +]) + +enumeration_ask('Rain', {'Traffic': 'high'}, rain_net).show_approx() + +# %% [markdown] +# ### Loading the Insurance network +# +# Networks in the standard **BIF** (Bayesian Interchange Format) of the [bnlearn Bayesian Network Repository](https://www.bnlearn.com/bnrepository/) can be read with `read_bif`. The Insurance network ships in `aima-data`, so `insurance()` returns it directly — these are the discrete conditional distributions the book refers to: + +# %% +insurance_net = insurance() +len(insurance_net.variables), insurance_net.variables[:8] + +# %% [markdown] +# Its variables are multi-valued, e.g. the driver's `Age` and the medical-cost outcome `MedCost`: + +# %% +insurance_net.variable_values('Age'), insurance_net.variable_values('MedCost') + +# %% [markdown] +# Exact inference runs on the full 27-node network. The prior over the driver's age: + +# %% +elimination_ask('Age', dict(), insurance_net).show_approx() + +# %% [markdown] +# And a query with evidence — the good-student probability for a wealthy adolescent (both of `GoodStudent`'s parents are observed here, so this reads straight off its CPT): + +# %% +elimination_ask('GoodStudent', dict(Age='Adolescent', SocioEcon='Wealthy'), insurance_net).show_approx() + +# %% [markdown] +# ## Approximate Inference in Bayesian Networks +# +# Exact inference fails to scale for very large and complex Bayesian Networks. This section covers implementation of randomized sampling algorithms, also called Monte Carlo algorithms. + +# %% +psource(BayesNode.sample) + +# %% [markdown] +# Before we consider the different algorithms in this section let us look at the **BayesNode.sample** method. It samples from the distribution for this variable conditioned on event's values for parent_variables. That is, return True/False at random according to with the conditional probability given the parents. The **probability** function is a simple helper from **utils** module which returns True with the probability passed to it. +# +# ### Prior Sampling +# +# The idea of Prior Sampling is to sample from the Bayesian Network in a topological order. We start at the top of the network and sample as per **P(Xi | parents(Xi)** i.e. the probability distribution from which the value is sampled is conditioned on the values already assigned to the variable's parents. This can be thought of as a simulation. + +# %% +psource(prior_sample) + +# %% [markdown] +# The function **prior_sample** implements the algorithm described in **Figure 14.13** of the book. Nodes are sampled in the topological order. The old value of the event is passed as evidence for parent values. We will use the Bayesian Network in **Figure 14.12** to try out the **prior_sample** +# +# +# +# Traversing the graph in topological order is important. +# There are two possible topological orderings for this particular directed acyclic graph. +#
+# 1. `Cloudy -> Sprinkler -> Rain -> Wet Grass` +# 2. `Cloudy -> Rain -> Sprinkler -> Wet Grass` +#
+#
+# We can follow any of the two orderings to sample from the network. +# Any ordering other than these two, however, cannot be used. +#
+# One way to think about this is that `Cloudy` can be seen as a precondition of both `Rain` and `Sprinkler` and just like we have seen in planning, preconditions need to be satisfied before a certain action can be executed. +#
+# We store the samples on the observations. Let us find **P(Rain=True)** by taking 1000 random samples from the network. + +# %% +N = 1000 +all_observations = [prior_sample(sprinkler) for x in range(N)] + +# %% [markdown] +# Now we filter to get the observations where Rain = True + +# %% +rain_true = [observation for observation in all_observations if observation['Rain'] == True] + +# %% [markdown] +# Finally, we can find **P(Rain=True)** + +# %% +answer = len(rain_true) / N +print(answer) + +# %% [markdown] +# Sampling this another time might give different results as we have no control over the distribution of the random samples + +# %% +N = 1000 +all_observations = [prior_sample(sprinkler) for x in range(N)] +rain_true = [observation for observation in all_observations if observation['Rain'] == True] +answer = len(rain_true) / N +print(answer) + +# %% [markdown] +# To evaluate a conditional distribution. We can use a two-step filtering process. We first separate out the variables that are consistent with the evidence. Then for each value of query variable, we can find probabilities. For example to find **P(Cloudy=True | Rain=True)**. We have already filtered out the values consistent with our evidence in **rain_true**. Now we apply a second filtering step on **rain_true** to find **P(Rain=True and Cloudy=True)** + +# %% +rain_and_cloudy = [observation for observation in rain_true if observation['Cloudy'] == True] +answer = len(rain_and_cloudy) / len(rain_true) +print(answer) + +# %% [markdown] +# ### Rejection Sampling +# +# Rejection Sampling is based on an idea similar to what we did just now. +# First, it generates samples from the prior distribution specified by the network. +# Then, it rejects all those that do not match the evidence. +#
+# Rejection sampling is advantageous only when we know the query beforehand. +# While prior sampling generally works for any query, it might fail in some scenarios. +#
+# Let's say we have a generic Bayesian network and we have evidence `e`, and we want to know how many times a state `A` is true, given evidence `e` is true. +# Normally, prior sampling can answer this question, but let's assume that the probability of evidence `e` being true in our actual probability distribution is very small. +# In this situation, it might be possible that sampling never encounters a data-point where `e` is true. +# If our sampled data has no instance of `e` being true, `P(e) = 0`, and therefore `P(A | e) / P(e) = 0/0`, which is undefined. +# We cannot find the required value using this sample. +#
+# We can definitely increase the number of sample points, but we can never guarantee that we will encounter the case where `e` is non-zero (assuming our actual probability distribution has atleast one case where `e` is true). +# To guarantee this, we would have to consider every single data point, which means we lose the speed advantage that approximation provides us and we essentially have to calculate the exact inference model of the Bayesian network. +#
+#
+# Rejection sampling will be useful in this situation, as we already know the query. +#
+# While sampling from the network, we will reject any sample which is inconsistent with the evidence variables of the given query (in this example, the only evidence variable is `e`). +# We will only consider samples that do not violate **any** of the evidence variables. +# In this way, we will have enough data with the required evidence to infer queries involving a subset of that evidence. +#
+#
+# The function **rejection_sampling** implements the algorithm described by **Figure 14.14** + +# %% +psource(rejection_sampling) + +# %% [markdown] +# The function keeps counts of each of the possible values of the Query variable and increases the count when we see an observation consistent with the evidence. It takes in input parameters **X** - The Query Variable, **e** - evidence, **bn** - Bayes net and **N** - number of prior samples to generate. +# +# **consistent_with** is used to check consistency. + +# %% +psource(consistent_with) + +# %% [markdown] +# To answer **P(Cloudy=True | Rain=True)** + +# %% +p = rejection_sampling('Cloudy', dict(Rain=True), sprinkler, 1000) +p[True] + +# %% [markdown] +# ### Likelihood Weighting +# +# Rejection sampling takes a long time to run when the probability of finding consistent evidence is low. It is also slow for larger networks and more evidence variables. +# Rejection sampling tends to reject a lot of samples if our evidence consists of a large number of variables. Likelihood Weighting solves this by fixing the evidence (i.e. not sampling it) and then using weights to make sure that our overall sampling is still consistent. +# +# The pseudocode in **Figure 14.15** is implemented as **likelihood_weighting** and **weighted_sample**. + +# %% +psource(weighted_sample) + +# %% [markdown] +# +# **weighted_sample** samples an event from Bayesian Network that's consistent with the evidence **e** and returns the event and its weight, the likelihood that the event accords to the evidence. It takes in two parameters **bn** the Bayesian Network and **e** the evidence. +# +# The weight is obtained by multiplying **P(xi | parents(xi))** for each node in evidence. We set the values of **event = evidence** at the start of the function. + +# %% +weighted_sample(sprinkler, dict(Rain=True)) + +# %% +psource(likelihood_weighting) + +# %% [markdown] +# **likelihood_weighting** implements the algorithm to solve our inference problem. The code is similar to **rejection_sampling** but instead of adding one for each sample we add the weight obtained from **weighted_sampling**. + +# %% +likelihood_weighting('Cloudy', dict(Rain=True), sprinkler, 200).show_approx() + +# %% [markdown] +# ### Gibbs Sampling +# +# In likelihood sampling, it is possible to obtain low weights in cases where the evidence variables reside at the bottom of the Bayesian Network. This can happen because influence only propagates downwards in likelihood sampling. +# +# Gibbs Sampling solves this. The implementation of **Figure 14.16** is provided in the function **gibbs_ask** + +# %% +psource(gibbs_ask) + +# %% [markdown] +# In **gibbs_ask** we initialize the non-evidence variables to random values. And then select non-evidence variables and sample it from **P(Variable | value in the current state of all remaining vars) ** repeatedly sample. In practice, we speed this up by using **markov_blanket_sample** instead. This works because terms not involving the variable get canceled in the calculation. The arguments for **gibbs_ask** are similar to **likelihood_weighting** + +# %% +gibbs_ask('Cloudy', dict(Rain=True), sprinkler, 200).show_approx() + +# %% [markdown] +# #### Runtime analysis +# Let's take a look at how much time each algorithm takes. + +# %% +# %%timeit +all_observations = [prior_sample(sprinkler) for x in range(1000)] +rain_true = [observation for observation in all_observations if observation['Rain'] == True] +len([observation for observation in rain_true if observation['Cloudy'] == True]) / len(rain_true) + +# %% +# %%timeit +rejection_sampling('Cloudy', dict(Rain=True), sprinkler, 1000) + +# %% +# %%timeit +likelihood_weighting('Cloudy', dict(Rain=True), sprinkler, 200) + +# %% +# %%timeit +gibbs_ask('Cloudy', dict(Rain=True), sprinkler, 200) + +# %% [markdown] +# As expected, all algorithms have a very similar runtime. +# However, rejection sampling would be a lot faster and more accurate when the probabiliy of finding data-points consistent with the required evidence is small. +#
+# Likelihood weighting is the fastest out of all as it doesn't involve rejecting samples, but also has a quite high variance. + +# %% [markdown] +# ## HIDDEN MARKOV MODELS + +# %% [markdown] +# Often, we need to carry out probabilistic inference on temporal data or a sequence of observations where the order of observations matter. +# We require a model similar to a Bayesian Network, but one that grows over time to keep up with the latest evidences. +# If you are familiar with the `mdp` module or Markov models in general, you can probably guess that a Markov model might come close to representing our problem accurately. +#
+# A Markov model is basically a chain-structured Bayesian Network in which there is one state for each time step and each node has an identical probability distribution. +# The first node, however, has a different distribution, called the prior distribution which models the initial state of the process. +# A state in a Markov model depends only on the previous state and the latest evidence and not on the states before it. +#
+# A **Hidden Markov Model** or **HMM** is a special case of a Markov model in which the state of the process is described by a single discrete random variable. +# The possible values of the variable are the possible states of the world. +#
+# But what if we want to model a process with two or more state variables? +# In that case, we can still fit the process into the HMM framework by redefining our state variables as a single "megavariable". +# We do this because carrying out inference on HMMs have standard optimized algorithms. +# A HMM is very similar to an MDP, but we don't have the option of taking actions like in MDPs, instead, the process carries on as new evidence appears. +#
+# If a HMM is truncated at a fixed length, it becomes a Bayesian network and general BN inference can be used on it to answer queries. +# +# Before we start, it will be helpful to understand the structure of a temporal model. We will use the example of the book with the guard and the umbrella. In this example, the state $\textbf{X}$ is whether it is a rainy day (`X = True`) or not (`X = False`) at Day $\textbf{t}$. In the sensor or observation model, the observation or evidence $\textbf{U}$ is whether the professor holds an umbrella (`U = True`) or not (`U = False`) on **Day** $\textbf{t}$. Based on that, the transition model is +# +# | $X_{t-1}$ | $X_{t}$ | **P**$(X_{t}| X_{t-1})$| +# | ------------- |------------- | ----------------------------------| +# | ***${False}$*** | ***${False}$*** | 0.7 | +# | ***${False}$*** | ***${True}$*** | 0.3 | +# | ***${True}$*** | ***${False}$*** | 0.3 | +# | ***${True}$*** | ***${True}$*** | 0.7 | +# +# And the the sensor model will be, +# +# | $X_{t}$ | $U_{t}$ | **P**$(U_{t}|X_{t})$| +# | :-------------: |:-------------: | :------------------------:| +# | ***${False}$*** | ***${True}$*** | 0.2 | +# | ***${False}$*** | ***${False}$*** | 0.8 | +# | ***${True}$*** | ***${True}$*** | 0.9 | +# | ***${True}$*** | ***${False}$*** | 0.1 | +# + +# %% [markdown] +# HMMs are implemented in the **`HiddenMarkovModel`** class. +# Let's have a look. + +# %% +psource(HiddenMarkovModel) + +# %% [markdown] +# We instantiate the object **`hmm`** of the class using a list of lists for both the transition and the sensor model. + +# %% +umbrella_transition_model = [[0.7, 0.3], [0.3, 0.7]] +umbrella_sensor_model = [[0.9, 0.2], [0.1, 0.8]] +hmm = HiddenMarkovModel(umbrella_transition_model, umbrella_sensor_model) + +# %% [markdown] +# The **`sensor_dist()`** method returns a list with the conditional probabilities of the sensor model. + +# %% +hmm.sensor_dist(ev=True) + +# %% [markdown] +# Now that we have defined an HMM object, our task here is to compute the belief $B_{t}(x)= P(X_{t}|U_{1:t})$ given evidence **U** at each time step **t**. +#
+# The basic inference tasks that must be solved are: +# 1. **Filtering**: Computing the posterior probability distribution over the most recent state, given all the evidence up to the current time step. +# 2. **Prediction**: Computing the posterior probability distribution over the future state. +# 3. **Smoothing**: Computing the posterior probability distribution over a past state. Smoothing provides a better estimation as it incorporates more evidence. +# 4. **Most likely explanation**: Finding the most likely sequence of states for a given observation +# 5. **Learning**: The transition and sensor models can be learnt, if not yet known, just like in an information gathering agent +#
+#
+# +# There are three primary methods to carry out inference in Hidden Markov Models: +# 1. The Forward-Backward algorithm +# 2. Fixed lag smoothing +# 3. Particle filtering +# +# Let's have a look at how we can carry out inference and answer queries based on our umbrella HMM using these algorithms. + +# %% [markdown] +# ### FORWARD-BACKWARD +# This is a general algorithm that works for all Markov models, not just HMMs. +# In the filtering task (inference) we are given evidence **U** in each time **t** and we want to compute the belief $B_{t}(x)= P(X_{t}|U_{1:t})$. +# We can think of it as a three step process: +# 1. In every step we start with the current belief $P(X_{t}|e_{1:t})$ +# 2. We update it for time +# 3. We update it for evidence +# +# The forward algorithm performs the step 2 and 3 at once. It updates, or better say reweights, the initial belief using the transition and the sensor model. Let's see the umbrella example. On **Day 0** no observation is available, and for that reason we will assume that we have equal possibilities to rain or not. In the **`HiddenMarkovModel`** class, the prior probabilities for **Day 0** are by default [0.5, 0.5]. + +# %% [markdown] +# The observation update is calculated with the **`forward()`** function. Basically, we update our belief using the observation model. The function returns a list with the probabilities of **raining or not** on **Day 1**. + +# %% +psource(forward) + +# %% +umbrella_prior = [0.5, 0.5] +belief_day_1 = forward(hmm, umbrella_prior, ev=True) +print ('The probability of raining on day 1 is {:.2f}'.format(belief_day_1[0])) + +# %% [markdown] +# In **Day 2** our initial belief is the updated belief of **Day 1**. +# Again using the **`forward()`** function we can compute the probability of raining in **Day 2** + +# %% +belief_day_2 = forward(hmm, belief_day_1, ev=True) +print ('The probability of raining in day 2 is {:.2f}'.format(belief_day_2[0])) + +# %% [markdown] +# In the smoothing part we are interested in computing the distribution over past states given evidence up to the present. Assume that we want to compute the distribution for the time **k**, for $0\leq k +# Additionally, the forward pass stores $t$ vectors of size $S$ which makes the auxiliary space requirement equivalent to $O(St)$. +#
+#
+# Is there any way we can improve the time or space complexity? +#
+# Fortunately, the matrix representation of HMM properties allows us to do so. +#
+# If $f$ and $b$ represent the forward and backward messages respectively, we can modify the smoothing algorithm by first +# running the standard forward pass to compute $f_{t:t}$ (forgetting all the intermediate results) and then running +# backward pass for both $b$ and $f$ together, using them to compute the smoothed estimate at each step. +# This optimization reduces auxlilary space requirement to constant (irrespective of the length of the sequence) provided +# the transition matrix is invertible and the sensor model has no zeros (which is sometimes hard to accomplish) +#
+#
+# Let's look at another algorithm, that carries out smoothing in a more optimized way. + +# %% [markdown] +# ### FIXED LAG SMOOTHING +# The matrix formulation allows to optimize online smoothing with a fixed lag. +#
+# Since smoothing can be done in constant, there should exist an algorithm whose time complexity is independent of the length of the lag. +# For smoothing a time slice $t - d$ where $d$ is the lag, we need to compute $\alpha f_{1:t-d}$ x $b_{t-d+1:t}$ incrementally. +#
+# As we already know, the forward equation is +#
+# $$f_{1:t+1} = \alpha O_{t+1}{T}^{T}f_{1:t}$$ +#
+# and the backward equation is +#
+# $$b_{k+1:t} = TO_{k+1}b_{k+2:t}$$ +#
+# where $T$ and $O$ are the transition and sensor models respectively. +#
+# For smoothing, the forward message is easy to compute but there exists no simple relation between the backward message of this time step and the one at the previous time step, hence we apply the backward equation $d$ times to get +#
+# $$b_{t-d+1:t} = \left ( \prod_{i=t-d+1}^{t}{TO_i} \right )b_{t+1:t} = B_{t-d+1:t}1$$ +#
+# where $B_{t-d+1:t}$ is the product of the sequence of $T$ and $O$ matrices. +#
+# Here's how the `probability` module implements `fixed_lag_smoothing`. +#
+ +# %% +psource(fixed_lag_smoothing) + +# %% [markdown] +# This algorithm applies `forward` as usual and optimizes the smoothing step by using the equations above. +# This optimization could be achieved only because HMM properties can be represented as matrices. +#
+# `vector_to_diagonal`, `matrix_multiplication` and `inverse_matrix` are matrix manipulation functions to simplify the implementation. +#
+# `normalize` is used to normalize the output before returning it. + +# %% [markdown] +# Here's how we can use `fixed_lag_smoothing` for inference on our umbrella HMM. + +# %% +umbrella_transition_model = [[0.7, 0.3], [0.3, 0.7]] +umbrella_sensor_model = [[0.9, 0.2], [0.1, 0.8]] +hmm = HiddenMarkovModel(umbrella_transition_model, umbrella_sensor_model) + +# %% [markdown] +# Given evidence T, F, T, F and T, we want to calculate the probability distribution for the fourth day with a fixed lag of 2 days. +#
+# Let `e_t = False` + +# %% +e_t = F +evidence = [T, F, T, F, T] +fixed_lag_smoothing(e_t, hmm, d=2, ev=evidence, t=4) + +# %% +e_t = T +evidence = [T, T, F, T, T] +fixed_lag_smoothing(e_t, hmm, d=1, ev=evidence, t=4) + +# %% [markdown] +# We cannot calculate probability distributions when $t$ is less than $d$ + +# %% +fixed_lag_smoothing(e_t, hmm, d=5, ev=evidence, t=4) + +# %% [markdown] +# As expected, the output is `None` + +# %% [markdown] +# ### PARTICLE FILTERING +# The filtering problem is too expensive to solve using the previous methods for problems with large or continuous state spaces. +# Particle filtering is a method that can solve the same problem but when the state space is a lot larger, where we wouldn't be able to do these computations in a reasonable amount of time as fast, as time goes by, and we want to keep track of things as they happen. +#
+# The downside is that it is a sampling method and hence isn't accurate, but the more samples we're willing to take, the more accurate we'd get. +#
+# In this method, instead of keping track of the probability distribution, we will drop particles in a similar proportion at the required regions. +# The internal representation of this distribution is usually a list of particles with coordinates in the state-space. +# A particle is just a new name for a sample. +# +# Particle filtering can be divided into four steps: +# 1. __Initialization__: +# If we have some idea about the prior probability distribution, we drop the initial particles accordingly, or else we just drop them uniformly over the state space. +# +# 2. __Forward pass__: +# As time goes by and measurements come in, we are going to move the selected particles into the grid squares that makes the most sense in terms of representing the distribution that we are trying to track. +# When time goes by, we just loop through all our particles and try to simulate what could happen to each one of them by sampling its next position from the transition model. +# This is like prior sampling - samples' frequencies reflect the transition probabilities. +# If we have enough samples we are pretty close to exact values. +# We work through the list of particles, one particle at a time, all we do is stochastically simulate what the outcome might be. +# If we had no dimension of time, and we had no new measurements come in, this would be exactly the same as what we did in prior sampling. +# +# 3. __Reweight__: +# As observations come in, don't sample the observations, fix them and downweight the samples based on the evidence just like in likelihood weighting. +# $$w(x) = P(e/x)$$ +# $$B(X) \propto P(e/X)B'(X)$$ +#
+# As before, the probabilities don't sum to one, since most have been downweighted. +# They sum to an approximation of $P(e)$. +# To normalize the resulting distribution, we can divide by $P(e)$ +#
+# Likelihood weighting wasn't the best thing for Bayesian networks, because we were not accounting for the incoming evidence so we were getting samples from the prior distribution, in some sense not the right distribution, so we might end up with a lot of particles with low weights. +# These samples were very uninformative and the way we fixed it then was by using __Gibbs sampling__. +# Theoretically, Gibbs sampling can be run on a HMM, but as we iterated over the process infinitely many times in a Bayesian network, we cannot do that here as we have new incoming evidence and we also need computational cycles to propagate through time. +#
+# A lot of samples with very low weight and they are not representative of the _actual probability distribution_. +# So if we keep running likelihood weighting, we keep propagating the samples with smaller weights and carry out computations for that even though these samples have no significant contribution to the actual probability distribution. +# Which is why we require this last step. +# +# 4. __Resample__: +# Rather than tracking weighted samples, we _resample_. +# We choose from our weighted sample distribution as many times as the number of particles we initially had and we replace these particles too, so that we have a constant number of particles. +# This is equivalent to renormalizing the distribution. +# The samples with low weight are rarely chosen in the new distribution after resampling. +# This newer set of particles after resampling is in some sense more representative of the actual distribution and so we are better allocating our computational cycles. +# Now the update is complete for this time step, continue with the next one. +# +#
+# Let's see how this is implemented in the module. + +# %% +psource(particle_filtering) + +# %% [markdown] +# Here, `scalar_vector_product` and `vector_add` are helper functions to help with vector math and `weighted_sample_with_replacement` resamples from a weighted sample and replaces the original sample, as is obvious from the name. +#
+# This implementation considers two state variables with generic names 'A' and 'B'. +# + +# %% [markdown] +# Here's how we can use `particle_filtering` on our umbrella HMM, though it doesn't make much sense using particle filtering on a problem with such a small state space. +# It is just to get familiar with the syntax. + +# %% +umbrella_transition_model = [[0.7, 0.3], [0.3, 0.7]] +umbrella_sensor_model = [[0.9, 0.2], [0.1, 0.8]] +hmm = HiddenMarkovModel(umbrella_transition_model, umbrella_sensor_model) + +# %% +particle_filtering(T, 10, hmm) + +# %% [markdown] +# We got 5 samples from state `A` and 5 samples from state `B` + +# %% +particle_filtering([F, T, F, F, T], 10, hmm) + +# %% [markdown] +# This time we got 2 samples from state `A` and 8 samples from state `B` + +# %% [markdown] +# Comparing runtimes for these algorithms will not be useful, as each solves the filtering task efficiently for a different scenario. +#
+# `forward_backward` calculates the exact probability distribution. +#
+# `fixed_lag_smoothing` calculates an approximate distribution and its runtime will depend on the value of the lag chosen. +#
+# `particle_filtering` is an efficient method for approximating distributions for a very large or continuous state space. + +# %% [markdown] +# ## MONTE CARLO LOCALIZATION +# In the domain of robotics, particle filtering is used for _robot localization_. +# __Localization__ is the problem of finding out where things are, in this case, we want to find the position of a robot in a continuous state space. +#
+# __Monte Carlo Localization__ is an algorithm for robots to _localize_ using a _particle filter_. +# Given a map of the environment, the algorithm estimates the position and orientation of a robot as it moves and senses the environment. +#
+# Initially, particles are distributed uniformly over the state space, ie the robot has no information of where it is and assumes it is equally likely to be at any point in space. +#
+# When the robot moves, it analyses the incoming evidence to shift and change the probability to better approximate the probability distribution of its position. +# The particles are then resampled based on their weights. +#
+# Gradually, as more evidence comes in, the robot gets better at approximating its location and the particles converge towards the actual position of the robot. +#
+# The pose of a robot is defined by its two Cartesian coordinates with values $x$ and $y$ and its direction with value $\theta$. +# We use the kinematic equations of motion to model a deterministic state prediction. +# This is our motion model (or transition model). +#
+# Next, we need a sensor model. +# There can be two kinds of sensor models, the first assumes that the sensors detect _stable_, _recognizable_ features of the environment called __landmarks__. +# The robot senses the location and bearing of each landmark and updates its belief according to that. +# We can also assume the noise in measurements to be Gaussian, to simplify things. +#
+# Another kind of sensor model is used for an array of range sensors, each of which has a fixed bearing relative to the robot. +# These sensors provide a set of range values in each direction. +# This will also be corrupted by Gaussian noise, but we can assume that the errors for different beam directions are independent and identically distributed. +#
+# After evidence comes in, the robot updates its belief state and reweights the particle distribution to better aproximate the actual distribution. +#
+#
+# Let's have a look at how this algorithm is implemented in the module + +# %% +psource(monte_carlo_localization) + +# %% [markdown] +# Our implementation of Monte Carlo Localization uses the range scan method. +# The `ray_cast` helper function casts rays in different directions and stores the range values. +#
+# `a` stores the `v` and `w` components of the robot's velocity. +#
+# `z` is a range scan. +#
+# `P_motion_sample` is the motion or transition model. +#
+# `P_sensor` is the range sensor noise model. +#
+# `m` is the 2D map of the environment +#
+# `S` is a vector of samples of size N + +# %% [markdown] +# We'll now define a simple 2D map to run Monte Carlo Localization on. +#
+# Let's say this is the map we want +#
+ +# %% +m = MCLmap([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0], + [1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0], + [0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0], + [0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0], + [0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0]]) + +heatmap(m.m, cmap='binary') + + +# %% [markdown] +# Let's define the motion model as a function `P_motion_sample`. + +# %% +def P_motion_sample(kin_state, v, w): + """Sample from possible kinematic states. + Returns from a single element distribution (no uncertainity in motion)""" + pos = kin_state[:2] + orient = kin_state[2] + + # for simplicity the robot first rotates and then moves + orient = (orient + w)%4 + for _ in range(orient): + v = (v[1], -v[0]) + pos = vector_add(pos, v) + return pos + (orient,) + + +# %% [markdown] +# Define the sensor model as a function `P_sensor`. + +# %% +def P_sensor(x, y): + """Conditional probability for sensor reading""" + # Need not be exact probability. Can use a scaled value. + if x == y: + return 0.8 + elif abs(x - y) <= 2: + return 0.05 + else: + return 0 + + +# %% [markdown] +# Initializing variables. + +# %% +a = {'v': (0, 0), 'w': 0} +z = (2, 4, 1, 6) + +# %% [markdown] +# Let's run `monte_carlo_localization` with these parameters to find a sample distribution S. + +# %% +S = monte_carlo_localization(a, z, 1000, P_motion_sample, P_sensor, m) + +# %% [markdown] +# Let's plot the values in the sample distribution `S`. + +# %% +grid = [[0]*17 for _ in range(11)] +for x, y, _ in S: + if 0 <= x < 11 and 0 <= y < 17: + grid[x][y] += 1 +print("GRID:") +print_table(grid) +heatmap(grid, cmap='Oranges') + +# %% [markdown] +# The distribution is highly concentrated at `(5, 3)`, but the robot is not very confident about its position as some other cells also have high probability values. + +# %% [markdown] +# Let's look at another scenario. + +# %% +a = {'v': (0, 1), 'w': 0} +z = (2, 3, 5, 7) +S = monte_carlo_localization(a, z, 1000, P_motion_sample, P_sensor, m, S) +grid = [[0]*17 for _ in range(11)] +for x, y, _ in S: + if 0 <= x < 11 and 0 <= y < 17: + grid[x][y] += 1 +print("GRID:") +print_table(grid) +heatmap(grid, cmap='Oranges') + +# %% [markdown] +# In this case, the robot is 99.9% certain that it is at position `(6, 7)`. + +# %% [markdown] +# ## DECISION THEORETIC AGENT +# We now move into the domain of probabilistic decision making. +#
+# To make choices between different possible plans in a certain situation in a given environment, an agent must have _preference_ between the possible outcomes of the various plans. +#
+# __Utility theory__ is used to represent and reason with preferences. +# The agent prefers states with a higher _utility_. +# While constructing multi-agent systems, one major element in the design is the mechanism the agents use for making decisions about which actions to adopt in order to achieve their goals. +# What is usually required is a mechanism which ensures that the actions adopted lead to benefits for both individual agents, and the community of which they are part. +# The utility of a state is _relative_ to an agent. +#
+# Preferences, as expressed by utilities, are combined with probabilities in the general theory of rational decisions called __decision theory__. +#
+# An agent is said to be _rational_ if and only if it chooses the action that yields the highest expected utility, averaged over all the possible outcomes of the action. + +# %% [markdown] +# Here we'll see how a decision-theoretic agent is implemented in the module. + +# %% +psource(DTAgentProgram) + +# %% [markdown] +# The `DTAgentProgram` function is pretty self-explanatory. +#
+# It encapsulates a function `program` that takes in an observation or a `percept`, updates its `belief_state` and returns the action that maximizes the `expected_outcome_utility`. + +# %% [markdown] +# ## INFORMATION GATHERING AGENT +# Before we discuss what an information gathering agent is, we'll need to know what decision networks are. +# For an agent in an environment, a decision network represents information about the agent's current state, its possible actions, the state that will result from the agent's action, and the utility of that state. +# Decision networks have three primary kinds of nodes which are: +# 1. __Chance nodes__: These represent random variables, just like in Bayesian networks. +# 2. __Decision nodes__: These represent points where the decision-makes has a choice between different actions and the decision maker tries to find the optimal decision at these nodes with regard to the cost, safety and resulting utility. +# 3. __Utility nodes__: These represent the agent's utility function. +# A description of the agent's utility as a function is associated with a utility node. +#
+#
+# To evaluate a decision network, we do the following: +# 1. Initialize the evidence variables according to the current state. +# 2. Calculate posterior probabilities for each possible value of the decision node and calculate the utility resulting from that action. +# 3. Return the action with the highest utility. +#
+# Let's have a look at the implementation of the `DecisionNetwork` class. + +# %% +psource(DecisionNetwork) + +# %% [markdown] +# The `DecisionNetwork` class inherits from `BayesNet` and has a few extra helper methods. +#
+# `best_action` returns the best action in the network. +#
+# `get_utility` is an abstract method which is supposed to return the utility of a particular action and state in the network. +#
+# `get_expected_utility` computes the expected utility, given an action and evidence. +#
+ +# %% [markdown] +# Before we proceed, we need to know a few more terms. +#
+# Having __perfect information__ refers to a state of being fully aware of the current state, the cost functions and the outcomes of actions. +# This in turn allows an agent to find the exact utility value of each state. +# If an agent has perfect information about the environment, maximum expected utility calculations are exact and can be computed with absolute certainty. +#
+# In decision theory, the __value of perfect information__ (VPI) is the price that an agent would be willing to pay in order to gain access to _perfect information_. +# VPI calculations are extensively used to calculate expected utilities for nodes in a decision network. +#
+# For a random variable $E_j$ whose value is currently unknown, the value of discovering $E_j$, given current information $e$ must average over all possible values $e_{jk}$ that we might discover for $E_j$, using our _current_ beliefs about its value. +# The VPI of $E_j$ is then given by: +#
+#
+# $$VPI_e(E_j) = \left(\sum_{k}P(E_j=e_{jk}\ |\ e) EU(\alpha_{e_{jk}}\ |\ e, E_j=e_{jk})\right) - EU(\alpha\ |\ e)$$ +#
+# VPI is _non-negative_, _non-additive_ and _order-indepentent_. + +# %% [markdown] +# An information gathering agent is an agent with certain properties that explores decision networks as and when required with heuristics driven by VPI calculations of nodes. +# A sensible agent should ask questions in a reasonable order, should avoid asking irrelevant questions, should take into account the importance of each piece of information in relation to its cost and should stop asking questions when that is appropriate. +# _VPI_ is used as the primary heuristic to consider all these points in an information gathering agent as the agent ultimately wants to maximize the utility and needs to find the optimal cost and extent of finding the required information. +#
+# As an overview, an information gathering agent works by repeatedly selecting the observations with the highest information value, until the cost of the next observation is greater than its expected benefit. +#
+# The `InformationGatheringAgent` class is an abstract class that inherits from `Agent` and works on the principles discussed above. +# Let's have a look. +#
+ +# %% +psource(InformationGatheringAgent) + +# %% [markdown] +# The `cost` method is an abstract method that returns the cost of obtaining the evidence through tests, consultants, questions or any other means. +#
+# The `request` method returns the value of the given random variable as the next percept. +#
+# The `vpi_cost_ratio` method returns a list of VPI divided by cost for each variable in the `variables` list provided to it. +#
+# The `vpi` method calculates the VPI for a given variable +#
+# And finally, the `execute` method executes the general information gathering algorithm, as described in __figure 16.9__ in the book. +#
+# Our agent implements a form of information gathering that is called __myopic__ as the VPI formula is used shortsightedly here. +# It calculates the value of information as if only a single evidence variable will be acquired. +# This is similar to greedy search, where we do not look at the bigger picture and aim for local optimizations to hopefully reach the global optimum. +# This often works well in practice but a myopic agent might hastily take an action when it would have been better to request more variables before taking an action. +# A _conditional plan_, on the other hand might work better for some scenarios. +#
+# + +# %% [markdown] +# With this we conclude this notebook. diff --git a/reinforcement_learning.ipynb b/notebooks/reinforcement_learning.ipynb similarity index 98% rename from reinforcement_learning.ipynb rename to notebooks/reinforcement_learning.ipynb index ee3b6a5eb..afb6a6a9b 100644 --- a/reinforcement_learning.ipynb +++ b/notebooks/reinforcement_learning.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -17,7 +26,7 @@ }, "outputs": [], "source": [ - "from reinforcement_learning import *" + "from aima.reinforcement_learning import *" ] }, { @@ -121,14 +130,14 @@ }, "outputs": [], "source": [ - "from mdp import sequential_decision_environment" + "from aima.mdp import sequential_decision_environment" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The `sequential_decision_environment` is a GridMDP object as shown below. The rewards are **+1** and **-1** in the terminal states, and **-0.04** in the rest. Now we define actions and a policy similar to **Fig 21.1** in the book." + "The `sequential_decision_environment` is a GridMDP object as shown below. The rewards are **+1** and **-1** in the terminal states, and **-0.04** in the rest. Now we define actions and a policy similar to **Fig 21.1** in the book." ] }, { @@ -353,7 +362,7 @@ }, "outputs": [], "source": [ - "from mdp import value_iteration" + "from aima.mdp import value_iteration" ] }, { @@ -641,4 +650,4 @@ }, "nbformat": 4, "nbformat_minor": 1 -} \ No newline at end of file +} diff --git a/notebooks/reinforcement_learning.py b/notebooks/reinforcement_learning.py new file mode 100644 index 000000000..18e17c0bd --- /dev/null +++ b/notebooks/reinforcement_learning.py @@ -0,0 +1,316 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Reinforcement Learning +# +# This Jupyter notebook acts as supporting material for **Chapter 21 Reinforcement Learning** of the book* Artificial Intelligence: A Modern Approach*. This notebook makes use of the implementations in `rl.py` module. We also make use of implementation of MDPs in the `mdp.py` module to test our agents. It might be helpful if you have already gone through the Jupyter notebook dealing with Markov decision process. Let us import everything from the `rl` module. It might be helpful to view the source of some of our implementations. Please refer to the Introductory Jupyter notebook for more details. + +# %% +from aima.reinforcement_learning import * + +# %% [markdown] +# ## CONTENTS +# +# * Overview +# * Passive Reinforcement Learning +# - Direct Utility Estimation +# - Adaptive Dynamic Programming +# - Temporal-Difference Agent +# * Active Reinforcement Learning +# - Q learning + +# %% [markdown] +# ## OVERVIEW +# +# Before we start playing with the actual implementations let us review a couple of things about RL. +# +# 1. Reinforcement Learning is concerned with how software agents ought to take actions in an environment so as to maximize some notion of cumulative reward. +# +# 2. Reinforcement learning differs from standard supervised learning in that correct input/output pairs are never presented, nor sub-optimal actions explicitly corrected. Further, there is a focus on on-line performance, which involves finding a balance between exploration (of uncharted territory) and exploitation (of current knowledge). +# +# -- Source: [Wikipedia](https://en.wikipedia.org/wiki/Reinforcement_learning) +# +# In summary we have a sequence of state action transitions with rewards associated with some states. Our goal is to find the optimal policy $\pi$ which tells us what action to take in each state. + +# %% [markdown] +# ## PASSIVE REINFORCEMENT LEARNING +# +# In passive Reinforcement Learning the agent follows a fixed policy $\pi$. Passive learning attempts to evaluate the given policy $pi$ - without any knowledge of the Reward function $R(s)$ and the Transition model $P(s'\ |\ s, a)$. +# +# This is usually done by some method of **utility estimation**. The agent attempts to directly learn the utility of each state that would result from following the policy. Note that at each step, it has to *perceive* the reward and the state - it has no global knowledge of these. Thus, if a certain the entire set of actions offers a very low probability of attaining some state $s_+$ - the agent may never perceive the reward $R(s_+)$. +# +# Consider a situation where an agent is given a policy to follow. Thus, at any point it knows only its current state and current reward, and the action it must take next. This action may lead it to more than one state, with different probabilities. +# +# For a series of actions given by $\pi$, the estimated utility $U$: +# $$U^{\pi}(s) = E(\sum_{t=0}^\inf \gamma^t R^t(s')$$) +# Or the expected value of summed discounted rewards until termination. +# +# Based on this concept, we discuss three methods of estimating utility: +# +# 1. **Direct Utility Estimation (DUE)** +# +# The first, most naive method of estimating utility comes from the simplest interpretation of the above definition. We construct an agent that follows the policy until it reaches the terminal state. At each step, it logs its current state, reward. Once it reaches the terminal state, it can estimate the utility for each state for *that* iteration, by simply summing the discounted rewards from that state to the terminal one. +# +# It can now run this 'simulation' $n$ times, and calculate the average utility of each state. If a state occurs more than once in a simulation, both its utility values are counted separately. +# +# Note that this method may be prohibitively slow for very large statespaces. Besides, **it pays no attention to the transition probability $P(s'\ |\ s, a)$.** It misses out on information that it is capable of collecting (say, by recording the number of times an action from one state led to another state). The next method addresses this issue. +# +# 2. **Adaptive Dynamic Programming (ADP)** +# +# This method makes use of knowledge of the past state $s$, the action $a$, and the new perceived state $s'$ to estimate the transition probability $P(s'\ |\ s,a)$. It does this by the simple counting of new states resulting from previous states and actions.
+# The program runs through the policy a number of times, keeping track of: +# - each occurrence of state $s$ and the policy-recommended action $a$ in $N_{sa}$ +# - each occurrence of $s'$ resulting from $a$ on $s$ in $N_{s'|sa}$. +# +# It can thus estimate $P(s'\ |\ s,a)$ as $N_{s'|sa}/N_{sa}$, which in the limit of infinite trials, will converge to the true value.
+# Using the transition probabilities thus estimated, it can apply `POLICY-EVALUATION` to estimate the utilities $U(s)$ using properties of convergence of the Bellman functions. +# +# 3. **Temporal-difference learning (TD)** +# +# Instead of explicitly building the transition model $P$, the temporal-difference model makes use of the expected closeness between the utilities of two consecutive states $s$ and $s'$. +# For the transition $s$ to $s'$, the update is written as: +# $$U^{\pi}(s) \leftarrow U^{\pi}(s) + \alpha \left( R(s) + \gamma U^{\pi}(s') - U^{\pi}(s) \right)$$ +# This model implicitly incorporates the transition probabilities by being weighed for each state by the number of times it is achieved from the current state. Thus, over a number of iterations, it converges similarly to the Bellman equations. +# The advantage of the TD learning model is its relatively simple computation at each step, rather than having to keep track of various counts. +# For $n_s$ states and $n_a$ actions the ADP model would have $n_s \times n_a$ numbers $N_{sa}$ and $n_s^2 \times n_a$ numbers $N_{s'|sa}$ to keep track of. The TD model must only keep track of a utility $U(s)$ for each state. + +# %% [markdown] +# #### Demonstrating Passive agents +# +# Passive agents are implemented in `rl.py` as various `Agent-Class`es. +# +# To demonstrate these agents, we make use of the `GridMDP` object from the `MDP` module. `sequential_decision_environment` is similar to that used for the `MDP` notebook but has discounting with $\gamma = 0.9$. +# +# The `Agent-Program` can be obtained by creating an instance of the relevant `Agent-Class`. The `__call__` method allows the `Agent-Class` to be called as a function. The class needs to be instantiated with a policy ($\pi$) and an `MDP` whose utility of states will be estimated. + +# %% +from aima.mdp import sequential_decision_environment + +# %% [markdown] +# The `sequential_decision_environment` is a GridMDP object as shown below. The rewards are **+1** and **-1** in the terminal states, and **-0.04** in the rest. Now we define actions and a policy similar to **Fig 21.1** in the book. + +# %% +# Action Directions +north = (0, 1) +south = (0,-1) +west = (-1, 0) +east = (1, 0) + +policy = { + (0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, + (0, 1): north, (2, 1): north, (3, 1): None, + (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west, +} + + +# %% [markdown] +# ### Direction Utility Estimation Agent +# +# The `PassiveDEUAgent` class in the `rl` module implements the Agent Program described in **Fig 21.2** of the AIMA Book. `PassiveDEUAgent` sums over rewards to find the estimated utility for each state. It thus requires the running of a number of iterations. + +# %% +# %psource PassiveDUEAgent + +# %% +DUEagent = PassiveDUEAgent(policy, sequential_decision_environment) +for i in range(200): + run_single_trial(DUEagent, sequential_decision_environment) + DUEagent.estimate_U() + + + +# %% [markdown] +# The calculated utilities are: + +# %% +print('\n'.join([str(k)+':'+str(v) for k, v in DUEagent.U.items()])) + +# %% [markdown] +# ### Adaptive Dynamic Programming Agent +# +# The `PassiveADPAgent` class in the `rl` module implements the Agent Program described in **Fig 21.2** of the AIMA Book. `PassiveADPAgent` uses state transition and occurrence counts to estimate $P$, and then $U$. Go through the source below to understand the agent. + +# %% +# %psource PassiveADPAgent + +# %% [markdown] +# We instantiate a `PassiveADPAgent` below with the `GridMDP` shown and train it over 200 iterations. The `rl` module has a simple implementation to simulate iterations. The function is called **run_single_trial**. + +# %% +ADPagent = PassiveADPAgent(policy, sequential_decision_environment) +for i in range(200): + run_single_trial(ADPagent, sequential_decision_environment) + +# %% [markdown] +# The calculated utilities are: + +# %% +print('\n'.join([str(k)+':'+str(v) for k, v in ADPagent.U.items()])) + +# %% [markdown] +# ### Passive Temporal Difference Agent +# +# `PassiveTDAgent` uses temporal differences to learn utility estimates. We learn the difference between the states and backup the values to previous states. Let us look into the source before we see some usage examples. + +# %% +# %psource PassiveTDAgent + +# %% [markdown] +# In creating the `TDAgent`, we use the **same learning rate** $\alpha$ as given in the footnote of the book on **page 837**. + +# %% +TDagent = PassiveTDAgent(policy, sequential_decision_environment, alpha = lambda n: 60./(59+n)) + +# %% [markdown] +# Now we run **200 trials** for the agent to estimate Utilities. + +# %% +for i in range(200): + run_single_trial(TDagent,sequential_decision_environment) + +# %% [markdown] +# The calculated utilities are: + +# %% +print('\n'.join([str(k)+':'+str(v) for k, v in TDagent.U.items()])) + +# %% [markdown] +# ## Comparison with value iteration method +# +# We can also compare the utility estimates learned by our agent to those obtained via **value iteration**. +# +# **Note that value iteration has a priori knowledge of the transition table $P$, the rewards $R$, and all the states $s$.** + +# %% +from aima.mdp import value_iteration + +# %% [markdown] +# The values calculated by value iteration: + +# %% +U_values = value_iteration(sequential_decision_environment) +print('\n'.join([str(k)+':'+str(v) for k, v in U_values.items()])) + +# %% [markdown] +# ## Evolution of utility estimates over iterations +# +# We can explore how these estimates vary with time by using plots similar to **Fig 21.5a**. We will first enable matplotlib using the inline backend. We also define a function to collect the values of utilities at each iteration. + +# %% +# %matplotlib inline +import matplotlib.pyplot as plt + +def graph_utility_estimates(agent_program, mdp, no_of_iterations, states_to_graph): + graphs = {state:[] for state in states_to_graph} + for iteration in range(1,no_of_iterations+1): + run_single_trial(agent_program, mdp) + for state in states_to_graph: + graphs[state].append((iteration, agent_program.U[state])) + for state, value in graphs.items(): + state_x, state_y = zip(*value) + plt.plot(state_x, state_y, label=str(state)) + plt.ylim([0,1.2]) + plt.legend(loc='lower right') + plt.xlabel('Iterations') + plt.ylabel('U') + + +# %% [markdown] +# Here is a plot of state $(2,2)$. + +# %% +agent = PassiveTDAgent(policy, sequential_decision_environment, alpha=lambda n: 60./(59+n)) +graph_utility_estimates(agent, sequential_decision_environment, 500, [(2,2)]) + +# %% [markdown] +# It is also possible to plot multiple states on the same plot. As expected, the utility of the finite state $(3,2)$ stays constant and is equal to $R((3,2)) = 1$. + +# %% +graph_utility_estimates(agent, sequential_decision_environment, 500, [(2,2), (3,2)]) + +# %% [markdown] +# ## ACTIVE REINFORCEMENT LEARNING +# +# Unlike Passive Reinforcement Learning in Active Reinforcement Learning we are not bound by a policy pi and we need to select our actions. In other words the agent needs to learn an optimal policy. The fundamental tradeoff the agent needs to face is that of exploration vs. exploitation. + +# %% [markdown] +# ### QLearning Agent +# +# The QLearningAgent class in the rl module implements the Agent Program described in **Fig 21.8** of the AIMA Book. In Q-Learning the agent learns an action-value function Q which gives the utility of taking a given action in a particular state. Q-Learning does not required a transition model and hence is a model free method. Let us look into the source before we see some usage examples. + +# %% +# %psource QLearningAgent + +# %% [markdown] +# The Agent Program can be obtained by creating the instance of the class by passing the appropriate parameters. Because of the __ call __ method the object that is created behaves like a callable and returns an appropriate action as most Agent Programs do. To instantiate the object we need a mdp similar to the PassiveTDAgent. +# +# Let us use the same GridMDP object we used above. **Figure 17.1 (sequential_decision_environment)** is similar to **Figure 21.1** but has some discounting as **gamma = 0.9**. The class also implements an exploration function **f** which returns fixed **Rplus** until agent has visited state, action **Ne** number of times. This is the same as the one defined on page **842** of the book. The method **actions_in_state** returns actions possible in given state. It is useful when applying max and argmax operations. + +# %% [markdown] +# Let us create our object now. We also use the **same alpha** as given in the footnote of the book on **page 837**. We use **Rplus = 2** and **Ne = 5** as defined on page 843. **Fig 21.7** + +# %% +q_agent = QLearningAgent(sequential_decision_environment, Ne=5, Rplus=2, + alpha=lambda n: 60./(59+n)) + +# %% [markdown] +# Now to try out the q_agent we make use of the **run_single_trial** function in rl.py (which was also used above). Let us use **200** iterations. + +# %% +for i in range(200): + run_single_trial(q_agent,sequential_decision_environment) + +# %% [markdown] +# Now let us see the Q Values. The keys are state-action pairs. Where different actions correspond according to: +# +# north = (0, 1) +# south = (0,-1) +# west = (-1, 0) +# east = (1, 0) + +# %% +q_agent.Q + +# %% [markdown] +# The Utility **U** of each state is related to **Q** by the following equation. +# +# **U (s) = max a Q(s, a)** +# +# Let us convert the Q Values above into U estimates. +# +# + +# %% +U = defaultdict(lambda: -1000.) # Very Large Negative Value for Comparison see below. +for state_action, value in q_agent.Q.items(): + state, action = state_action + if U[state] < value: + U[state] = value + +# %% +U + +# %% [markdown] +# Let us finally compare these estimates to value_iteration results. + +# %% +print(value_iteration(sequential_decision_environment)) + +# %% + +# %% diff --git a/notebooks/sarsa.ipynb b/notebooks/sarsa.ipynb new file mode 100644 index 000000000..aa416c66a --- /dev/null +++ b/notebooks/sarsa.ipynb @@ -0,0 +1,106 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, + { + "cell_type": "markdown", + "id": "df8c6d04", + "metadata": {}, + "source": [ + "# SARSA: on-policy temporal-difference control (Section 21.3)\n", + "\n", + "`SARSALearningAgent` from [`reinforcement_learning.py`](reinforcement_learning.py) is the on-policy counterpart of Q-learning: it bootstraps on the action its exploration policy actually takes, rather than the greedy maximum." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e09d067c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:12.314587Z", + "iopub.status.busy": "2026-06-23T10:42:12.314299Z", + "iopub.status.idle": "2026-06-23T10:42:12.476638Z", + "shell.execute_reply": "2026-06-23T10:42:12.475299Z" + } + }, + "outputs": [], + "source": [ + "from aima.reinforcement_learning import SARSALearningAgent, QLearningAgent, run_single_trial\n", + "from aima.mdp import sequential_decision_environment as env" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c6d05046", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-23T10:42:12.482836Z", + "iopub.status.busy": "2026-06-23T10:42:12.482453Z", + "iopub.status.idle": "2026-06-23T10:42:12.522062Z", + "shell.execute_reply": "2026-06-23T10:42:12.520759Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Learned SARSA utilities U(s) = max_a Q(s, a):\n", + " (0, 0) -> -0.347\n", + " (0, 1) -> -0.305\n", + " (0, 2) -> -0.289\n", + " (1, 0) -> -0.255\n", + " (1, 2) -> -0.27\n", + " (2, 0) -> -0.372\n", + " (2, 1) -> 0.349\n", + " (2, 2) -> -0.06\n", + " (3, 0) -> -0.393\n" + ] + } + ], + "source": [ + "sarsa = SARSALearningAgent(env, Ne=5, Rplus=2, alpha=lambda n: 60. / (59 + n))\n", + "for _ in range(200):\n", + " run_single_trial(sarsa, env)\n", + "\n", + "print('Learned SARSA utilities U(s) = max_a Q(s, a):')\n", + "U = {}\n", + "for (state, action), q in sarsa.Q.items():\n", + " if action is not None:\n", + " U[state] = max(U.get(state, -float('inf')), q)\n", + "for state in sorted(U):\n", + " print(' ', state, '->', round(U[state], 3))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/sarsa.py b/notebooks/sarsa.py new file mode 100644 index 000000000..692560314 --- /dev/null +++ b/notebooks/sarsa.py @@ -0,0 +1,38 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # SARSA: on-policy temporal-difference control (Section 21.3) +# +# `SARSALearningAgent` from [`reinforcement_learning.py`](reinforcement_learning.py) is the on-policy counterpart of Q-learning: it bootstraps on the action its exploration policy actually takes, rather than the greedy maximum. + +# %% +from aima.reinforcement_learning import SARSALearningAgent, QLearningAgent, run_single_trial +from aima.mdp import sequential_decision_environment as env + +# %% +sarsa = SARSALearningAgent(env, Ne=5, Rplus=2, alpha=lambda n: 60. / (59 + n)) +for _ in range(200): + run_single_trial(sarsa, env) + +print('Learned SARSA utilities U(s) = max_a Q(s, a):') +U = {} +for (state, action), q in sarsa.Q.items(): + if action is not None: + U[state] = max(U.get(state, -float('inf')), q) +for state in sorted(U): + print(' ', state, '->', round(U[state], 3)) diff --git a/search.ipynb b/notebooks/search.ipynb similarity index 90% rename from search.ipynb rename to notebooks/search.ipynb index caf231dcc..55f6cb6ce 100644 --- a/search.ipynb +++ b/notebooks/search.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": { @@ -19,8 +28,8 @@ }, "outputs": [], "source": [ - "from search import *\n", - "from notebook import psource, heatmap, gaussian_kernel, show_map, final_path_colors, display_visual, plot_NQueens\n", + "from aima.search import *\n", + "from aima.notebook_utils import psource, heatmap, gaussian_kernel, show_map, final_path_colors, display_visual, plot_NQueens\n", "\n", "# Needed to hide warnings in the matplotlib sections\n", "import warnings\n", @@ -1120,7 +1129,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1132,7 +1141,11 @@ " # we use these two variables at the time of visualisations\n", " iterations = 0\n", " all_node_colors = []\n", - " node_colors = {k : 'white' for k in problem.graph.nodes()}\n", + " if hasattr(problem, 'graph'):\n", + " node_colors = {k : 'white' for k in problem.graph.nodes()}\n", + " else:\n", + " node_colors = {}\n", + "\n", " \n", " #Adding first node to the queue\n", " frontier = deque([Node(problem.initial)])\n", @@ -1208,7 +1221,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1220,7 +1233,10 @@ " # we use these two variables at the time of visualisations\n", " iterations = 0\n", " all_node_colors = []\n", - " node_colors = {k : 'white' for k in problem.graph.nodes()}\n", + " if hasattr(problem, 'graph'):\n", + " node_colors = {k : 'white' for k in problem.graph.nodes()}\n", + " else:\n", + " node_colors = {}\n", " \n", " #Adding first node to the stack\n", " frontier = [Node(problem.initial)]\n", @@ -2107,6 +2123,58 @@ "recursive_best_first_search(puzzle).solution()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## GRID SEARCH VISUALIZATION\n", + "\n", + "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.\n", + "\n", + "`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).\n", + "\n", + "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\\***:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABLEAAAFdCAYAAAD8Af8KAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAvvJJREFUeJzs3Xd4U+XbB/DvyepM94Iyyi5bpgOVKVMFlKE/VBABEcUBKEPZIALiRMEXUBScqCggKKKCiMqUvVeBli7aNB1JmvG8f2AjISnNOaVNKd/PdfUKnD73uZ/TJOc+5zlLEkIIEBERERERERERVWAqX3eAiIiIiIiIiIioJBzEIiIiIiIiIiKiCo+DWEREREREREREVOFxEIuIiIiIiIiIiCo8DmIREREREREREVGFx0EsIiIiIiIiIiKq8DiIRUREREREREREFR4HsYiIiIiIiIiIqMLjIBbd1AYPHoz777+/xGl0bW3atMG4ceMq/DyJiG5EGzZsQEJCAg4fPnzNaXRtCxYsQEJCAux2e4WeJxFRWbrzzjvxwgsvlDiNimc0GpGQkIBFixZV6HlWVhzEqqC+++47JCQkOH9q1aqFli1bon///vj5559LbH/lT//+/V3abtmyBQ8//DDatGmDZs2a4YEHHsCyZctQUFBQqj5v3boVDz74IBo2bIgWLVrgueeeQ1pamlu7t956q9i+ZmRklKoPcqWlpSElJaXEaXRt58+fR2ZmZoWfJ1FlY7PZ8Nlnn2HgwIFo0aIFEhMT0bFjRwwdOhTr1q2DzWZztk1KSnJZ39auXRu33HILhgwZgn/++cdt3le3v/pnxIgR16Vt3bp10bJlS/Tt2xdz587FhQsXnG2XLVuGhIQErFmzxuPyf/7550hISMAXX3yh6O/3119/oVatWkhISMCpU6fcfp+ZmYn/+7//Q5cuXZCQkICpU6cqylNa+fn5SEpKQmFh4TWn0bVlZ2cjKSkJQogKPU+iG4XZbEaTJk2QkJCAbdu2FdvOYrHg/fffR8+ePdGkSRPccccdGDZsGLZs2eJsM3jw4GvWEU/7NVu2bEFCQgKaNWtW7Lrw119/dYmvU6cO2rRpg9GjR+PMmTPX5e+wevVq9O7dG40aNUKnTp3wwQcfuA1sjx492uPyNGvW7Lr0QY4LFy647XN5mkbFczgcSEpKQk5OToWeZ2Wl8XUHyLO8vDwkJSXhgw8+QNeuXQEA6enpWLx4Mbp27Yr33nsPo0aNumb7Iv7+/s5/T506FTNmzMBTTz2Ft99+G2FhYdi8eTMmTZqElStX4rffflPU388//xyPPPIIhgwZghUrViA3NxcTJkxA27ZtsX37dsTFxTnbGgwGJCUl4dChQwgMDHSZT0REhKL8REQ3mwsXLqB37964cOECxo4di7FjxyIsLAwXLlzAN998gwceeACPPPIIPvzwQwCA1WpFUlISRo4cifHjx8PhcODUqVOYOHEibr31Vvz2229o166dc/5Xt7/alevv0rQVQsBgMODAgQNYsmQJpk6dijlz5uCFF17AkCFDsHTpUgwfPhy33XYbYmJiXJZ/1KhRaNWqFQYOHCj775efn4/HHnsMWVlZMBqNsFqtLr83Go1o2rQp7rvvPgwbNgwPP/wwLl26JDsPEVFl9c033+DQoUMIDQ3FBx984FJDiphMJtxxxx04f/48Zs6ciTvuuAMWiwXLly9Hp06d8P777+PJJ5/EvHnzYDKZnHEbNmzAqFGj8Oabb6JPnz7O6Vfu1yxevBg5OTlISkrCd999hwEDBrjlLygoQFJSEubPn49+/frBarXiwIEDGDt2LD777DPs2bMHNWvWVPw3GDZsGNauXYuZM2firrvuQm5uLj7++GMsWrQIzzzzjLNdRkYGLBYL/vrrL5d4lYrnlBDJxUGsCi4mJgYJCQkAgISEBLRp0wabNm1yG8Ty1P5qWVlZePXVV3H//ffj/fffd05v1KgRHnzwQcyaNUtRH61WK5599lm0bt0ay5Ytc05fs2YN6tSpg1deeQVLly51i6tRowaCg4MV5SQiuplZrVb06tULmZmZ2LNnD+Lj452/q1+/Pjp16oQnn3wSn3/+uVtsaGios07Url0bdevWRe3atTFv3jx8//3312xfktK0bdGiBR599FE888wzGDNmDOLi4vDwww9jxYoVuOWWWzBs2DDnGVlCCDz++OOQJAnLly+HJEle5bzS2LFjIUkShg8fjgULFrj9Xq/X4/z589BoNEhNTZU9fyKiym7p0qW444470K1bN7z22mt45513EBYW5tJm5cqV2Lt3Lz777DM8/PDDzult27bFfffd51y/xsbGusRFR0cDAKKiojzWlaysLKxevRpTp07F6tWrsXTpUo+DWEWunE+9evUQEBCAnj174v3338fcuXMVLD2wZMkSfPzxx9i1axeaN2/usmyeLjFWq9Ve10giKh6Hfm8wkiRBr9fD4XDIjk1NTYXNZkOjRo3cfhcbG4u3337bZVrR/TY++eSTa8734MGDyMzMRPfu3d3m2bJlS3z55ZduR7hL6/Dhw3jyySfRokULNGvWDIMHD8bRo0fd2gwbNgzNmzdHw4YN0b9/f+zcuVNRvpMnT+LJJ59EmzZt0LRpU/Tv39/rs9ZK6sc777yDWrVqYdOmTS5xy5cvR0JCAlavXg3g8iULCQkJWLJkCf766y/07NkTiYmJuPfee/Hnn3+65W3ZsqXzVOV69eqhffv2WLBggcvp1lfO859//sH999+PxMRE9OjRA3///bfbPC0WC2bOnIlWrVqhWbNmePHFF12Oml3tu+++w/3334+GDRvilltuwQsvvOB2qrLceRIR8Omnn2L//v2YNWuWywDWlZo1a4bZs2eXOK9atWohJCQEJ0+evN7dlE2SJLz++uuIjIzEtGnTAAB169bFggULsHbtWixZsgTA5fXmpk2bsGjRIlSrVg3NmjVDz549vc7z448/YsmSJViyZInLUf2r+6LRlO5YX0FBAV5//XV07NgRiYmJ6NatG1asWOFy6VlBQQHmzZuHu+66C/Xr18ddd92FhQsXKqrzhYWFeOutt9CxY0c0bNgQXbp0wfz5871ap5bUj6SkJDRs2BDDhw93iUtLS0Pz5s3x0EMPOZdrxIgR6Ny5M4xGI0aPHo0mTZqgbdu2eP3119127ObOnetyiWuLFi0wYsQInDhxwqVd0TxNJhNefPFFNG3aFK1bt8a8efM8Xsr3888/o3v37khMTETv3r09XjJb5MyZM3jmmWfQsmVLJCYm4v777/dY4+XMk6iyO3HiBLZs2YJRo0Zh+PDhsNls+PTTT93anT9/HgA87n/06NEDgwcPVpT/k08+gRACTzzxBEaNGoVNmzbJujzwlltuAQC32rdw4cISL48s8tprr6FHjx4uA1hF1Gq1133x1h9//IFBgwahadOmaNWqFUaPHo2LFy+6tNm6dSsGDhyIxo0bo0mTJnjiiSc8Xi7vjZ07d2LQoEFo0aIFWrZsicGDB2P//v1exZbUj+eeew7169fH8ePHXeImT56MOnXqYM+ePQCAXbt2ISEhAb/++iu+//57tG/fHg0bNsTDDz+MY8eOucQW7dcU/TRo0ADdunXDxx9/7FInrpznxo0b0blzZyQmJmLAgAFutadovi+88ILz7z5//vxiLyF3OBxYsmQJ7rnnHjRo0ABt27bFjBkz3G7bI2ee5IGgCmnFihUCgFi9erXbdEmSxPTp071qfyWz2SxCQkJEYmKiSE1NLbEPq1atEgDEu+++e812f/31lwAg5syZ4/a7rl27CgDiwIEDzmlTp04VAESbNm1E3bp1xW233SYmTJgg0tPTS+yTEEKsX79e+Pv7i+7du4uffvpJ7N+/X3zyySeiTZs2zjY//fST8Pf3Fw888IDYsmWL2LNnj3j22WeFTqcTP//8s7Ndt27dRKtWrVzmf/W0rKwsERcXJ3r27Cn++OMPcfjwYfHtt9+Kzp07i3/++eeaffWmH1arVdx1110iOjpanD9/XgghxN69e4W/v7/43//+55xXRkaGACAee+wx0atXL7F582bx119/iQcffFBotVrx66+/uuQ+d+6cOHPmjDhz5ow4ePCgWLp0qYiMjBTDhg1zm+cTTzwh+vTpIzZv3iy2b98uunTpIoKCgtw+J/fdd5/Q6/ViyZIlYt++fWLx4sXi4YcfFrGxsWLw4MEubV988UWh0+nEtGnTxO7du8Vvv/0m2rVrJ2rVqiUyMzMVzZOILuvbt68A4PJdKsmJEycEADF+/HiX6QaDQUiSJDp27OhVeznzVtq2f//+AoBISkpyTrv33ntFcHCwWLt2rfD39xeDBg1y/i40NFQ0b968xNxCCHHp0iVRpUoVMXLkSCGEEC+//LIAII4cOVJszMWLFwUA8fTTT3uVQwghcnJyxC233CJiY2PFsmXLxP79+8XGjRvFY489Jr788kshhBC5ubmiZcuWonr16uLTTz8VBw4cECtXrhTR0dHi0Ucfdc6rqB5fWXM8TRs1apSIiooSX3zxhThy5IjYvHmzmDBhgnjxxRev2Vdv+7Fs2TIBQLz33ntCCCFsNpvo0KGDiIyMFOfOnXO26927t6hfv77o06ePWL58udi3b5945513hJ+fnxgyZIhL7qysLGetOnnypPj5559F586dRUxMjMt2Qe/evUWDBg3EoEGDxPLly8X+/fvFvHnzhEqlEm+99ZbLPL/55huhUqnEkCFDxI4dO8Qvv/wiOnfuLB5++GEBQFitVmfbHTt2iNDQUNG5c2exadMmsXfvXjF58mSh0WjE559/rmieRDeDl156SURHRwuz2SyEuLze9rQe/vbbbwUA8cwzzwibzeb1/IvWcStWrPD4+8aNGzu3k00mk4iIiBAvv/yyW7u1a9cKAOKjjz5ymb59+3YBQDz11FMu02fOnCkAuOwveHLq1CkBQEyePFnMmDFDtGnTRtSvX1/06tVLbNiwwa39wIEDhb+/v2jVqpWoV6+euPvuu8WcOXNEfn7+NfMUWbhwoZAkSQwdOlRs3rxZ7NmzRyxcuFDcd999zjaLFy8WarVaPPPMM+Kvv/5y7ieEh4eLo0ePOtvVrFnTpYZ6mnbo0CEREBAghg8fLnbu3Cn2798vVq5cKVq2bFlin73pR3Z2tqhdu7Zo0qSJc37fffedACBmzJjhnNfWrVsFADF8+HDx2GOPie3bt4tff/1V3H777SI8PFwcP37c2dZutzvryZkzZ8SePXvEvHnzhE6nE6+//rrbPJ955hkxdOhQ8ffff4vNmzeLxo0bi1q1aonCwkJn24KCAtG0aVMRHx8vvv76a7F3714xY8YM8dRTT7nt/9rtdtGnTx8RFhYmFi5cKPbv3y/WrVsn6tevL9q1a+ecr5x5kmccxKqgigaloqOjRc2aNUXNmjVFcHCwUKlUYtKkSV61L/rZuXOns93XX38tgoKCREBAgOjVq5eYMmWK+Omnn4TJZHKbZ35+vjhz5owwGo3X7GtWVpZQq9XigQcecJluNptFbGysACA2bdrknD5nzhwxbtw48csvv4j9+/eLpUuXiqpVq4oqVaqI06dPXzOXyWQSsbGx4rbbbhMOh8Pld0WF0WKxiKpVq4q7777brc19990nEhMTnf/3ZhBr/fr1AoDYvXu3W3+uVYzl9CMlJUXExsaK22+/XaSnp4s6deqIRo0aiby8PGebogGnKlWquLxfDodDNGrUSDRu3LjYvhRZsmSJACAuXbrkMs8aNWoIi8XibHf+/HmhUqnE7NmzndN++OEHAUAsX77cZZ6LFy8WkiS5DDgVDWzOnTvXpW12draIiIgQ48aNkz1PIvpP8+bNRUhIiKwYT4NH+fn54tFHHxUAxBdffOGxfUhIiFtdqVmzpli2bFmp2l5rEOvFF18UAMTWrVud01JTU0V0dLQAIKpXry4MBoPzd0lJSSI5Odmrv0P//v1FtWrVRE5OjhCi7AaxxowZI1Qqldi3b5/b74pqx/jx44VGoxEHDx50+f33338vAIg//vhDCOH9IFbVqlXddsiuzFccb/shhBCPP/640Ol0Yvv27eKll14SKpVK/Pjjjy5xvXv3FpIkia+//tpl+uuvvy4AiL/++uua/SkoKBD+/v5i3rx5bvNcs2aNS9uePXuKWrVquSxrtWrVxJ133unSLjs7W4SGhroNODVu3Fg0atTIZadFCCGGDx8uYmJihMVikT1PosrOarWK2NhYMWHCBOe03377TQBw2e8o8vjjjwsAIj4+XgwZMkS8+eabHteNV7rWINaff/7ptm4aO3asiI+Pd1vfeRrESk9PFx06dBBarVbs2LHDpb3BYBBnzpzxuG90pV9//VUAEEFBQeLWW28Vv/76q9i5c6cYMmSIACD+7//+z6X9U089JWbPni22bdsmdu/eLebPny/0er1o1qyZsx4VJykpSWi1Wo/bxUXLe+HCBaHT6cTw4cPdft+wYUPRp08f5zRvBrHeeOMNAcBtwMput7vt11xJTj92794t/Pz8xCOPPCJOnDghQkNDRY8ePVzmXzTgdPvtt7vMLycnR4SGhooHH3yw2L4UGTt2rIiJiXGbZ/v27V3aFb2n33zzjXPaggULPNatMWPGuA04ffTRRwKA+P77713aHj58WKjVarF06VLZ8yTPOIhVQRUNSn3wwQcuo8lz584VWq1WvPLKKyW2L/opOkJSJCsrS3z44YdixIgRom3btkKlUomoqCjx2WefKe7vyJEjhUqlEitWrBAOh0OYzWYxcuRIAcDtaIanjemDBw8KnU4n+vXrd808mzZt8ng05UpFK6Ard5qKFA3iFB3d92YQ6+DBg0KSJNGnTx+xf//+a/ZPaT+K2qvVahEdHS2Cg4PddqiKBpw87aAUHTW6cn7Hjx8XTz31lGjdurWoXbu2qFmzpoiJiXFZaRbNc/To0W7zrFq1qkuxfOaZZ4QkSW5FPTc3VwBwaVu0EvZ0xl/v3r1F06ZNZc+TiP7TuHFjERER4TZ92bJlLoNH9evXd/7u6oGm6tWrC41GI/z9/cWnn37qNq+i9iNHjnSrK2fOnHHZ6FbS9lqDWBMnThQAxObNm12mv/322wKA+Pjjj2X9vYqsXLlSAHAZCCmrQayEhATRrl27a7apXbu224a5EJd3EDUajZg8ebIQwvtBrDvuuENUqVJFfPvtt6KgoMDrvnrbDyEuDzA1b95cREVFCUmSxNSpU93ievfuLfz8/ITdbneZXvR3vHJ+ZrNZvPnmm6JTp06ifv36zs+uWq0WTzzxhMs8AwMD3XJNmTJFSJLkPBCzd+9el7PFrjRw4ECXAafDhw8LAC4HbIoU7fhu375d1jyJbgZFZyaeOXPGZXqjRo3EiBEjPMYcPHhQvPbaa2LgwIGiZs2aAoC47bbbXM6kudK1BrEef/xx0axZM5dpJ06c8DjQXfRdjoyMFDVr1hTx8fFCpVKJ6OhosWXLFhlL7erHH390DmJdfTVJ69atRWhoqMv2raf9n6K+Xb1fd7WFCxeWeADg/fffFwDEtm3b3H43duxYERgY6Bwc8mYQ68oz6Eo6yUBpP4QQ4v/+7/+cJ2LUqFHD7QzzogGnt99+221+gwYNEkFBQS7z+/vvv8Vjjz0mmjdvLmrVqiVq1qwpwsPDBQCRnZ3tMs+r1+lGo9FtEKlLly4uB0qK7Ny5061tz549PW6bCSFEgwYNRP/+/WXPkzzjjd0ruKtv7N6iRQtcuHABs2fPxsCBA9GkSZNi2xcnPDwcjz/+OB5//HEAl+8F8cADD+Cxxx5D48aNFT3q9e2334Zer8fTTz+NUaNGobCwEO3bt8fYsWOxYMECl5s1erpGvHHjxmjZsqXbfaGulpycDADXfIpI0bX3kydPxquvvgpxebAWwOWnUQGX7+FRo0YNr5atcePGWLp0KaZMmYJmzZohJiYGHTt2xLBhw9ClS5fr1o+OHTuiU6dO+PnnnzF+/HgkJiZ6nK+n+99Uq1YNwOX7ntWoUQMnT55E69at0apVK8yYMQO1a9eGn58fNm3ahOHDh8NsNpc4z/DwcJcncaWmpiIyMtLt/jHBwcEIDQ11W3ZJkpxPqbly2TMyMpxPKpMzTyL6T9WqVXHkyBGYTCYEBAQ4p/fr1w+dOnUCcPkeQr///rtb7P/+9z+MHz8eVqsV//zzD0aPHo13330Xffv2dZlXkbK6sfu1FHej35CQEJdXOXJzc/HMM8/goYcewn333VfqPpYkOTkZd9xxxzXbnD9/Hunp6ahbty6A/9aVQgg4HA6kpaXJyvnhhx/iqaeeQr9+/aDVatGmTRv06dMHo0aN8vjeKulHQEAAJk2ahIEDByI2NhavvPKKx3lWqVLF7albcXFxbjfK79+/P7Zu3Yq5c+eibdu2CA0NhSRJaN26tVutqlq1qlue8PBwCCGQnZ2N2NhY57yvVSuvXG7g8j3WPvzwQ2edEkI4c6elpUGn03k9T6KbwZIlSyBJEjp06OAy/dKlS/j888/xxhtvICgoyOV3jRs3RuPGjZ3/X7NmDfr3748HH3zQ6/ssAZfX5V999RVUKpVbvVGpVFiyZInHdfyECRPQr18/mEwmbN68GWPGjMGiRYtw1113KXo4SHh4OACgVatWzpvQF+nRowd27dqFAwcOoE2bNgA87//ce++9CAsLw6ZNmzBz5sxic8nZ/xk0aBDUarXLtrfBYEBBQQHy8vKg1+u9Wr6+ffti6tSpePfdd7Fw4ULUrFkTnTt3xujRo533E7se/XjiiSfw6quv4uzZs/joo48QGRnpcb7FrX/z8/ORm5uLkJAQ/Prrr+jWrRv69++PN998E9WqVYNWq8UHH3yA1157rcT9H71eD7Va7bb/4+26//z588jLy/NYSy9evOjcppEzT/KMg1g3oFtuuQVCCOzatcttEEuJWrVqYerUqejbty82bdqkaBBLp9Nh3rx5mDNnDtLS0qDX66HX6/H4448jLCzM480crxYYGOi2crla0cDG1TcHv1LRzs3MmTOdO3NXq1KlSon9udLQoUMxdOhQHDp0CH/88QdWrFiBe+65BytXrsSgQYOuSz9WrlyJn3/+GQkJCXjvvffw+OOPo0GDBm4xnh7xXjStKOfy5cuRm5uLb775xllkgcs3UfekuJtPiituMBgSEgKDwQCHw+GyY2K1WpGbm+sSFxISAo1Ggw0bNkCr1RabT848ieg/HTt2xM8//4zff/8d3bp1c04PCQlxrgeKBouvduVAU7169RAXF4f27dtj/PjxeOedd8q87yURQuD3339HVFSUx3WgUjk5OTAYDPj1119ddnwMBgMAoHPnzoiNjXXeTLa0QkNDr1mrgMvvV8uWLfF///d/Hn/v7c5GkQYNGuDXX39FZmYm/vzzT2zYsAGTJk3CunXrrvkwEjn9yMzMxNixY1GzZk0kJSVh9uzZmDp1qluMp1qVk5MDm83m/IyePXsWa9euxfz58zFixAhnO7PZjKysLLf4a90ouaheFc37WrWySFHbF154AQMHDvQ435iYGOzbt8/reRJVdufPn8fGjRvx5ZdfonXr1i6/E0KgRYsW+PLLLzF06NBrzuf+++9H37598eWXXyIlJcXjILUnn332GTQaDf755x+3waddu3bhoYce8ji/K59O2LBhQ2g0GowYMQJ33XWXxye+l6Rhw4ZQq9UeDxAUTbvyYUrFkbv/U9w+zJX7AMUNdl09sFiSadOmYfLkyfjnn3/w+++/Y+nSpfj444+xbds23HrrrdelH1OmTEFSUhJq1KiBadOm4f7770dERIRbTHHrX7Va7dzeef/99xETE4OVK1e67Ffk5eV57Ie3+z/ervtDQkJQrVq1Yk/KKDpoL2ee5BmfTngDunDhAgDIPlPl7Nmz+O677zz+rujMIE8DDnKo1WpUrVoVer3e+ejb4cOHl/iEjuzsbOzZswctWrS4Zrs777wTfn5+Hh8Df2Ubf39/7Nmzx+UJFVf++Pn5KVq+xo0b48knn8Rvv/2G0NBQrF279rr049ChQ3jyySfx4IMPYs+ePYiOjka/fv3cnmQBAL/++qvbtE2bNiE6Ohr169cHcPkpUzqdzu0z8s033yhabgBo164dbDab25kdv/76q9tTtLp06QKr1YpTp055XO7q1avLnicR/WfEiBEIDw/HjBkzYLPZSjWvu+++G/3798eiRYs8PpWnvC1fvhynTp3C6NGjFR0dL06VKlVw5swZbN++HZs3b3b+PProowAuP+nqWrVFri5duuDvv/9Genr6Ndvs3bsXERERHteVxR2RLklUVBTuv/9+LFq0CCNHjsTmzZuL3YiX0w+Hw4H//e9/yMvLw2+//YZx48ZhxowZHjfYc3NzsWvXLpdpRe2KztItqnExMTEu7b799lvFT2lq3rw5goKC3GqlzWbD5s2bXaa1bNkSERER2LlzZ7F1OjAwUNY8iSq7ZcuWISgoCL1793b7vtSqVQtdunRxPkkWAL7++mvnvsvVivY/5DwJdsmSJejSpQtq1arllr93794IDAzERx99VOJ8ip4cPnXqVEUHTvV6PTp16oR9+/a5DVbt2LEDWq3W5cwzT44cOYKUlJQS93+Krvy4Vo0qanPgwIFi12dXnx3rDbVajdatW2PMmDH46aefYLfbsWHDhuvSj/Xr1+PVV1/FpEmTsGnTJmRnZ+PRRx/1uP6/ev1rt9uxefNm3Hrrrc7PT0FBASIjI90OjF9rf60k7dq1w4kTJ5xnmBXxVPe6dOmCs2fPwm63e1zuuLg42fMkzziIdYPZsWOH85TOrl27yoo1m8144IEHMHLkSJw9e9Y5/e+//8bLL7+M8PBw9OvXzzl9w4YNSEhIwCeffFLivFetWoXVq1c7d6ZOnjyJPn36ICEhweUIrd1ux+OPP44DBw44BynOnDmDAQMGIC8v75qn0gJAZGQkpkyZgs8//9zltNDz589j9OjRAC5vvE+fPh2LFi3CG2+84WxjsVjw22+/yT7asnr1asybN8/l8offf/8dubm51zxrzdt+5OXloV+/foiPj8eHH36I8PBwfP311zhx4gSeeuopt/nq9XpMmTIFVqsVdrsd7733Hn788Ue88sorzpV2p06dYLFY8MYbbzhzTp8+vVQDQw899BDq1KmDZ555xvmI3OPHj2Px4sVuO1r9+/dHhw4dMGzYMGzcuNGZNycnBx999BHeffdd2fMkov9ERkZi1apVOHDgALp164Zdu3a5PT766seGX8uMGTMghMDkyZPLorteOXfuHCZPnowRI0agb9++mDRpktexzZo1Q8+ePa/ZRq1We9yoLBrsj4+Pdw6wXw/Tp0+HSqXCgAEDcPr0aQCX6/Dy5cuxceNGAMCsWbNgt9vRv39/l0ePnz17Fi+//DK2b9/udT673Y5HHnkEO3fuhN1uB3D5rKm///4bderUueYReG/7MX36dGzatAmffPIJatWqhTlz5qBdu3b43//+57zcpUj16tUxe/ZsnDt3DsDlgzUvvvgiWrRo4bzUp169eqhWrRoWL17sPPPqzz//xLJly9wGtrwVGBiIMWPG4Msvv8SKFSvgcDhgsVgwZswYt8s3dDod3njjDXzzzTeYPHmyc0fWarXi77//xuDBg2XPk6gyczgc+PDDD9GpU6diD3z36NEDf//9Nw4ePAgAOHjwIG655RZ88MEHzoHrwsJCvPfee/jhhx/Qp08fr7/ve/fuxe7du9G9e3ePv9dqtejcuTOWLVtW4kC4JEmYOXMmMjMzsWDBAuf0hQsXIiEhAdu2bSuxP7Nnz0ZWVhbGjBmDwsJCCCGcB0Sef/55hIWFAbi8XzRu3DicOXPGGfvPP/9g4MCBCA0NxYQJE66Zp1WrVnj00Ufx2muvYcWKFc79rcOHDzsv6W7dujUef/xxTJo0CZ9//rmzTX5+PlavXo0pU6aUuDxXeu+997B06VLn2cpCCKxfvx4Arrn/420/kpKS8Oijj6Jjx46YPn066tWrhw8//BDr16/HnDlz3OabmpqK5cuXO9e/48aNw6lTp1y2Wzp16oSDBw86B/tyc3MxbNgwJJTiNgejR49GYGAghg4diszMTADAtm3bsGXLFre2zz//POrUqYMHH3wQu3fvdk5PS0vDggULnCcTyJknFaOM77lFCnl62mBoaKgIDg4WgwcPdrmB95XtV69eXew8LRaL+Pjjj0WXLl1EcHCwiI6OFkFBQSIwMFD06dNHHD582KV90Q0V33333RL7e+7cOfHQQw+JkJAQERMTI8LCwsTTTz/t8vSoIitXrhRt27YVQUFBIjIyUmg0GnHXXXeJ3377zau/jRCXb4xeu3ZtoVarnTdqLHriQ5FPP/1UNG3aVKjVahETEyOCgoJEly5dXJ6i5M2N3dPS0sTEiRNF1apVRVhYmAgPDxexsbFi6tSpXt3ItaR+DBw4UAQGBrrdNH7x4sUuTzcpugn7/Pnzxfz580VUVJQICgoSoaGhHm8AOHv2bOfvw8LCxPjx451PAyz6W185z6s1btxY9OrVy2Xa6dOnRYcOHYQkSSIiIkK0bNlSHD9+XMTGxrrdhN1kMomJEyeK2NhY4efnJ6KiokRERIQYNmyYy0085cyTiFydPHlSDB061Lk+r1q1qggICBBxcXHikUceET/99JOzbUk3VB8yZIiQJMl5o/CSnjjYqVMnt3kraVujRg0RFhYmwsLCRI8ePcSqVauKXd6iJ/9cXetCQ0M9PtrdG9e6sXvXrl1FzZo1RbVq1QQAodfrnct05d+2OIcOHRLdunUTGo1GREREiJCQEPHEE0+IjIwMZ5sTJ06IBx54QAQEBIiwsDCh1+tFnTp1xJw5c0Rubq4Qwvsbu3/66afitttuE4GBgSIuLk74+/uL3r17i2PHjpXY15L68eOPPwqVSiUmTpzoEpecnCxiY2NFu3btnDWxd+/eokGDBmLfvn3OhxCoVCrRo0cPcfHiRZf47du3i8aNGwutVivCw8PFnXfeKU6fPi3i4+NdbjJcNM+rvfnmmwKAy3ztdruYNGmSCAoKEsHBwSImJkYsXrzY+V5fXbvXrVsn2rRp46zTgYGBol27di6fRbnzJKqMip7YvWjRomLbJCcnCwDiueeeE0Jcflrd9OnTRaNGjYS/v7+Ii4sTGo1GxMfHi/Hjxxf7EApPN3YfNWqUACDOnTtXbP4PPvjA5aFSnp5OeKXbbrtN6PV653q56GFJVz6U6lp++eUX0bRpU6HT6YRerxdRUVFi5syZLg+2MJvN4p133hGJiYlCr9eLkJAQ4efnJ+699163p8IWx2aziRkzZojY2Fih0+lEeHi4aNq0qVi/fr2zjd1uFwsWLBAJCQlCo9GImJgYodfrRf/+/V2eGunNjd1PnDghnnzySRERESGioqKcNeH9998vsa8l9cNisYg2bdqI+Ph4kZaW5hL7/PPPC7Va7dxXKboJ+3fffSeeffZZER4eLnQ6nahSpYrbQ2msVqsYOXKk0Gq1IjIyUkRHR4uFCxe61Ymiea5du9at72q1WowdO9Zl2vbt20WzZs2EJEkiPDxcdOvWTZw8edLjTdgzMjLEsGHDnPvtYWFhomrVquKll15yeeCVnHmSO0kIhedrU5nKz893u5eGXq8v9uyUovYxMTHF3gflahkZGXA4HIiOjvZ4emlBQQHS09MRGRnp9X05rFYrsrKyip3nlQoLC5GZmYmIiAi3G3t7KysrC5Ikudz36Wq5ubnIz89HbGys26UpaWlpcDgcLteXe5pWxGAwQJIkRTcd99QPu92O8+fPIzAw0ONRqHPnzkGj0aBq1arIzMxEdHQ05s+fj3HjxsFqtTqnFXcatsPhQEZGBiIiIqDVamEymZCWloa4uDj4+/vD4XDg3LlziIiIcLtRckpKCjQajcd+Fd3XpOjzeP78eQQEBCAqKspjPzIzM6FSqTxe4650nkTkKjs7G2azudh1gs1mw4ULFxAaGupxnVm0zg8PD0doaKizfXG0Wq3zLJTStNVoNNDr9V6tV/Py8pCZmelW665cV8plMBhgMBgQHx/vdmZBSkpKsfc0kVNvzWaz88bjxdVGm82GjIwMhIWFud1jpei9qVq1qvMm456mFZFTi73tR3p6OgoKClC9enW3WwRkZWXBaDQ6+9KnTx8cPXoUR48eBXB5e8Pf3/+a2xIGgwEqlcpZiy5cuAB/f39nDUhPT4fVanU788loNCIrK8tjvwoLC3Hp0iXExMRArVY73+vijsoXFBQgJyfH2d4TufMkqkyKPu9F25HFOXfuHLRardu2tM1mQ1pamlcP8Clax0VHRzvPJL148SKsVus1H85ksVhw8eJFZy0r2vaNiopCcHCwW/ucnBxkZ2c71+lF/y9pGa+WnZ0Ni8XicX/j6uXKyclBVFSU4tu4pKenIzAw0OPyFDEYDLBarW43nQfc16/FTStS9FAmuffUKq4fZrMZqampHrdHirYRgoKCEB0djT/++AN33XUX1q5di3vvvRdmsxkGgwExMTHXrKeXLl1y1sCr60TRZyQ2Ntat3iYlJSEkJMTjdlJmZiZ0Oh1CQkKc+1BFn7OrFe2DBQYGXrP2yZkn/YeDWEQ3iKsHsYiIiCqiqwexiIiIlLh6EIsI4D2xiIiIiIiIiIjoBsBBLCIiIiIiIiIiqvB4OSHRDeJa968iIiKqKIq7fxUREZEc17p/Fd28OIhFREREREREREQVHi8nJCIiIiIiIiKiCs/9GdxlzOFwICUlBXq9/pqPHyUiIu8IIZCbm4uqVasW+7jhmwnrDBHR9cU644p1hojo+pJTZ8p9ECslJQXVq1cv77RERJXe+fPnUa1aNV93w+dYZ4iIygbrzGWsM0REZcObOlPug1h6vR4A8MaWTxAQHCgrth5icALpsnOmmtSyYwCgrS4KOwozFcUaLMqOUnUIisTm/Euy4/Ltyo4CdQ+OwI95WYpiTTZly9g7NBzf52TLjstXmG9ARCi+yspRFJtTqCzn4zEh+CjdKDvOWKDsFnWjqofi/fPKltFoVJbzxQahmH9Mfk5TeqGifC+3jcTsHfK/GwCgviD/vQCAiT2rY87687LjVOdzFeUb/1gDzP3kmOw4h82EpN/GOdevN7uiv8P58+dlPwQhJSUFVatWLYtuVZicXMbKkZPLWDly3ijLaDQaUb16ddaZf5WmzgD8nFWGfL7IyWW88fP5IueNsoxy6ky5D2IVnXIbEByIgOAgWbFBCEYA8mXn9FcrG8QK0gXDv9CkKNZPq2zwIygoGH6SWXac1aZsECsoOBg6YVEUa1c4qBQYHAydTf5ARmEp8mktNkWxGoXvY2BwMDT5dvn5oGxAKTA4GJpAZcuotirLGRAUDHWA/Jwqf63ifCp/+d8NAFDplP1tAgKDodLJG2wHAJVWab4gqLTKn7zCSxouK/o7hISEyN65yM3NLfenf5Z3Ti5j5cjJZawcOW+0ZWSduaw0dQbg56wy5PNFTi7jjZ/PFzlvtGX0ps7wonYiIiIiIiIiIqrwOIhFREREREREREQVHgexiIiIiIiIiIiowuMgFhERERERERERVXgcxCIiIiIiIiIiogqPg1hERERERERERFThcRCLiIiIiIiIiIgqPFmDWDk5OXj66adRtWpV6HQ6tG7dGr///ntZ9Y2IiG5C77//Pho0aACdTocaNWrgzTff9HWXiIioEtm2bRvuvvtuBAcHIzQ0FI8//jiMRqOvu0VERF6QNYg1ePBgbNmyBb/88gtycnIwfPhw9OjRAydPniyr/hER0U1k6dKlGDNmDF5//XXk5ubiq6++wrx58/DBBx/4umtERFQJnDp1Ct26dcPtt9+O1NRUHDp0CMnJyRg0aJCvu0ZERF7wehArPz8fa9euxaRJk9CwYUMEBATgySefROPGjfHuu++WZR+JiOgm8cUXX+CBBx7AfffdBz8/P9x222146qmnMG/ePF93jYiIKoG1a9dCo9HgtddeQ3BwMKpVq4bXXnsN69atw5EjR3zdPSIiKoHse2IJIdz+/8cff1y3DhER0c1LCOGxzpw+fRoXL170Ua+IiKiyKKoxV9aaon9zn4aIqOLzehArKCgIPXv2xOzZs7F3717k5ORg4cKF2LNnD1JTU4uNs1gsMBqNLj9ERESeDBgwAN9++y2+/fZbGI1G/P7771i8eDEAFFtrWGeIiMhb9957LwoLC/Hiiy8iMzMTp0+fxksvvQSVSsU6Q0R0A5DE1Ye8ryErKwsTJ07E+vXrkZOTg+7duyMmJgZr1qzBuXPnPMZMmzYN06dPd5u+affvCAoOltXZYPghDxZZMQBgsUuyYwAgXKVDtqNQUWyhQ1nOaLUOGXb5Oe0ORekQq9EhzaZsGe1C2TJW0Wpx0WqVHWfz+pPqqppOiwuF8vMBgE3h+1jTT4Mki012nNWuKB1qB2hw2iQ/HwDYrMr+sPX0WpzIlf93tVuUfVgbROhwLEvZZ1VS+LepHxeA46km+YFK89UIxvFzefLTFeRj5KNdkJOTg5CQEEW5K5K3334bH3zwAc6dO4f69etj2LBhePrpp7Fv3z40a9bMrX1xdebIkSPQ6/WycpvNZvj7+yvuuxLlnZPLWDlychkrR84bZRlzc3PRsGHDSlNnNm/ejMmTJ+PAgQMICQnBK6+8gkmTJmHMmDGYNGmSW/vrWWcAfs4qQz5f5OQy3vj5fJHzRllGOXVG1iCWJ3379kV2djY2b97s8fcWiwUWy38DT0ajEdWrV8ei3V8jIDhIVq4GiMUxpMnu40WTWnYMANyui8ZfhRmKYrMtsq/UBAB0CYrCpvxM2XF5NmWDLffpI7E295KiWJNN2TI+GBaBbwxZsuPyFOYbFBmGTy8ZFMUaCpXlfDI2FB+k5ciOM+Yr+zo+XzMMbyUZFMXm5CjL+XLDMMw+Ij9nQZqygagZt0dhyl/yvxsAoD4n/70AgGn318S0NUmy41TnlB2hnfJEQ8xYJv9+HA6rCWd+frrS7FxcbdmyZRgxYgSys7M9Ll9xdUbJ3yM5ORnx8fGl7nNFzsllrBw5uYyVI+eNsoxGoxGhoaGVts6kpaUhLi4On3/+OR566CG331/POgPwc1YZ8vkiJ5fxxs/ni5w3yjLKqTOa0nQuMzMTP//8M1599dVi2/j5+cHPz680aYiI6Cb22WefoXPnzsUWNNYZIiIqjc8++wyBgYHo1q2bx9+zzhARVRyyTjNZsmQJVqxYgezsbBw6dAgPPPAAEhMTMXLkyLLqHxER3USOHDmC8ePH4/z580hLS8O4ceOwc+dOvPHGG77uGhERVRLDhg3DP//8g9zcXHzxxReYPHky5s2bh/DwcF93jYiISiBrEKtfv37YsmUL6tWrh65du6J58+bYtGkTdDpdWfWPiIhuIomJiahSpQrat2+PBg0a4OjRo/jjjz/QpEkTX3eNiIgqiUcffRQjR45E1apVMX/+fCxZsgRPP/20r7tFRERekHU5YXh4OJYuXYqlS5eWVX+IiOgmJkkSnn/+eTz//PO+7goREVVS7du3x/bt233dDSIiUkDZXauJiIiIiIiIiIjKEQexiIiIiIiIiIiowuMgFhERERERERERVXgcxCIiIiIiIiIiogqPg1hERERERERERFThcRCLiIiIiIiIiIgqPA5iERERERERERFRhcdBLCIiIiIiIiIiqvA0vkocEyAQFOiQFeNnAeL85MUAgMUhyY4BAK0kEKEgHwBY7IrCoJYEAjXycxY61IryqSRAp3Ao06YWCnMKaBXE6hxK8wE6lbLYYK2y91+tEopirQHK3gy1GggIUPY5dyj8rGo0EvTB8nPabVpF+VQ6FfwilcVa7HpFccJfA3u8glibss8b/DVwVJWfz1Go7PtPJEftp75RHDvt/pqYNutv2XFSvlVRvqkDamP6xK3yA802RfkAYOqgepj+wm+y4ySzspXwlMcbYMZHm+QHFij7mwLAlJGNMWPxBtlxkkXZ33XK6KaY8e5a2XEnto5UlI9Irl4b/4ImMEh23KgqoXh/3xnZcVaFX9/R1ULx7i75+WxKt2cAPF8zDG/9fVp2nNJlHFc7DK9vlZ/PZlW+jOPrh2Hur6dkx1kVlppJDcPw6kb5+UqzjJObhGPmDydlx5lNynLOaBmBKV+fkB0nZZqU5esQhymL9yuKTZrZQ1EclR7PxCIiIiIiIiIiogqPg1hERERERERERFThcRCLiIiIiIiIiIgqPA5iERERERERERFRhcdBLCIiIiIiIiIiqvA4iEVERERERERERBUeB7GIiIiIiIiIiKjC4yAWERERERERERFVeBq5ASkpKVi3bh3S09NRtWpV9O7dG5GRkWXRNyIiugmZzWZ8//33OHXqFPR6PTp27IgmTZr4ultERFSJbNmyBbt27YLdbkezZs3QrVs3SJLk624REVEJZJ2JtXHjRtSuXRsbNmyA2WzG559/joSEBOzataus+kdERDeR9PR0NG7cGHPmzEFeXh727NmD1q1b4/XXX/d114iIqJJ4+OGH8eCDD+L8+fNIT0/H0KFD0bVrV9hsNl93jYiISiDrTKzXX38d3bp1w+rVq53T2rZti3feeQeffPLJde/clfwsmQjOuYCcUH9Y/KLKNBcABBVmIKQwCUG6QOTross8HwDorRkIs56FHkHI1ZZ9zlBbBsLzTiPUpkeOpnyWMcyWgYi8UwizhcBQDjkj7OmIzDuBCHsostQxZZ4PACLt6YjKP45IexgulUPOKEc6ovOPIcoRjkxV+SxjjEhHTMFxxIgwpEvlk5NuDl988QUyMjKwf/9+BAUFAQBq1aqFOXPmYNy4cT7uHSkRp7uEWGsG4nQqpBaW/ZnbcX6XEGvLQpyfQKqlfM4Uj/PPQqx9N+L8bUg1R5R9voB/8wUUItVU9vkAIC4wC7GO3YgLLEBqQTksY1A2Yh17EBeUh9T88DLPRzeP06dP44svvsD69evRo0cPAMD//vc/tGrVCjt27MAdd9zh4x6SXL7YLo1BOmJMxxGDMKSj7HPGIh2xpuOIRRjSyiEfAMRK6Yg1nUCsFIo0UfY541QZiDOfRJwqFKmOst9PjNNkIq7wPOI0AUi1lf34Al0/sgaxwsLCYDKZnP8XQsDhcCA8vGw3LuIv/oTGJ96FBIG6kHCo3mgkV+lWZvkaZP6Iu869DRUEGkHC1hrP4VhU9zLLBwAtDBtwb+rbUMGBW6DCurjn8E9YjzLLd6txPQZkvgkVHGgFFb6KegHbQ3qWWT4AaJe7Ho9kvQEVHGgDFVZGjME2fdnlbJ//A57IWQAVHLgNKiwLHYstQb3KLB8AdDb9gKeMr0OV6cAdUGFRyDj8ElB2OXta1mGMaT7URgfuggpvBLyI9X73llk+ALjfsQ4THa9DnexAe6gwRzUOa1Rlm5NuHmFhYRBCQAjhnGa32xEWFua7TpFiA2J/wex6i6HOE+jSVsLLJ0biq7TOZZavf/xvmN1kCdQFAl06SHj54HCsSu5YZvkAoH/NzZh9y4dQmwW6dJPw8t6hWJXUoezy1d6CWW2WQ10o0OV+Ca/sHIJVp9uXWT4A6F9vK2a1WwG1TaDLAAmvbHsUq07cVWb5+iVuw6z2n0LtEOj8iIRXtgzC10fblVk+urkEBQVBo9HA4XA4p9ntdkiSxFpzA+ot1uFl8TrUKQ50gAqzMQ7fS2W7XdpX+gGTpcs5O6pUmCnGYbUou+39B1Q/YLrmdagvOtBJp8JU2zh86yjbfZp+mh8w028B1GkOdA5UYbJlLL62lV3Ogf4/4LXgN6DOcOCeCBUm5I3Bl+YyzBf6E+ZUeQfqbIEudSVMvPgsvswpu/EFur4kceWeQgnOnz+P4cOHQ61Wo1GjRti9ezeioqKwePFiRER4PipnsVhgsVic/zcajahevTq+ObwKQfrAEnP6WTLRfvsQSPivmwKARRsGSGqv+m0T3l/fLgk7Am3ZuDJCACjQhEN4mQ8AHF7/VS/nDLa758xTe59Tbr4Qh3s+o0reMspICUnYEeohZ46MnN5/Ui/nCxPu+QyS3GX0/rOjEnaEiSwPOSPgKIP3USXsiIB7vix4n092TtgRdVVOO1Torf7K6yNfBoOj5EYeTG4SjpkHsxXFWtItJTfyYEa7GEzZli47Tp1kVJRvWt8ETFt9Vnaco7AASZ8MRk5ODkJCQhTlrihsNhtefPFFbNu2DXfddRdSU1Nx4sQJLFy4EG3btvUYU1ydUfL3SE5ORnx8fKmWQa7yzqk0X+2nvpHVPk53CVvbjoRauqJ+CyC9MAwOb+9mIGv95ECMnwFX3tJGCCDdIiefnMr2b07/HPec5lCvc0pyllFyINpDvgxzKByijJZRciA6wOie0xQiI6fMfIGu+ewOFTqsnOX1GVknto70PuFVbpTvY3nnNBqNCA0NrRR1BgA+++wzzJ07F3fccQc0Gg22bNmCUaNGYeRIz5+d4urMnat+hCYwSHb+UVVC8f7FHNlxVqvsEADA6GqhePeC/Hw2m7z1xZWerxmGt5IMsuPkLGOMSMc6MQBq/LdtKQBkIgIOeLktLHMRndvCV60T5eSUk1IFO6I95MuQtYxya5sd0VK2e04R7v0yyty/iFG550t3eJ9Pzg6NCnbEaFy3F2xChXYnl8s6IytpprITTlhnPJNTZ2SdiXXp0iWcP38etWvXhlarBQCcPXsWOTk5xQ5izZkzB9OnT3ebHm2JRbAuuMScwTkXXAawAEAC4G81yOl6qUgAgmzKdp5Lk1NvL7+cEoBQR/kvY1g55pQAhIts2cWq9Dmzyi2nBCAS5ZcPANRwYExcDtID63vV3mpV1rn6ei0mN1F21qfDomzgrEGEDjPayT99Wmqp7FKb+lUCMK1vguw4U0EeRpbtFd3lpqCgAElJSbBYLNBqtdBoNEhNTUVKSkqxMcXVmZSUFOTm5srKbzabkZycLLvfpVHeOZXmm3Z/TVntY60ZUOddVb8lINbPIDu3UpIExPqXXz5nzgD5O4ulyRdTjvmcOQOVDdYroVY5MHlIMNJUTb1qX5rv043yfSzvnHLXpRXd2bNnkZ2dDa1WC61Wi9zcXJw6dQpCCI83dy+uzgyLDUFgcMn7M1dL8NNgVJVQ2XEOZZszqB2gwehq8vPJHPtwy/l8zTD5OWUsY0zBcahTXAMkANHI8n4m1+Fe/pIkM+d1yBfjg2WMkcpxv00CYtUy8nl//N4jjeTA9LZmpOnivI5Ruu5mnfFMTp2RdSZWkyZNcOutt2LZsmXOab169YLVasXGjRs9xvBMLO9y8kwsL/LxTCy3fDwTyzs8E+vGMWHCBHzxxRc4evQo/P39AQCLFi3Ciy++iHPnznk8YMIzsconH8/EKiYnz8TyIqfMfDwTq8LlrExnYv3xxx+46667sGPHDrRp0wYAcObMGdSvXx9ffvklHnjgAbcYnoklH8/Euj4peSYWz8QqrcpYZ7w+E8tut+Pw4cMYM2aMy/Q77rgD77zzTrFxfn5+8PPz8zaNG4tfFA7VG43GJ96BhMtf+kP1npV1T6ykPHlDs5fvifUOVHDAARW21nhW9j2xLhbIevCjyz2xHAruiWUolLeMV94Ty6HwnlgFdnnD+u1y12NQ1htQwwE7VPhU5j2x8q3y/qbt83/A0JwFznwfKrgnltxl7Gz6ASONrztzLpZ5T6xsi7xldN4T6998Su6JlZ8nr7Dd71iHlx3znN/HOapxvLk7XTcHDhxAy5YtnQNYwOU6k5+fj1OnTnkcxCptnaGykVoYiZdPjMSceosgSZc3SCeeeErWPbGkfHl7bP3jf8OsJkuhkRywCRVeOThM3j2xzPKfTNa/5mbMav4RNCoHbA4VXtn3uKx7Yklmu7x8tbdgVuuPoVY5YHeo8MquwfLuiVUgfy/48j2xVv6Xc9sjsu6JJVnk/V37JW7Dqx1WOj83r2z5H2/uTtfNgQMHoNVqnQNYwOUHiMTFxWHfvn0eB7FYZyqmdCkGszEOk8V/26UzpZdk3RPLpuAKgb7SD5iM16GWHLAL+ffEssosNc57Yv2bT+49sZQso/OeWP/mlHtPLLNJXs6B/j9gTvAbzvo9UeY9saRMU8mNrswX+hPmVnnbWWcmXRzNm7vfQLwexFKr1ahXrx62bNmCoUOHOqf//vvvSExMLJPOFUmu0g11z34Cf6sBFm1Ymd7UHQCORXXHhZBWaF5owj5dQLk8nfCfsB44GdQaHVCAzQgs86cTbg/piaOBbdBLk4sfyunphNv0PXEooA0e0BnxbWHZP51wS1AvHPBvg4cCjPjCFFIuTyf8JaAX9uraYHBwDj7OCy3zpxOu97sXO7Rt8XSYAe8Zwsrl6YRrVPfiScdSRCMLmYjgTd3pukpMTMSqVatQUFCAwMDLZ+tu2bIFGo0GdevW9XHvSK6v0jrjhZqfI9bPgPTCsDK9qTsArEruiK2ZzTCllwozfnCUy9MJVyV1wNa0ZpjSR4MZ35X90wlXnW6PrRebYnJ/P8xcZSmXpxOuOnEXtiY3xuRHgjBzZX6ZP53w66Pt8ELbNYgJMiKjIIQ3dafrKjExEVarFX/99Rduv/12AMCJEydw8eLFMt+noevve+lePCX+2y4t65u6A8Bq0QvbRBuMr56DuedDy/zphN86emFbYRtMSsjBq2dDy+XphF/bemGrvQ0m1zZi5umQMn864ZfmXthS2BYzEo2YcjSkzJ9O+GVON4yN/hixWgPSbWG8qfsNRtY9sd5++23069cPKSkpaNasGf7++28cOXIEP/30U1n17z9Fl0fJuEyqNPJ10TD6xSBfyL+USKlcbTQM/tHINWeUS74cTTSygxORk3epXPIBgEETjazgBjAYy+e68Sx1DC4FN0BWYfldw31JHYPMoPq4ZDKUS75MVQwyguoh01h+90QpOrXX61N8ibw0YcIErF27Fi1atECPHj2QmpqK1atXY+7cuWX+JFwqG0WX1Xl9SV8ppVoikaapjVTL6XLJBwCp5gikqesh1XyifPKZIpCmboBU07FyyQcAqQURSFM1RmrBoXLJV3SpoteXLBJ5qWPHjhg0aBC6deuGhx56CFqtFl999RW6dOmCAQMG+Lp7pIAvtkvTEYP0gPpIh6Fc8qUhBmkB9ZFWTvkAIE3EIC2gAdJE+exHpTqikerfAKmO8tlP5P7MjUvWIFb37t1x6tQp/Pzzz0hLS8Po0aPRvXt3Po6WiIiui+joaBw6dAgbNmzAqVOn0LRpU0yfPh0NGjTwddeIiKiSWLlyJf7++2/s2bMHdrsdq1atQocOHXzdLSIi8oKsQSwAiI2NxSOPPFIWfSEiIoJWq8X999/v624QEVEldtttt+G2227zdTeIiEgmnqNNREREREREREQVHgexiIiIiIiIiIiowuMgFhERERERERERVXgcxCIiIiIiIiIiogqPg1hERERERERERFThcRCLiIiIiIiIiIgqPI2vO0A3rsKsbNjy82XH2WsHouD8BdlxVj89tOHhsuOIiOjGJFmNUNlM8uPs8VCb0mTHOexaCE2I7DgiIrpBGbMBs/z9GQBATX8gXf4+jaQNgtBzn4ZIKZ8NYkX6OxDs7/C6vUr67zVKRhwAFNolWe2L+FuBKlp5uYpYFObUqYAIPyE7rtChrJ9qSSBQIz/WlGXAsTlvoLCwUHZs7tixOLLgbdlx0GgQPWYS1GHyVvpWhwRDobKTDnMLlb2PhXYJl0zyc+bmyn/vAcAaBeTkKIs1GOS//w69AFSAwyGQeUlevD1V/g4pADjqhsCSrCxWfS5HUZzUIhzqU9my41QpeYrywWSFSklfFezk042v1pg1iuKmdY/HtAW7ZcfZEyMV5YNG5Xy1yZiHKj8bcfuWK6ozmoIYhJxZITtOp9Ph22+/RVxcnOzY5ORkPNb7VtlxSiUnJ+OxvreVWz5nzn63y46r1/pd+cmE+O/VZJUfT1QOqgfZoAuyyY4LUAvUDJYfZ1a4fxGgEagWYi+3fADgrxGIC5W/bWq2y99+VmUDcAAqFRAd7n2f7YZs5H86V1GdAQCp6lhoP39DdpxGrYHhgQlwBMvbp7HbAVOB/L+pPV/+Z80ZaxUwG+THqy4p2zaVGoUo2o5WlK+WALQA7ALqiwq33ckneDkhKWLNy1e8wlfMZoMjnysYIqKbgcpc/nWmsLAQBoOhXHMSEZFviPy88t+fASDZbZCUnv1FRBzEIiIiIiIiIiKiio+DWEREREREREREVOFxEIuIiIiIiIiIiCo8DmIREREREREREVGFx0EsIiIiIiIiIiKq8DiIRUREREREREREFR4HsYiIiIiIiIiIqMLTyGlcWFgIh8PhNl2tVkOr1V63ThER0c3JZrPBZrN5/J2/v38594aIiCobh8OBwsJCj7/T6XRQqXiMn4ioIpO1lm7Tpg3CwsJcfgICAvDQQw+VVf+IiOgmMnnyZLc6ExQUhPDwcI8HUYiIiOT4448/3OpMSEgIAgIC8Msvv/i6e0REVAJZg1j79u2D2Wx2/vzzzz8AgAEDBpRJ54iI6OYyZ84clzpjMplQu3Zt9OvXj0fHiYio1O6++26XOmM2m/HUU08hJiYGHTp08HX3iIioBKXaI1i2bBmioqLQt2/f69Wf4gm76ysR+YwKdpdXorKyZcsWnDx5EsOHD/d1V0ghri9ICZXkcHklKisWiwWffvopBg8ezNuj3KBU/+4fqrifSDKo4HB5pRuH4kEsq9WKFStW4LHHHoNOp7uefXITeWEjtIUGAIC20IDICxvLNB8RFW+A9gfESNkAgBgpGwO0P/i4R1SZLVu2DA0aNMDdd9/t666QAgP9f0CM6t/1hSobA/25vqCS9WvyF6KDcgEA0UG56NfkLx/3iCqz7777DpcuXcKwYcN83RVSoJt5HSJEFgAgQmShm3mdj3tEN4IBVX5FjJ8BABDjZ8CAKr/6tkMki+JBrLVr1yI9Pb3EFb7FYoHRaHT5kUNrzkTNwwsh/ft/CUDNw+9Ba85U1nEiUixOSsfsgAWQ/v1CShIwK2AB4qR033aMKqWcnBx88803ZV5nqGzEqTLwWvAbLuuLOcFvIE6V4duOUYUWF5yNWV2+cK0zXb5AXHC2bztGldayZcvQvn171K9fv9g2rDMVU5Q9Hc/mz3fZT3w2fz6i7NwupeLF+V3C7MQPXOtM4v8hzu+SbztGXpOEEEJJYK9evWA0GrF169Zrtps2bRqmT5/uNn37vi0I1geXmCcwZycSDo90m3620QcoCG3tVV/NdqnkRh74O/xgVlkUxRbYlOUMlXTIEZ6fmHItFoVnz0apdci0y89nNZmRfeKMopx16tTBqVOnFMXqatWB5B8gK6aGToNzhZ6fdlYSm8KzS2v5a3DGLD9nMQ9lK1HdIA1O5itcRqv3q4BY8w50S3O/rOun2KVI82/j1TyEwg9rg0g/HLuk7PsomZT9bepXCcTxiwXyAxW89wBQv6Yex5NyZceZCvIx8rF7kJOTg5CQEEW5K6JFixbh+eefx4ULFxAdHV1su+LqzJEjR6DX62XlNJvN5f4URKU5Nx1KVZSvQUwAjqWbZMeJAHmX2cSZd6B7hvv64sfopUj1Yn2hspqgTTstK2eR0tSZli1bIji45O2Tq5X3Z+dG+qz++rv32wuxYg+64jm36RvxDtKkFl7No9PdtbzOdzW+j57l5uaiYcOGla7OJCUloXbt2vjkk08waNCgYtsVV2e+3LYNgQrWF1W1WqRYrbLjHIr22oB4nRbJheWXDwCq67Q4ryCnXXi/DxWdvwN3n3M/0LWlxjJkBpVcZ4TZBOtZZbUCKF2tscbWhvCTt0/TIESLY0b5f1NhU/5GJobrcDRb/r6iVKhwez/aH8cyzPLzWb3PF2vdhW75o9ym/xS0CGnaVl7Pp3Pzql63vRLrjGdy6oyiQazk5GTUrFkTH374IR577LFrtrVYLLBY/tvxNBqNqF69Ojaf/hLB+sASc2nNmWj6+1BI+K+bAiocuHsZrP5RXvU3JV/tVburxVvjkKxVtpNwNk9ZzhbqGPyj4OhBqknZSXWdAqLwq0n+WW3Gc8n469V3FeUcO3YsFixYoCg28ukx0MZXlxUzNCYMH6YbFOXLLVQ2GPl01VC8l5IjP1+usiIzrk4YXj9lUBRrMHg/UhcnpWOr/iGor7hHiU2ocHfuF0gVMV7Nw54qf+cZAGa0j8WULWmKYtXn5L8XADCtX21M+1r+TrQqJU9RvikjGmHG/x2WHeewmXDm19GVbueidevWqF27Nr766qtrtiuuzij5eyQnJyM+Pl5Rf5VSmrPWmDWK8k3rHo9pPybLjrPXlPe3jFNl4K8I9/XFHVlfINVR/KBkEc2lC4hY/5bcbgIoXZ1ZuXIlEhMTZceV92fnRvqs1mvt/fZCXHA2Ng+bBrXqv3pod0josHQaUvPCvZrHiV2jZfexCN9Hz4xGI0JDQytdnZk2bRreffddJCcnX3OHq7g68/D6ddAFBcnO2y8sAl8bsmTHKT0w/7+IcHyWJf9sRqX5AGBIVBiWZxoU5PR+nybKno6PDf2hvuKeRnaoMDhsFTLVJW+X2pLPw/C+sloBlK7WGO57AfaoarJipt0Sjml75b+PdoUHugFgxq1RmLJd/r6i6pKy7f3pXapi6qaUMs0X53cJW+8YBbX0X52xCRXu/vM9pFoivZ7P6aX9ZfWxCOuMZ3LqjKKRj+XLl0Ov16N//5LfOD8/P4SEhLj8yGH1j0JSo2ecQ1gCQFKjp70ewCKi6ydVxOBl01gUDX0LAbxiGuv1ABaRt/bt24fdu3djxIgRJbYtbZ2hspHqiMaEvDEu64uJeWO8GsCim1dqXjhe2fSQa53Z9JDXA1hE3nI4HPjoo4/w6KOPlnjGAOtMxZSpjsE7QS+67Ce+E/SiVwNYdPNKtUTi5aNPutaZoyNkDWCRb8kexBJC4MMPP8QjjzyCgAB5p0AqdalaV1h1YQAAqy4Ml6p1LZe8ROTuK2svpIvLOxPpIhxfWXv5uEdUGS1btgy1atVC586dfd0VKoUvzb2Q7vh3feEIx5dmri+oZF8fvB0Z+ZcvBc7I1+Prg7f7uEdUGW3atAnnzp3jDd1vcD/534ssKQIAkCVF4Cf/e33cI7oRfHWxE9ItYQCAdEsYvrrYybcdIllkD2Jt3boVycnJ5f+4c0nt+kpEPuOA2uWV6HoqLCzEN998gxEjRkCSlF/KQBUD1xekhEOoXF6JrreVK1firrvuQpMmTXzdFSolx7/7hw7uJ5IMjn+HQhzKn3VHPqKRG3D33XfDbJZ/szUiIiJv6HQ6JCfLv2cTERGRtz755BNfd4GIiBTgsCMREREREREREVV4HMQiIiIiIiIiIqIKj4NYRERERERERERU4XEQi4iIiIiIiIiIKjwOYhERERERERERUYXHQSwiIiIiIiIiIqrwOIhFREREREREREQVHgexiIiIiIiIiIiowtP4KnGwWiBYI7xuL0n/vcqJA4DYALus9kX87EJxrMWhKAz+NoEqfvJzWhXm06qASD/5wQ7JqixhKRnzHVAVSLJirHYgW2ZMkbxcZX9YW7SAIVt+bE6OvM92EWt1gaxLCj8EKXnyYxqIy0PgdgHHeXnxmnNG+fkASK0joDmVrSz2ooJlBACTFSoF/bVnGRSlE5ZC2NOzZMc57GZF+ej6qjl5g6K4GR3iMGXxftlxugahivKpgrXQKIitEqNWlE9turz+VaslVKvh/WaHQwXYFGUsncLCQh9krdwKbfmyYwSE81VJPFF5qBFog1+Q/DVVoMaBmgrizHZl27MBGlGu+UqV0yE/p8ZweX2hkQRqBnu/j5KnM8MgO1vpCQDCYIJDbZEXWOiAI1tmDAD1JZPsmCKSJQxqBdvRKoU5JYtNUT7JIP/vIjmE81WVXiA7nnyHZ2KRImqNsp2Z0if2UV4iIipfPlrf63Q6n+QlIqLypdL45nwOCeA+DVEpcBCLiIiIiIiIiIgqPA5iERERERERERFRhcdBLCIiIiIiIiIiqvA4iEVERERERERERBUeB7GIiIiIiIiIiKjC4yAWERERERERERFVeBzEIiIiIiIiIiKiCk+jJOjgwYP48ccfIUkS+vbti9q1a1/vfhER0U0sIyMDq1evRlpaGu6++260b9/e110iIqJKxGazYc2aNTh48CDq1KmDAQMGQKvV+rpbRERUAtlnYk2ePBm33XYbTp48CYPBgAcffBB//PFHWfSNiIhuQps2bUK9evWwdu1aOBwOvPbaa5gxY4avu0VERJVEamoqWrVqhcmTJ8NqteL3339Hp06dfN0tIiLygqwzsdasWYNXX30VW7ZswZ133gkAePnll5GWllYmnXMh7K6vROQzKthdXomul0uXLmHAgAEYNWoUXn31Vef0o0eP+rBXVBpcX5ASKsnh8kp0PQ0dOhR+fn7YsWMH/Pz8ALDO3Mikf/cPJe4nkgysMzcuWWdivfvuu7jnnnucA1gA4O/vj5o1a173jl0p9PxGaCwGAIDGYkDo+Y1lmo+IijcgfCNiNAYAQIzGgAHh/D7S9bNy5UqYTCZMnDjRZXpiYqKPekSlca9tHSKRBQCIRBbuta3zcY/oRjCw6U7EBOUBAGKC8jCw6U4f94gqk1OnTmHDhg2YNGmScwALYJ25UbXLXY9QRzYAINSRjXa5633cI7oR9K+9BdH+OQCAaP8c9K+9xcc9IjlknYm1Y8cOjB8/Hlu2bMHmzZsRExODnj17XnMQy2KxwGKxOP9vNBrlddCUidgD70H69/8SgNgD7yM/qiVsAVGy5kVEpROnycScqu9C+vcLKUnAq1UX4vfclki18ftIpbdjxw60aNECmZmZWLRoESRJQrt27XDHHXcUG1PaOkNlI1qkY7x1vkv9Hm+dj+3qtsiQYnzZNarA4oJzMKfbatc60201tpypj9S8UN92jiqFHTt2AABatWqF//u//0NaWhoaNmyIPn36QKPxvGvEOlMxhdky8EjWGy51ZlDWGzgU0AYGTbQvu0YVWFxAFma1We5SZ2a1/hhbLzZFqinCt50jr0hCCOFNQyEEVCoVWrRoAa1Wi65du+Lw4cPYsGEDvv76a/Ts2dNj3LRp0zB9+nS36bv2b0GwPrjEvAE5O1Hj0Ei36ecafwBTaGtvug6bwjME1XZ/2NVmRbEmu1RyIw8ChB9MkqXkhlcpsCnLF6bSweAolB1nNZmRfuyMopx16tTBqVOnFMWqatQB/ANkxdQO0OC0yaYon93m1dfDTd0gLU7mW2XHWeWHAAAahGpxLEdhsNn7v01c4U50M7h/H38M+wBpOu++j5LC96J+fBCOJ+crioVFYc4EPY6fzZUdJwqVvRcN6obj2Mls2XGmgnw8NbwXcnJyEBISoih3RXHvvffi6NGjUKvV6N27N0wmE5YvX44RI0ZgwYIFHmOKqzNHjhyBXq+Xld9sNsPf319R3zcdTVcU1yDKD8cy5a/3VQFqRfnq67U4niv/M6rTyaszMQU70PHCMLfpv1ZbhozANiXGC4sJ4pyyWlGaOtOyZUsEB5e8fXK10nx2lCjvfKXJuWnLSa/bxuEfdMMLbtN/xJtIQwuv5tGlfV2v812N76Nnubm5aNiwYaWoM4sXL8bTTz+NFi1aoFmzZqhSpQpWrVqF4OBg/PHHHwgMDHSLKa7OrNm+FUEK1heRKh0uKdj+tgtl2/vRah0y7PLzOZRtBgMAYjQ6pNvKNmdE3k7cema42/TttZYgK7jkOmM3mZF76rSc7rkoTa2xRtWC0Mj7HjaI0OFYlvy/KazKL7NsEBOAY+km2XFSobKc9asG4XiKgu19q/c7/LH23ehW+Izb9J907yFN3dLr+XRuW93rtldinfFMTp3x+kwsSZIQFBSEgoICHDx40Hmk4oknnsDYsWOLHcSaOHEixowZ4/y/0WhE9erVgdCLQIh7kbia1U8HcUiChP/WaAIqWGO0QECyV33PL1S2wg/Kq4r84BRFsefyle1cJNjicFaTKj+fWVm+1poY7LLJ3/HKSk7GjwveU5Rz7Nixxe6MlkT7xFioqshbYTxbPRTvnM9RlC8vV9kI6Pj6YZh73CA7LidH2dbC9FvCMXWv/MEPAEBKntdN4zT+6NJAglr6r582ocK0v/2Qarvo1TzU55QdvZw6sA6mf6lsQ0G66P0yXmnKU40xY9Eh2XH2LIOifNNebItp83fIjnPYlQ22V0TBwcE4deoUDh8+jIYNGwIAOnXqhAceeABPPfUU6tZ13zktrs5UrVpV9s5WcnIy4uPjFfV9yuL9iuJmdIjDlM3y1/u6aiXXUE+mNA3HjAPy1xfRMfLqTLQIx91QQY3/1qN2qPB2ZhgypJLXyY6L52H7UFmtKE2dWblypaLPQGk+O0qUd77S5Jwyb4XXbeOCc9BlpAS16oo645AwdXEaUvP+8moeg/+n/GmmfB89q0xnHgUHB8PhcOChhx7CuHHjAABjxoxBrVq1sGTJEjz33HNuMcXVmW2mS/BTcMC7fUAUtpgyZceZFR4k7xIUhU355ZcPAHrqI7E+95L8nA7vc4bZQtAGKqiuqjPfFobAYMwqMb7g/AUcWfC27D4WKU2tye7wNGxh8r6HM9rFYMo2+fttqkvyB6GKTOtRDdM2XCi3nFMH1Mb0r+QPLEoG7w8GxgUUosv9rvszdocKM1dZkGo65vV8Hut7m6w+FmGd8UxOnZF1T6xGjRqhZcuWLqfa3nrrrTh16hSKO6HLz88PISEhLj9y2AKikNb0aecQlgCQ1nQULyUk8oFUWxQmpoxG0dddCGBSyjO8lJCum0aNGkGv1zsHsIDLdQZAsUc7S1tnqGxkSDGYq33RpX7P1b7ISwnpmlLzQjHxp76udeanvryUkK6bRo0aAQDatm3rnBYZGYm6devi5EnPZw2yzlRMBk00VkaMcakzn0aM4aWEdE2ppgi8snOIS515ZddgXkp4A5E1iPXQQw9h+/btMJv/O+KwefNmNGnSBJKkfKS+JDnVu8LmFwYAsPmFIad61zLLRUTX9lV2V6TbwgAA6bYwfJXN7yNdP/3790d+fj727NnjnLZ582aoVCqXgS26MazT3ItLuLxReAkRWKe518c9ohvBlwfaID3/8iVa6fnB+PJAyZcFEXmrRYsWqF+/PjZv3uycdvHiRRw/fhxNmzb1XcdIkW36nshRhQMAclTh2Kb3fHUQ0ZVWnW6PDPPlgyMZ5lCsOq38DF4qf7Ju7P70009j/fr1aNmyJbp06YJDhw5h3759WLeuHJ42JKldX4nIZxxQu7wSXS8NGzbE7Nmz0aVLF/Tr1w8mkwnffvst5s6dixo1avi6e6QA1xekhEOoXF6JrhdJkvDRRx+hV69e2Lt3L6pUqYLvv/8ed955J4YOHerr7pEC4t/9Q8H9RJKBdebGJWsQy8/PDxs3bsRPP/2EY8eOoV27dujatSvCw8PLqn9ERHSTmTBhAnr27IktW7YgMDAQr7zyCho0aODrbhERUSVxxx134NixY1i3bh0KCgrQv39/dOjQwdfdIiIiL8gaxAIAlUqFHj16oEePHmXRHyIiIjRr1gzNmjXzdTeIiKiSiomJ4ZlXREQ3IJ47R0REREREREREFR4HsYiIiIiIiIiIqMLjIBYREREREREREVV4HMQiIiIiIiIiIqIKj4NYRERERERERERU4XEQi4iIiIiIiIiIKjwOYhERERERERERUYWn8VXiYJ2AXie8bq+64lVOHAA4ZLW+IqdKfq4iVRzKsvoVCFQJkB9rdUiK8vnbBaro5OcTGquifKUhAOQarBAy/z7WOAGDQdn7kZujLK6wEMjOkh+rupCrKB8a6IEko6JQ9XkFOes4AC0AmwPq0zmyQlUX8+TnAyCZbFAp6SsAiyFTUZyjsBCFmRmy44x55xTls9qaINt4SnaccBQqylfZNV7wG1T+QbJiZtwehSmfHFGUL7iOvFxF1EFqRbExMcqOQ/kHSKhWXS07rmWkRVE+3VkB2AGdWuD2qt7PI8dcgL2KMpZOYSG/T9eb1Sp/vS+EcL4qiScqD7VDbAgItsmOCxICdUPkx5nsyrb3gyQH6ijJZ1OWDwACNQK19eWzjGrpv9daMt6PS9pCKKv4pSMASGm5UBXIXLeZI6BKkb8+VGWZZccUkcx2qC7my48zKMxpsUNKL5CfL0dBPodwvkoZ8peRfIdnYpEiak35j39KlxOXe14iIip/Ko3WJ3l1Op1P8hIRUflSaeUf0LkeJABQcZ+GSCkOYhERERERERERUYXHQSwiIiIiIiIiIqrwOIhFREREREREREQVHgexiIiIiIiIiIiowuMgFhERERERERERVXgcxCIiIiIiIiIiogqPg1hERERERERERFThaeQ0tlqtsFgsLtMkSUJQUNB17RQREd2cHA4HCgoK3KYHBgZCpeJxFyIiKr2CggI4HA6XaTqdDjqdzkc9IiIib8naI5g7dy5CQ0MRFxfn/GnQoEFZ9Y2IiG4y+/fvh16vd6kzcXFx2L59u6+7RkRElUT9+vURFRXlUmdmz57t624REZEXZJ2JBQBt2rTB33//XRZ9ISIiAgCcPXsWUVFRvu4GERFVUosXL8aQIUN83Q0iIpJJ0bUZdrsdQojr3ZdrE3bXVyLyGRUcLq9EZcFms/m6C3QdSP/WbYn1m2RQSQ6XV6KyYLVafd0Fug5YZ0gJ1pkbl+xBrD179iAwMBDBwcFo3749duzYURb9chF49meoLAYAgMpiQODZn8s8JxF51r/GZsT45wAAYvxz0L/GZt92iCqlmjVrIjAwEA0aNMCyZct83R1SqK1xA0Ic2QCAEEc22ho3+LhHdCN4uPlexAbnAwBig/PxcPO9vu0QVUqjRo1CQEAAqlSpgueffx65ubm+7hIp0Cx7A4Ltl+tMsD0bzbJZZ6hk/Rr9iehAIwAgOtCIfo3+9HGPSA5Zg1g1atTAN998g5ycHJw9exb16tVDp06dcPr06WJjLBYLjEajy4+sDpoyEbr3PUj//l8CELrvfahMmbLmQ0SlF+d/CbObL4X07xdSkoBZzZYhzv+SbztGlYZOp8PcuXORlJSEnJwcTJgwASNHjsSHH35YbExp6wyVjVBbBvpnvOlSv/tnvIlQW4Yvu0UVXBW9EfN6rnepM3N7rkcVPb/XdP0MHDgQ//zzDywWC7799lusXbsWgwcPLrY960zFpLdmoHvK2y51pnvK29BbWWeoeHFB2ZjV8TPX/ZmOnyMuKNu3HSOvSaIU1wXabDbUqlULjz32WLE3Q5w2bRqmT5/uNv2fg5uh1weXmMPfsAvxB0e6TU9ushjmsNbe9dMhldzIE6s/oDUrCi1UeFaixu4Pm1p+TpNN2TIGwQ/5sJTc8CqWAjMuHDmrKGedOnVw6tQpRbGOqnUg/PxlxdQL1uJEnrLTxW0KzzJPDNPiqEF+sGRWdvlUg2h/HMtQ9lmVTN7njLXtQjfz027Tf/J/H2maVt7lU7iM9WvpcfyMsqOkdluhorjEuhE4ejJLdpzNZlKUr3H9OBw6nio7zmQqwLMj+yEnJwchISGKcldkw4cPx44dO7Bv3z6Pvy+uzixatxUBQSXXmSs1iNDhWJayz4vaX9nTE5Wuo/z8lK33E/w0OGuR/z0M1corbOF5O9Hm9HC36TtrL0F2cJsS420mE3JOnpGVs0hp6kzLli0RHCzvcwMAZrMZ/v7y6lNplHe+0uTc+NtRr9vGYS96qMa5Td/geB2puMWreXTtmOh1vqvxffQsNzcXDRs2rLR15vvvv0efPn1w7tw5VK9e3e33xdWZX3b/jiAF6wul2992hXttevghV1E+hftQAMIkHQxCfj11yFjGsNydaH5yhNv0vXWXIEdf8n5iYYEZmceV1RmgdLXGqq8BofaTFdMgLgDHUuVvY0pW5ZfL1Y8PwvHkfPmBVmWXdtavqcfxJPnb+5LN+2WMFXvQ1fGs2/SNqneQJrX0ej6d7kzwuu2VWGc8k1NnZN/Y3SVYo0HdunWveSbWxIkTMWbMGOf/jUYjqlevjoDIiwgICSwxhypIC3FQgoT/1mhCUkEdr0VAQLJX/cwpVLYCVhni4QjzLsfVjGZlOzMhBVVgDLwoO+5cvlpRvjr2WJxSp8mOS8tIwcoF7yvKOXbsWCxYsEBRrHnAGIiYarJiJiaGYc5Rg6J8uTnKVvozWkZgyh75gx+qC8oGaaZ3jcfUjco+q+rz3ueM87ejyz0S1NJ/30ebQ4UZa2xINZ/0ah6qi3my+wgAU55ughnvHVQUazEoO3Nzxvg7MGWu/NOLjXnnFOV7fWpvjJv+vew44VA26HKjSExMxBdffFHs74urM7N3XILKX97g7ozbozDlL2Wfl+Aqyh7NrnQdFROjrM6MqhKK9y/myI5rGSlvByjUHoxWULncO88BFTbYg5GTX/LfOPdcMvYseEd2P4HS1ZmVK1ciPj5edlxycrKiOKXKO19pcr48Z5HXbavojej6jAS16so6I+GVhUm4mOvdUfLHH+ksu49F+D56VtnPPEpMvDzwefr0aY+DWMXVmZNSOgIk+Tv49UUsjkvyt79NCg/MN5FicFCky8+n8CA5ALTUxGCPTUFOu/c59aogNPVQZ7apApFbWPLZWNnJyfhlwULZfSxSmlpjaDwU9qA4WTHT7quBaWvlb2OqspQd6AaAqf+ri+mfebeNfyXJoCznlOENMWPJEfn5crzPFxdkROchrnXG7lBh5odGpOZ7PmDqyaMD28nqYxHWGc/k1BllW8D/MpvNOHLkiMeVfRE/Pz+EhIS4/MjhCIhCzi1PO4ewBICc5qPgCOBTq4jKW6o5Ei/vG4ai8zeFAF7Z/wRSzZG+7RhVanv27CnTOkNlI0cTjVXRL7jU71XRLyBHE+3LblEFdzE3BC+t7+lSZ8av74mLufxeU9nZs2cPABRba1hnKqZcbTR+rPqcS535sepzyNWyzlDxUvPD8cpv/3Pdn/ntYaTmh/u2Y+Q1WYNYvXr1wvr163Hx4kUcOHAADz30EEwmE0aOdL/c73oqSLgHDr8wAIDDLwwFCfeUaT4iKt6qcx2Qbg4FAKSbQ7HqXAffdogqlQkTJmDx4sU4efIkzp07hzlz5uDzzz/HSy+95OuukQI7QnrAqLq8UWhUhWNHSA8f94huBJ/vuwVpeUEAgLS8IHy+7xbfdogqlTVr1mDMmDHYs2cP0tLSsHr1aowZMwYPPvggateu7evukUz7w3sgT325zuSpw7E/nHWGSvb14TuQUXB5MDqjIARfH77Dxz0iOWRdTjhr1izMmjUL27dvR2BgINq2bYvdu3eXzwpfUru+EpHPOP4d/3aU7mROIjfjxo3Dq6++ijfffBP5+flITEzEDz/8gB49uFF6oxL/1m3B+k0yOITK5ZXoeunZsydSUlIwcuRIJCUloXr16hg3bhxGjx7t666RQqwzpATrzI1L1iBWixYt8M0335RVX4iI6CYXFRWFN954A2+88Yavu0JERJWQRqPByJEjy/xKEiIiKhscdiQiIiIiIiIiogqPg1hERERERERERFThcRCLiIiIiIiIiIgqPA5iERERERERERFRhcdBLCIiIiIiIiIiqvA4iEVERERERERERBUeB7GIiIiIiIiIiKjC4yAWERERERERERFVeBpfJQ7QaBGo0XndXpL+e5UTBwAOUSirfRGLSsBPKxTF2hzK4jRmIEQnPzbWYVeUz88kEBsgP9aktijKVxoCQMElM2wqm6w4W6FAbqa8mCLqlDxFcVJDPdRJRtlxqgvyYwBAKoiBOilHUazqovxllOzC+apKltdnkyFVdj4AsFvrwpSVoig2J++cojirtTmyjSdlx+We+VBRvuTkZAx7rIfsOKPRiNDQjxTlrMwia/pBHegnK0YXqEZULXkxReIjFYUhyF9CnXhJdlyHuAJF+WI1wbivuvzY22OsivIFXBCAHQjQCPRLMHsdd9qUjz2KMpZOYaGybQYqnsWaqyDK4XxVFk9U9mrrHQjSy9+ODsoXqBukYPtb2eY+gkwCdQPkbwubbPJrkzNnoUDdQAU57fJzalTC+Vo3xPucF3Tlvz8DXN6nkTJzocrTy4qTLHao0vJl55MM3tdeN4U2SBkKcuYoyykV2iFlys+Xb0qTHeP4d//Z4bAj36hsH4N8g2dikSIabfmPf0oAoPbZuCsREZUjrQ/qDADodPIOlBER0Y3JF/szwOV9GkniPg2RUhzEIiIiIiIiIiKiCo+DWEREREREREREVOFxEIuIiIiIiIiIiCo8DmIREREREREREVGFx0EsIiIiIiIiIiKq8DiIRUREREREREREFR4HsYiIiIiIiIiIqMJTPIh18uRJjBs3Dp988sn17A8REREAwGazYcqUKZg4caKvu0JERJXUihUrMG7cOJw4ccLXXSEiIi8oGsQqLCzEwIEDsXz5cqxZs+Z694mIiAgvv/wy3n//fbz55pu+7goREVVCW7Zswbhx47BgwQIkJSX5ujtEROQFRYNYL774Ilq0aIHbbrvteveneMLu+kpEPqOSHC6vRNfbxo0bsXr1akyYMMHXXaHSYv0mBVhnqKxdunQJgwcPxnvvvefrrlApSf/WF4l1hmRgnblxyR7EWrduHTZs2IC33367LPrjke7MT5DMBgCAZDZAd+ancstNRK76JW5DdKARABAdaES/xG0+7hFVNmlpaRg6dChWrFiB4OBgX3eHSiEmZSN0hQYAgK7QgJiUjb7tEN0QBrU4hNhgEwAgNtiEQS0O+bhHVBkNGTIEQ4cOLd+D8nTdJaT9BH+rAQDgbzUgIY37iVSyh5vvRWxwPgAgNjgfDzff69sOkSyyBrGSk5MxfPhwrFy5EkFBQV7FWCwWGI1Glx85pIIMBOx+G1LR/wEE7HkHUkGGrPkQUenFBWVjVvtPIf37hZQkYFb7zxAXlO3bjlGlIYTAY489hieffBK33nqrVzGlrTNUNnTmTNQ+8q5L/a59ZCF05kxfdosquKohuXjzvl9d6swb9/2KqiG5vu0YVSpvvfUWLl26hJdfftmr9qwzFVOAJROtTr/jUmdann4XARbWGSpeFb0R83qud6kzc3uuRxU9v9c3Co23DR0OBwYNGoSnn34abdu29TrBnDlzMH36dLfpOekxcJhKPsLubziHUAiXaZJwwHTeDHNYrFd9sAtlpwjaCwNguRSvKFbjkEpu5IHK6g+NsarsuEiFZ8/q7P6INFWRHRcYFo6xY8cqylmnTh3FsdboWnDoAmTFJIbpMKNtpKJ8kjlUUVyDmABM6y7/syOZvPtMX61+tSBMHVRPUaxksXndNtaxB2qH6/dRrXJg8pBgpKmaejUPu7W+rP4VSawbiVkT7lYUa7WZFMU1blAFb0x7UHZccnKyonxms1lRbG5u5dm5mzt3LvLy8jBp0iSvY4qrM6MTQhEo80yuOkEajKsdJiumiL9OURiq6zR4PFp+zkiNXlG+MJUOrTUxsuP0+fJqaVDOBUhX1284EJFlQX5oyXWnVohv6owkSYq+h0q/v0qVd77S5Jw3uafXbatgH9Qq18+NRiXw2nOJSEVzr+ZRmr8L30fPKlOd2bNnD2bPno3t27dDrVZ7FVNcnQnOj0WwSv4Zwxq7P/T5Cra/RcltPFG6vW8XyvZnAMDf4YcqhfJzOmQsoz7/vFudUcGBuvlm5KrjSoyPiwpTXCuA0tUam188hMpPVkz96kGY+qiC7Wib8sss6yfoMeWpxrLjJKuy/e/6dUIxZWwL2XE2u8XrtnHY67HOzHqmJlJxi9fzKe/tfaUqY52RhBBerSrWr1+Pvn37YtSoUc4V/urVq6HRaHDffffh5ZdfRnh4uFucxWKBxfLfh8poNKJ69epIyvgaISEln80lFWQgZP1glxWUkFQw9lgOERjtTdeRZy30qt3VLJfi4Rep7A3Ptih78KPGWBW2kBTZcRlmZUUm0lQFlwIuyo67cCoF77zwgaKcY8eOxYIFCxTFZnV9FraIarJiZrSNxJQdlxTlU6fkKYqb1j0e036U/9lRXVB2BGDqoHqY/qmyp+qoLnq/jHFB2dj8yMsuK367Q4UOK2chNd/9+++JyZAqu48AMGvC3Xjltd8VxebknVMU98a0BzFm2jey43LPfKgoX3JyMuLj5Q9+Go1GhIaGIicnByEhIYpyVwR2ux3+/v7o06cPatasCQDYt28ffvvtNzz//PPo27cv2rVr5xZXXJ1pvmwD1IHenTVcZFztMLx+2qCo//HKxsrxeHQYPsqQn7NDnLLB2daaGOyypcuOuz3GKqu9zpyJltsed63fUGFPuw9R6B9VYvz5kymY91z515mVK1ciMTFRdpzS769S5Z2vNDkj6z/ndduqIbnY+/xylzpjc0ho8dYQpBi9G7i9dFz5LS/4PnpWWeoMANx///1ITk5Gx44dAQB5eXn44IMP0L9/f3Tt2hXDhg1ziymuzvx84isE6QNl90GfXwW5QfK3v00KxyKUbu+bbMoHsaoUVsFFnZJl9D5ngCUTPfe47ic6oMKGlsth8iu5zqSeTsHy8Ytk97FIaWqNMe5h2P3kHVCa+mh9TF9xXHYuyWCWHVNkylONMWOR/Eu6pRxlOaeMbYEZC/6RHZdvSvO6bRW9ETueWehWZ25d+Awu5nq/fks5NEtWH4uwzngmp854PdJSv359zJ49G/Hx8YiLi0NcXBz8/Pzg5+eHuLi4Yo9k+Pn5ISQkxOVHDhEYDVOr55yrJgHA1PJZrwewiOj6Sc0PxytbBqFo6FsI4JUt//N6AIvoWlQqFebMmYNbb73VWWdCQ0MhSRLi4uKKvYy9tHWGykahfxRONxztUr9PN3zGqwEsunmlGPV4YW0nlzozZm0nrwewiEoyePBgPPzww846Ex19eZ8iPDzc4wF5gHWmojL5RWF37Wdd6sye2qO9GsCim9fF3BC8tL6nS50Zv76nrAEs8i2vLyesW7cuxo0b5zJt8+bN8Pf3d5t+vRXW6gb/Q8shmQ0Q/mEorNWtTPMRUfG+PtoOL7Rdg5ggIzIKQvD1UfczY4iUkCTJrZ4sXrwY69atK/M6Q2UjvWpXVDv1CfwKDSjUhSG9aldfd4luAJ/+0xiTOv2JOL0JaXkB+PQf+ZeyEBXnwQddbxNw4cIFzJo1C/3790eXLl181CtS6mxsNzQ+/zECrAaYtWE4G8v9RCrZ5/tuwYvtNyNOn4+0vCB8vu8WX3eJZFB2zZsvSGrXVyLyGYdQubwSERWL9ZsUYJ0hIm+Jf+uLYJ0hGVhnblxen4nlyRNPPAGNplSzICIiKtbtt9+O1157zdfdICKiSiosLAzz589H/frKHjxDRETlq1QjUH379r1e/SAiInLTvHlzNG/u3RPJiIiI5AoODuYl60RENxCeO0dERERERERERBUeB7GIiIiIiIiIiKjC4yAWERERERERERFVeBzEIiIiIiIiIiKiCo+DWEREREREREREVOFxEIuIiIiIiIiIiCo8DmIREREREREREVGFp/FVYp1KD50qSEaEyvmqU+ll5QrSGGS1L2KTJARp1MpiHQ5FcVYJCNAK2XF2+SEAAK0FiPCTH5wFq7KEpSAAqFJyoM6V9/5LTUKgTspRlFOVnKsoTiqIgeqsQX6+i3nK8pltUJ03KorNN6bIjnE47M7X/KwLsmINeWdl5wOAQlsrZBlPKootSFqpKC45ORnDB/dSFEu+Vy/WAW2QvHVxsL9Agzhl6+9OVcyK4uIQjPtrFMiOaxdbqCifyuBAjzCL7LhQnbJCo5H+e62pt3sdZ9IqW77SKiz0Td7KzGrLlx0jhHC+KoknKg8Rfg4E+yuoGSYgUkGcTVl5gqoQiAuQH2x1SMoSAtDZBarJrMGXc8rPdWWdSQj2vs44tPJr4fUgAEjZ+VCp5dV+qdAOVYb87QVbrrJ9BAAQhVbYswyy4/JN6YryWW2JyMk7JzvOYpW/ryeEw/mab1bWX/INnolFimi15T/+KQGApGxQkYiIbiy+qDMAoNPpfJKXiIjKl8ZHdYb7NESlw0EsIiIiIiIiIiKq8DiIRUREREREREREFR4HsYiIiIiIiIiIqMLjIBYREREREREREVV4HMQiIiIiIiIiIqIKj4NYRERERERERERU4XEQi4iIiIiIiIiIKjzFg1iFhYXXsx9EREQuWGeIiKgsWa1WCCF83Q0iIpJB1iDWqVOnMHjwYERGRkKv16N27dp4//33y6pvRER0k8nLy8Ps2bORkJCAkJAQhIeHY+TIkcjPz/d114iIqJJYtWoV2rZti7CwMAQGBuKee+7BwYMHfd0tIiLygqxBrFWrVuH+++/H2bNnYTKZMHfuXIwePRpr1qwpq/4REdFN5M8//4RGo8G2bdtgNpvx559/4qeffsILL7zg664REVElYLFY8MMPP2DJkiXIzc1FcnIywsLC0L17d56VRUR0A5A1iDVhwgQ8+OCD0Ov1UKlU6N+/P+Lj47F3794y6t4VhN31lYh8RiU5XF6JrpeuXbti/PjxiI+PBwA0bNgQPXr0KJ86Q2WD9ZsUYJ2hsuLn54fly5ejefPmUKlUiIiIwOOPP47k5GRkZGT4unukBOsMKcA6c+OSfU8sh8MBg8GA5ORkvP322zAajXjggQfKom9O6tPrIJmzAACSOQvq0+vKNB8RFe/h5nsRG3z50q7Y4Hw83HyvbztElZLRaERGRgY2bdqE1atXY8iQIb7uEikQePZnqCwGAIDKYkDg2Z992yG6ITza6hji9GYAQJzejEdbHfNxj6gyMplMyM7OxoEDB/Dmm2+iZ8+eiImJ8XW3SKao5I3QFhoAANpCA6KSN/q2Q3RDeLTlUcTpTQCAOL0Jj7Y86uMekRyyB7GOHTuGhIQEJCQkYMKECXj77bfRpEmTYttbLBYYjUaXHzmkgnTods2DVPR/ALrd8yAVpMvtOhGVUhW9EfN6rof07xdSkoC5Pdejil7e95qoJHfeeSdq1KiBe+65Bz179sSIESOKbVvaOkNlQ2XKROje91zqd+i+96EyZfqyW1TBVQ3Jxzu9t7nUmbd7b0PVEN4Xj66vqVOnombNmmjWrBkyMjKwePHiYtuyzlRMWnMmah5e6FJnah5+D1oz6wwVr2pIHt7u/YdLnXmr9x+oGpLn246R1ySh8OJvm82Gb7/9Fo8++ig+/vhjPPTQQx7bTZs2DdOnT3ebfvDwn9Drg0vM42fYgdj9w9ympzVbBktYG6/66hBWr9pdzWrxh9bPrCxW4VmJwuoPSSs/p9J8Kps/HBr5+Uz5Zhw/kKQoZ506dXDq1ClFsdaQGhBqP1kxDWIDcCzNpCifZLYpiqtfPRjHz8tfEUoWZadB168dguOnlW1Q2azev/9x2IseqnFu0zc4XkcqbvFqHlZ7gdf5rtSkQTwOHktWFNurS0tFcWazGf7+/opiyzNfbm4uGjZsiJycHISEhJRBz3xn//796N+/P9q2bYsVK1Z4bFNcnfli2zYEBpdcZ64Ur9Ui2aqsZkT5Kfv+6uGHXFhkx0X4Kbx3i9UfUFBnNCp5+fwNuxB/cKTb9OQmi2EOa11ifEGeBQf3lX+dadmyJYJlfm6AG2d94YucG37Z53XbKtI+9FJPcpv+g30OLopmXs2jR+fmXue7Gt9HzypzncnIyMCzzz6Lbdu24dChQ9Dr9W5tiqszu/dvQbAX+zNuFK6HlV70JFn9IRTkK80twpTuY8hZxsCcnah12L3OnGn0AQpCS64zpnwzTijcnwFKV2tsqiqApJMVU7+WHsfP5MrO5bAp264BgAZ1w3HsZLbsOLtd2ROmG9aPxpHj8i/rlbO/fz3qDKC81rDOeCanzigexCrSr18/5OXl4ccff/T4e4vFAovlv41zo9GI6tWr4+KlnxASElTi/KWCdPiv6wfpilWakFQw9/oaItC7U34tdoNX7a6Wk14FoTEXlcUWKisz1qx4aCPk76gbCqWSG3mgM1ZFYUiK7LikEymY+cz/Kco5duxYLFiwQFGsoelQ2IOryIqZ1qs6pv1wXlE+VbL8QgEAUx+rj+mfHJef76KyIwBTnm+OGW95v5NwpXyj9+9/Fb0RO55ZCPUVO7M2h4RbFz6Di7nebdQa8s7K7SIA4K0ZA/H8lC8VxRYkrVQUl5yc7Lw3U3lQms9oNCI0NLRS7lwAwPLly/HEE0+goKAAfn7ug9jF1Zl+636ANqjkOnOlhyPC8XmW/I01AOhURdlBj8aIwSHIP7u4XayyDUSVIR6OMPl1JlQnb3NBZcpE7E/DIOG/OCGpkNZ1CRwBUSXGnzp2ES8MXyK7n0Dp6szKlSuRmJgoO+5GWV/4ImdIbfeDkcWpGpKPQ+O+dKszTV4fiBSjd99n4+mlsvtYhO+jZ5W9zmRmZiI6OhqrV69Gnz593H5fXJ3Zfe4LBIcEyk+YHQ+Ey18P25QetM6JhyNUfj6rQ9n+BaB8H0POgXmtORPNtg51rTNQYf9dy2D1L7nOnDuZgldHK9ufAUpXa3L9+8KhKrmPV5ryTFPMWHhAdi5brvIzB6e92BbT5u+QHZdvUnbV1Gsvd8WE2fIvCbVYc7xuWzUkDwfHfuFWZ5oueAgpRu8HpXNOKfvssM54JqfOyL6c0FMyna74UWQ/Pz+EhIS4/MghAmNQ2Pol56pJAChs9ZLXA1hEdP1czA3BS+t7Oo/MCQGMX9/T6wEsIiWMRiM0Gg1UKs8lq7R1hsqGIyAKObc87VK/c5qP8moAi25eKcYgPPt9O5c689z37bwewCJSoujywOL2aVhnKiarfxSSGj3jUmeSGj3t1QAW3bxSjMF47vs7XerM89/fKWsAi3xL1iBWt27dsG7dOpw/fx7Hjx/H1KlT8csvv+DJJ58sq/4BAOy174XwjwAACP8I2GvfW6b5iKh4n++7BWl5l3cm0vKC8Pm+W3zbIapUxo0bh8WLF+Po0aO4cOECvvzyS8yYMQPDhg2DVqv1dfdIpoKEe+DwCwMAOPzCUJBwj287RDeEFbsbIDX38mUIqbn+WLG7gY97RJXJt99+izFjxmDnzp1ITU3F1q1b8cgjj6B+/fro1KmTr7tHMmXGd4VVFwYAsOrCkBnf1bcdohvCij2JSM0NAACk5gZgxR75Z2CT72jkNJ4/fz5mz56NZ555BlqtFo0bN8Zvv/2Gu+++u6z69x9J7fpKRD7jECqXV6LrZeLEiZg7dy4WLlyInJwcJCQkYM6cORg6dKivu0ZKsX6TAqwzVFZ69+6NS5cu4fnnn8fp06cRFxeHrl27Yty4ceV+3xi6TlhnSAHWmRuXrEGsZs2a4csvld2ThoiIqCSRkZGYN28e5s2b5+uuEBFRJaRWqzF8+HAMHz7c110hIiIFOOxIREREREREREQVHgexiIiIiIiIiIiowuMgFhERERERERERVXgcxCIiIiIiIiIiogqPg1hERERERERERFThcRCLiIiIiIiIiIgqPA5iERERERERERFRhcdBLCIiIiIiIiIiqvA0vkqsVQVAqwr0ur0EyfkqJw4AhLDLal9EJWmgU+kVxeq1BkVxRpWAXitkx9nlh1yOUwF+CvLphE1ZwlIQAFQpRsAvSFacZLJBnWRUlFO6mKcszmSH6nyu7Dhj3jlF+azWejAakhTFGvPl5xT/vv9C2JCde0pWrOnc57LzAUBycjJGDL5PUSzdnN6/wx8hIQGyYtIvqtG1sbyYIjaHpCjuUpoazWLl57QJZfmyc1UID/STn89hVpTvSn5yDp3Zyr/OAEBhYaFP8lZmVlu+gijhfFUWT1T2qgdHIyQ4WHZceq4fYoJjZcdZ7DmyYwAgO0+F8CB/2XEmu0lRPgDIzxeICHTIjiuwya9tKum/10h/7/drMiXf1BkhgEJjJmyQtw9mt1pgNmTIzldgSpMdU8Rmb6po/8RsVfZZtTssyDfL76/DYVWQzeF8LbTK328j3+GZWKSIRlv+458SAElSl3teIiIqf1qdb46z6XQ6n+QlIqLypfXB/gwASBIA7tMQKcZBLCIiIiIiIiIiqvA4iEVERERERERERBUeB7GIiIiIiIiIiKjC4yAWEcl2NsOB2s9ffiUiIrreWGeIiKgssc7cuDiIRUSyfbfTjjMZwPe7lD35k4iI6FpYZ4iIqCyxzty4OIhFRLKt3213eSUiIrqeWGeIiKgssc7cuDiIRUSyOKy5OHxR4CEAh1IEHLZcX3eJiIgqEdYZIiIqS6wzNzaN3IC//voLW7ZsgdVqRZs2bdC9e/ey6BcRVVAO0wUESMA7AvheAqwFF6AKaejrblElkpSUhB9++AEpKSmoW7cuBgwYgMDAQF93i4jKCesMlbWCggJ89913OHz4MKKjo9GnTx/UrFnT190ionLCOnNjk3UmVq9evTBu3DgYDAaYzWYMGTIEffr0gcPBm6ER3SykgnPoIYBoAN3F5f8TXS/vvPMO7rnnHhw6dAgBAQFYtGgREhMTceHCBV93jYjKCesMlaU9e/agcePG+OGHHxAQEIDt27cjMTERq1ev9nXXiKicsM7c2GSdiTVr1iy0aNHC+f9BgwahcePG+Omnn9CjR4/r3jkXwu76SkRlxp5/FsKwB5Jw/51VFOLBf//dD8BqazZw4Vu3dkICpLCWUAcllGVXqZLp2LEjnnrqKWi1WgDASy+9hKZNm2LOnDl47733yjZ5QRr8DJlAaBQQGFu2uf4lFaTDz3Ackj4MIjCmHPJlwN+QBEkfCBEYXeb5ALB+k0esM+QrMTEx2LFjB6Kj/1sHPvvss3j++efRt2/fsu9AOdcaqSADfoazkPRB5bbeV5ky4W+4CFWwFo6AqLJPyDpDHrDOVF6yBrGuHMACgAYNGkCn0yE5Ofm6dupq0qnvAPOly/8xX4J06juIOn3KNCfRzUzSRQCSFlZHPpoD6H/F7wIA50r/QQCpAEyi0Pn7rwDsB6BVBV2eD5EMTZs2dfm/VqtFgwYNyrzOqE6vgWbnHMTAAbFfBVubiXDUvr9Mc2rO/ADdrtcR+G/OwtbjYKvVq8zyac/8iIDdbyMEDogDKphaPQdrrbK9JYDf2Z+hshgAACqLAX5nf4Yl4Z4yzUk3BtYZ8pVq1aq5TWvatCk++OADOBwOqFRld8vg8q412jMb4L/7bej/Xe+bWz0Ha62yPfHA/+zPCPnnPUgQEAclGFs8DXMZrveDz22E+t86o7YYEHxuI/JqdC2zfHTjYJ2pvGTfE+tKn332GWw2G+68885i21gsFlgsFuf/jUajvCQFaVDvnAXp3/9KANS7ZsFW5fZyO1JOdLNRaUMg4rpDnb0L+wqS0BjAYgD6q9r5ARjz77+NAJ7C5RW+OjABqvBWkFTa8us0VUoXLlzAL7/8ghkzZhTb5nrUGc3OOZBw+dJ4CQ5ods4GDiwCJLXXs5H1aRd2SOasK2qbA7pd86A9uFRGTg+HFq+ZL9slX8DuN+F/aLnX+YSQke/fnCqLwaV+B/3zPqwxLeAILIcj81Shsc5QRSGEwCeffIJ27doVO4BV6joDXJdaoy3let9/95vwk7HeDyr1el8g5J+FCD680vtaIzOf+qo6E7X/fZiiW8JeHmeAUYXGOlN5SUL2VullBw4cQLt27fD0009jzpw5xbabNm0apk+f7jb98OHt0OuDS8zjZ9iO6P1D3aZnNPsIlrC2XvXVAZtX7a5WaNFC52dVFGsXyuJslgBo/Eyy46wOqeRGHgP9Aa1ZdlhBnhlH9icpSlmnTh2cOnVKUaxNVxVC5Scrpn71YBw/n6con2RWdlpy/TohOH5K/gaO1V6gKF/DetE4ciJDUazNdu3P2/a/NuOz5e+his2GLx0OtPHQZieAgSoVUjVaPDxkFG69vUOx8+t1TytF/TSbzfD391cUq1R551SaLzc3Fw0bNkROTg5CQkLKoGe+UVBQgI4dO8Jut2Pbtm3w8/P83S+uzhw6/LeXdWYHYjzUGSobF5suhjmsdYnt8vMsOLC3/OtMy5YtERxc8ufmajfK+sIXOX/4efc1f19R6gzA97E4lbXOAMCECRPw3nvv4e+//0bjxo09tiltnQFYa8rThSYfwBRacp0pyDfj8D5ldQYoXa0pFFEQQt75JIl1I3D0ZJbsXHaHpeRGxWhUPxaHj6fJjnM4lO1/N25QFYeOpciOE+La9+i+3nUGuHH2aSpjnVE0iHXkyBF07NgRvXr1wtKlSyFJxQ+geDpyUb16dWRm/46QEC9W+gVp0Kzp5TxqAQBCUsF23w9en4lVaFf2yMxLaWGIjDUoijXblcUZM+IREi3/shlDobJBLHt2PNTh8vOdPn4RE0f+n6KcY8eOxYIFCxTF5kYNhF0n774xUx5vgBkfHVOUT7qobPBryphbMOONvbLjjHnKbio45+UumDh7k6JYY37JOR22XDgytkJvM8JT6QwHkKcJgSr6Lqg0Vx/fcGU697mifiYnJyM+Pl5RrFLlnVNpPqPRiNDQ0Eq1c2EymXDffffh4sWL2Lx5s8u9S65WXJ1Jz/rF6zqjW9vHtc4AgH+ErDOx5B49vvJMrKJ4ISun8iPy/+ULL7czsYDL9dvQbYlXZ2KdOHYRzzyxRF7Of5WmzqxcuRKJiYmy426U9YUvcgbUeLjENhWhzgB8H4tTGesMAMyZMwezZs3CunXr0LFjx2LblbrOANel1ogbcb0PwOEXVi5nYgGX68z5zku9OhPr9PGLGP+ksv0ZoHS15pK1A2wiTFbMzPHtMHnuNtm5CkzyB6GKzJvSCy/N+EF2nNmaoyjfW9MH4PmpX8mOczhKPoHketYZ4MbZp6mMdUb25YTHjh1Dp06d0KNHDyxZsuSaA1gA4OfnV+zRc68ExsLe5hWod86AhMsrNnvrV3gpIVE5Umn0cKj90KKYgyotAGxV+3u1wicqidlsRu/evZGSkoLffvvtmgNYwPWpM7Y2E6HZOdtZZ2xtXpZ9nxKbQ95ZrZozP0C3+3VIwgEhqVDYSt49sWxCXj7tmR8RsOdtZz5TS3n3xJK7fMDle2IF/fO+M2d+i1G8lJA8Yp2h8jZ37lzMnDmzxAEs4DrUGeC61BqLXd7AgPbMBvhfsd43t5R3TyyTXf7VIf5nf0bI3v/W+8ZbRsm6J1aBTd6B+eBzGxG1/798mc1G8VJC8oh1pvKQNYh1/PhxdOzYET169MDSpUvL9MaHVxJ1+gAH3rt8c3f/SN7UnaicCbsFdksGBv77/w0AXgUwCUAPAAMBbLakQ223QFKXciOPbmoWiwW9e/dGcnIyfvvtN8TGls8BC0ft+y/fl8ScBfhHlPlN3QHAVqsX7LFtkHs+B/rqoWX+dEJrre6wxbZCwXkTAqsHlMtTqiwJ98Aa0wLmZCv847UcwKJisc5QeZo/fz5mzJiBdevWoVOnTuWWt7xrjbVWD9hiWyP/fAGCqpfPU2nNCfegMLYFrBes0FYr+6cT5tXoClN0S6hSrXDEaTmARcVinak8ZA1ide3aFbm5uQgODsaYMWOc07t3747u3cv2CUfOU1BlXNpBRNeH3XQBANATl298+CaA8LBw9DRkYwyA553tkqEJru2TPlLlMHbsWGzcuBEPP/ywy/0Wa9So4VJ3yoQP6owIjIElrD6CAw3llC8a5rAYBASml0s+AHAERsEcFg9dYNk+YZJubKwzVF5+/PFHvPTSS7j11luxZs0arFmzxvm7mTNnQq8v47MwyrnWiMBoWMKiERio7N6pSjgCLq/31QHls963B0TBEhoPTTnloxsT60zlIWsQa9y4cbDZ3M+/CwsLu179IaKKqOA8qgPoBQkHAGjCbsGrCyZj9NiZeNOwF5vw/+3deVxU9f4/8NcMMOwImCAgiqJoGi5ZbpGUXVdEc0VTvCXa8kjL4t7Mb5Y/09RSr6almWKWlpBe9xZzSSsXxJ1Ewn0BBNkZlmGZz+8PYq4oFnOYzzDg6/l4eLtzmM95fQ7Lec/5nHM+B/CFQErRdYA7faqFgQMHonXr1vcsN9cVWURUR1hnyEyaN2+OJUuWVPs1KyueLCdqsFhnGgyjBrGmTJkiqx9EZKGEvgTlujRcB5Bq5QCbh56AWuMOtdoK1s5tobZtgvMZh1BaXgBVcRqs9CVQqTV13W2qp0JCaj4nFBE1DKwzZE7t27dH+/bt67obRGRGrDMNi3kmtSKiekuvy4SAgJVDS6ibDoBa417l62qNO9RNB8DKwQ8CAnpdZh31lIiI6iPWGSIikol1pmEx+umERPRgUdt5QuPZH2qN233fo1LbwKZxD1g5t4XKppEZe0dERPUd6wwREcnEOtOwcBCLiP6SSqWG6i92+Hf6q8JARERUHdYZIiKSiXWmYeHthEREREREREREZPE4iEVERERERERERBaPg1hERERERERERGTxOIhFREREREREREQWr84mdrdS2cFKZWdEC5Xhv8a1A2zUeqPe/79Ea9ioHRW1FShX1E6tsoatlbPR7Zxt8hXlaVUCTjbC6HYaUaoorzaEAPTp2RDG/toWlULczFKUmVNwXVG7krI2yM67aHS7jD+WKMpLTk7GxPF9FbVVKjk5GS8+P8SsmUTGMGedAYCdN5U9jtmvzBnHbhYY3a61i7I6oynTIye/xOh2jTSqv3/TfZTpVcgrNa59TpGy7autkhLjvzf014qub1TUjnWGLJ212gHWagcjW/2v1hjbtlzh52+VykpBPwHbWnzeL1KpYas2/lCzVF2mKE+vErBRG3dMI8rNfzxTafv6CejYsaNRbZKTk/H8uKcl9ej+mRHhA8yaN/mfg82axzpT//BKLFLExsb8458qFaCCldlziYjI/OqizgCARqOpk1wiIjKvuqozAGsNUW1wEIuIiIiIiIiIiCweB7GIiIiIiIiIiMjicRCLiIiIiIiIiIgsHgexiIiIiIiIiIjI4nEQi4iIiIiIiIiILB4HsYiIiIiIiIiIyOJxEIuIiIiIiIiIiCyeokEsvV6PjIwM6HQ6U/eHiIgIAJCTk4P8/Py67gYRETVQhYWFyMzMrOtuEBGREYwaxMrIyMD8+fPRunVrNGnSBJs2bZLVLyIiegCVlZXhm2++wZNPPonGjRtj8uTJdd0lIiJqYI4cOYLw8HA89NBDaNGiRV13h4iIjGDUINb27duRm5uLffv2yeoPERE9wC5duoSdO3di7ty5GDhwYF13h4iIGqBly5ahb9++mDdvXl13hYiIjGRtzJsjIiJk9ePvifKq/yUioganbdu22LhxIwBg4cKF5g1nnSEieiBU1pnPPvvM/OGsNUREtVI/Jna/9F+gOKPi/xdnVLwmIiIyEdWlLUDxn/OiFGdWvCYiIjIh1hoiotoz6kosJXQ6XZUJ4PPy8oxbQeEtqI7NgurPlyoAiJsF4fUE4NDUVN0kIqJ6qvZ1Jg3quDlV6oz6+ByUez0BOHiarJ9ERFQ/1brOAH8e08y+65hmNoRXLx7TEBEZQfog1vz58zF79ux7lqemqqHV/v2FYLbZ19EE+irLVEKPjKs3oHP1rlEfhLCvWWfvotOpkZaqrK1e4be2VKdBZpq70e3K9S6K8spL7KG97WN0O2fNQ4iMjFSU6e/vr7htmfCAgMaoNgGtXTHrX48pyist76CoXfsAD3w40/j5fJKTkxXlFRcXK26rlLkzuY3396A/we9+dSYt1QaFWpu/bW+bk1xtncm+mgKda7Ma98OvTNlBiL2wVdRWkycU5anL7KDJq1n9vFNZLa7dFqV2KMsyrtY8ZN+4TuqMSqVS9HdYX/YX9SmT22g5mawz1deZW6nWKNDW7DO/bXb1tSbLiFqjF8o+75forJFxy/i2euGgKA8ASnW2yEn3MrpdudD//Zuqa1diD12mcXXGzVb58Qxg/lpTX/YXzLOszPqyjcbUGemDWDNmzMCbb75peJ2XlwdfX194eenh4lKDnZRrc4izaqju2OkLlRqN/XwBh5rt5Mr0RUb3GwDSUu3h6aWsbYlewRkaAJlp7mjsmWV0u8IyZR8utLd94NTE+F/qW1mpWLx4taLMyMhILF68WFHbPP1AlMO4Qb5Z/3oMsxcdV5SXW3BdUbsPZw7E9Lk/GN0uIryforzk5GT4+Bg/GFkb5s7kNt6fojPCDcj96oynVylcXEr/fgWuPtXWGTc/b8ChBu3/dPTaLaP6XcmvrCmuWhvftrWLsvlUNHneKHFJMbqdvUbZoBkAlGX5wNrduFqTkZGKxYs/V5RXmzqzYcMGRX+H9WV/UZ8yuY2Wk8k6U32daepVBheXspqt5D61xt3PG3Co2Tp05cp+Dhm3XPBQU+PblpTnKsoDgJx0L7h6pBrdTltWw+/nXXSZPrBtbFyduZmp/HgGMH+tqS/7C+ZZVmZ92UZj6oz0ObFsbW3h4uJS5Z9RHJpCdJuNyo/OAoB4fDYvuyUiIgCmqDOe0D/+bpU6o3/sXd5KSEREAExQZ4A/j2lm3XVMM4vHNERERjJqEKu0tBQZGRnIyKiYZF2r1SIjIwNarVZK5wz8RwB2D1X8f7uHKl4TEVGDlJmZiYyMDJSWlqKkpAQZGRnIyjL+ClVjCP/hgF3jihd2jSteExFRg5Sfn4+MjAwUFBQAgOH4pkzhVUA1xVpDRFR7Rt1OeOTIEQwfXrGzbdy4MWbOnImZM2ciPDwcS5YskdJBA5VV1f8SEVGD1KlTJxQXFxtet2vXDp6enjh37pzcYNYZIqIHwuuvv44dO3YAAOzs7NCuXTsAwL59+9CpUye54aw1RES1YtQgVu/evQ1XYREREclw8+bNuu4CERE1YGvXrq3rLhARkULS58QiIiIiIiIiIiKqLQ5iERERERERERGRxeMgFhERERERERERWTwOYhERERERERERkcXjIBYREREREREREVk8DmIREREREREREZHF4yAWERERERERERFZPA5iERERERERERGRxbOuq2C1ygpqlZXR7VQAVEa2s1JpjM6pyFIpbmutdlDUTg0rRW3trMoU5RWprGBnZW90O1W5+X91hADyCq6hRJ9lVLvS8oeRnX9RUWbWhU8VtUtOTkZEeD9FbYnINKzVdrBW2xnRQvXn/6qMbFdhT4rxbQDgWRc19uQZ3zY+q1xRXrC9GgezjM/r5VmiKA8APMpUSM81rnZfz9YrzquNkhLl20lEDxYrlUbBscL/ao2xbW1qcXyhpK1elCrKAyqO15Qc09hb5SnKK1UB9kYeWqrLlB0/mQJrDZFyvBKLFLHRGD8AWVsqFcBfWSKiB4OVTd2cZ9NolJ28IiKi+sVGU2fXc7DWENUCRwSIiIiIiIiIiMjicRCLiIiIiIiIiIgsHgexiIiIiIiIiIjI4tXdjcBGEEJAW6SHNhtwgh5OQkBVMUESERFRrbHOEBGRbKw1RES1Z9FXYqWnZ2L+/FXwbRkMl3G34T0FcBl3G74tgzF//iqkp2fWdReJiKgeY50hIiLZWGuIiEzHIgex9Ho9ZsxYDJ9mT2Lm+0uR3CcN+AbALgDfAMl90jDz/aXwafYkZsxYDL2+bh7DTURE9RPrDBERycZaQ0RkehZ3O6Fer8e48H8heuN3wP8DMAWA+11vGgvoF+mhXw4smP05rt1IwYavFkKttsgxOSIisiCsM0REJBtrDRGRHEYPYmVnZ2P9+vW4du0a2rRpgwkTJsDBwcFkHXrnnSUVO/toAKP/4o3uAGYBeBjYOGYX/Jr7YN68N03WDyIiqht6vR6bN29GXFwc3NzcMHbsWLRs2dJk62edISKiuLg47NixA2VlZRgwYACCg4NNun7WGiIiOYwa5k9LS8Ojjz6KTZs2wdXVFZ9//jl69OgBrVZrks6kp2di0eKoih35X+3s7zQawCxg4eIo3k9ORNQAhIWFITIyEg4ODjh16hQCAwNx7Ngxk6ybdYaIiL744gsEBQUhJycHZWVlGDRoED788EOTrZ+1hohIHqMGsebOnQs7Ozvs3bsX7777Ln7++WfcunULy5YtM0lnoqI2Q28lgKlGNpwC6NV6rF37X5P0g4iI6sbu3buxefNm/Pjjj5g9ezY2bdqEfv36Ydq0aSZZP+sMEdGDrbCwEG+88QbmzJmD5cuXY+HChVi+fDnee+89pKammiSDtYaISB6jBrG2b9+OUaNGwdbWFgDQqFEjhIaGYvv27bXuiBACn676Gvqx+nvvF/87jQH9GD0++WwDhBC17gsREdWN7du3o0uXLujQoYNhWXh4OI4cOYL09PRarZt1hoiIDhw4gNzcXIwfP96wLCwsDADw448/1nr9rDVERHLVeBBLp9Phxo0b98xL0qpVK1y4cOEv2+Xl5VX5Vx2ttgDJ19KAvjXt0V36AcnX0lBQUKhwBUREVNcuXLhQbZ0BgIsXL1bbhnWGiIhq6sKFC9BoNPD29jYsc3R0hIeHx32PaWpaZwDWGiIi2Wo8sXtRUREAwNnZucpyFxcXFBbefyc7f/58zJ49+57lKSlAfv7/Xqel/bkOl5r26C5/dispqQCeno5VviSEsocw6nRqpKYoayvg+PdvqjbTGrdTjW+rF7aK8kp1GmSnNTG6nb21MyIjIxVl+vv7K25bXOYMvZHPI2gf4ImF74UqyktOTlbUrri4WHHb+pBXF5ncxvvLv3NnWs8VFRXBx8enyjIXl4rCcL9aYwl1BgCedTH2lHuFptYaRW1trZSdpW+s1iDY/iGj27nplF8VYFtuCw+dl1FtXNzc6qTOqFQqRX+H9WV/UZ8yuY2Wk9nQ6szdxzPAXx/T1LTOALKPaZR93tfp1EhLMb5tudGXkv1Pqc4GmWnGt9eLe382NVGms0febZ+/f+MdnDQPKa4VgPlrTX3ZXzDPsjLryzYaU2dqPBrg6OgIlUqFnJycKsuzs7MNBxjVmTFjBt58839P2MjLy4Ovry+8vYE7m7m4/PmEw/uf2Phrf25zQIAjnJyqfqlcX6Zolakp1vDyVta2VCg7e3I71RFNvAqMbldSruwbl53WBG6et41udzsnGYsXf6IoMzIyEosXL1bU9mb+IyjRGzfIt/C9UPz7/Z2K8iLCByhql5ycfM+BuEzmzquLTG7j/f3VGeH6xsnJqdo6A+C+tcYS6gwAvH81S9Fqn3Vxx7Y849s2tStXlBds/xAOFmUY3a6XS4miPADw0Hkh3da4uWaSb6ZgxeLPFOXVps5s2LBB0d9hfdlf1KdMbqPlZDa0OpObmwshBFQqlWH5Xx3T1LTOAHJrTalep2iVaSm28PQ2vq2uPFdRHgBkprmjsafxtU2n8Jgm77YPXJoYd9CclpWKxYtXK8oDzF9r6sv+gnmWlVlfttGYOlPj2wltbGzQpk0bnD9/vsry8+fPo3379vdtZ2trCxcXlyr/quPk5AifFp7Anpr26C4/AT4tPOHo6KBwBUREVNc6dOhQbZ2xsrJCQEBAtW1YZ4iIqKY6dOiAsrKyKreoZ2ZmIj09/b7HNDWtMwBrDRGRbEZN7B4WFoaYmBhkZVWMql+7dg27du0yTIZYGyqVCq++NA7qjWrA2EH7TEAdrcaUl8dXOaNCRET1y+jRo5GUlIR9+/YBAMrKyrBq1Sr0798frq6utVo36wwREQUFBcHb2xsrVqwwLFu1ahUcHR0xYICyK/DvxFpDRCSXUYNY06dPR/PmzdG1a1eMGzcOPXv2RHBwMCZNmmSSzkREjIS6XAUsN7LhJ4Bar8bEiSNM0g8iIqob3bt3x4wZMzBs2DCEhYWhW7duuHr1KpYtW2aS9bPOEBE92GxsbLBu3TqsWbMG/fr1Q2hoKObOnYtVq1bV+mRJJdYaIiJ5jBrEcnR0xK+//orPPvsMTz31FL755hvs3LkTNjY2JumMh0dj/CsyApgN4NsaNvoWwGzg35ER8PBobJJ+EBFR3Zk3bx5+/fVX9O3bF++88w4SEhLg7+9vknWzzhARUd++fXHhwgVMmDABI0aMQGJiIsaMGWOy9bPWEBHJY/Sj96ysrNC/f38ZfQEAfPDBG7h2IwUbx+wCzgOYClT7YIwsVJzdmA2MfW4w5s6dJq1PRERkXp06dUKnTp2krJt1hoiImjZtivHjx0tbP2sNEZEcRg9iyaZWq7Hhq4Xwa+6DhfOioF+gh36MHuiHikfO5gP4qeJ+cbVejX+/HYG5c6dBrTbqojIiInpAsc4QEZFsrDVERHJY3CAWULHTnzfvTUyb9k+sXftffPLZBiSvSzN83aeFJ6bMGo+JE0fwclsiIjIa6wwREcnGWkNEZHoWOYhVycOjMd5++0VMnz4ZBQWFSEoqQECAIxwdHfjEDiIiqjXWGSIiko21hojIdCx6EKuSSqWCk5MjPD0d4eRU170hIqKGhnWGiIhkY60hIqo93nRNREREREREREQWj4NYRERERERERERk8cx+O6EQAgCQl6c1um1+PpCXZ3xmuV5nfCMA+fnWcMwrU9S2VBQpzBSwdSw0ul1JeYHCPHtY2RvfVqstQnl5uaLMwsJCxW315SUQwsaoNkVFBRD6EkV5eUp+4QDk5+crblsf8uoik9t4f5VtKvevD7q6qDMAUFKgbD9cqNYoaqsrU7YfLSi3g67I+LwCh1JFeQCg1WlRUGJcbSsqqJs6o9VqFf0d1pf9RX3K5DZaTibrTFW1qTOA8lpTqlf6eb8U9nnGHw/pFB5fVGRqYKPgGKOk3PjjoIo8LVS2xrUtqMXxDGD+WlNf9hfMs6zM+rKNxtQZlTBzNbp58yZ8fX3NGUlE9EC4ceMGmjVrVtfdqHOsM0REcrDOVGCdISKSoyZ1xuyDWHq9HikpKXB2djbqaRx5eXnw9fXFjRs34OLiIrGHdZNXF5ncxoaRyW1sGJm1yRNCID8/H97e3lCreZd4fakzdZHJbWwYmdzGhpFZn7aRdaYqpXUG4O9ZQ8iri0xuY/3Pq4vM+rSNxtQZs99OqFara3UGx8XFxWw/gLrIq4tMbmPDyOQ2NoxMpXmNGjWS0Jv6qb7VmbrI5DY2jExuY8PIrC/byDrzP7WtMwB/zxpCXl1kchvrf15dZNaXbaxpneGpFCIiIiIiIiIisngcxCIiIiIiIiIiIotXbwaxbG1tMWvWLNja2jbIvLrI5DY2jExuY8PIrIttpKr4e9YwMrmNDSOT29hwMqkq/p7V/7y6yOQ21v+8ushsqNto9ondiYiIiIiIiIiIjFVvrsQiIiIiIiIiIqIHFwexiIiIiIiIiIjI4nEQi4iIiIiIiIiILJ51XXegJrKysnD48GFoNBoEBQXBwcFBemZCQgLOnj2LoKAgNGvWzCx5ly5dQrNmzdC5c2eoVCqpecXFxTh58iSys7PRrl07+Pv7S8270/79+5Geno6hQ4fC3t5eSsbp06eRmJhYZZmjoyNCQ0Ol5N3p7NmzuHr1Kjp16oQWLVpIy9m6dSt0Ot09y/38/NCjRw9puRcvXkRiYiLs7OzQuXNnPPTQQ9KyAECr1eLUqVPIzs5G165d4ePjY/KMU6dO4Y8//kCfPn3g4eFxz9f1ej1iY2ORlpaGRx55BK1bt65VnlarxZ49e9CoUSP06dNH8XvIdEpLS3Ho0CHk5ubisccek/J7dreMjAz8/PPP8PPzw+OPPy49LzU1FadPn4azszO6dOkCR0dH6Zm///47Ll++DG9vb3Tt2lV6bauUmJiI06dP4/HHH5dW39LS0vDzzz/fszwkJATOzs5SMiulpKTgxIkT8PLywmOPPSYtJzY2FleuXLlnuUajwfDhw6XlZmZm4tSpUyguLkbbtm3Rpk0baVkAIITA6dOncf36dbRo0QKdO3c2ecatW7fwyy+/ICAg4L7rv3DhAs6dO4emTZuiW7duUKuVn2sWQuDAgQNIS0vDyJEjYW1970f+mryHTOvs2bO4dOkSWrZsKeX37G6lpaXYu3cvSkpKMHToUOl5Wq0WJ0+eRFFRETp27AgvLy/pmSkpKTh9+jQcHR3RtWtXODk5Sc8EgPz8fHz33Xfw8fHBk08+KS1n06ZNKC8vr7KsS5cuaNu2rbRMACgsLMSRI0cghECvXr2kHX9fv34dhw8frvZrffv2RePGjaXklpaWIi4uDunp6fD29sZjjz1Wq31uTVy5cgUJCQlwdHREr169oNFoTLp+nU6Hn376CVZWVhg0aFC178nOzsbhw4dhbW2NoKCgWn8WrMmYiZRxFWHhtm3bJpydnUWvXr1Ex44dhaenpzh+/Li0vMOHD4vg4GAREBAgAIhNmzZJyxJCiLi4ONG1a1fRvn17MXjwYOHj4yO6du0qUlJSpGVu3LhRtGrVSgQHB4sBAwYIJycnMWrUKFFSUiIts9L+/fuFvb29ACBu3LghLScyMlI0bdpUhIWFGf5NmTJFWp4QQty6dUsEBQUJT09PMXToUNGhQwcxb948aXmTJ0+usn1Dhw4VAMSMGTOk5JWVlYlx48YJJycnERISIoKCgoSDg4P47LPPpOQJIcTu3buFh4eH6Nq1qxg4cKBwdnYWixYtMtn69+zZI7p37y7atGkjAIiff/75nvfk5OSIHj16CG9vb/GPf/xDODg4iOnTpyvKKy4uFq+++qrw8vIS3t7eIjg4WNF7yLSuXbsmAgIChL+/v3j66aeFvb29WL58ubS89PR0ER4eLry9vYW7u7uIiIiQliVExe/w6NGjRbNmzcTAgQNF586dhYeHh/jhhx+kZcbHx4vHH39cdO7cWQwZMkQ0a9ZMPPzww+LKlSvSMitlZWUJf39/oVKpxMqVK6Xl7NmzRwCosh8OCwsTycnJ0jLLy8vFG2+8IRwcHET//v1FcHCw6N+/vyguLpaSt2LFinu2z83NTQQGBkrJE0KI1atXCwcHBxEUFCRCQkKEk5OTGDNmjCgrK5OSl5qaKh577DHh6+srQkNDhY+Pjxg8eLAoKioyyfpv3LghRo8eLXx8fISLi4uIjIys9n2RkZHC0dFR9O3bV3h5eYlevXqJ3NxcRZmrV68WrVu3Fv7+/gKAyM/PV/QeMp2ysjIRFhYmXF1dRb9+/YS7u7sYNmyY1M/e77//vvD19RV+fn7C09NTWk6lefPmCR8fH/Hkk0+Kvn37Cnt7ezFz5kxpeUVFReK5554Tfn5+IjQ0VHTp0kW4urqKzZs3S8u809ixY4VGoxEhISFSc2xtbcWTTz5ZZT+8c+dOqZmbN28Wbm5uolu3biI0NFR06NBBnD17VkrW4cOH76kz7du3F2q1WtqxYnx8vPD19RUBAQFi6NChonnz5qJt27bi2rVrUvL0er2YNGmScHZ2FoMGDRKdOnUSrVq1EklJSSbLeOutt4S3t7dhW6qzc+dO4ezsLHr27Ck6deokPDw8xLFjxxTl1WTMROa4ikUPYmVnZ4tGjRqJOXPmGJaNGTNGPPzww0Kv10vJ/PHHH8X+/ftFUVGRWQaxDhw4IE6ePGl4XVhYKDp37ixGjRolLXP79u0iKyvL8Pry5cvC2tpafPXVV9IyhRDi9u3bokWLFmLevHlmGcTq37+/tPVX54knnhBBQUFCq9UKISoONmQXmTutX79eqFQqceHCBSnr//777wUAcebMGcOy+fPnC41GI+UASqfTCXd39yof+E+ePClsbGxEXFycSTK2bt0qDh8+LG7cuHHfQaxXX31VBAQEiJycHCGEEL/++qsAIPbu3Wt0Xn5+vli+fLnIyckRERER1Q5Q1eQ9ZFqVg7KVBxPr168XVlZWIjExUUrepUuXxJdffimKiopEcHCw9EGs1NRUERMTI8rLyw3LIiMjRaNGjYROp5OSGRsbKxISEgyvdTqd6Ny5s3juueek5N1p+PDhYs6cOcLW1tYsg1jm9MEHH4hGjRqJc+fOGZbt27fPbAMQ6enpwsbGRixdulTK+nU6nbCzs6tyAig+Pl4AEDt27JCSOWbMGNG5c2dRWFgohBBCq9WK9u3bi9mzZ5tk/b///ruIjo4WOp1OdOrUqdpBrB9++EGo1Wpx9OhRIUTFQGzLli3FtGnTFGWuWbNGXLhwQWzduvW+A1Q1eQ+ZzsqVK4WLi4u4ePGiEEKIK1euCFdXV2l/S0IIsXTpUpGSkiIWLlxolkGsqKioKgOv+/fvFwDEvn37pOTl5OSIbdu2VVn21ltvCVdX1yr1ToaoqCjRq1cv8eyzz5plEMucxxMnTpwQ1tbWYtWqVYZlN27cECdOnDBbH7p37y4GDBggbf1DhgwRQUFBht+T4uJi0bZtW/Hiiy9KyVu3bp2ws7Or8rnopZdeEj179jRZxqJFi8Tt27fFO++8U+0gVm5urnBzcxOzZs0yLBs/frwICAhQNK5SkzETmeMqFj0n1q5du1BYWIgpU6YYlr355ps4f/48Tp48KSWzf//+ePrpp6WsuzrBwcHo0qWL4bW9vT0GDhyI06dPS8scMmQI3NzcDK99fX3h4OCA/Px8aZlCCDz//POYPHmyWW6bAYC8vDzs3LkTBw4cQFZWltSsAwcO4NChQ/jPf/5juCxTrVZj8ODBUnPvFBUVhaeeeqrWt7rdj06ng0qlgp+fn2FZq1atoNfrUVpaavK8CxcuICsrC6NGjTIs69KlC/z9/bF+/XqTZDz77LPo2bPnfb8uhMDXX3+NiIgINGrUCAAQFBSEbt26YcOGDUbnOTk5YcqUKYZ1KX0PmU5mZiZ++OEHTJ06FTY2NgCA5557Dk2aNEF0dLSUzFatWmHChAmws7OTsv67NW3aFKNHj65ymfzw4cORm5uLq1evSsns1q0bHn74YcNrjUaDli1bSq0zALBixQqkpaVhxowZUnPutG/fPnz//ffSvpeVSktLsWjRIrz++uto3769YXmfPn3MdvvMV199BbVajfDwcCnrLysrQ3l5eZU64+fnB7VajZKSEimZR48exeDBgw3TGzg6OmLQoEH48ssvTbL+Dh06ICws7C9vG9mwYQOeeOIJdO/eHQDg5uaGF154QVGdAYCIiIi//SxQk/eQ6WzYsAFDhw413N7s5+eH4cOHK/4Z18Trr79ultv5Kk2cOBEuLi6G108//TTc3NykHdM0atTonlsk27Zti6KiontuvzOlxMREvPPOO9iwYQOsrKyk5dzp999/x7Zt23D69Gmp2wYAH330ETp27IgXX3zRsKxZs2Z49NFHpeZWOnfuHGJjYzF58mRpGTqdDs2bNzd8LrK1tYW3t7fUOvPII49U+Vw0atQoHDlyBElJSSbJiIyM/MspXr7//nvk5eXhtddeMyx78803kZSUhGPHjhmdV5MxE5njKhZ983t8fDyaNWsGV1dXw7KOHTsavta1a9c66pk8Qgjs378fjzzyiNSczMxM7NmzB1qtFjExMejWrRsmTJggLW/JkiXIzc3F22+/Xe0cIjIkJiYaDmiSkpKwcOFCvPLKK1KyfvnlF7i5uaFz5844cOCAYS4Ac8yrAwCXLl3CwYMH8fXXX0vLGDx4MEaNGoWhQ4di/PjxyMvLw8qVK/Hxxx9LOYCq/OCVmJho+GCfn5+PlJQUnDlzxuR51bl58yZycnLu+XsMDAyUOtBM5pOQkAC9Xl/lZ6xWq9GhQwfEx8fXYc/k2rt3LxwdHasMFsiwZcsWaLVaxMXF4fTp09iyZYu0rLNnz2L27NmIjY0124GFjY0NZs+eDWtraxw+fBijR49GVFSUYUDUlOLj45GdnY1+/fohPj4ely9fRqtWrRAYGGjyrPtZu3YtRowYAXd3dynrd3BwwNKlSzFr1izcunULLi4u2LBhA0aOHCltPh9vb+975tBMTEzE5cuXodVqzTJAGB8fjyeeeKLKssDAQGRkZODWrVto2rSp9D6QXPHx8RgyZEiVZYGBgVI/t9W1yrl3ZR/THD16FFeuXMHVq1exatUqLFu2TMo+GKiYUzgsLAwffvghWrZsKSXjbiqVCuvXr4efnx+OHz+OZs2aISYmRtog9C+//IJ//vOfuHXrFmJjY9GkSRM8+uijZjvxFhUVBU9PT6nzGM+bNw+jR4/G9OnT0b59e8O8t6tXr5aS5+3tjWvXrqGoqMhwwqSy7pw9exYBAQFScu8UHx8Pb2/vKvU7MDAQKpUK8fHxhmOt+sKiB7Fyc3Pv+aBka2sLBwcH5OTk1E2nJJs3bx7OnDmDzz//XGpOdnY2tm3bhqysLJw5cwaTJk2StnM6ceIEFixYgGPHjpntwGLIkCGYPXu24aqo1atX4+WXX8ajjz4q5Y80PT0dLi4u6N27NzQaDTQaDX777Tf83//9H959912T591t7dq1cHNzkzrRrrW1NXr27IklS5Zg8+bNyM/Ph52dXZUrAkzJ3d0dr732GqZNm4abN2+iSZMmWLduHZydnZGbmysl826VOXfvhxo3btxg90EPmr/6GWdmZtZFl6SLjY3F/PnzMW/ePJNPKnq37777DhkZGTh+/Dh69OgBT09PKTmFhYUYM2YMFi1aJH1grlKLFi2QkJBgOJBISEhAjx490KZNGyn7/fT0dADA8uXLcebMGbRu3RqHDx9G165dsX37dmkPSql05MgRJCQk4NNPP5Wa0759e9jb22PTpk1wdXXF5cuXERoaKm3S8ZkzZ2LIkCF46aWX0L17dxw6dMhwoiQ3N9csg1jVfd6tnMw4JyeHg1j1nBACeXl51f6MdTodiouLzTZAYC55eXkIDw/HM888g759+0rNOnHiBA4cOICkpCQ0atRI6oMgIiMj0a5dO6kn/u+2ZcsWDBw4EEDFydyBAwdi3LhxiI2NlZKXnp6O+Ph49OjRA4GBgUhKSoJOp8O2bdukP4ygpKQE69evR0REhLSBSKBiUKlTp07YsmULzp8/j7Nnz+Lpp5+W9rCql156CStWrMCAAQMQHh6O5ORkxMTEQKVSmfWY5u59kLW1NZydnevlMY1F305oa2sLrVZbZZler2+QO3sAWLVqFd5//31ER0cbrjiTpXXr1oiOjsZPP/2EY8eOYdWqVViyZImUrJdeegl9+vTB0aNHER0djQMHDgAAduzYgVOnTknJ7N27d5WnLUyePBm+vr747rvvpOTZ2dnh2rVreOGFF3Dw4EHs2bMHMTExeO+993DixAkpmZXKy8vx5ZdfYsKECbC1tZWW88033+Dtt9/Gjz/+iB9++AG//fYbXnnlFYSEhCA1NVVK5scff4wvvvgCaWlpOHbsGN5991307t3bbLfOVH4/794PabXaBrkPehA9aD/j+Ph4hISEICIiAm+88Yb0vKioKGzfvh2XL19GVlYWxo8fLyXno48+QmlpKWxsbBAdHY3o6Gjo9XocP35c2n6/TZs2Vc6Et2/fHmPHjsXOnTul5FX+PhYXF+PcuXPYuXMnEhIScOrUKXz00UdSMu8UFRWFNm3aIDg4WFrGrVu3EBISgkmTJuHw4cP4/vvvsWfPHsycOVPaFSsDBw7EyZMn4e7ujl9++QWBgYGYM2cOAJi11lS3DwLQIPdDDxqVSgWNRlPtz7jyaw1JQUEBQkJCoNFosGnTJulPpX311VexadMmnDlzBmFhYRgyZAhu375t8py4uDh8/vnneOqppwx15ubNm0hJSUF0dLS02+UrB7AAwNnZGdOnT8exY8cMJzZMzc7ODnFxcYiLizPUmcDAQKm391XasWMHMjMzMWnSJKk5o0ePRnFxMRITE7Fjxw788ccfSEhIwMsvvywlz8PDA+fOncOgQYNw+PBhFBUVYdOmTRBC1GmdEUKgsLCwXtYZix7E8vf3R0pKCsrKygzLrl+/Dr1ej1atWtVhz0xv9erVeO211xAdHW2WR+Deyc/PD0FBQfj111+lrL9yzqFt27Zh27ZtOHToEABg9+7dZr1dx9nZWUpRA2CY42DEiBGGZaGhobCxsZE+iPXjjz8iOTlZ+g7/4MGDCAwMvOd+7sLCQmlng4CKeauWLVuGNWvWYODAgYiNjTXLY6kBoHnz5rC2tsb169erLL927VqD2wc9qCr/dh+En3F8fDz69OmDUaNGYfny5WbNtrW1xejRo6XVmRYtWqBr166GOrNt2zaUl5fjzJkz2LNnj5TM6ri4uEivM8OGDTPM4+Hp6YmgoCDpdaagoAAxMTGYNGmS1APSuLg4FBYWYvTo0YZlAQEB6Nixo9SpCAIDAzF//nysW7cO06ZNw4kTJ+Dn52e2uQn9/f2r3QdpNBrTPY6c6tT9fsaVc741FJUDWHl5edi7d2+VOXjNITw8HFqtVspJco1GgxEjRuDgwYOGOpOcnIy0tDRs27ZN+pyPlSrnHZNZa3r37o0mTZoAAKysrPDss8+aZT4u2fP7AhUn/w8dOoThw4cb7hCytbVFaGio1Drj7u6O6dOnY+3atViwYAGuXbsGAGY7pvH390dqamqVeYyTk5NRVlZWLz/vWvRec8CAAdBqtdi9e7dhWUxMDFxcXBAUFFSHPTOtqKgoTJkyBRs3bsSwYcOk59191YxOp8O5c+fg6+srJW/58uWGMxbR0dF45513AACffvqptMtx797GhIQEJCYmSptUftCgQbCysqoyr8bly5dRWloq7ftaKSoqCr169UKHDh2k5vj6+hru5650/vx5AJD2ITsjI6PK661bt+Lq1auIiIiQknc3W1tbPPPMM9i0aVOVPu3fvx8hISFm6QPJ1apVK7Rt27bKz/jcuXM4d+5cg/oZnzt3Ds888wxGjhyJFStWSD8zXt3VmSdOnJC2P3zhhReq1Jno6GjY2NggIiICS5culZJ59zaWlJRg165d0uqMr68vOnXqVKXO6PV6JCUlSa8zMTEx0Ol0+Oc//yk1p3I7KmsLUHHl2dWrV6XVmZycnConS9PS0rBhw4YqkxrLNmjQIOzZs6fKLR3ffvst+vXrJ+02SjKvQYMGYfv27YaJo0tLS7F169YGVWcKCwsREhKC7Oxs7Nu3z3BLrCzp6enQ6/VVllUO6MvYJ3bq1OmeOtO9e3d06dIF0dHR8Pb2NnlmWlraPdu4ZcsWuLq6SrttMjQ0FElJSRBCGJadP38e3t7eUqeFuXnzJn766SfpV3xZWVnBy8urSp0BKrZR5kmDO49p9Ho9lixZgt69e6Nt27bSMu80YMAAFBcX4/vvvzcsi4mJgZOTE3r37m2WPpiSRVfGgIAATJ06Fc8//zzeeustaLVaLFiwAEuXLoWDg4OUzJSUFPzyyy+GUcpDhw6hrKwMAQEBUp7KsG3bNkyePBljxoxBSUmJ4WlYNjY2Va7qMaXKJwUEBgaioKAAX3/9NXQ6Hd566y0peXWhX79+6NOnDwIDA5Gamorly5cjODhY2q0sLVq0wNtvv42xY8fizTffhI2NDZYvX46nn34a/fr1k5IJVBTwXbt2SZ9DDai4JfPTTz9F3759MWHCBOTl5WHp0qUYNGiQtIcsrFmzxnD1yMWLF7F8+XIsXLjQZGctrly5gtjYWMPTK/fv349bt27hkUceMUxEumDBAgQFBSE8PBw9e/bEmjVr8PDDD+P5559XlLlz504UFBTg8uXLSE9PN/zNjxkzxqj3kOksXboUoaGh0Gg08Pf3x8cff4yQkBD0799fSp4QAjExMQAq/obVajWio6Ph7Ows5YAmNTUVzzzzDBo3bozevXsbsoGKJ9t5eHiYPHPOnDnIzMxEUFAQbG1tceDAAWzZsgUbN240eVZdef/995GRkYHg4GAIIfDll18iLy8PH3zwgbTMZcuWYfDgwSgvL0e7du2wfft2pKWl4d///re0TKDiZMmQIUOkzWlWqXPnzhgyZAiee+45vPHGG3B1dcX69eshhMBLL70kJfPy5ct47bXXEBYWhrKyMnzyySfo3r07/vWvf5lk/SUlJYYHGuTk5OCPP/5AdHQ03N3dDZ8PJk2ahDVr1qBv37544YUX8NtvvyEuLg6HDx9WlHny5EkkJSXh+PHjAID//ve/sLW1Re/evQ0H2jV5D5nOW2+9hW+//RYhISEYPnw4tm/fjvz8fKlPUj148CBSU1Nx+vRpFBcXGz5LhISEwNnZ2eR5I0eORGxsLBYtWoS9e/calrdr107K1SZHjhzB/PnzMWTIEHh7eyMhIQGrVq3CK6+8UuWugfrs6NGjmDdvHoYOHQoPDw/s378fW7ZswerVq6XdhhoZGYmYmBgMHz4coaGhOH/+PD755BNERUVJyav0xRdfwNXVVer8vpXeffddTJ06FeXl5ejYsSOOHj2KmJgYbN68WVpmv379MGzYMHh5eSEmJgZ//PGHYYodU9i7dy8yMjKQkJCA/Px8w9/7s88+Czs7O7Rq1QrTpk3DxIkTMX36dBQWFmLBggVYtGiRolsaazJmInNcRSXuHGa1UDExMfjpp5+g0WgwcuRIPPPMM9KyTp06hQ8//PCe5f3798cLL7xg8rxvv/222qc12dnZYd26dSbPAyrOaq5fvx5xcXGws7NDx44dMW7cOOmTwlb6/fffMXfuXKxYsULaE46Ki4vx1Vdf4fjx43BxcUGvXr0wbNgw6Vcf7Ny5E7t27YJarUb37t0xfvx4qWdRf/75Z6xevRpr1qyRNrB7p+zsbKxduxaJiYmws7NDjx49MGbMGKlnZjZv3ozdu3ejUaNGCAsLM+lVDgcPHsTKlSvvWT5y5EiMHDnS8DopKQlr1qxBWloaAgMD8corr1SZc80YU6dOrfYS8MpiU9P3kGmdOHECX331FXJzc9GzZ09MnDhR2qSier0ezz333D3LmzZtKuWqoYsXL2LmzJnVfu29996T9nCGyvmMCgsL0bJlS4SHh5vtia0AMGHCBEyYMAH/+Mc/pGV899132LNnD0pKStChQwc8//zzivcNNXXu3DmsW7cOmZmZaNOmDSZPnixtMlqgYjLYl19+GVOnTkWvXr2k5VTS6/WIjo7G0aNHUVRUhLZt22LixInSPi8AwOnTp/Hll1+isLDQcMWiqW7xys/Pr/bKAn9//yoDnlqtFitXrsTvv/+Opk2bYvLkyYpvqfniiy+q3MVQafr06ejSpUuN30OmlZ6ejpUrV+LSpUto2bIlXnnlFamT9i9YsKDaJyn/5z//kTJQ+eKLLyIvL++e5aGhoRg3bpzJ84CKJ7xt3LgRN27cgJeXFwYPHmyYxsQcli5dipKSEqkXAiQkJCA6OhrJyclo2bIlxo4da7i9XJacnBx89tlnSExMRNOmTTF69GgpF3Pc6e2330aLFi2kPUn+brGxsdi6dSvS0tLg4+ODMWPGSH2SZmZmJj799FNcuXIFHTt2xMSJE016y/q7776LCxcu3LN81apVVXI2bdqE3bt3w9raGiNGjFD84IWajJnIHFepF4NYRERERERERET0YLPoObGIiIiIiIiIiIgADmIREREREREREVE9wEEsIiIiIiIiIiKyeBzEIiIiIiIiIiIii8dBLCIiIiIiIiIisngcxCIiIiIiIiIiIovHQSwiIiIiIiIiIrJ4HMQiIiIiIiIiIiKLx0EsIiIiIiIiIiKyeBzEIiIiIiIiIiIii8dBLCIiIiIiIiIisngcxCIiIiIiIiIiIov3/wE/KADd0oxw8QAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%matplotlib inline\n", + "from aima.notebook_utils import grid_search_steps, plot_grid_search\n", + "import matplotlib.pyplot as plt\n", + "\n", + "walls = [(4, y) for y in range(8)] # vertical wall x=4, with a gap at y=8,9\n", + "grid = GridProblem((0, 0), (8, 2), 12, 10, obstacles=walls)\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", + "for ax, strategy in zip(axes, ['bfs', 'greedy', 'astar']):\n", + " explored, path = grid_search_steps(grid, strategy)\n", + " plot_grid_search(grid, explored, path, ax=ax,\n", + " title='{}: {} cells expanded'.format(strategy.upper(), len(explored)))\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "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." + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -2823,7 +2891,7 @@ }, { "cell_type": "code", - "execution_count": 59, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -2854,8 +2922,8 @@ " break\n", " neighbor = argmax_random_tie(neighbors,\n", " key=lambda node: problem.value(node.state))\n", - " if problem.value(neighbor.state) <= problem.value(current.state):\n", - " \"\"\"Note that it is based on negative path cost method\"\"\"\n", + " # value() returns negative path cost, so move to the neighbor when it is better\n", + " if problem.value(neighbor.state) >= problem.value(current.state):\n", " current.state = neighbor.state\n", " iterations -= 1\n", " \n", @@ -3457,6 +3525,16 @@ "This could also be solved by Hill Climbing as follows." ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# re-import the library's generic hill_climbing (the TSP cell above shadowed it with a two_opt-based version)\n", + "from aima.search import hill_climbing" + ] + }, { "cell_type": "code", "execution_count": 15, @@ -5305,13 +5383,14 @@ }, { "cell_type": "code", - "execution_count": 83, + "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ - "dfts = depth_first_tree_search(nqp).solution()" + "_, _, goal_node = depth_first_tree_search(nqp) # Unpack the tuple\n", + "dfts = goal_node.solution()" ] }, { @@ -5361,13 +5440,14 @@ }, { "cell_type": "code", - "execution_count": 86, + "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ - "bfts = breadth_first_tree_search(nqp).solution()" + "_, _, goal_node = breadth_first_tree_search(nqp) # Unpack the tuple\n", + "bfts = goal_node.solution()" ] }, { @@ -6529,7 +6609,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.6" + "version": "3.12.13" }, "pycharm": { "stem_cell": { diff --git a/notebooks/search.py b/notebooks/search.py new file mode 100644 index 000000000..29251696d --- /dev/null +++ b/notebooks/search.py @@ -0,0 +1,2232 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Solving problems by Searching +# +# This notebook serves as supporting material for topics covered in **Chapter 3 - Solving Problems by Searching** and **Chapter 4 - Beyond Classical Search** from the book *Artificial Intelligence: A Modern Approach.* This notebook uses implementations from [search.py](https://github.com/aimacode/aima-python/blob/master/search.py) module. Let's start by importing everything from search module. + +# %% +from aima.search import * +from aima.notebook_utils import psource, heatmap, gaussian_kernel, show_map, final_path_colors, display_visual, plot_NQueens + +# Needed to hide warnings in the matplotlib sections +import warnings +warnings.filterwarnings("ignore") + +# %% [markdown] +# ## CONTENTS +# +# * Overview +# * Problem +# * Node +# * Simple Problem Solving Agent +# * Search Algorithms Visualization +# * Breadth-First Tree Search +# * Breadth-First Search +# * Best First Search +# * Uniform Cost Search +# * Greedy Best First Search +# * A\* Search +# * Hill Climbing +# * Simulated Annealing +# * Genetic Algorithm +# * AND-OR Graph Search +# * Online DFS Agent +# * LRTA* Agent + +# %% [markdown] +# ## OVERVIEW +# +# Here, we learn about a specific kind of problem solving - building goal-based agents that can plan ahead to solve problems. In particular, we examine navigation problem/route finding problem. We must begin by precisely defining **problems** and their **solutions**. We will look at several general-purpose search algorithms. +# +# Search algorithms can be classified into two types: +# +# * **Uninformed search algorithms**: Search algorithms which explore the search space without having any information about the problem other than its definition. +# * Examples: +# 1. Breadth First Search +# 2. Depth First Search +# 3. Depth Limited Search +# 4. Iterative Deepening Search +# +# +# * **Informed search algorithms**: These type of algorithms leverage any information (heuristics, path cost) on the problem to search through the search space to find the solution efficiently. +# * Examples: +# 1. Best First Search +# 2. Uniform Cost Search +# 3. A\* Search +# 4. Recursive Best First Search +# +# *Don't miss the visualisations of these algorithms solving the route-finding problem defined on Romania map at the end of this notebook.* + +# %% [markdown] +# For visualisations, we use networkx and matplotlib to show the map in the notebook and we use ipywidgets to interact with the map to see how the searching algorithm works. These are imported as required in `notebook.py`. + +# %% +# %matplotlib inline +import networkx as nx +import matplotlib.pyplot as plt +from matplotlib import lines + +from ipywidgets import interact +import ipywidgets as widgets +from IPython.display import display +import time + +# %% [markdown] +# ## PROBLEM +# +# Let's see how we define a Problem. Run the next cell to see how abstract class `Problem` is defined in the search module. + +# %% +psource(Problem) + +# %% [markdown] +# The `Problem` class has six methods. +# +# * `__init__(self, initial, goal)` : This is what is called a `constructor`. It is the first method called when you create an instance of the class as `Problem(initial, goal)`. The variable `initial` specifies the initial state $s_0$ of the search problem. It represents the beginning state. From here, our agent begins its task of exploration to find the goal state(s) which is given in the `goal` parameter. +# +# +# * `actions(self, state)` : This method returns all the possible actions agent can execute in the given state `state`. +# +# +# * `result(self, state, action)` : This returns the resulting state if action `action` is taken in the state `state`. This `Problem` class only deals with deterministic outcomes. So we know for sure what every action in a state would result to. +# +# +# * `goal_test(self, state)` : Return a boolean for a given state - `True` if it is a goal state, else `False`. +# +# +# * `path_cost(self, c, state1, action, state2)` : Return the cost of the path that arrives at `state2` as a result of taking `action` from `state1`, assuming total cost of `c` to get up to `state1`. +# +# +# * `value(self, state)` : This acts as a bit of extra information in problems where we try to optimise a value when we cannot do a goal test. + +# %% [markdown] +# ## NODE +# +# Let's see how we define a Node. Run the next cell to see how abstract class `Node` is defined in the search module. + +# %% +psource(Node) + +# %% [markdown] +# The `Node` class has nine methods. The first is the `__init__` method. +# +# * `__init__(self, state, parent, action, path_cost)` : This method creates a node. `parent` represents the node that this is a successor of and `action` is the action required to get from the parent node to this node. `path_cost` is the cost to reach current node from parent node. +# +# The next 4 methods are specific `Node`-related functions. +# +# * `expand(self, problem)` : This method lists all the neighbouring(reachable in one step) nodes of current node. +# +# * `child_node(self, problem, action)` : Given an `action`, this method returns the immediate neighbour that can be reached with that `action`. +# +# * `solution(self)` : This returns the sequence of actions required to reach this node from the root node. +# +# * `path(self)` : This returns a list of all the nodes that lies in the path from the root to this node. +# +# The remaining 4 methods override standards Python functionality for representing an object as a string, the less-than ($<$) operator, the equal-to ($=$) operator, and the `hash` function. +# +# * `__repr__(self)` : This returns the state of this node. +# +# * `__lt__(self, node)` : Given a `node`, this method returns `True` if the state of current node is less than the state of the `node`. Otherwise it returns `False`. +# +# * `__eq__(self, other)` : This method returns `True` if the state of current node is equal to the other node. Else it returns `False`. +# +# * `__hash__(self)` : This returns the hash of the state of current node. + +# %% [markdown] +# We will use the abstract class `Problem` to define our real **problem** named `GraphProblem`. You can see how we define `GraphProblem` by running the next cell. + +# %% +psource(GraphProblem) + +# %% [markdown] +# Have a look at our romania_map, which is an Undirected Graph containing a dict of nodes as keys and neighbours as values. + +# %% +romania_map = UndirectedGraph(dict( + Arad=dict(Zerind=75, Sibiu=140, Timisoara=118), + Bucharest=dict(Urziceni=85, Pitesti=101, Giurgiu=90, Fagaras=211), + Craiova=dict(Drobeta=120, Rimnicu=146, Pitesti=138), + Drobeta=dict(Mehadia=75), + Eforie=dict(Hirsova=86), + Fagaras=dict(Sibiu=99), + Hirsova=dict(Urziceni=98), + Iasi=dict(Vaslui=92, Neamt=87), + Lugoj=dict(Timisoara=111, Mehadia=70), + Oradea=dict(Zerind=71, Sibiu=151), + Pitesti=dict(Rimnicu=97), + Rimnicu=dict(Sibiu=80), + Urziceni=dict(Vaslui=142))) + +romania_map.locations = dict( + Arad=(91, 492), Bucharest=(400, 327), Craiova=(253, 288), + Drobeta=(165, 299), Eforie=(562, 293), Fagaras=(305, 449), + Giurgiu=(375, 270), Hirsova=(534, 350), Iasi=(473, 506), + Lugoj=(165, 379), Mehadia=(168, 339), Neamt=(406, 537), + Oradea=(131, 571), Pitesti=(320, 368), Rimnicu=(233, 410), + Sibiu=(207, 457), Timisoara=(94, 410), Urziceni=(456, 350), + Vaslui=(509, 444), Zerind=(108, 531)) + +# %% [markdown] +# It is pretty straightforward to understand this `romania_map`. The first node **Arad** has three neighbours named **Zerind**, **Sibiu**, **Timisoara**. Each of these nodes are 75, 140, 118 units apart from **Arad** respectively. And the same goes with other nodes. +# +# And `romania_map.locations` contains the positions of each of the nodes. We will use the straight line distance (which is different from the one provided in `romania_map`) between two cities in algorithms like A\*-search and Recursive Best First Search. +# +# **Define a problem:** +# Now it's time to define our problem. We will define it by passing `initial`, `goal`, `graph` to `GraphProblem`. So, our problem is to find the goal state starting from the given initial state on the provided graph. +# +# Say we want to start exploring from **Arad** and try to find **Bucharest** in our romania_map. So, this is how we do it. + +# %% +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) + +# %% [markdown] +# ### Romania Map Visualisation +# +# Let's have a visualisation of Romania map [Figure 3.2] from the book and see how different searching algorithms perform / how frontier expands in each search algorithm for a simple problem named `romania_problem`. + +# %% [markdown] +# Have a look at `romania_locations`. It is a dictionary defined in search module. We will use these location values to draw the romania graph using **networkx**. + +# %% +romania_locations = romania_map.locations +print(romania_locations) + +# %% [markdown] +# Let's get started by initializing an empty graph. We will add nodes, place the nodes in their location as shown in the book, add edges to the graph. + +# %% +# node colors, node positions and node label positions +node_colors = {node: 'white' for node in romania_map.locations.keys()} +node_positions = romania_map.locations +node_label_pos = { k:[v[0],v[1]-10] for k,v in romania_map.locations.items() } +edge_weights = {(k, k2) : v2 for k, v in romania_map.graph_dict.items() for k2, v2 in v.items()} + +romania_graph_data = { 'graph_dict' : romania_map.graph_dict, + 'node_colors': node_colors, + 'node_positions': node_positions, + 'node_label_positions': node_label_pos, + 'edge_weights': edge_weights + } + +# %% [markdown] +# We have completed building our graph based on romania_map and its locations. It's time to display it here in the notebook. This function `show_map(node_colors)` helps us do that. We will be calling this function later on to display the map at each and every interval step while searching, using variety of algorithms from the book. + +# %% [markdown] +# We can simply call the function with node_colors dictionary object to display it. + +# %% +show_map(romania_graph_data) + +# %% [markdown] +# Voila! You see, the romania map as shown in the Figure[3.2] in the book. Now, see how different searching algorithms perform with our problem statements. + +# %% [markdown] +# ## SIMPLE PROBLEM SOLVING AGENT PROGRAM +# +# Let us now define a Simple Problem Solving Agent Program. Run the next cell to see how the abstract class `SimpleProblemSolvingAgentProgram` is defined in the search module. + +# %% +psource(SimpleProblemSolvingAgentProgram) + + +# %% [markdown] +# The SimpleProblemSolvingAgentProgram class has six methods: +# +# * `__init__(self, intial_state=None)`: This is the `contructor` of the class and is the first method to be called when the class is instantiated. It takes in a keyword argument, `initial_state` which is initially `None`. The argument `initial_state` represents the state from which the agent starts. +# +# * `__call__(self, percept)`: This method updates the `state` of the agent based on its `percept` using the `update_state` method. It then formulates a `goal` with the help of `formulate_goal` method and a `problem` using the `formulate_problem` method and returns a sequence of actions to solve it (using the `search` method). +# +# * `update_state(self, percept)`: This method updates the `state` of the agent based on its `percept`. +# +# * `formulate_goal(self, state)`: Given a `state` of the agent, this method formulates the `goal` for it. +# +# * `formulate_problem(self, state, goal)`: It is used in problem formulation given a `state` and a `goal` for the `agent`. +# +# * `search(self, problem)`: This method is used to search a sequence of `actions` to solve a `problem`. + +# %% [markdown] +# Let us now define a Simple Problem Solving Agent Program. We will create a simple `vacuumAgent` class which will inherit from the abstract class `SimpleProblemSolvingAgentProgram` and overrides its methods. We will create a simple intelligent vacuum agent which can be in any one of the following states. It will move to any other state depending upon the current state as shown in the picture by arrows: +# +# ![simple problem solving agent](images/simple_problem_solving_agent.jpg) + +# %% +class vacuumAgent(SimpleProblemSolvingAgentProgram): + def update_state(self, state, percept): + return percept + + def formulate_goal(self, state): + goal = [state7, state8] + return goal + + def formulate_problem(self, state, goal): + problem = state + return problem + + def search(self, problem): + if problem == state1: + seq = ["Suck", "Right", "Suck"] + elif problem == state2: + seq = ["Suck", "Left", "Suck"] + elif problem == state3: + seq = ["Right", "Suck"] + elif problem == state4: + seq = ["Suck"] + elif problem == state5: + seq = ["Suck"] + elif problem == state6: + seq = ["Left", "Suck"] + return seq + + +# %% [markdown] +# Now, we will define all the 8 states and create an object of the above class. Then, we will pass it different states and check the output: + +# %% +state1 = [(0, 0), [(0, 0), "Dirty"], [(1, 0), ["Dirty"]]] +state2 = [(1, 0), [(0, 0), "Dirty"], [(1, 0), ["Dirty"]]] +state3 = [(0, 0), [(0, 0), "Clean"], [(1, 0), ["Dirty"]]] +state4 = [(1, 0), [(0, 0), "Clean"], [(1, 0), ["Dirty"]]] +state5 = [(0, 0), [(0, 0), "Dirty"], [(1, 0), ["Clean"]]] +state6 = [(1, 0), [(0, 0), "Dirty"], [(1, 0), ["Clean"]]] +state7 = [(0, 0), [(0, 0), "Clean"], [(1, 0), ["Clean"]]] +state8 = [(1, 0), [(0, 0), "Clean"], [(1, 0), ["Clean"]]] + +a = vacuumAgent(state1) + +print(a(state6)) +print(a(state1)) +print(a(state3)) + + +# %% [markdown] +# ## SEARCHING ALGORITHMS VISUALIZATION +# +# In this section, we have visualizations of the following searching algorithms: +# +# 1. Breadth First Tree Search +# 2. Depth First Tree Search +# 3. Breadth First Search +# 4. Depth First Graph Search +# 5. Best First Graph Search +# 6. Uniform Cost Search +# 7. Depth Limited Search +# 8. Iterative Deepening Search +# 9. Greedy Best First Search +# 9. A\*-Search +# 10. Recursive Best First Search +# +# We add the colors to the nodes to have a nice visualisation when displaying. So, these are the different colors we are using in these visuals: +# * Un-explored nodes - white +# * Frontier nodes - orange +# * Currently exploring node - red +# * Already explored nodes - gray + +# %% [markdown] +# ## 1. BREADTH-FIRST TREE SEARCH +# +# We have a working implementation in search module. But as we want to interact with the graph while it is searching, we need to modify the implementation. Here's the modified breadth first tree search. + +# %% +def tree_breadth_search_for_vis(problem): + """Search through the successors of a problem to find a goal. + The argument frontier should be an empty queue. + Don't worry about repeated paths to a state. [Figure 3.7]""" + + # we use these two variables at the time of visualisations + iterations = 0 + all_node_colors = [] + if hasattr(problem, 'graph'): + node_colors = {k : 'white' for k in problem.graph.nodes()} + else: + node_colors = {} + + + #Adding first node to the queue + frontier = deque([Node(problem.initial)]) + + node_colors[Node(problem.initial).state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + while frontier: + #Popping first node of queue + node = frontier.popleft() + + # modify the currently searching node to red + node_colors[node.state] = "red" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + if problem.goal_test(node.state): + # modify goal node to green after reaching the goal + node_colors[node.state] = "green" + iterations += 1 + all_node_colors.append(dict(node_colors)) + return(iterations, all_node_colors, node) + + frontier.extend(node.expand(problem)) + + for n in node.expand(problem): + node_colors[n.state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + # modify the color of explored nodes to gray + node_colors[node.state] = "gray" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + return None + +def breadth_first_tree_search(problem): + "Search the shallowest nodes in the search tree first." + iterations, all_node_colors, node = tree_breadth_search_for_vis(problem) + return(iterations, all_node_colors, node) + + +# %% [markdown] +# Now, we use `ipywidgets` to display a slider, a button and our romania map. By sliding the slider we can have a look at all the intermediate steps of a particular search algorithm. By pressing the button **Visualize**, you can see all the steps without interacting with the slider. These two helper functions are the callback functions which are called when we interact with the slider and the button. + +# %% +all_node_colors = [] +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) +a, b, c = breadth_first_tree_search(romania_problem) +display_visual(romania_graph_data, user_input=False, + algorithm=breadth_first_tree_search, + problem=romania_problem) + + +# %% [markdown] +# ## 2. DEPTH-FIRST TREE SEARCH +# Now let's discuss another searching algorithm, Depth-First Tree Search. + +# %% +def tree_depth_search_for_vis(problem): + """Search through the successors of a problem to find a goal. + The argument frontier should be an empty queue. + Don't worry about repeated paths to a state. [Figure 3.7]""" + + # we use these two variables at the time of visualisations + iterations = 0 + all_node_colors = [] + if hasattr(problem, 'graph'): + node_colors = {k : 'white' for k in problem.graph.nodes()} + else: + node_colors = {} + + #Adding first node to the stack + frontier = [Node(problem.initial)] + + node_colors[Node(problem.initial).state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + while frontier: + #Popping first node of stack + node = frontier.pop() + + # modify the currently searching node to red + node_colors[node.state] = "red" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + if problem.goal_test(node.state): + # modify goal node to green after reaching the goal + node_colors[node.state] = "green" + iterations += 1 + all_node_colors.append(dict(node_colors)) + return(iterations, all_node_colors, node) + + frontier.extend(node.expand(problem)) + + for n in node.expand(problem): + node_colors[n.state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + # modify the color of explored nodes to gray + node_colors[node.state] = "gray" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + return None + +def depth_first_tree_search(problem): + "Search the deepest nodes in the search tree first." + iterations, all_node_colors, node = tree_depth_search_for_vis(problem) + return(iterations, all_node_colors, node) + + +# %% +all_node_colors = [] +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) +display_visual(romania_graph_data, user_input=False, + algorithm=depth_first_tree_search, + problem=romania_problem) + + +# %% [markdown] +# ## 3. BREADTH-FIRST GRAPH SEARCH +# +# Let's change all the `node_colors` to starting position and define a different problem statement. + +# %% +def breadth_first_search_graph(problem): + "[Figure 3.11]" + + # we use these two variables at the time of visualisations + iterations = 0 + all_node_colors = [] + node_colors = {k : 'white' for k in problem.graph.nodes()} + + node = Node(problem.initial) + + node_colors[node.state] = "red" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + if problem.goal_test(node.state): + node_colors[node.state] = "green" + iterations += 1 + all_node_colors.append(dict(node_colors)) + return(iterations, all_node_colors, node) + + frontier = deque([node]) + + # modify the color of frontier nodes to blue + node_colors[node.state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + explored = set() + while frontier: + node = frontier.popleft() + node_colors[node.state] = "red" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + explored.add(node.state) + + for child in node.expand(problem): + if child.state not in explored and child not in frontier: + if problem.goal_test(child.state): + node_colors[child.state] = "green" + iterations += 1 + all_node_colors.append(dict(node_colors)) + return(iterations, all_node_colors, child) + frontier.append(child) + + node_colors[child.state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + node_colors[node.state] = "gray" + iterations += 1 + all_node_colors.append(dict(node_colors)) + return None + + +# %% +all_node_colors = [] +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) +display_visual(romania_graph_data, user_input=False, + algorithm=breadth_first_search_graph, + problem=romania_problem) + + +# %% [markdown] +# ## 4. DEPTH-FIRST GRAPH SEARCH +# Although we have a working implementation in search module, we have to make a few changes in the algorithm to make it suitable for visualization. + +# %% +def graph_search_for_vis(problem): + """Search through the successors of a problem to find a goal. + The argument frontier should be an empty queue. + If two paths reach a state, only use the first one. [Figure 3.7]""" + # we use these two variables at the time of visualisations + iterations = 0 + all_node_colors = [] + node_colors = {k : 'white' for k in problem.graph.nodes()} + + frontier = [(Node(problem.initial))] + explored = set() + + # modify the color of frontier nodes to orange + node_colors[Node(problem.initial).state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + while frontier: + # Popping first node of stack + node = frontier.pop() + + # modify the currently searching node to red + node_colors[node.state] = "red" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + if problem.goal_test(node.state): + # modify goal node to green after reaching the goal + node_colors[node.state] = "green" + iterations += 1 + all_node_colors.append(dict(node_colors)) + return(iterations, all_node_colors, node) + + explored.add(node.state) + frontier.extend(child for child in node.expand(problem) + if child.state not in explored and + child not in frontier) + + for n in frontier: + # modify the color of frontier nodes to orange + node_colors[n.state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + # modify the color of explored nodes to gray + node_colors[node.state] = "gray" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + return None + + +def depth_first_graph_search(problem): + """Search the deepest nodes in the search tree first.""" + iterations, all_node_colors, node = graph_search_for_vis(problem) + return(iterations, all_node_colors, node) + + +# %% +all_node_colors = [] +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) +display_visual(romania_graph_data, user_input=False, + algorithm=depth_first_graph_search, + problem=romania_problem) + + +# %% [markdown] +# ## 5. BEST FIRST SEARCH +# +# Let's change all the `node_colors` to starting position and define a different problem statement. + +# %% +def best_first_graph_search_for_vis(problem, f): + """Search the nodes with the lowest f scores first. + You specify the function f(node) that you want to minimize; for example, + if f is a heuristic estimate to the goal, then we have greedy best + first search; if f is node.depth then we have breadth-first search. + There is a subtlety: the line "f = memoize(f, 'f')" means that the f + values will be cached on the nodes as they are computed. So after doing + a best first search you can examine the f values of the path returned.""" + + # we use these two variables at the time of visualisations + iterations = 0 + all_node_colors = [] + node_colors = {k : 'white' for k in problem.graph.nodes()} + + f = memoize(f, 'f') + node = Node(problem.initial) + + node_colors[node.state] = "red" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + if problem.goal_test(node.state): + node_colors[node.state] = "green" + iterations += 1 + all_node_colors.append(dict(node_colors)) + return(iterations, all_node_colors, node) + + frontier = PriorityQueue('min', f) + frontier.append(node) + + node_colors[node.state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + explored = set() + while frontier: + node = frontier.pop() + + node_colors[node.state] = "red" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + if problem.goal_test(node.state): + node_colors[node.state] = "green" + iterations += 1 + all_node_colors.append(dict(node_colors)) + return(iterations, all_node_colors, node) + + explored.add(node.state) + for child in node.expand(problem): + if child.state not in explored and child not in frontier: + frontier.append(child) + node_colors[child.state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + elif child in frontier: + incumbent = frontier[child] + if f(child) < incumbent: + del frontier[child] + frontier.append(child) + node_colors[child.state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + node_colors[node.state] = "gray" + iterations += 1 + all_node_colors.append(dict(node_colors)) + return None + + +# %% [markdown] +# ## 6. UNIFORM COST SEARCH +# +# Let's change all the `node_colors` to starting position and define a different problem statement. + +# %% +def uniform_cost_search_graph(problem): + "[Figure 3.14]" + #Uniform Cost Search uses Best First Search algorithm with f(n) = g(n) + iterations, all_node_colors, node = best_first_graph_search_for_vis(problem, lambda node: node.path_cost) + return(iterations, all_node_colors, node) + + + +# %% +all_node_colors = [] +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) +display_visual(romania_graph_data, user_input=False, + algorithm=uniform_cost_search_graph, + problem=romania_problem) + + +# %% [markdown] +# ## 7. DEPTH LIMITED SEARCH +# +# Let's change all the 'node_colors' to starting position and define a different problem statement. +# Although we have a working implementation, but we need to make changes. + +# %% +def depth_limited_search_graph(problem, limit = -1): + ''' + Perform depth first search of graph g. + if limit >= 0, that is the maximum depth of the search. + ''' + # we use these two variables at the time of visualisations + iterations = 0 + all_node_colors = [] + node_colors = {k : 'white' for k in problem.graph.nodes()} + + frontier = [Node(problem.initial)] + explored = set() + + cutoff_occurred = False + node_colors[Node(problem.initial).state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + while frontier: + # Popping first node of queue + node = frontier.pop() + + # modify the currently searching node to red + node_colors[node.state] = "red" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + if problem.goal_test(node.state): + # modify goal node to green after reaching the goal + node_colors[node.state] = "green" + iterations += 1 + all_node_colors.append(dict(node_colors)) + return(iterations, all_node_colors, node) + + elif limit >= 0: + cutoff_occurred = True + limit += 1 + all_node_colors.pop() + iterations -= 1 + node_colors[node.state] = "gray" + + + explored.add(node.state) + frontier.extend(child for child in node.expand(problem) + if child.state not in explored and + child not in frontier) + + for n in frontier: + limit -= 1 + # modify the color of frontier nodes to orange + node_colors[n.state] = "orange" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + # modify the color of explored nodes to gray + node_colors[node.state] = "gray" + iterations += 1 + all_node_colors.append(dict(node_colors)) + + return 'cutoff' if cutoff_occurred else None + + +def depth_limited_search_for_vis(problem): + """Search the deepest nodes in the search tree first.""" + iterations, all_node_colors, node = depth_limited_search_graph(problem) + return(iterations, all_node_colors, node) + + +# %% +all_node_colors = [] +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) +display_visual(romania_graph_data, user_input=False, + algorithm=depth_limited_search_for_vis, + problem=romania_problem) + + +# %% [markdown] +# ## 8. ITERATIVE DEEPENING SEARCH +# +# Let's change all the 'node_colors' to starting position and define a different problem statement. + +# %% +def iterative_deepening_search_for_vis(problem): + for depth in range(sys.maxsize): + iterations, all_node_colors, node=depth_limited_search_for_vis(problem) + if iterations: + return (iterations, all_node_colors, node) + + +# %% +all_node_colors = [] +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) +display_visual(romania_graph_data, user_input=False, + algorithm=iterative_deepening_search_for_vis, + problem=romania_problem) + + +# %% [markdown] +# ## 9. GREEDY BEST FIRST SEARCH +# Let's change all the node_colors to starting position and define a different problem statement. + +# %% +def greedy_best_first_search(problem, h=None): + """Greedy Best-first graph search is an informative searching algorithm with f(n) = h(n). + You need to specify the h function when you call best_first_search, or + else in your Problem subclass.""" + h = memoize(h or problem.h, 'h') + iterations, all_node_colors, node = best_first_graph_search_for_vis(problem, lambda n: h(n)) + return(iterations, all_node_colors, node) + + + +# %% +all_node_colors = [] +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) +display_visual(romania_graph_data, user_input=False, + algorithm=greedy_best_first_search, + problem=romania_problem) + + +# %% [markdown] +# ## 10. A\* SEARCH +# +# Let's change all the `node_colors` to starting position and define a different problem statement. + +# %% +def astar_search_graph(problem, h=None): + """A* search is best-first graph search with f(n) = g(n)+h(n). + You need to specify the h function when you call astar_search, or + else in your Problem subclass.""" + h = memoize(h or problem.h, 'h') + iterations, all_node_colors, node = best_first_graph_search_for_vis(problem, + lambda n: n.path_cost + h(n)) + return(iterations, all_node_colors, node) + + + +# %% +all_node_colors = [] +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) +display_visual(romania_graph_data, user_input=False, + algorithm=astar_search_graph, + problem=romania_problem) + + +# %% [markdown] +# ## 11. RECURSIVE BEST FIRST SEARCH +# Let's change all the `node_colors` to starting position and define a different problem statement. + +# %% +def recursive_best_first_search_for_vis(problem, h=None): + """[Figure 3.26] Recursive best-first search""" + # we use these two variables at the time of visualizations + iterations = 0 + all_node_colors = [] + node_colors = {k : 'white' for k in problem.graph.nodes()} + + h = memoize(h or problem.h, 'h') + + def RBFS(problem, node, flimit): + nonlocal iterations + def color_city_and_update_map(node, color): + node_colors[node.state] = color + nonlocal iterations + iterations += 1 + all_node_colors.append(dict(node_colors)) + + if problem.goal_test(node.state): + color_city_and_update_map(node, 'green') + return (iterations, all_node_colors, node), 0 # the second value is immaterial + + successors = node.expand(problem) + if len(successors) == 0: + color_city_and_update_map(node, 'gray') + return (iterations, all_node_colors, None), infinity + + for s in successors: + color_city_and_update_map(s, 'orange') + s.f = max(s.path_cost + h(s), node.f) + + while True: + # Order by lowest f value + successors.sort(key=lambda x: x.f) + best = successors[0] + if best.f > flimit: + color_city_and_update_map(node, 'gray') + return (iterations, all_node_colors, None), best.f + + if len(successors) > 1: + alternative = successors[1].f + else: + alternative = infinity + + node_colors[node.state] = 'gray' + node_colors[best.state] = 'red' + iterations += 1 + all_node_colors.append(dict(node_colors)) + result, best.f = RBFS(problem, best, min(flimit, alternative)) + if result[2] is not None: + color_city_and_update_map(node, 'green') + return result, best.f + else: + color_city_and_update_map(node, 'red') + + node = Node(problem.initial) + node.f = h(node) + + node_colors[node.state] = 'red' + iterations += 1 + all_node_colors.append(dict(node_colors)) + result, bestf = RBFS(problem, node, infinity) + return result + + +# %% +all_node_colors = [] +romania_problem = GraphProblem('Arad', 'Bucharest', romania_map) +display_visual(romania_graph_data, user_input=False, + algorithm=recursive_best_first_search_for_vis, + problem=romania_problem) + +# %% +all_node_colors = [] +# display_visual(romania_graph_data, user_input=True, algorithm=breadth_first_tree_search) +algorithms = { "Breadth First Tree Search": tree_breadth_search_for_vis, + "Depth First Tree Search": tree_depth_search_for_vis, + "Breadth First Search": breadth_first_search_graph, + "Depth First Graph Search": graph_search_for_vis, + "Best First Graph Search": best_first_graph_search_for_vis, + "Uniform Cost Search": uniform_cost_search_graph, + "Depth Limited Search": depth_limited_search_for_vis, + "Iterative Deepening Search": iterative_deepening_search_for_vis, + "Greedy Best First Search": greedy_best_first_search, + "A-star Search": astar_search_graph, + "Recursive Best First Search": recursive_best_first_search_for_vis} +display_visual(romania_graph_data, algorithm=algorithms, user_input=True) + +# %% [markdown] +# ## RECURSIVE BEST-FIRST SEARCH +# Recursive best-first search is a simple recursive algorithm that improves upon heuristic search by reducing the memory requirement. +# RBFS uses only linear space and it attempts to mimic the operation of standard best-first search. +# Its structure is similar to recursive depth-first search but it doesn't continue indefinitely down the current path, the `f_limit` variable is used to keep track of the f-value of the best _alternative_ path available from any ancestor of the current node. +# RBFS remembers the f-value of the best leaf in the forgotten subtree and can decide whether it is worth re-expanding the tree later. +#
+# However, RBFS still suffers from excessive node regeneration. +#
+# Let's have a look at the implementation. + +# %% +psource(recursive_best_first_search) + +# %% [markdown] +# This is how `recursive_best_first_search` can solve the `romania_problem` + +# %% +recursive_best_first_search(romania_problem).solution() + +# %% [markdown] +# `recursive_best_first_search` can be used to solve the 8 puzzle problem too, as discussed later. + +# %% +puzzle = EightPuzzle((2, 4, 3, 1, 5, 6, 7, 8, 0)) +assert puzzle.check_solvability((2, 4, 3, 1, 5, 6, 7, 8, 0)) +recursive_best_first_search(puzzle).solution() + +# %% [markdown] +# ## GRID SEARCH VISUALIZATION +# +# 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. +# +# `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). +# +# 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\***: + +# %% +# %matplotlib inline +from aima.notebook_utils import grid_search_steps, plot_grid_search +import matplotlib.pyplot as plt + +walls = [(4, y) for y in range(8)] # vertical wall x=4, with a gap at y=8,9 +grid = GridProblem((0, 0), (8, 2), 12, 10, obstacles=walls) + +fig, axes = plt.subplots(1, 3, figsize=(15, 5)) +for ax, strategy in zip(axes, ['bfs', 'greedy', 'astar']): + explored, path = grid_search_steps(grid, strategy) + plot_grid_search(grid, explored, path, ax=ax, + title='{}: {} cells expanded'.format(strategy.upper(), len(explored))) +plt.show() + +# %% [markdown] +# 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. + +# %% [markdown] +# ## A* HEURISTICS +# +# Different heuristics provide different efficiency in solving A* problems which are generally defined by the number of explored nodes as well as the branching factor. With the classic 8 puzzle we can show the efficiency of different heuristics through the number of explored nodes. +# +# ### 8 Puzzle Problem +# +# The *8 Puzzle Problem* consists of a 3x3 tray in which the goal is to get the initial configuration to the goal state by shifting the numbered tiles into the blank space. +# +# example:- +# +# Initial State Goal State +# | 7 | 2 | 4 | | 1 | 2 | 3 | +# | 5 | 0 | 6 | | 4 | 5 | 6 | +# | 8 | 3 | 1 | | 7 | 8 | 0 | +# +# We have a total of 9 blank tiles giving us a total of 9! initial configuration but not all of these are solvable. The solvability of a configuration can be checked by calculating the Inversion Permutation. If the total Inversion Permutation is even then the initial configuration is solvable else the initial configuration is not solvable which means that only 9!/2 initial states lead to a solution. +#
+# Let's define our goal state. + +# %% +goal = [1, 2, 3, 4, 5, 6, 7, 8, 0] + +# %% [markdown] +# #### Heuristics :- +# +# 1) Manhattan Distance:- For the 8 puzzle problem Manhattan distance is defined as the distance of a tile from its goal state( for the tile numbered '1' in the initial configuration Manhattan distance is 4 "2 for left and 2 for upward displacement"). +# +# 2) No. of Misplaced Tiles:- The heuristic calculates the number of misplaced tiles between the current state and goal state. +# +# 3) Sqrt of Manhattan Distance:- It calculates the square root of Manhattan distance. +# +# 4) Max Heuristic:- It assign the score as the maximum between "Manhattan Distance" and "No. of Misplaced Tiles". + +# %% +# Heuristics for 8 Puzzle Problem +import math + +def linear(node): + return sum([1 if node.state[i] != goal[i] else 0 for i in range(8)]) + +def manhattan(node): + state = node.state + index_goal = {0:[2,2], 1:[0,0], 2:[0,1], 3:[0,2], 4:[1,0], 5:[1,1], 6:[1,2], 7:[2,0], 8:[2,1]} + index_state = {} + index = [[0,0], [0,1], [0,2], [1,0], [1,1], [1,2], [2,0], [2,1], [2,2]] + x, y = 0, 0 + + for i in range(len(state)): + index_state[state[i]] = index[i] + + mhd = 0 + + for i in range(8): + for j in range(2): + mhd = abs(index_goal[i][j] - index_state[i][j]) + mhd + + return mhd + +def sqrt_manhattan(node): + state = node.state + index_goal = {0:[2,2], 1:[0,0], 2:[0,1], 3:[0,2], 4:[1,0], 5:[1,1], 6:[1,2], 7:[2,0], 8:[2,1]} + index_state = {} + index = [[0,0], [0,1], [0,2], [1,0], [1,1], [1,2], [2,0], [2,1], [2,2]] + x, y = 0, 0 + + for i in range(len(state)): + index_state[state[i]] = index[i] + + mhd = 0 + + for i in range(8): + for j in range(2): + mhd = (index_goal[i][j] - index_state[i][j])**2 + mhd + + return math.sqrt(mhd) + +def max_heuristic(node): + score1 = manhattan(node) + score2 = linear(node) + return max(score1, score2) + + +# %% [markdown] +# We can solve the puzzle using the `astar_search` method. + +# %% +# Solving the puzzle +puzzle = EightPuzzle((2, 4, 3, 1, 5, 6, 7, 8, 0)) +puzzle.check_solvability((2, 4, 3, 1, 5, 6, 7, 8, 0)) # checks whether the initialized configuration is solvable or not + +# %% [markdown] +# This case is solvable, let's proceed. +#
+# The default heuristic function returns the number of misplaced tiles. + +# %% +astar_search(puzzle).solution() + +# %% [markdown] +# In the following cells, we use different heuristic functions. +#
+ +# %% +astar_search(puzzle, linear).solution() + +# %% +astar_search(puzzle, manhattan).solution() + +# %% +astar_search(puzzle, sqrt_manhattan).solution() + +# %% +astar_search(puzzle, max_heuristic).solution() + +# %% [markdown] +# And here's how `recursive_best_first_search` can be used to solve this problem too. + +# %% +recursive_best_first_search(puzzle, manhattan).solution() + +# %% [markdown] +# Even though all the heuristic functions give the same solution, the difference lies in the computation time. +#
+# This might make all the difference in a scenario where high computational efficiency is required. +#
+# Let's define a few puzzle states and time `astar_search` for every heuristic function. +# We will use the %%timeit magic for this. + +# %% +puzzle_1 = EightPuzzle((2, 4, 3, 1, 5, 6, 7, 8, 0)) +puzzle_2 = EightPuzzle((1, 2, 3, 4, 5, 6, 0, 7, 8)) +puzzle_3 = EightPuzzle((1, 2, 3, 4, 5, 7, 8, 6, 0)) + +# %% [markdown] +# The default heuristic function is the same as the `linear` heuristic function, but we'll still check both. + +# %% +# %%timeit +astar_search(puzzle_1) +astar_search(puzzle_2) +astar_search(puzzle_3) + +# %% +# %%timeit +astar_search(puzzle_1, linear) +astar_search(puzzle_2, linear) +astar_search(puzzle_3, linear) + +# %% +# %%timeit +astar_search(puzzle_1, manhattan) +astar_search(puzzle_2, manhattan) +astar_search(puzzle_3, manhattan) + +# %% +# %%timeit +astar_search(puzzle_1, sqrt_manhattan) +astar_search(puzzle_2, sqrt_manhattan) +astar_search(puzzle_3, sqrt_manhattan) + +# %% +# %%timeit +astar_search(puzzle_1, max_heuristic) +astar_search(puzzle_2, max_heuristic) +astar_search(puzzle_3, max_heuristic) + +# %% [markdown] +# We can infer that the `manhattan` heuristic function works the fastest. +#
+# `sqrt_manhattan` has an extra `sqrt` operation which makes it quite a lot slower than the others. +#
+# `max_heuristic` should have been a bit slower as it calls two functions, but in this case, those values were already calculated which saved some time. +# Feel free to play around with these functions. + +# %% [markdown] +# For comparison, this is how RBFS performs on this problem. + +# %% +# %%timeit +recursive_best_first_search(puzzle_1, linear) +recursive_best_first_search(puzzle_2, linear) +recursive_best_first_search(puzzle_3, linear) + +# %% [markdown] +# It is quite a lot slower than `astar_search` as we can see. + +# %% [markdown] +# ## HILL CLIMBING +# +# Hill Climbing is a heuristic search used for optimization problems. +# Given a large set of inputs and a good heuristic function, it tries to find a sufficiently good solution to the problem. +# This solution may or may not be the global optimum. +# The algorithm is a variant of generate and test algorithm. +#
+# As a whole, the algorithm works as follows: +# - Evaluate the initial state. +# - If it is equal to the goal state, return. +# - Find a neighboring state (one which is heuristically similar to the current state) +# - Evaluate this state. If it is closer to the goal state than before, replace the initial state with this state and repeat these steps. +#
+ +# %% +psource(hill_climbing) + + +# %% [markdown] +# We will find an approximate solution to the traveling salespersons problem using this algorithm. +#
+# We need to define a class for this problem. +#
+# `Problem` will be used as a base class. + +# %% +class TSP_problem(Problem): + + """ subclass of Problem to define various functions """ + + def two_opt(self, state): + """ Neighbour generating function for Traveling Salesman Problem """ + neighbour_state = state[:] + left = random.randint(0, len(neighbour_state) - 1) + right = random.randint(0, len(neighbour_state) - 1) + if left > right: + left, right = right, left + neighbour_state[left: right + 1] = reversed(neighbour_state[left: right + 1]) + return neighbour_state + + def actions(self, state): + """ action that can be excuted in given state """ + return [self.two_opt] + + def result(self, state, action): + """ result after applying the given action on the given state """ + return action(state) + + def path_cost(self, c, state1, action, state2): + """ total distance for the Traveling Salesman to be covered if in state2 """ + cost = 0 + for i in range(len(state2) - 1): + cost += distances[state2[i]][state2[i + 1]] + cost += distances[state2[0]][state2[-1]] + return cost + + def value(self, state): + """ value of path cost given negative for the given state """ + return -1 * self.path_cost(None, None, None, state) + + +# %% [markdown] +# We will use cities from the Romania map as our cities for this problem. +#
+# A list of all cities and a dictionary storing distances between them will be populated. + +# %% +distances = {} +all_cities = [] + +for city in romania_map.locations.keys(): + distances[city] = {} + all_cities.append(city) + +all_cities.sort() +print(all_cities) + +# %% [markdown] +# Next, we need to populate the individual lists inside the dictionary with the manhattan distance between the cities. + +# %% +import numpy as np +for name_1, coordinates_1 in romania_map.locations.items(): + for name_2, coordinates_2 in romania_map.locations.items(): + distances[name_1][name_2] = np.linalg.norm( + [coordinates_1[0] - coordinates_2[0], coordinates_1[1] - coordinates_2[1]]) + distances[name_2][name_1] = np.linalg.norm( + [coordinates_1[0] - coordinates_2[0], coordinates_1[1] - coordinates_2[1]]) + + +# %% [markdown] +# The way neighbours are chosen currently isn't suitable for the travelling salespersons problem. +# We need a neighboring state that is similar in total path distance to the current state. +#
+# We need to change the function that finds neighbors. + +# %% +def hill_climbing(problem): + + """From the initial node, keep choosing the neighbor with highest value, + stopping when no neighbor is better. [Figure 4.2]""" + + def find_neighbors(state, number_of_neighbors=100): + """ finds neighbors using two_opt method """ + + neighbors = [] + + for i in range(number_of_neighbors): + new_state = problem.two_opt(state) + neighbors.append(Node(new_state)) + state = new_state + + return neighbors + + # as this is a stochastic algorithm, we will set a cap on the number of iterations + iterations = 10000 + + current = Node(problem.initial) + while iterations: + neighbors = find_neighbors(current.state) + if not neighbors: + break + neighbor = argmax_random_tie(neighbors, + key=lambda node: problem.value(node.state)) + # value() returns negative path cost, so move to the neighbor when it is better + if problem.value(neighbor.state) >= problem.value(current.state): + current.state = neighbor.state + iterations -= 1 + + return current.state + + +# %% [markdown] +# An instance of the TSP_problem class will be created. + +# %% +tsp = TSP_problem(all_cities) + +# %% [markdown] +# We can now generate an approximate solution to the problem by calling `hill_climbing`. +# The results will vary a bit each time you run it. + +# %% +hill_climbing(tsp) + +# %% [markdown] +# The solution looks like this. +# It is not difficult to see why this might be a good solution. +#
+# ![title](images/hillclimb-tsp.png) + +# %% [markdown] +# ## SIMULATED ANNEALING +# +# The intuition behind Hill Climbing was developed from the metaphor of climbing up the graph of a function to find its peak. +# There is a fundamental problem in the implementation of the algorithm however. +# To find the highest hill, we take one step at a time, always uphill, hoping to find the highest point, +# but if we are unlucky to start from the shoulder of the second-highest hill, there is no way we can find the highest one. +# The algorithm will always converge to the local optimum. +# Hill Climbing is also bad at dealing with functions that flatline in certain regions. +# If all neighboring states have the same value, we cannot find the global optimum using this algorithm. +#
+#
+# Let's now look at an algorithm that can deal with these situations. +#
+# Simulated Annealing is quite similar to Hill Climbing, +# but instead of picking the _best_ move every iteration, it picks a _random_ move. +# If this random move brings us closer to the global optimum, it will be accepted, +# but if it doesn't, the algorithm may accept or reject the move based on a probability dictated by the _temperature_. +# When the `temperature` is high, the algorithm is more likely to accept a random move even if it is bad. +# At low temperatures, only good moves are accepted, with the occasional exception. +# This allows exploration of the state space and prevents the algorithm from getting stuck at the local optimum. +# + +# %% +psource(simulated_annealing) + +# %% [markdown] +# The temperature is gradually decreased over the course of the iteration. +# This is done by a scheduling routine. +# The current implementation uses exponential decay of temperature, but we can use a different scheduling routine instead. +# + +# %% +psource(exp_schedule) + +# %% [markdown] +# Next, we'll define a peak-finding problem and try to solve it using Simulated Annealing. +# Let's define the grid and the initial state first. +# + +# %% +initial = (0, 0) +grid = [[3, 7, 2, 8], [5, 2, 9, 1], [5, 3, 3, 1]] + +# %% [markdown] +# We want to allow only four directions, namely `N`, `S`, `E` and `W`. +# Let's use the predefined `directions4` dictionary. + +# %% +directions4 + +# %% [markdown] +# Define a problem with these parameters. + +# %% +problem = PeakFindingProblem(initial, grid, directions4) + +# %% [markdown] +# We'll run `simulated_annealing` a few times and store the solutions in a set. + +# %% +solutions = {problem.value(simulated_annealing(problem)) for i in range(100)} + +# %% +max(solutions) + +# %% [markdown] +# Hence, the maximum value is 9. + +# %% [markdown] +# Let's find the peak of a two-dimensional gaussian distribution. +# We'll use the `gaussian_kernel` function from notebook.py to get the distribution. + +# %% +grid = gaussian_kernel() + +# %% [markdown] +# Let's use the `heatmap` function from notebook.py to plot this. + +# %% +heatmap(grid, cmap='jet', interpolation='spline16') + +# %% [markdown] +# Let's define the problem. +# This time, we will allow movement in eight directions as defined in `directions8`. + +# %% +directions8 + +# %% [markdown] +# We'll solve the problem just like we did last time. +#
+# Let's also time it. + +# %% +problem = PeakFindingProblem(initial, grid, directions8) + +# %% +# %%timeit +solutions = {problem.value(simulated_annealing(problem)) for i in range(100)} + +# %% +max(solutions) + +# %% [markdown] +# The peak is at 1.0 which is how gaussian distributions are defined. +#
+# This could also be solved by Hill Climbing as follows. + +# %% +# re-import the library's generic hill_climbing (the TSP cell above shadowed it with a two_opt-based version) +from aima.search import hill_climbing + +# %% +# %%timeit +solution = problem.value(hill_climbing(problem)) + +# %% +solution = problem.value(hill_climbing(problem)) +solution + +# %% [markdown] +# As you can see, Hill-Climbing is about 24 times faster than Simulated Annealing. +# (Notice that we ran Simulated Annealing for 100 iterations whereas we ran Hill Climbing only once.) +#
+# Simulated Annealing makes up for its tardiness by its ability to be applicable in a larger number of scenarios than Hill Climbing as illustrated by the example below. +#
+ +# %% [markdown] +# Let's define a 2D surface as a matrix. + +# %% +grid = [[0, 0, 0, 1, 4], + [0, 0, 2, 8, 10], + [0, 0, 2, 4, 12], + [0, 2, 4, 8, 16], + [1, 4, 8, 16, 32]] + +# %% +heatmap(grid, cmap='jet', interpolation='spline16') + +# %% [markdown] +# The peak value is 32 at the lower right corner. +#
+# The region at the upper left corner is planar. + +# %% [markdown] +# Let's instantiate `PeakFindingProblem` one last time. + +# %% +problem = PeakFindingProblem(initial, grid, directions8) + +# %% [markdown] +# Solution by Hill Climbing + +# %% +solution = problem.value(hill_climbing(problem)) + +# %% +solution + +# %% [markdown] +# Solution by Simulated Annealing + +# %% +solutions = {problem.value(simulated_annealing(problem)) for i in range(100)} +max(solutions) + +# %% [markdown] +# Notice that even though both algorithms started at the same initial state, +# Hill Climbing could never escape from the planar region and gave a locally optimum solution of **0**, +# whereas Simulated Annealing could reach the peak at **32**. +#
+# A very similar situation arises when there are two peaks of different heights. +# One should carefully consider the possible search space before choosing the algorithm for the task. + +# %% [markdown] +# ## GENETIC ALGORITHM +# +# Genetic algorithms (or GA) are inspired by natural evolution and are particularly useful in optimization and search problems with large state spaces. +# +# Given a problem, algorithms in the domain make use of a *population* of solutions (also called *states*), where each solution/state represents a feasible solution. At each iteration (often called *generation*), the population gets updated using methods inspired by biology and evolution, like *crossover*, *mutation* and *natural selection*. + +# %% [markdown] +# ### Overview +# +# A genetic algorithm works in the following way: +# +# 1) Initialize random population. +# +# 2) Calculate population fitness. +# +# 3) Select individuals for mating. +# +# 4) Mate selected individuals to produce new population. +# +# * Random chance to mutate individuals. +# +# 5) Repeat from step 2) until an individual is fit enough or the maximum number of iterations is reached. + +# %% [markdown] +# ### Glossary +# +# Before we continue, we will lay the basic terminology of the algorithm. +# +# * Individual/State: A list of elements (called *genes*) that represent possible solutions. +# +# * Population: The list of all the individuals/states. +# +# * Gene pool: The alphabet of possible values for an individual's genes. +# +# * Generation/Iteration: The number of times the population will be updated. +# +# * Fitness: An individual's score, calculated by a function specific to the problem. + +# %% [markdown] +# ### Crossover +# +# Two individuals/states can "mate" and produce one child. This offspring bears characteristics from both of its parents. There are many ways we can implement this crossover. Here we will take a look at the most common ones. Most other methods are variations of those below. +# +# * Point Crossover: The crossover occurs around one (or more) point. The parents get "split" at the chosen point or points and then get merged. In the example below we see two parents get split and merged at the 3rd digit, producing the following offspring after the crossover. +# +# ![point crossover](images/point_crossover.png) +# +# * Uniform Crossover: This type of crossover chooses randomly the genes to get merged. Here the genes 1, 2 and 5 were chosen from the first parent, so the genes 3, 4 were added by the second parent. +# +# ![uniform crossover](images/uniform_crossover.png) + +# %% [markdown] +# ### Mutation +# +# When an offspring is produced, there is a chance it will mutate, having one (or more, depending on the implementation) of its genes altered. +# +# For example, let's say the new individual to undergo mutation is "abcde". Randomly we pick to change its third gene to 'z'. The individual now becomes "abzde" and is added to the population. + +# %% [markdown] +# ### Selection +# +# At each iteration, the fittest individuals are picked randomly to mate and produce offsprings. We measure an individual's fitness with a *fitness function*. That function depends on the given problem and it is used to score an individual. Usually the higher the better. +# +# The selection process is this: +# +# 1) Individuals are scored by the fitness function. +# +# 2) Individuals are picked randomly, according to their score (higher score means higher chance to get picked). Usually the formula to calculate the chance to pick an individual is the following (for population *P* and individual *i*): +# +# $$ chance(i) = \dfrac{fitness(i)}{\sum_{k \, in \, P}{fitness(k)}} $$ + +# %% [markdown] +# ### Implementation +# +# Below we look over the implementation of the algorithm in the `search` module. +# +# First the implementation of the main core of the algorithm: + +# %% +psource(genetic_algorithm) + +# %% [markdown] +# The algorithm takes the following input: +# +# * `population`: The initial population. +# +# * `fitness_fn`: The problem's fitness function. +# +# * `gene_pool`: The gene pool of the states/individuals. By default 0 and 1. +# +# * `f_thres`: The fitness threshold. If an individual reaches that score, iteration stops. By default 'None', which means the algorithm will not halt until the generations are ran. +# +# * `ngen`: The number of iterations/generations. +# +# * `pmut`: The probability of mutation. +# +# The algorithm gives as output the state with the largest score. + +# %% [markdown] +# For each generation, the algorithm updates the population. First it calculates the fitnesses of the individuals, then it selects the most fit ones and finally crosses them over to produce offsprings. There is a chance that the offspring will be mutated, given by `pmut`. If at the end of the generation an individual meets the fitness threshold, the algorithm halts and returns that individual. +# +# The function of mating is accomplished by the method `recombine`: + +# %% +psource(recombine) + +# %% [markdown] +# The method picks at random a point and merges the parents (`x` and `y`) around it. +# +# The mutation is done in the method `mutate`: + +# %% +psource(mutate) + +# %% [markdown] +# We pick a gene in `x` to mutate and a gene from the gene pool to replace it with. +# +# To help initializing the population we have the helper function `init_population`: + +# %% +psource(init_population) + +# %% [markdown] +# The function takes as input the number of individuals in the population, the gene pool and the length of each individual/state. It creates individuals with random genes and returns the population when done. + +# %% [markdown] +# ### Explanation +# +# Before we solve problems using the genetic algorithm, we will explain how to intuitively understand the algorithm using a trivial example. +# +# #### Generating Phrases +# +# In this problem, we use a genetic algorithm to generate a particular target phrase from a population of random strings. This is a classic example that helps build intuition about how to use this algorithm in other problems as well. Before we break the problem down, let us try to brute force the solution. Let us say that we want to generate the phrase "genetic algorithm". The phrase is 17 characters long. We can use any character from the 26 lowercase characters and the space character. To generate a random phrase of length 17, each space can be filled in 27 ways. So the total number of possible phrases is +# +# $$ 27^{17} = 2153693963075557766310747 $$ +# +# which is a massive number. If we wanted to generate the phrase "Genetic Algorithm", we would also have to include all the 26 uppercase characters into consideration thereby increasing the sample space from 27 characters to 53 characters and the total number of possible phrases then would be +# +# $$ 53^{17} = 205442259656281392806087233013 $$ +# +# If we wanted to include punctuations and numerals into the sample space, we would have further complicated an already impossible problem. Hence, brute forcing is not an option. Now we'll apply the genetic algorithm and see how it significantly reduces the search space. We essentially want to *evolve* our population of random strings so that they better approximate the target phrase as the number of generations increase. Genetic algorithms work on the principle of Darwinian Natural Selection according to which, there are three key concepts that need to be in place for evolution to happen. They are: +# +# * **Heredity**: There must be a process in place by which children receive the properties of their parents.
+# For this particular problem, two strings from the population will be chosen as parents and will be split at a random index and recombined as described in the `recombine` function to create a child. This child string will then be added to the new generation. +# +# +# * **Variation**: There must be a variety of traits present in the population or a means with which to introduce variation.
If there is no variation in the sample space, we might never reach the global optimum. To ensure that there is enough variation, we can initialize a large population, but this gets computationally expensive as the population gets larger. Hence, we often use another method called mutation. In this method, we randomly change one or more characters of some strings in the population based on a predefined probability value called the mutation rate or mutation probability as described in the `mutate` function. The mutation rate is usually kept quite low. A mutation rate of zero fails to introduce variation in the population and a high mutation rate (say 50%) is as good as a coin flip and the population fails to benefit from the previous recombinations. An optimum balance has to be maintained between population size and mutation rate so as to reduce the computational cost as well as have sufficient variation in the population. +# +# +# * **Selection**: There must be some mechanism by which some members of the population have the opportunity to be parents and pass down their genetic information and some do not. This is typically referred to as "survival of the fittest".
+# There has to be some way of determining which phrases in our population have a better chance of eventually evolving into the target phrase. This is done by introducing a fitness function that calculates how close the generated phrase is to the target phrase. The function will simply return a scalar value corresponding to the number of matching characters between the generated phrase and the target phrase. + +# %% [markdown] +# Before solving the problem, we first need to define our target phrase. + +# %% +target = 'Genetic Algorithm' + +# %% [markdown] +# We then need to define our gene pool, i.e the elements which an individual from the population might comprise of. Here, the gene pool contains all uppercase and lowercase letters of the English alphabet and the space character. + +# %% +# The ASCII values of uppercase characters ranges from 65 to 91 +u_case = [chr(x) for x in range(65, 91)] +# The ASCII values of lowercase characters ranges from 97 to 123 +l_case = [chr(x) for x in range(97, 123)] + +gene_pool = [] +gene_pool.extend(u_case) # adds the uppercase list to the gene pool +gene_pool.extend(l_case) # adds the lowercase list to the gene pool +gene_pool.append(' ') # adds the space character to the gene pool + +# %% [markdown] +# We now need to define the maximum size of each population. Larger populations have more variation but are computationally more expensive to run algorithms on. + +# %% +max_population = 100 + +# %% [markdown] +# As our population is not very large, we can afford to keep a relatively large mutation rate. + +# %% +mutation_rate = 0.07 # 7% + + +# %% [markdown] +# Great! Now, we need to define the most important metric for the genetic algorithm, i.e the fitness function. This will simply return the number of matching characters between the generated sample and the target phrase. + +# %% +def fitness_fn(sample): + # initialize fitness to 0 + fitness = 0 + for i in range(len(sample)): + # increment fitness by 1 for every matching character + if sample[i] == target[i]: + fitness += 1 + return fitness + + +# %% [markdown] +# Before we run our genetic algorithm, we need to initialize a random population. We will use the `init_population` function to do this. We need to pass in the maximum population size, the gene pool and the length of each individual, which in this case will be the same as the length of the target phrase. + +# %% +population = init_population(max_population, gene_pool, len(target)) + +# %% [markdown] +# We will now define how the individuals in the population should change as the number of generations increases. First, the `select` function will be run on the population to select *two* individuals with high fitness values. These will be the parents which will then be recombined using the `recombine` function to generate the child. + +# %% +parents = select(2, population, fitness_fn) + +# %% +# The recombine function takes two parents as arguments, so we need to unpack the previous variable +child = recombine(*parents) + +# %% [markdown] +# Next, we need to apply a mutation according to the mutation rate. We call the `mutate` function on the child with the gene pool and mutation rate as the additional arguments. + +# %% +child = mutate(child, gene_pool, mutation_rate) + +# %% [markdown] +# The above lines can be condensed into +# +# `child = mutate(recombine(*select(2, population, fitness_fn)), gene_pool, mutation_rate)` +# +# And, we need to do this `for` every individual in the current population to generate the new population. + +# %% +population = [mutate(recombine(*select(2, population, fitness_fn)), gene_pool, mutation_rate) for i in range(len(population))] + +# %% [markdown] +# The individual with the highest fitness can then be found using the `max` function. + +# %% +current_best = max(population, key=fitness_fn) + +# %% [markdown] +# Let's print this out + +# %% +print(current_best) + +# %% [markdown] +# We see that this is a list of characters. This can be converted to a string using the join function + +# %% +current_best_string = ''.join(current_best) +print(current_best_string) + +# %% [markdown] +# We now need to define the conditions to terminate the algorithm. This can happen in two ways +# 1. Termination after a predefined number of generations +# 2. Termination when the fitness of the best individual of the current generation reaches a predefined threshold value. +# +# We define these variables below + +# %% +ngen = 1200 # maximum number of generations +# we set the threshold fitness equal to the length of the target phrase +# i.e the algorithm only terminates whne it has got all the characters correct +# or it has completed 'ngen' number of generations +f_thres = len(target) + + +# %% [markdown] +# To generate `ngen` number of generations, we run a `for` loop `ngen` number of times. After each generation, we calculate the fitness of the best individual of the generation and compare it to the value of `f_thres` using the `fitness_threshold` function. After every generation, we print out the best individual of the generation and the corresponding fitness value. Lets now write a function to do this. + +# %% +def genetic_algorithm_stepwise(population, fitness_fn, gene_pool=[0, 1], f_thres=None, ngen=1200, pmut=0.1): + for generation in range(ngen): + population = [mutate(recombine(*select(2, population, fitness_fn)), gene_pool, pmut) for i in range(len(population))] + # stores the individual genome with the highest fitness in the current population + current_best = ''.join(max(population, key=fitness_fn)) + print(f'Current best: {current_best}\t\tGeneration: {str(generation)}\t\tFitness: {fitness_fn(current_best)}\r', end='') + + # compare the fitness of the current best individual to f_thres + fittest_individual = fitness_threshold(fitness_fn, f_thres, population) + + # if fitness is greater than or equal to f_thres, we terminate the algorithm + if fittest_individual: + return fittest_individual, generation + return max(population, key=fitness_fn) , generation + + +# %% [markdown] +# The function defined above is essentially the same as the one defined in `search.py` with the added functionality of printing out the data of each generation. + +# %% +psource(genetic_algorithm) + +# %% [markdown] +# We have defined all the required functions and variables. Let's now create a new population and test the function we wrote above. + +# %% +population = init_population(max_population, gene_pool, len(target)) +solution, generations = genetic_algorithm_stepwise(population, fitness_fn, gene_pool, f_thres, ngen, mutation_rate) + +# %% [markdown] +# The genetic algorithm was able to converge! +# We implore you to rerun the above cell and play around with `target, max_population, f_thres, ngen` etc parameters to get a better intuition of how the algorithm works. To summarize, if we can define the problem states in simple array format and if we can create a fitness function to gauge how good or bad our approximate solutions are, there is a high chance that we can get a satisfactory solution using a genetic algorithm. +# - There is also a better GUI version of this program `genetic_algorithm_example.py` in the GUI folder for you to play around with. + +# %% [markdown] +# ### Usage +# +# Below we give two example usages for the genetic algorithm, for a graph coloring problem and the 8 queens problem. +# +# #### Graph Coloring +# +# First we will take on the simpler problem of coloring a small graph with two colors. Before we do anything, let's imagine how a solution might look. First, we have to represent our colors. Say, 'R' for red and 'G' for green. These make up our gene pool. What of the individual solutions though? For that, we will look at our problem. We stated we have a graph. A graph has nodes and edges, and we want to color the nodes. Naturally, we want to store each node's color. If we have four nodes, we can store their colors in a list of genes, one for each node. A possible solution will then look like this: ['R', 'R', 'G', 'R']. In the general case, we will represent each solution with a list of chars ('R' and 'G'), with length the number of nodes. +# +# Next we need to come up with a fitness function that appropriately scores individuals. Again, we will look at the problem definition at hand. We want to color a graph. For a solution to be optimal, no edge should connect two nodes of the same color. How can we use this information to score a solution? A naive (and ineffective) approach would be to count the different colors in the string. So ['R', 'R', 'R', 'R'] has a score of 1 and ['R', 'R', 'G', 'G'] has a score of 2. Why that fitness function is not ideal though? Why, we forgot the information about the edges! The edges are pivotal to the problem and the above function only deals with node colors. We didn't use all the information at hand and ended up with an ineffective answer. How, then, can we use that information to our advantage? +# +# We said that the optimal solution will have all the edges connecting nodes of different color. So, to score a solution we can count how many edges are valid (aka connecting nodes of different color). That is a great fitness function! +# +# Let's jump into solving this problem using the `genetic_algorithm` function. + +# %% [markdown] +# First we need to represent the graph. Since we mostly need information about edges, we will just store the edges. We will denote edges with capital letters and nodes with integers: + +# %% +edges = { + 'A': [0, 1], + 'B': [0, 3], + 'C': [1, 2], + 'D': [2, 3] +} + +# %% [markdown] +# Edge 'A' connects nodes 0 and 1, edge 'B' connects nodes 0 and 3 etc. +# +# We already said our gene pool is 'R' and 'G', so we can jump right into initializing our population. Since we have only four nodes, `state_length` should be 4. For the number of individuals, we will try 8. We can increase this number if we need higher accuracy, but be careful! Larger populations need more computating power and take longer. You need to strike that sweet balance between accuracy and cost (the ultimate dilemma of the programmer!). + +# %% +population = init_population(8, ['R', 'G'], 4) +print(population) + + +# %% [markdown] +# We created and printed the population. You can see that the genes in the individuals are random and there are 8 individuals each with 4 genes. +# +# Next we need to write our fitness function. We previously said we want the function to count how many edges are valid. So, given a coloring/individual `c`, we will do just that: + +# %% +def fitness(c): + return sum(c[n1] != c[n2] for (n1, n2) in edges.values()) + + +# %% [markdown] +# Great! Now we will run the genetic algorithm and see what solution it gives. + +# %% +solution = genetic_algorithm(population, fitness, gene_pool=['R', 'G']) +print(solution) + +# %% [markdown] +# The algorithm converged to a solution. Let's check its score: + +# %% +print(fitness(solution)) + +# %% [markdown] +# The solution has a score of 4. Which means it is optimal, since we have exactly 4 edges in our graph, meaning all are valid! +# +# *NOTE: Because the algorithm is non-deterministic, there is a chance a different solution is given. It might even be wrong, if we are very unlucky!* + +# %% [markdown] +# #### Eight Queens +# +# Let's take a look at a more complicated problem. +# +# In the *Eight Queens* problem, we are tasked with placing eight queens on an 8x8 chessboard without any queen threatening the others (aka queens should not be in the same row, column or diagonal). In its general form the problem is defined as placing *N* queens in an NxN chessboard without any conflicts. +# +# First we need to think about the representation of each solution. We can go the naive route of representing the whole chessboard with the queens' placements on it. That is definitely one way to go about it, but for the purpose of this tutorial we will do something different. We have eight queens, so we will have a gene for each of them. The gene pool will be numbers from 0 to 7, for the different columns. The *position* of the gene in the state will denote the row the particular queen is placed in. +# +# For example, we can have the state "03304577". Here the first gene with a value of 0 means "the queen at row 0 is placed at column 0", for the second gene "the queen at row 1 is placed at column 3" and so forth. +# +# We now need to think about the fitness function. On the graph coloring problem we counted the valid edges. The same thought process can be applied here. Instead of edges though, we have positioning between queens. If two queens are not threatening each other, we say they are at a "non-attacking" positioning. We can, therefore, count how many such positionings are there. +# +# Let's dive right in and initialize our population: + +# %% +population = init_population(100, range(8), 8) +print(population[:5]) + + +# %% [markdown] +# We have a population of 100 and each individual has 8 genes. The gene pool is the integers from 0 to 7, in string form. Above you can see the first five individuals. +# +# Next we need to write our fitness function. Remember, queens threaten each other if they are at the same row, column or diagonal. +# +# Since positionings are mutual, we must take care not to count them twice. Therefore for each queen, we will only check for conflicts for the queens after her. +# +# A gene's value in an individual `q` denotes the queen's column, and the position of the gene denotes its row. We can check if the aforementioned values between two genes are the same. We also need to check for diagonals. A queen *a* is in the diagonal of another queen, *b*, if the difference of the rows between them is equal to either their difference in columns (for the diagonal on the right of *a*) or equal to the negative difference of their columns (for the left diagonal of *a*). Below is given the fitness function. + +# %% +def fitness(q): + non_attacking = 0 + for row1 in range(len(q)): + for row2 in range(row1+1, len(q)): + col1 = int(q[row1]) + col2 = int(q[row2]) + row_diff = row1 - row2 + col_diff = col1 - col2 + + if col1 != col2 and row_diff != col_diff and row_diff != -col_diff: + non_attacking += 1 + + return non_attacking + + +# %% [markdown] +# Note that the best score achievable is 28. That is because for each queen we only check for the queens after her. For the first queen we check 7 other queens, for the second queen 6 others and so on. In short, the number of checks we make is the sum 7+6+5+...+1. Which is equal to 7\*(7+1)/2 = 28. +# +# Because it is very hard and will take long to find a perfect solution, we will set the fitness threshold at 25. If we find an individual with a score greater or equal to that, we will halt. Let's see how the genetic algorithm will fare. + +# %% +solution = genetic_algorithm(population, fitness, f_thres=25, gene_pool=range(8)) +print(solution) +print(fitness(solution)) + +# %% [markdown] +# Above you can see the solution and its fitness score, which should be no less than 25. + +# %% [markdown] +# This is where we conclude Genetic Algorithms. + +# %% [markdown] +# ### N-Queens Problem +# Here, we will look at the generalized cae of the Eight Queens problem. +#
+# We are given a `N` x `N` chessboard, with `N` queens, and we need to place them in such a way that no two queens can attack each other. +#
+# We will solve this problem using search algorithms. +# To do this, we already have a `NQueensProblem` class in `search.py`. + +# %% +psource(NQueensProblem) + +# %% [markdown] +# In [`csp.ipynb`](https://github.com/aimacode/aima-python/blob/master/csp.ipynb) we have seen that the N-Queens problem can be formulated as a CSP and can be solved by +# the `min_conflicts` algorithm in a way similar to Hill-Climbing. +# Here, we want to solve it using heuristic search algorithms and even some classical search algorithms. +# The `NQueensProblem` class derives from the `Problem` class and is implemented in such a way that the search algorithms we already have, can solve it. +#
+# Let's instantiate the class. + +# %% +nqp = NQueensProblem(8) + +# %% [markdown] +# Let's use `depth_first_tree_search` first. +#
+# We will also use the %%timeit magic with each algorithm to see how much time they take. + +# %% +# %%timeit +depth_first_tree_search(nqp) + +# %% +_, _, goal_node = depth_first_tree_search(nqp) # Unpack the tuple +dfts = goal_node.solution() + +# %% +plot_NQueens(dfts) + +# %% [markdown] +# `breadth_first_tree_search` + +# %% +# %%timeit +breadth_first_tree_search(nqp) + +# %% +_, _, goal_node = breadth_first_tree_search(nqp) # Unpack the tuple +bfts = goal_node.solution() + +# %% +plot_NQueens(bfts) + +# %% [markdown] +# `uniform_cost_search` + +# %% +# %%timeit +uniform_cost_search(nqp) + +# %% +ucs = uniform_cost_search(nqp).solution() + +# %% +plot_NQueens(ucs) + +# %% [markdown] +# `depth_first_tree_search` is almost 20 times faster than `breadth_first_tree_search` and more than 200 times faster than `uniform_cost_search`. + +# %% [markdown] +# We can also solve this problem using `astar_search` with a suitable heuristic function. +#
+# The best heuristic function for this scenario will be one that returns the number of conflicts in the current state. + +# %% +psource(NQueensProblem.h) + +# %% +# %%timeit +astar_search(nqp) + +# %% [markdown] +# `astar_search` is faster than both `uniform_cost_search` and `breadth_first_tree_search`. + +# %% +astar = astar_search(nqp).solution() + +# %% +plot_NQueens(astar) + +# %% [markdown] +# ## AND-OR GRAPH SEARCH +# An _AND-OR_ graph is a graphical representation of the reduction of goals to _conjunctions_ and _disjunctions_ of subgoals. +#
+# An _AND-OR_ graph can be seen as a generalization of a directed graph. +# It contains a number of vertices and generalized edges that connect the vertices. +#
+# Each connector in an _AND-OR_ graph connects a set of vertices $V$ to a single vertex, $v_0$. +# A connector can be an _AND_ connector or an _OR_ connector. +# An __AND__ connector connects two edges having a logical _AND_ relationship, +# while and __OR__ connector connects two edges having a logical _OR_ relationship. +#
+# A vertex can have more than one _AND_ or _OR_ connector. +# This is why _AND-OR_ graphs can be expressed as logical statements. +#
+#
+# _AND-OR_ graphs also provide a computational model for executing logic programs and you will come across this data-structure in the `logic` module as well. +# _AND-OR_ graphs can be searched in depth-first, breadth-first or best-first ways searching the state sapce linearly or parallely. +#
+# Our implementation of _AND-OR_ search searches over graphs generated by non-deterministic environments and returns a conditional plan that reaches a goal state in all circumstances. +# Let's have a look at the implementation of `and_or_graph_search`. + +# %% +psource(and_or_graph_search) + +# %% [markdown] +# The search is carried out by two functions `and_search` and `or_search` that recursively call each other, traversing nodes sequentially. +# It is a recursive depth-first algorithm for searching an _AND-OR_ graph. +#
+# A very similar algorithm `fol_bc_ask` can be found in the `logic` module, which carries out inference on first-order logic knowledge bases using _AND-OR_ graph-derived data-structures. +#
+# _AND-OR_ trees can also be used to represent the search spaces for two-player games, where a vertex of the tree represents the problem of one of the players winning the game, starting from the initial state of the game. +#
+# Problems involving _MIN-MAX_ trees can be reformulated as _AND-OR_ trees by representing _MAX_ nodes as _OR_ nodes and _MIN_ nodes as _AND_ nodes. +# `and_or_graph_search` can then be used to find the optimal solution. +# Standard algorithms like `minimax` and `expectiminimax` (for belief states) can also be applied on it with a few modifications. + +# %% [markdown] +# Here's how `and_or_graph_search` can be applied to a simple vacuum-world example. + +# %% +vacuum_world = GraphProblemStochastic('State_1', ['State_7', 'State_8'], vacuum_world) +plan = and_or_graph_search(vacuum_world) + +# %% +plan + + +# %% +def run_plan(state, problem, plan): + if problem.goal_test(state): + return True + if len(plan) is not 2: + return False + predicate = lambda x: run_plan(x, problem, plan[1][x]) + return all(predicate(r) for r in problem.result(state, plan[0])) + + +# %% +run_plan('State_1', vacuum_world, plan) + +# %% [markdown] +# ## ONLINE DFS AGENT +# So far, we have seen agents that use __offline search__ algorithms, +# which is a class of algorithms that compute a complete solution before executing it. +# In contrast, an __online search__ agent interleaves computation and action. +# Online search is better for most dynamic environments and necessary for unknown environments. +#
+# Online search problems are solved by an agent executing actions, rather than just by pure computation. +# For a fully observable environment, an online agent cycles through three steps: taking an action, computing the step cost and checking if the goal has been reached. +#
+# For online algorithms in partially-observable environments, there is usually a tradeoff between exploration and exploitation to be taken care of. +#
+#
+# Whenever an online agent takes an action, it receives a _percept_ or an observation that tells it something about its immediate environment. +# Using this percept, the agent can augment its map of the current environment. +# For a partially observable environment, this is called the belief state. +#
+# Online algorithms expand nodes in a _local_ order, just like _depth-first search_ as it does not have the option of observing farther nodes like _A* search_. +# Whenever an action from the current state has not been explored, the agent tries that action. +#
+# Difficulty arises when the agent has tried all actions in a particular state. +# An offline search algorithm would simply drop the state from the queue in this scenario whereas an online search agent has to physically move back to the previous state. +# To do this, the agent needs to maintain a table where it stores the order of nodes it has been to. +# This is how our implementation of _Online DFS-Agent_ works. +# This agent works only in state spaces where the action is reversible, because of the use of backtracking. +#
+# Let's have a look at the `OnlineDFSAgent` class. + +# %% +psource(OnlineDFSAgent) + +# %% [markdown] +# It maintains two dictionaries `untried` and `unbacktracked`. +# `untried` contains nodes that have not been visited yet. +# `unbacktracked` contains the sequence of nodes that the agent has visited so it can backtrack to it later, if required. +# `s` and `a` store the state and the action respectively and `result` stores the final path or solution of the problem. +#
+# Let's look at another online search algorithm. + +# %% [markdown] +# ## LRTA* AGENT +# We can infer now that hill-climbing is an online search algorithm, but it is not very useful natively because for complicated search spaces, it might converge to the local minima and indefinitely stay there. +# In such a case, we can choose to randomly restart it a few times with different starting conditions and return the result with the lowest total cost. +# Sometimes, it is better to use random walks instead of random restarts depending on the problem, but progress can still be very slow. +#
+# A better improvement would be to give hill-climbing a memory element. +# We store the current best heuristic estimate and it is updated as the agent gains experience in the state space. +# The estimated optimal cost is made more and more accurate as time passes and each time the the local minima is "flattened out" until we escape it. +#
+# This learning scheme is a simple improvement upon traditional hill-climbing and is called _learning real-time A*_ or __LRTA*__. +# Similar to _Online DFS-Agent_, it builds a map of the environment and chooses the best possible move according to its current heuristic estimates. +#
+# Actions that haven't been tried yet are assumed to lead immediately to the goal with the least possible cost. +# This is called __optimism under uncertainty__ and encourages the agent to explore new promising paths. +# This algorithm might not terminate if the state space is infinite, unlike A* search. +#
+# Let's have a look at the `LRTAStarAgent` class. + +# %% +psource(LRTAStarAgent) + +# %% [markdown] +# `H` stores the heuristic cost of the paths the agent may travel to. +#
+# `s` and `a` store the state and the action respectively. +#
+# `problem` stores the problem definition and the current map of the environment is stored in `problem.result`. +#
+# The `LRTA_cost` method computes the cost of a new path given the current state `s`, the action `a`, the next state `s1` and the estimated cost to get from `s` to `s1` is extracted from `H`. + +# %% [markdown] +# Let's use `LRTAStarAgent` to solve a simple problem. +# We'll define a new `LRTA_problem` instance based on our `one_dim_state_space`. + +# %% +one_dim_state_space + +# %% [markdown] +# Let's define an instance of `OnlineSearchProblem`. + +# %% +LRTA_problem = OnlineSearchProblem('State_3', 'State_5', one_dim_state_space) + +# %% [markdown] +# Now we initialize a `LRTAStarAgent` object for the problem we just defined. + +# %% +lrta_agent = LRTAStarAgent(LRTA_problem) + +# %% [markdown] +# We'll pass the percepts `[State_3, State_4, State_3, State_4, State_5]` one-by-one to our agent to see what action it comes up with at each timestep. + +# %% +lrta_agent('State_3') + +# %% +lrta_agent('State_4') + +# %% +lrta_agent('State_3') + +# %% +lrta_agent('State_4') + +# %% [markdown] +# If you manually try to see what the optimal action should be at each step, the outputs of the `lrta_agent` will start to make sense if it doesn't already. + +# %% +lrta_agent('State_5') + +# %% [markdown] +# There is no possible action for this state. + +# %% [markdown] +#
+# This concludes the notebook. +# Hope you learned something new! diff --git a/text.ipynb b/notebooks/text.ipynb similarity index 99% rename from text.ipynb rename to notebooks/text.ipynb index 327bd1160..7ef5b29e1 100644 --- a/text.ipynb +++ b/notebooks/text.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -17,9 +26,9 @@ }, "outputs": [], "source": [ - "from text import *\n", - "from utils import open_data\n", - "from notebook import psource" + "from aima.text import *\n", + "from aima.utils import open_data\n", + "from aima.notebook_utils import psource" ] }, { diff --git a/notebooks/text.py b/notebooks/text.py new file mode 100644 index 000000000..a9cd9742b --- /dev/null +++ b/notebooks/text.py @@ -0,0 +1,386 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # TEXT +# +# This notebook serves as supporting material for topics covered in **Chapter 22 - Natural Language Processing** from the book *Artificial Intelligence: A Modern Approach*. This notebook uses implementations from [text.py](https://github.com/aimacode/aima-python/blob/master/text.py). + +# %% +from aima.text import * +from aima.utils import open_data +from aima.notebook_utils import psource + +# %% [markdown] +# ## CONTENTS +# +# * Text Models +# * Viterbi Text Segmentation +# * Information Retrieval +# * Information Extraction +# * Decoders + +# %% [markdown] +# ## TEXT MODELS +# +# Before we start analyzing text processing algorithms, we will need to build some language models. Those models serve as a look-up table for character or word probabilities (depending on the type of model). These models can give us the probabilities of words or character sequences appearing in text. Take as example "the". Text models can give us the probability of "the", *P("the")*, either as a word or as a sequence of characters ("t" followed by "h" followed by "e"). The first representation is called "word model" and deals with words as distinct objects, while the second is a "character model" and deals with sequences of characters as objects. Note that we can specify the number of words or the length of the char sequences to better suit our needs. So, given that number of words equals 2, we have probabilities in the form *P(word1, word2)*. For example, *P("of", "the")*. For char models, we do the same but for chars. +# +# It is also useful to store the conditional probabilities of words given preceding words. That means, given we found the words "of" and "the", what is the chance the next word will be "world"? More formally, *P("world"|"of", "the")*. Generalizing, *P(Wi|Wi-1, Wi-2, ... , Wi-n)*. +# +# We call the word model *N-Gram Word Model* (from the Greek "gram", the root of "write", or the word for "letter") and the char model *N-Gram Character Model*. In the special case where *N* is 1, we call the models *Unigram Word Model* and *Unigram Character Model* respectively. +# +# In the `text` module we implement the two models (both their unigram and n-gram variants) by inheriting from the `CountingProbDist` from `learning.py`. Note that `CountingProbDist` does not return the actual probability of each object, but the number of times it appears in our test data. +# +# For word models we have `UnigramWordModel` and `NgramWordModel`. We supply them with a text file and they show the frequency of the different words. We have `UnigramCharModel` and `NgramCharModel` for the character models. +# +# Execute the cells below to take a look at the code. + +# %% +psource(UnigramWordModel, NgramWordModel, UnigramCharModel, NgramCharModel) + +# %% [markdown] +# Next we build our models. The text file we will use to build them is *Flatland*, by Edwin A. Abbott. We will load it from [here](https://github.com/aimacode/aima-data/blob/a21fc108f52ad551344e947b0eb97df82f8d2b2b/EN-text/flatland.txt). In that directory you can find other text files we might get to use here. + +# %% [markdown] +# ### Getting Probabilities +# +# Here we will take a look at how to read text and find the probabilities for each model, and how to retrieve them. +# +# First the word models: + +# %% +flatland = open_data("EN-text/flatland.txt").read() +wordseq = words(flatland) + +P1 = UnigramWordModel(wordseq) +P2 = NgramWordModel(2, wordseq) + +print(P1.top(5)) +print(P2.top(5)) + +print(P1['an']) +print(P2[('i', 'was')]) + +# %% [markdown] +# We see that the most used word in *Flatland* is 'the', with 2081 occurrences, while the most used sequence is 'of the' with 368 occurrences. Also, the probability of 'an' is approximately 0.003, while for 'i was' it is close to 0.001. Note that the strings used as keys are all lowercase. For the unigram model, the keys are single strings, while for n-gram models we have n-tuples of strings. +# +# Below we take a look at how we can get information from the conditional probabilities of the model, and how we can generate the next word in a sequence. + +# %% +flatland = open_data("EN-text/flatland.txt").read() +wordseq = words(flatland) + +P3 = NgramWordModel(3, wordseq) + +print("Conditional Probabilities Table:", P3.cond_prob[('i', 'was')].dictionary, '\n') +print("Conditional Probability of 'once' give 'i was':", P3.cond_prob[('i', 'was')]['once'], '\n') +print("Next word after 'i was':", P3.cond_prob[('i', 'was')].sample()) + +# %% [markdown] +# First we print all the possible words that come after 'i was' and the times they have appeared in the model. Next we print the probability of 'once' appearing after 'i was', and finally we pick a word to proceed after 'i was'. Note that the word is picked according to its probability of appearing (high appearance count means higher chance to get picked). + +# %% [markdown] +# Let's take a look at the two character models: + +# %% +flatland = open_data("EN-text/flatland.txt").read() +wordseq = words(flatland) + +P1 = UnigramCharModel(wordseq) +P2 = NgramCharModel(2, wordseq) + +print(P1.top(5)) +print(P2.top(5)) + +print(P1['z']) +print(P2[('g', 'h')]) + +# %% [markdown] +# The most common letter is 'e', appearing more than 19000 times, and the most common sequence is "\_t". That is, a space followed by a 't'. Note that even though we do not count spaces for word models or unigram character models, we do count them for n-gram char models. +# +# Also, the probability of the letter 'z' appearing is close to 0.0006, while for the bigram 'gh' it is 0.003. + +# %% [markdown] +# ### Generating Samples +# +# Apart from reading the probabilities for n-grams, we can also use our model to generate word sequences, using the `samples` function in the word models. + +# %% +flatland = open_data("EN-text/flatland.txt").read() +wordseq = words(flatland) + +P1 = UnigramWordModel(wordseq) +P2 = NgramWordModel(2, wordseq) +P3 = NgramWordModel(3, wordseq) + +print(P1.samples(10)) +print(P2.samples(10)) +print(P3.samples(10)) + +# %% [markdown] +# For the unigram model, we mostly get gibberish, since each word is picked according to its frequency of appearance in the text, without taking into consideration preceding words. As we increase *n* though, we start to get samples that do have some semblance of conherency and do remind a little bit of normal English. As we increase our data, these samples will get better. +# +# Let's try it. We will add to the model more data to work with and let's see what comes out. + +# %% +data = open_data("EN-text/flatland.txt").read() +data += open_data("EN-text/sense.txt").read() + +wordseq = words(data) + +P3 = NgramWordModel(3, wordseq) +P4 = NgramWordModel(4, wordseq) +P5 = NgramWordModel(5, wordseq) +P7 = NgramWordModel(7, wordseq) + +print(P3.samples(15)) +print(P4.samples(15)) +print(P5.samples(15)) +print(P7.samples(15)) + +# %% [markdown] +# Notice how the samples start to become more and more reasonable as we add more data and increase the *n* parameter. We are still a long way to go though from realistic text generation, but at the same time we can see that with enough data even rudimentary algorithms can output something almost passable. + +# %% [markdown] +# ## VITERBI TEXT SEGMENTATION +# +# ### Overview +# +# We are given a string containing words of a sentence, but all the spaces are gone! It is very hard to read and we would like to separate the words in the string. We can accomplish this by employing the `Viterbi Segmentation` algorithm. It takes as input the string to segment and a text model, and it returns a list of the separate words. +# +# The algorithm operates in a dynamic programming approach. It starts from the beginning of the string and iteratively builds the best solution using previous solutions. It accomplishes that by segmentating the string into "windows", each window representing a word (real or gibberish). It then calculates the probability of the sequence up that window/word occurring and updates its solution. When it is done, it traces back from the final word and finds the complete sequence of words. + +# %% [markdown] +# ### Implementation + +# %% +psource(viterbi_segment) + +# %% [markdown] +# The function takes as input a string and a text model, and returns the most probable sequence of words, together with the probability of that sequence. +# +# The "window" is `w` and it includes the characters from *j* to *i*. We use it to "build" the following sequence: from the start to *j* and then `w`. We have previously calculated the probability from the start to *j*, so now we multiply that probability by `P[w]` to get the probability of the whole sequence. If that probability is greater than the probability we have calculated so far for the sequence from the start to *i* (`best[i]`), we update it. + +# %% [markdown] +# ### Example +# +# The model the algorithm uses is the `UnigramTextModel`. First we will build the model using the *Flatland* text and then we will try and separate a space-devoid sentence. + +# %% +flatland = open_data("EN-text/flatland.txt").read() +wordseq = words(flatland) +P = UnigramWordModel(wordseq) +text = "itiseasytoreadwordswithoutspaces" + +s, p = viterbi_segment(text,P) +print("Sequence of words is:",s) +print("Probability of sequence is:",p) + +# %% [markdown] +# The algorithm correctly retrieved the words from the string. It also gave us the probability of this sequence, which is small, but still the most probable segmentation of the string. + +# %% [markdown] +# ## INFORMATION RETRIEVAL +# +# ### Overview +# +# With **Information Retrieval (IR)** we find documents that are relevant to a user's needs for information. A popular example is a web search engine, which finds and presents to a user pages relevant to a query. Information retrieval is not limited only to returning documents though, but can also be used for other type of queries. For example, answering questions when the query is a question, returning information when the query is a concept, and many other applications. An IR system is comprised of the following: +# +# * A body (called corpus) of documents: A collection of documents, where the IR will work on. +# +# * A query language: A query represents what the user wants. +# +# * Results: The documents the system grades as relevant to a user's query and needs. +# +# * Presententation of the results: How the results are presented to the user. +# +# How does an IR system determine which documents are relevant though? We can sign a document as relevant if all the words in the query appear in it, and sign it as irrelevant otherwise. We can even extend the query language to support boolean operations (for example, "paint AND brush") and then sign as relevant the outcome of the query for the document. This technique though does not give a level of relevancy. All the documents are either relevant or irrelevant, but in reality some documents are more relevant than others. +# +# So, instead of a boolean relevancy system, we use a *scoring function*. There are many scoring functions around for many different situations. One of the most used takes into account the frequency of the words appearing in a document, the frequency of a word appearing across documents (for example, the word "a" appears a lot, so it is not very important) and the length of a document (since large documents will have higher occurrences for the query terms, but a short document with a lot of occurrences seems very relevant). We combine these properties in a formula and we get a numeric score for each document, so we can then quantify relevancy and pick the best documents. +# +# These scoring functions are not perfect though and there is room for improvement. For instance, for the above scoring function we assume each word is independent. That is not the case though, since words can share meaning. For example, the words "painter" and "painters" are closely related. If in a query we have the word "painter" and in a document the word "painters" appears a lot, this might be an indication that the document is relevant but we are missing out since we are only looking for "painter". There are a lot of ways to combat this. One of them is to reduce the query and document words into their stems. For example, both "painter" and "painters" have "paint" as their stem form. This can improve slightly the performance of algorithms. +# +# To determine how good an IR system is, we give the system a set of queries (for which we know the relevant pages beforehand) and record the results. The two measures for performance are *precision* and *recall*. Precision measures the proportion of result documents that actually are relevant. Recall measures the proportion of relevant documents (which, as mentioned before, we know in advance) appearing in the result documents. + +# %% [markdown] +# ### Implementation +# +# You can read the source code by running the command below: + +# %% +psource(IRSystem) + +# %% [markdown] +# The `stopwords` argument signifies words in the queries that should not be accounted for in documents. Usually they are very common words that do not add any significant information for a document's relevancy. +# +# A quick guide for the functions in the `IRSystem` class: +# +# * `index_document`: Add document to the collection of documents (named `documents`), which is a list of tuples. Also, count how many times each word in the query appears in each document. +# +# * `index_collection`: Index a collection of documents given by `filenames`. +# +# * `query`: Returns a list of `n` pairs of `(score, docid)` sorted on the score of each document. Also takes care of the special query "learn: X", where instead of the normal functionality we present the output of the terminal command "X". +# +# * `score`: Scores a given document for the given word using `log(1+k)/log(1+n)`, where `k` is the number of query words in a document and `k` is the total number of words in the document. Other scoring functions can be used and you can overwrite this function to better suit your needs. +# +# * `total_score`: Calculate the sum of all the query words in given document. +# +# * `present`/`present_results`: Presents the results as a list. +# +# We also have the class `Document` that holds metadata of documents, like their title, url and number of words. An additional class, `UnixConsultant`, can be used to initialize an IR System for Unix command manuals. This is the example we will use to showcase the implementation. + +# %% [markdown] +# ### Example +# +# First let's take a look at the source code of `UnixConsultant`. + +# %% +psource(UnixConsultant) + +# %% [markdown] +# The class creates an IR System with the stopwords "how do i the a of". We could add more words to exclude, but the queries we will test will generally be in that format, so it is convenient. After the initialization of the system, we get the manual files and start indexing them. +# +# Let's build our Unix consultant and run a query: + +# %% +uc = UnixConsultant() + +q = uc.query("how do I remove a file") + +top_score, top_doc = q[0][0], q[0][1] +print(top_score, uc.documents[top_doc].url) + +# %% [markdown] +# We asked how to remove a file and the top result was the `rm` (the Unix command for remove) manual. This is exactly what we wanted! Let's try another query: + +# %% +q = uc.query("how do I delete a file") + +top_score, top_doc = q[0][0], q[0][1] +print(top_score, uc.documents[top_doc].url) + +# %% [markdown] +# Even though we are basically asking for the same thing, we got a different top result. The `diff` command shows the differences between two files. So the system failed us and presented us an irrelevant document. Why is that? Unfortunately our IR system considers each word independent. "Remove" and "delete" have similar meanings, but since they are different words our system will not make the connection. So, the `diff` manual which mentions a lot the word `delete` gets the nod ahead of other manuals, while the `rm` one isn't in the result set since it doesn't use the word at all. + +# %% [markdown] +# ## INFORMATION EXTRACTION +# +# **Information Extraction (IE)** is a method for finding occurrences of object classes and relationships in text. Unlike IR systems, an IE system includes (limited) notions of syntax and semantics. While it is difficult to extract object information in a general setting, for more specific domains the system is very useful. One model of an IE system makes use of templates that match with strings in a text. +# +# A typical example of such a model is reading prices from web pages. Prices usually appear after a dollar and consist of numbers, maybe followed by two decimal points. Before the price, usually there will appear a string like "price:". Let's build a sample template. +# +# With the following regular expression (*regex*) we can extract prices from text: +# +# `[$][0-9]+([.][0-9][0-9])?` +# +# Where `+` means 1 or more occurrences and `?` means atmost 1 occurrence. Usually a template consists of a prefix, a target and a postfix regex. In this template, the prefix regex can be "price:", the target regex can be the above regex and the postfix regex can be empty. +# +# A template can match with multiple strings. If this is the case, we need a way to resolve the multiple matches. Instead of having just one template, we can use multiple templates (ordered by priority) and pick the match from the highest-priority template. We can also use other ways to pick. For the dollar example, we can pick the match closer to the numerical half of the highest match. For the text "Price $90, special offer $70, shipping $5" we would pick "$70" since it is closer to the half of the highest match ("$90"). + +# %% [markdown] +# The above is called *attribute-based* extraction, where we want to find attributes in the text (in the example, the price). A more sophisticated extraction system aims at dealing with multiple objects and the relations between them. When such a system reads the text "$100", it should determine not only the price but also which object has that price. +# +# Relation extraction systems can be built as a series of finite state automata. Each automaton receives as input text, performs transformations on the text and passes it on to the next automaton as input. An automata setup can consist of the following stages: +# +# 1. **Tokenization**: Segments text into tokens (words, numbers and punctuation). +# +# 2. **Complex-word Handling**: Handles complex words such as "give up", or even names like "Smile Inc.". +# +# 3. **Basic-group Handling**: Handles noun and verb groups, segmenting the text into strings of verbs or nouns (for example, "had to give up"). +# +# 4. **Complex Phrase Handling**: Handles complex phrases using finite-state grammar rules. For example, "Human+PlayedChess("with" Human+)?" can be one template/rule for capturing a relation of someone playing chess with others. +# +# 5. **Structure Merging**: Merges the structures built in the previous steps. + +# %% [markdown] +# Finite-state, template based information extraction models work well for restricted domains, but perform poorly as the domain becomes more and more general. There are many models though to choose from, each with its own strengths and weaknesses. Some of the models are the following: +# +# * **Probabilistic**: Using Hidden Markov Models, we can extract information in the form of prefix, target and postfix from a given text. Two advantages of using HMMs over templates is that we can train HMMs from data and don't need to design elaborate templates, and that a probabilistic approach behaves well even with noise. In a regex, if one character is off, we do not have a match, while with a probabilistic approach we have a smoother process. +# +# * **Conditional Random Fields**: One problem with HMMs is the assumption of state independence. CRFs are very similar to HMMs, but they don't have the latter's constraint. In addition, CRFs make use of *feature functions*, which act as transition weights. For example, if for observation $e_{i}$ and state $x_{i}$ we have $e_{i}$ is "run" and $x_{i}$ is the state ATHLETE, we can have $f(x_{i}, e_{i}) = 1$ and equal to 0 otherwise. We can use multiple, overlapping features, and we can even use features for state transitions. Feature functions don't have to be binary (like the above example) but they can be real-valued as well. Also, we can use any $e$ for the function, not just the current observation. To bring it all together, we weigh a transition by the sum of features. +# +# * **Ontology Extraction**: This is a method for compiling information and facts in a general domain. A fact can be in the form of `NP is NP`, where `NP` denotes a noun-phrase. For example, "Rabbit is a mammal". + +# %% [markdown] +# ## DECODERS +# +# ### Introduction +# +# In this section we will try to decode ciphertext using probabilistic text models. A ciphertext is obtained by performing encryption on a text message. This encryption lets us communicate safely, as anyone who has access to the ciphertext but doesn't know how to decode it cannot read the message. We will restrict our study to Monoalphabetic Substitution Ciphers. These are primitive forms of cipher where each letter in the message text (also known as plaintext) is replaced by another another letter of the alphabet. +# +# ### Shift Decoder +# +# #### The Caesar cipher +# +# The Caesar cipher, also known as shift cipher is a form of monoalphabetic substitution ciphers where each letter is shifted by a fixed value. A shift by `n` in this context means that each letter in the plaintext is replaced with a letter corresponding to `n` letters down in the alphabet. For example the plaintext `"ABCDWXYZ"` shifted by `3` yields `"DEFGZABC"`. Note how `X` became `A`. This is because the alphabet is cyclic, i.e. the letter after the last letter in the alphabet, `Z`, is the first letter of the alphabet - `A`. + +# %% +plaintext = "ABCDWXYZ" +ciphertext = shift_encode(plaintext, 3) +print(ciphertext) + +# %% [markdown] +# #### Decoding a Caesar cipher +# +# To decode a Caesar cipher we exploit the fact that not all letters in the alphabet are used equally. Some letters are used more than others and some pairs of letters are more probable to occur together. We call a pair of consecutive letters a bigram. + +# %% +print(bigrams('this is a sentence')) + +# %% [markdown] +# We use `CountingProbDist` to get the probability distribution of bigrams. In the latin alphabet consists of only only `26` letters. This limits the total number of possible substitutions to `26`. We reverse the shift encoding for a given `n` and check how probable it is using the bigram distribution. We try all `26` values of `n`, i.e. from `n = 0` to `n = 26` and use the value of `n` which gives the most probable plaintext. + +# %% +# %psource ShiftDecoder + +# %% [markdown] +# #### Example +# +# Let us encode a secret message using Caeasar cipher and then try decoding it using `ShiftDecoder`. We will again use `flatland.txt` to build the text model + +# %% +plaintext = "This is a secret message" +ciphertext = shift_encode(plaintext, 13) +print('The code is', '"' + ciphertext + '"') + +# %% +flatland = open_data("EN-text/flatland.txt").read() +decoder = ShiftDecoder(flatland) + +decoded_message = decoder.decode(ciphertext) +print('The decoded message is', '"' + decoded_message + '"') + +# %% [markdown] +# ### Permutation Decoder +# Now let us try to decode messages encrypted by a general mono-alphabetic substitution cipher. The letters in the alphabet can be replaced by any permutation of letters. For example, if the alphabet consisted of `{A B C}` then it can be replaced by `{A C B}`, `{B A C}`, `{B C A}`, `{C A B}`, `{C B A}` or even `{A B C}` itself. Suppose we choose the permutation `{C B A}`, then the plain text `"CAB BA AAC"` would become `"ACB BC CCA"`. We can see that Caesar cipher is also a form of permutation cipher where the permutation is a cyclic permutation. Unlike the Caesar cipher, it is infeasible to try all possible permutations. The number of possible permutations in Latin alphabet is `26!` which is of the order $10^{26}$. We use graph search algorithms to search for a 'good' permutation. + +# %% +psource(PermutationDecoder) + +# %% [markdown] +# Each state/node in the graph is represented as a letter-to-letter map. If there is no mapping for a letter, it means the letter is unchanged in the permutation. These maps are stored as dictionaries. Each dictionary is a 'potential' permutation. We use the word 'potential' because every dictionary doesn't necessarily represent a valid permutation since a permutation cannot have repeating elements. For example the dictionary `{'A': 'B', 'C': 'X'}` is invalid because `'A'` is replaced by `'B'`, but so is `'B'` because the dictionary doesn't have a mapping for `'B'`. Two dictionaries can also represent the same permutation e.g. `{'A': 'C', 'C': 'A'}` and `{'A': 'C', 'B': 'B', 'C': 'A'}` represent the same permutation where `'A'` and `'C'` are interchanged and all other letters remain unaltered. To ensure that we get a valid permutation, a goal state must map all letters in the alphabet. We also prevent repetitions in the permutation by allowing only those actions which go to a new state/node in which the newly added letter to the dictionary maps to previously unmapped letter. These two rules together ensure that the dictionary of a goal state will represent a valid permutation. +# The score of a state is determined using word scores, unigram scores, and bigram scores. Experiment with different weightages for word, unigram and bigram scores and see how they affect the decoding. + +# %% +ciphertexts = ['ahed world', 'ahed woxld'] + +pd = PermutationDecoder(canonicalize(flatland)) +for ctext in ciphertexts: + print('"{}" decodes to "{}"'.format(ctext, pd.decode(ctext))) + +# %% [markdown] +# As evident from the above example, permutation decoding using best first search is sensitive to initial text. This is because not only the final dictionary, with substitutions for all letters, must have good score but so must the intermediate dictionaries. You could think of it as performing a local search by finding substitutions for each letter one by one. We could get very different results by changing even a single letter because that letter could be a deciding factor for selecting substitution in early stages which snowballs and affects the later stages. To make the search better we can use different definitions of score in different stages and optimize on which letter to substitute first. diff --git a/vacuum_world.ipynb b/notebooks/vacuum_world.ipynb similarity index 98% rename from vacuum_world.ipynb rename to notebooks/vacuum_world.ipynb index 6b05254c7..8fa52dffc 100644 --- a/vacuum_world.ipynb +++ b/notebooks/vacuum_world.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -77,8 +86,8 @@ "metadata": {}, "outputs": [], "source": [ - "from agents import *\n", - "from notebook import psource" + "from aima.agents import *\n", + "from aima.notebook_utils import psource" ] }, { @@ -563,7 +572,7 @@ "A model-based reflex agent maintains some sort of **internal state** that depends on the percept history and thereby reflects at least some of the unobserved aspects of the current state. In addition to this, it also requires a **model** of the world, that is, knowledge about \"how the world works\".\n", "\n", "The schematic diagram shown in **Figure 2.11** of the book will make this more clear:\n", - "" + "" ] }, { @@ -650,7 +659,7 @@ "A goal-based agent needs some sort of **goal** information that describes situations that are desirable, apart from the current state description.\n", "\n", "**Figure 2.13** of the book shows a model-based, goal-based agent:\n", - "\n", + "\n", "\n", "**Search** (Chapters 3 to 5) and **Planning** (Chapters 10 to 11) are the subfields of AI devoted to finding action sequences that achieve the agent's goals.\n", "\n", @@ -659,7 +668,7 @@ "A utility-based agent maximizes its **utility** using the agent's **utility function**, which is essentially an internalization of the agent's performance measure.\n", "\n", "**Figure 2.14** of the book shows a model-based, utility-based agent:\n", - "" + "" ] }, { @@ -673,7 +682,7 @@ "A learning agent can be divided into four conceptual components. The **learning element** is responsible for making improvements. It uses the feedback from the **critic** on how the agent is doing and determines how the performance element should be modified to do better in the future. The **performance element** is responsible for selecting external actions for the agent: it takes in percepts and decides on actions. The critic tells the learning element how well the agent is doing with respect to a fixed performance standard. It is necesaary because the percepts themselves provide no indication of the agent's success. The last component of the learning agent is the **problem generator**. It is responsible for suggesting actions that will lead to new and informative experiences. \n", "\n", "**Figure 2.15** of the book sums up the components and their working: \n", - "" + "" ] } ], diff --git a/notebooks/vacuum_world.py b/notebooks/vacuum_world.py new file mode 100644 index 000000000..af6076e12 --- /dev/null +++ b/notebooks/vacuum_world.py @@ -0,0 +1,283 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # THE VACUUM WORLD +# +# In this notebook, we will be discussing **the structure of agents** through an example of the **vacuum agent**. The job of AI is to design an **agent program** that implements the agent function: the mapping from percepts to actions. We assume this program will run on some sort of computing device with physical sensors and actuators: we call this the **architecture**: +# +#

agent = architecture + program

+ +# %% [markdown] +# Before moving on, please review [agents.ipynb](https://github.com/aimacode/aima-python/blob/master/agents.ipynb) + +# %% [markdown] +# ## CONTENTS +# +# * Agent +# * Random Agent Program +# * Table-Driven Agent Program +# * Simple Reflex Agent Program +# * Model-Based Reflex Agent Program +# * Goal-Based Agent Program +# * Utility-Based Agent Program +# * Learning Agent + +# %% [markdown] +# ## AGENT PROGRAMS +# +# An agent program takes the current percept as input from the sensors and returns an action to the actuators. There is a difference between an agent program and an agent function: an agent program takes the current percept as input whereas an agent function takes the entire percept history. +# +# The agent program takes just the current percept as input because nothing more is available from the environment; if the agent's actions depend on the entire percept sequence, the agent will have to remember the percept. +# +# We'll discuss the following agent programs here with the help of the vacuum world example: +# +# * Random Agent Program +# * Table-Driven Agent Program +# * Simple Reflex Agent Program +# * Model-Based Reflex Agent Program +# * Goal-Based Agent Program +# * Utility-Based Agent Program + +# %% [markdown] +# ## Random Agent Program +# +# A random agent program, as the name suggests, chooses an action at random, without taking into account the percepts. +# Here, we will demonstrate a random vacuum agent for a trivial vacuum environment, that is, the two-state environment. + +# %% [markdown] +# Let's begin by importing all the functions from the agents module: + +# %% +from aima.agents import * +from aima.notebook_utils import psource + +# %% [markdown] +# Let us first see how we define the TrivialVacuumEnvironment. Run the next cell to see how abstract class TrivialVacuumEnvironment is defined in agents module: + +# %% +psource(TrivialVacuumEnvironment) + +# %% +# These are the two locations for the two-state environment +loc_A, loc_B = (0, 0), (1, 0) + +# Initialize the two-state environment +trivial_vacuum_env = TrivialVacuumEnvironment() + +# Check the initial state of the environment +print("State of the Environment: {}.".format(trivial_vacuum_env.status)) + +# %% [markdown] +# Let's create our agent now. This agent will choose any of the actions from 'Right', 'Left', 'Suck' and 'NoOp' (No Operation) randomly. + +# %% +# Create the random agent +random_agent = Agent(program=RandomAgentProgram(['Right', 'Left', 'Suck', 'NoOp'])) + +# %% [markdown] +# We will now add our agent to the environment. + +# %% +# Add agent to the environment +trivial_vacuum_env.add_thing(random_agent) + +print("RandomVacuumAgent is located at {}.".format(random_agent.location)) + +# %% [markdown] +# Let's run our environment now. + +# %% +# Running the environment +trivial_vacuum_env.step() + +# Check the current state of the environment +print("State of the Environment: {}.".format(trivial_vacuum_env.status)) + +print("RandomVacuumAgent is located at {}.".format(random_agent.location)) + +# %% [markdown] +# ## TABLE-DRIVEN AGENT PROGRAM +# +# A table-driven agent program keeps track of the percept sequence and then uses it to index into a table of actions to decide what to do. The table represents explicitly the agent function that the agent program embodies. +# In the two-state vacuum world, the table would consist of all the possible states of the agent. + +# %% +table = {((loc_A, 'Clean'),): 'Right', + ((loc_A, 'Dirty'),): 'Suck', + ((loc_B, 'Clean'),): 'Left', + ((loc_B, 'Dirty'),): 'Suck', + ((loc_A, 'Dirty'), (loc_A, 'Clean')): 'Right', + ((loc_A, 'Clean'), (loc_B, 'Dirty')): 'Suck', + ((loc_B, 'Clean'), (loc_A, 'Dirty')): 'Suck', + ((loc_B, 'Dirty'), (loc_B, 'Clean')): 'Left', + ((loc_A, 'Dirty'), (loc_A, 'Clean'), (loc_B, 'Dirty')): 'Suck', + ((loc_B, 'Dirty'), (loc_B, 'Clean'), (loc_A, 'Dirty')): 'Suck' + } + +# %% [markdown] +# We will now create a table-driven agent program for our two-state environment. + +# %% +# Create a table-driven agent +table_driven_agent = Agent(program=TableDrivenAgentProgram(table=table)) + +# %% [markdown] +# Since we are using the same environment, let's remove the previously added random agent from the environment to avoid confusion. + +# %% +trivial_vacuum_env.delete_thing(random_agent) + +# %% +# Add the table-driven agent to the environment +trivial_vacuum_env.add_thing(table_driven_agent) + +print("TableDrivenVacuumAgent is located at {}.".format(table_driven_agent.location)) + +# %% +# Run the environment +trivial_vacuum_env.step() + +# Check the current state of the environment +print("State of the Environment: {}.".format(trivial_vacuum_env.status)) + +print("TableDrivenVacuumAgent is located at {}.".format(table_driven_agent.location)) + +# %% [markdown] +# ## SIMPLE REFLEX AGENT PROGRAM +# +# A simple reflex agent program selects actions on the basis of the *current* percept, ignoring the rest of the percept history. These agents work on a **condition-action rule** (also called **situation-action rule**, **production** or **if-then rule**), which tells the agent the action to trigger when a particular situation is encountered. +# +# The schematic diagram shown in **Figure 2.9** of the book will make this more clear: +# +# "![simple reflex agent](images/simple_reflex_agent.jpg)" + +# %% [markdown] +# Let us now create a simple reflex agent for the environment. + +# %% +# Delete the previously added table-driven agent +trivial_vacuum_env.delete_thing(table_driven_agent) + +# %% [markdown] +# To create our agent, we need two functions: INTERPRET-INPUT function, which generates an abstracted description of the current state from the percerpt and the RULE-MATCH function, which returns the first rule in the set of rules that matches the given state description. + +# %% + +loc_A = (0, 0) +loc_B = (1, 0) + +"""We change the simpleReflexAgentProgram so that it doesn't make use of the Rule class""" +def SimpleReflexAgentProgram(): + """This agent takes action based solely on the percept. [Figure 2.10]""" + + def program(percept): + loc, status = percept + return ('Suck' if status == 'Dirty' + else'Right' if loc == loc_A + else'Left') + return program + + +# Create a simple reflex agent the two-state environment +program = SimpleReflexAgentProgram() +simple_reflex_agent = Agent(program) + +# %% [markdown] +# Now add the agent to the environment: + +# %% +trivial_vacuum_env.add_thing(simple_reflex_agent) + +print("SimpleReflexVacuumAgent is located at {}.".format(simple_reflex_agent.location)) + +# %% +# Run the environment +trivial_vacuum_env.step() + +# Check the current state of the environment +print("State of the Environment: {}.".format(trivial_vacuum_env.status)) + +print("SimpleReflexVacuumAgent is located at {}.".format(simple_reflex_agent.location)) + +# %% [markdown] +# ## MODEL-BASED REFLEX AGENT PROGRAM +# +# A model-based reflex agent maintains some sort of **internal state** that depends on the percept history and thereby reflects at least some of the unobserved aspects of the current state. In addition to this, it also requires a **model** of the world, that is, knowledge about "how the world works". +# +# The schematic diagram shown in **Figure 2.11** of the book will make this more clear: +# + +# %% [markdown] +# We will now create a model-based reflex agent for the environment: + +# %% +# Delete the previously added simple reflex agent +trivial_vacuum_env.delete_thing(simple_reflex_agent) + + +# %% [markdown] +# We need another function UPDATE-STATE which will be responsible for creating a new state description. + +# %% +# TODO: Implement this function for the two-dimensional environment +def update_state(state, action, percept, model): + pass + +# Create a model-based reflex agent +model_based_reflex_agent = ModelBasedVacuumAgent() + +# Add the agent to the environment +trivial_vacuum_env.add_thing(model_based_reflex_agent) + +print("ModelBasedVacuumAgent is located at {}.".format(model_based_reflex_agent.location)) + +# %% +# Run the environment +trivial_vacuum_env.step() + +# Check the current state of the environment +print("State of the Environment: {}.".format(trivial_vacuum_env.status)) + +print("ModelBasedVacuumAgent is located at {}.".format(model_based_reflex_agent.location)) + +# %% [markdown] +# ## GOAL-BASED AGENT PROGRAM +# +# A goal-based agent needs some sort of **goal** information that describes situations that are desirable, apart from the current state description. +# +# **Figure 2.13** of the book shows a model-based, goal-based agent: +# +# +# **Search** (Chapters 3 to 5) and **Planning** (Chapters 10 to 11) are the subfields of AI devoted to finding action sequences that achieve the agent's goals. +# +# ## UTILITY-BASED AGENT PROGRAM +# +# A utility-based agent maximizes its **utility** using the agent's **utility function**, which is essentially an internalization of the agent's performance measure. +# +# **Figure 2.14** of the book shows a model-based, utility-based agent: +# + +# %% [markdown] +# ## LEARNING AGENT +# +# Learning allows the agent to operate in initially unknown environments and to become more competent than its initial knowledge alone might allow. Here, we will breifly introduce the main ideas of learning agents. +# +# A learning agent can be divided into four conceptual components. The **learning element** is responsible for making improvements. It uses the feedback from the **critic** on how the agent is doing and determines how the performance element should be modified to do better in the future. The **performance element** is responsible for selecting external actions for the agent: it takes in percepts and decides on actions. The critic tells the learning element how well the agent is doing with respect to a fixed performance standard. It is necesaary because the percepts themselves provide no indication of the agent's success. The last component of the learning agent is the **problem generator**. It is responsible for suggesting actions that will lead to new and informative experiences. +# +# **Figure 2.15** of the book sums up the components and their working: +# diff --git a/viterbi_algorithm.ipynb b/notebooks/viterbi_algorithm.ipynb similarity index 99% rename from viterbi_algorithm.ipynb rename to notebooks/viterbi_algorithm.ipynb index 9c23c4f75..346f7b3e1 100644 --- a/viterbi_algorithm.ipynb +++ b/notebooks/viterbi_algorithm.ipynb @@ -1,5 +1,14 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%run bootstrap.ipynb" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -25,7 +34,7 @@ "metadata": {}, "outputs": [], "source": [ - "from probability import *" + "from aima.probability import *" ] }, { @@ -368,7 +377,7 @@ "metadata": {}, "outputs": [], "source": [ - "from utils import rounder" + "from aima.utils import rounder" ] }, { diff --git a/notebooks/viterbi_algorithm.py b/notebooks/viterbi_algorithm.py new file mode 100644 index 000000000..efe7d8a9a --- /dev/null +++ b/notebooks/viterbi_algorithm.py @@ -0,0 +1,133 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.4 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# %run bootstrap.ipynb + +# %% [markdown] +# # Probabilistic Reasoning over Time +# --- +# # Finding the Most Likely Sequence with Viterbi Algorithm +# +# ## Introduction +# An ***Hidden Markov Model*** (HMM) network is parameterized by two distributions: +# +# - the *emission or sensor probabilties* giving the conditional probability of observing evidence values for each hidden state; +# - the *transition probabilities* giving the conditional probability of moving between states during the sequence. +# +# Additionally, an *initial distribution* describes the probability of a sequence starting in each state. +# +# At each time $t$, $X_t$ represents the *hidden state* and $E_t$ represents an *observation* at that time. + +# %% +from aima.probability import * + +# %% +# %psource HiddenMarkovModel + +# %% [markdown] +# ## Finding the Most Likely Sequence +# +# There is a linear-time algorithm for finding the most likely sequence: the easiest way to think about the problem is to view each sequence as a path through a graph whose nodes are the possible states at each time step. Now consider the task of finding the most likely path through this graph, where the likelihood of any path is the product of the transition probabilities along the path and the probabilities of the given observations at each state. There is a recursive relationship between most likely paths to each state $x_{t+1}$ and most likely paths to each state $x_t$ . We can write this relationship as an equation connecting the probabilities of the paths: +# +# $$ +# \begin{align*} +# m_{1:t+1} &= \max_{x_{1:t}} \textbf{P}(\textbf{x}_{1:t}, \textbf{X}_{t+1} | \textbf{e}_{1:t+1}) \\ +# &= \alpha \textbf{P}(\textbf{e}_{t+1} | \textbf{X}_{t+1}) \max_{x_t} \Big(\textbf{P} +# (\textbf{X}_{t+1} | \textbf{x}_t) \max_{x_{1:t-1}} P(\textbf{x}_{1:t-1}, \textbf{x}_{t} | \textbf{e}_{1:t})\Big) +# \end{align*} +# $$ +# +# The *Viterbi algorithm* is a dynamic programming algorithm for *finding the most likely sequence of hidden states*, called the Viterbi path, that results in a sequence of observed events in the context of HMMs. +# This algorithms is useful in many applications, including *speech recognition*, where the aim is to find the most likely sequence of words, given a series of sounds and the *reconstruction of bit strings transmitted over a noisy channel*. + +# %% +# %psource viterbi + +# %% [markdown] +# ### Umbrella World +# --- +# +# > You are the security guard stationed at a secret under-ground installation. Each day, you try to guess whether it’s raining today, but your only access to the outside world occurs each morning when you see the director coming in with, or without, an umbrella. +# +# In this problem $t$ corresponds to each day of the week, the hidden state $X_t$ represent the *weather* outside at day $t$ (whether it is rainy or sunny) and observations record $E_t$ whether at day $t$ the security guard sees the director carrying an *umbrella* or not. + +# %% [markdown] +# #### Observation Emission or Sensor Probabilities $P(E_t := Umbrella_t | X_t := Weather_t)$ +# We need to assume that we have some prior knowledge about the director's behavior to estimate the emission probabilities for each hidden state: +# +# | | $yes$ | $no$ | +# | --- | --- | --- | +# | $Sunny$ | 0.10 | 0.90 | +# | $Rainy$ | 0.80 | 0.20 | +# +# #### Initial Probability $P(X_0 := Weather_0)$ +# We will assume that we don't know anything useful about the likelihood of a sequence starting in either state. If the sequences start each week on Monday and end each week on Friday (so each week is a new sequence), then this assumption means that it's equally likely that the weather on a Monday may be Rainy or Sunny. We can assign equal probability to each starting state: +# +# | $Sunny$ | $Rainy$ | +# | --- | --- +# | 0.5 | 0.5 | +# +# #### State Transition Probabilities $P(X_{t} := Weather_t | X_{t-1} := Weather_{t-1})$ +# Finally, we will assume that we can estimate transition probabilities from something like historical weather data for the area. Under this assumption, we get the conditional probability: +# +# | | $Sunny$ | $Rainy$ | +# | --- | --- | --- | +# |$Sunny$| 0.70 | 0.30 | +# |$Rainy$| 0.30 | 0.70 | + +# %% +umbrella_transition = [[0.7, 0.3], [0.3, 0.7]] +umbrella_sensor = [[0.9, 0.2], [0.1, 0.8]] +umbrellaHMM = HiddenMarkovModel(umbrella_transition, umbrella_sensor) + +# %% +from graphviz import Digraph + +# %% +dot = Digraph() + +dot.node('I', 'Start', shape='doublecircle') +dot.node('R', 'Rainy') +dot.node('S','Sunny') + +dot.edge('I', 'R', label='0.5') +dot.edge('I', 'S', label='0.5') + +dot.edge('R', 'S', label='0.2') +dot.edge('S', 'R', label='0.4') + +dot.node('Y', 'Yes') +dot.node('N', 'No') + +dot.edge('R', 'R', label='0.6') +dot.edge('R', 'Y', label='0.8') +dot.edge('R', 'N', label='0.2') + +dot.edge('S', 'S', label='0.8') +dot.edge('S', 'Y', label='0.1') +dot.edge('S', 'N', label='0.9') + +dot + +# %% [markdown] +# Suppose that $[true, true, false, true, true]$ is the umbrella sequence for the security guard’s first five days on the job. What is the weather sequence most likely to explain this? + +# %% +from aima.utils import rounder + +# %% +umbrella_evidence = [True, True, False, True, True] + +rounder(viterbi(umbrellaHMM, umbrella_evidence)) diff --git a/obsolete_search4e.ipynb b/obsolete_search4e.ipynb deleted file mode 100644 index 72981d49b..000000000 --- a/obsolete_search4e.ipynb +++ /dev/null @@ -1,3835 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "*Note: This is not yet ready, but shows the direction I'm leaning in for Fourth Edition Search.*\n", - "\n", - "# State-Space Search\n", - "\n", - "This notebook describes several state-space search algorithms, and how they can be used to solve a variety of problems. We start with a simple algorithm and a simple domain: finding a route from city to city. Later we will explore other algorithms and domains.\n", - "\n", - "## The Route-Finding Domain\n", - "\n", - "Like all state-space search problems, in a route-finding problem you will be given:\n", - "- A start state (for example, `'A'` for the city Arad).\n", - "- A goal state (for example, `'B'` for the city Bucharest).\n", - "- Actions that can change state (for example, driving from `'A'` to `'S'`).\n", - "\n", - "You will be asked to find:\n", - "- A path from the start state, through intermediate states, to the goal state.\n", - "\n", - "We'll use this map:\n", - "\n", - "\n", - "\n", - "A state-space search problem can be represented by a *graph*, where the vertices of the graph are the states of the problem (in this case, cities) and the edges of the graph are the actions (in this case, driving along a road).\n", - "\n", - "We'll represent a city by its single initial letter. \n", - "We'll represent the graph of connections as a `dict` that maps each city to a list of the neighboring cities (connected by a road). For now we don't explicitly represent the actions, nor the distances\n", - "between cities." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "romania = {\n", - " 'A': ['Z', 'T', 'S'],\n", - " 'B': ['F', 'P', 'G', 'U'],\n", - " 'C': ['D', 'R', 'P'],\n", - " 'D': ['M', 'C'],\n", - " 'E': ['H'],\n", - " 'F': ['S', 'B'],\n", - " 'G': ['B'],\n", - " 'H': ['U', 'E'],\n", - " 'I': ['N', 'V'],\n", - " 'L': ['T', 'M'],\n", - " 'M': ['L', 'D'],\n", - " 'N': ['I'],\n", - " 'O': ['Z', 'S'],\n", - " 'P': ['R', 'C', 'B'],\n", - " 'R': ['S', 'C', 'P'],\n", - " 'S': ['A', 'O', 'F', 'R'],\n", - " 'T': ['A', 'L'],\n", - " 'U': ['B', 'V', 'H'],\n", - " 'V': ['U', 'I'],\n", - " 'Z': ['O', 'A']}" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "Suppose we want to get from `A` to `B`. Where can we go from the start state, `A`?" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "['Z', 'T', 'S']" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "romania['A']" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "We see that from `A` we can get to any of the three cities `['Z', 'T', 'S']`. Which should we choose? *We don't know.* That's the whole point of *search*: we don't know which immediate action is best, so we'll have to explore, until we find a *path* that leads to the goal. \n", - "\n", - "How do we explore? We'll start with a simple algorithm that will get us from `A` to `B`. We'll keep a *frontier*—a collection of not-yet-explored states—and expand the frontier outward until it reaches the goal. To be more precise:\n", - "\n", - "- Initially, the only state in the frontier is the start state, `'A'`.\n", - "- Until we reach the goal, or run out of states in the frontier to explore, do the following:\n", - " - Remove the first state from the frontier. Call it `s`.\n", - " - If `s` is the goal, we're done. Return the path to `s`.\n", - " - Otherwise, consider all the neighboring states of `s`. For each one:\n", - " - If we have not previously explored the state, add it to the end of the frontier.\n", - " - Also keep track of the previous state that led to this new neighboring state; we'll need this to reconstruct the path to the goal, and to keep us from re-visiting previously explored states.\n", - " \n", - "# A Simple Search Algorithm: `breadth_first`\n", - " \n", - "The function `breadth_first` implements this strategy:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "from collections import deque # Doubly-ended queue: pop from left, append to right.\n", - "\n", - "def breadth_first(start, goal, neighbors):\n", - " \"Find a shortest sequence of states from start to the goal.\"\n", - " frontier = deque([start]) # A queue of states\n", - " previous = {start: None} # start has no previous state; other states will\n", - " while frontier:\n", - " s = frontier.popleft()\n", - " if s == goal:\n", - " return path(previous, s)\n", - " for s2 in neighbors[s]:\n", - " if s2 not in previous:\n", - " frontier.append(s2)\n", - " previous[s2] = s\n", - " \n", - "def path(previous, s): \n", - " \"Return a list of states that lead to state s, according to the previous dict.\"\n", - " return [] if (s is None) else path(previous, previous[s]) + [s]" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "A couple of things to note: \n", - "\n", - "1. We always add new states to the end of the frontier queue. That means that all the states that are adjacent to the start state will come first in the queue, then all the states that are two steps away, then three steps, etc.\n", - "That's what we mean by *breadth-first* search.\n", - "2. We recover the path to an `end` state by following the trail of `previous[end]` pointers, all the way back to `start`.\n", - "The dict `previous` is a map of `{state: previous_state}`. \n", - "3. When we finally get an `s` that is the goal state, we know we have found a shortest path, because any other state in the queue must correspond to a path that is as long or longer.\n", - "3. Note that `previous` contains all the states that are currently in `frontier` as well as all the states that were in `frontier` in the past.\n", - "4. If no path to the goal is found, then `breadth_first` returns `None`. If a path is found, it returns the sequence of states on the path.\n", - "\n", - "Some examples:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "['A', 'S', 'F', 'B']" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "breadth_first('A', 'B', romania)" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "['L', 'T', 'A', 'S', 'F', 'B', 'U', 'V', 'I', 'N']" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "breadth_first('L', 'N', romania)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "['N', 'I', 'V', 'U', 'B', 'F', 'S', 'A', 'T', 'L']" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "breadth_first('N', 'L', romania)" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['E']" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "breadth_first('E', 'E', romania)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "Now let's try a different kind of problem that can be solved with the same search function.\n", - "\n", - "## Word Ladders Problem\n", - "\n", - "A *word ladder* problem is this: given a start word and a goal word, find the shortest way to transform the start word into the goal word by changing one letter at a time, such that each change results in a word. For example starting with `green` we can reach `grass` in 7 steps:\n", - "\n", - "`green` → `greed` → `treed` → `trees` → `tress` → `cress` → `crass` → `grass`\n", - "\n", - "We will need a dictionary of words. We'll use 5-letter words from the [Stanford GraphBase](http://www-cs-faculty.stanford.edu/~uno/sgb.html) project for this purpose. Let's get that file from aimadata." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "from search import *\n", - "sgb_words = open_data(\"EN-text/sgb-words.txt\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "We can assign `WORDS` to be the set of all the words in this file:" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "5757" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "WORDS = set(sgb_words.read().split())\n", - "len(WORDS)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "And define `neighboring_words` to return the set of all words that are a one-letter change away from a given `word`:" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "def neighboring_words(word):\n", - " \"All words that are one letter away from this word.\"\n", - " neighbors = {word[:i] + c + word[i+1:]\n", - " for i in range(len(word))\n", - " for c in 'abcdefghijklmnopqrstuvwxyz'\n", - " if c != word[i]}\n", - " return neighbors & WORDS" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For example:" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'cello', 'hallo', 'hells', 'hullo', 'jello'}" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "neighboring_words('hello')" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'would'}" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "neighboring_words('world')" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "Now we can create `word_neighbors` as a dict of `{word: {neighboring_word, ...}}`: " - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "word_neighbors = {word: neighboring_words(word)\n", - " for word in WORDS}" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "Now the `breadth_first` function can be used to solve a word ladder problem:" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "['green', 'greed', 'treed', 'trees', 'treys', 'trays', 'grays', 'grass']" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "breadth_first('green', 'grass', word_neighbors)" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "['smart',\n", - " 'start',\n", - " 'stars',\n", - " 'sears',\n", - " 'bears',\n", - " 'beans',\n", - " 'brans',\n", - " 'brand',\n", - " 'braid',\n", - " 'brain']" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "breadth_first('smart', 'brain', word_neighbors)" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "['frown',\n", - " 'flown',\n", - " 'flows',\n", - " 'slows',\n", - " 'slots',\n", - " 'slits',\n", - " 'spits',\n", - " 'spite',\n", - " 'smite',\n", - " 'smile']" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "breadth_first('frown', 'smile', word_neighbors)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# More General Search Algorithms\n", - "\n", - "Now we'll embelish the `breadth_first` algorithm to make a family of search algorithms with more capabilities:\n", - "\n", - "1. We distinguish between an *action* and the *result* of an action.\n", - "3. We allow different measures of the cost of a solution (not just the number of steps in the sequence).\n", - "4. We search through the state space in an order that is more likely to lead to an optimal solution quickly.\n", - "\n", - "Here's how we do these things:\n", - "\n", - "1. Instead of having a graph of neighboring states, we instead have an object of type *Problem*. A Problem\n", - "has one method, `Problem.actions(state)` to return a collection of the actions that are allowed in a state,\n", - "and another method, `Problem.result(state, action)` that says what happens when you take an action.\n", - "2. We keep a set, `explored` of states that have already been explored. We also have a class, `Frontier`, that makes it efficient to ask if a state is on the frontier.\n", - "3. Each action has a cost associated with it (in fact, the cost can vary with both the state and the action).\n", - "4. The `Frontier` class acts as a priority queue, allowing the \"best\" state to be explored next.\n", - "We represent a sequence of actions and resulting states as a linked list of `Node` objects.\n", - "\n", - "The algorithm `breadth_first_search` is basically the same as `breadth_first`, but using our new conventions:" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "def breadth_first_search(problem):\n", - " \"Search for goal; paths with least number of steps first.\"\n", - " if problem.is_goal(problem.initial): \n", - " return Node(problem.initial)\n", - " frontier = FrontierQ(Node(problem.initial), LIFO=False)\n", - " explored = set()\n", - " while frontier:\n", - " node = frontier.pop()\n", - " explored.add(node.state)\n", - " for action in problem.actions(node.state):\n", - " child = node.child(problem, action)\n", - " if child.state not in explored and child.state not in frontier:\n", - " if problem.is_goal(child.state):\n", - " return child\n", - " frontier.add(child)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Next is `uniform_cost_search`, in which each step can have a different cost, and we still consider first one os the states with minimum cost so far." - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "def uniform_cost_search(problem, costfn=lambda node: node.path_cost):\n", - " frontier = FrontierPQ(Node(problem.initial), costfn)\n", - " explored = set()\n", - " while frontier:\n", - " node = frontier.pop()\n", - " if problem.is_goal(node.state):\n", - " return node\n", - " explored.add(node.state)\n", - " for action in problem.actions(node.state):\n", - " child = node.child(problem, action)\n", - " if child.state not in explored and child not in frontier:\n", - " frontier.add(child)\n", - " elif child in frontier and frontier.cost[child] < child.path_cost:\n", - " frontier.replace(child)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, `astar_search` in which the cost includes an estimate of the distance to the goal as well as the distance travelled so far." - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "def astar_search(problem, heuristic):\n", - " costfn = lambda node: node.path_cost + heuristic(node.state)\n", - " return uniform_cost_search(problem, costfn)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# Search Tree Nodes\n", - "\n", - "The solution to a search problem is now a linked list of `Node`s, where each `Node`\n", - "includes a `state` and the `path_cost` of getting to the state. In addition, for every `Node` except for the first (root) `Node`, there is a previous `Node` (indicating the state that lead to this `Node`) and an `action` (indicating the action taken to get here)." - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "class Node(object):\n", - " \"\"\"A node in a search tree. A search tree is spanning tree over states.\n", - " A Node contains a state, the previous node in the tree, the action that\n", - " takes us from the previous state to this state, and the path cost to get to \n", - " this state. If a state is arrived at by two paths, then there are two nodes \n", - " with the same state.\"\"\"\n", - "\n", - " def __init__(self, state, previous=None, action=None, step_cost=1):\n", - " \"Create a search tree Node, derived from a previous Node by an action.\"\n", - " self.state = state\n", - " self.previous = previous\n", - " self.action = action\n", - " self.path_cost = 0 if previous is None else (previous.path_cost + step_cost)\n", - "\n", - " def __repr__(self): return \"\".format(self.state, self.path_cost)\n", - " \n", - " def __lt__(self, other): return self.path_cost < other.path_cost\n", - " \n", - " def child(self, problem, action):\n", - " \"The Node you get by taking an action from this Node.\"\n", - " result = problem.result(self.state, action)\n", - " return Node(result, self, action, \n", - " problem.step_cost(self.state, action, result)) " - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# Frontiers\n", - "\n", - "A frontier is a collection of Nodes that acts like both a Queue and a Set. A frontier, `f`, supports these operations:\n", - "\n", - "* `f.add(node)`: Add a node to the Frontier.\n", - "\n", - "* `f.pop()`: Remove and return the \"best\" node from the frontier.\n", - "\n", - "* `f.replace(node)`: add this node and remove a previous node with the same state.\n", - "\n", - "* `state in f`: Test if some node in the frontier has arrived at state.\n", - "\n", - "* `f[state]`: returns the node corresponding to this state in frontier.\n", - "\n", - "* `len(f)`: The number of Nodes in the frontier. When the frontier is empty, `f` is *false*.\n", - "\n", - "We provide two kinds of frontiers: One for \"regular\" queues, either first-in-first-out (for breadth-first search) or last-in-first-out (for depth-first search), and one for priority queues, where you can specify what cost function on nodes you are trying to minimize." - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "from collections import OrderedDict\n", - "import heapq\n", - "\n", - "class FrontierQ(OrderedDict):\n", - " \"A Frontier that supports FIFO or LIFO Queue ordering.\"\n", - " \n", - " def __init__(self, initial, LIFO=False):\n", - " \"\"\"Initialize Frontier with an initial Node.\n", - " If LIFO is True, pop from the end first; otherwise from front first.\"\"\"\n", - " super(FrontierQ, self).__init__()\n", - " self.LIFO = LIFO\n", - " self.add(initial)\n", - " \n", - " def add(self, node):\n", - " \"Add a node to the frontier.\"\n", - " self[node.state] = node\n", - " \n", - " def pop(self):\n", - " \"Remove and return the next Node in the frontier.\"\n", - " (state, node) = self.popitem(self.LIFO)\n", - " return node\n", - " \n", - " def replace(self, node):\n", - " \"Make this node replace the nold node with the same state.\"\n", - " del self[node.state]\n", - " self.add(node)" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "class FrontierPQ:\n", - " \"A Frontier ordered by a cost function; a Priority Queue.\"\n", - " \n", - " def __init__(self, initial, costfn=lambda node: node.path_cost):\n", - " \"Initialize Frontier with an initial Node, and specify a cost function.\"\n", - " self.heap = []\n", - " self.states = {}\n", - " self.costfn = costfn\n", - " self.add(initial)\n", - " \n", - " def add(self, node):\n", - " \"Add node to the frontier.\"\n", - " cost = self.costfn(node)\n", - " heapq.heappush(self.heap, (cost, node))\n", - " self.states[node.state] = node\n", - " \n", - " def pop(self):\n", - " \"Remove and return the Node with minimum cost.\"\n", - " (cost, node) = heapq.heappop(self.heap)\n", - " self.states.pop(node.state, None) # remove state\n", - " return node\n", - " \n", - " def replace(self, node):\n", - " \"Make this node replace a previous node with the same state.\"\n", - " if node.state not in self:\n", - " raise ValueError('{} not there to replace'.format(node.state))\n", - " for (i, (cost, old_node)) in enumerate(self.heap):\n", - " if old_node.state == node.state:\n", - " self.heap[i] = (self.costfn(node), node)\n", - " heapq._siftdown(self.heap, 0, i)\n", - " return\n", - "\n", - " def __contains__(self, state): return state in self.states\n", - " \n", - " def __len__(self): return len(self.heap)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# Search Problems\n", - "\n", - "`Problem` is the abstract class for all search problems. You can define your own class of problems as a subclass of `Problem`. You will need to override the `actions` and `result` method to describe how your problem works. You will also have to either override `is_goal` or pass a collection of goal states to the initialization method. If actions have different costs, you should override the `step_cost` method. " - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "class Problem(object):\n", - " \"\"\"The abstract class for a search problem.\"\"\"\n", - "\n", - " def __init__(self, initial=None, goals=(), **additional_keywords):\n", - " \"\"\"Provide an initial state and optional goal states.\n", - " A subclass can have additional keyword arguments.\"\"\"\n", - " self.initial = initial # The initial state of the problem.\n", - " self.goals = goals # A collection of possible goal states.\n", - " self.__dict__.update(**additional_keywords)\n", - "\n", - " def actions(self, state):\n", - " \"Return a list of actions executable in this state.\"\n", - " raise NotImplementedError # Override this!\n", - "\n", - " def result(self, state, action):\n", - " \"The state that results from executing this action in this state.\"\n", - " raise NotImplementedError # Override this!\n", - "\n", - " def is_goal(self, state):\n", - " \"True if the state is a goal.\" \n", - " return state in self.goals # Optionally override this!\n", - "\n", - " def step_cost(self, state, action, result=None):\n", - " \"The cost of taking this action from this state.\"\n", - " return 1 # Override this if actions have different costs " - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "def action_sequence(node):\n", - " \"The sequence of actions to get to this node.\"\n", - " actions = []\n", - " while node.previous:\n", - " actions.append(node.action)\n", - " node = node.previous\n", - " return actions[::-1]\n", - "\n", - "def state_sequence(node):\n", - " \"The sequence of states to get to this node.\"\n", - " states = [node.state]\n", - " while node.previous:\n", - " node = node.previous\n", - " states.append(node.state)\n", - " return states[::-1]" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# Two Location Vacuum World" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "dirt = '*'\n", - "clean = ' '\n", - "\n", - "class TwoLocationVacuumProblem(Problem):\n", - " \"\"\"A Vacuum in a world with two locations, and dirt.\n", - " Each state is a tuple of (location, dirt_in_W, dirt_in_E).\"\"\"\n", - "\n", - " def actions(self, state): return ('W', 'E', 'Suck')\n", - " \n", - " def is_goal(self, state): return dirt not in state\n", - " \n", - " def result(self, state, action):\n", - " \"The state that results from executing this action in this state.\" \n", - " (loc, dirtW, dirtE) = state\n", - " if action == 'W': return ('W', dirtW, dirtE)\n", - " elif action == 'E': return ('E', dirtW, dirtE)\n", - " elif action == 'Suck' and loc == 'W': return (loc, clean, dirtE)\n", - " elif action == 'Suck' and loc == 'E': return (loc, dirtW, clean) \n", - " else: raise ValueError('unknown action: ' + action)" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 26, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "problem = TwoLocationVacuumProblem(initial=('W', dirt, dirt))\n", - "result = uniform_cost_search(problem)\n", - "result" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['Suck', 'E', 'Suck']" - ] - }, - "execution_count": 27, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "action_sequence(result)" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[('W', '*', '*'), ('W', ' ', '*'), ('E', ' ', '*'), ('E', ' ', ' ')]" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "state_sequence(result)" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "['Suck']" - ] - }, - "execution_count": 29, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "problem = TwoLocationVacuumProblem(initial=('E', clean, dirt))\n", - "result = uniform_cost_search(problem)\n", - "action_sequence(result)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# Water Pouring Problem\n", - "\n", - "Here is another problem domain, to show you how to define one. The idea is that we have a number of water jugs and a water tap and the goal is to measure out a specific amount of water (in, say, ounces or liters). You can completely fill or empty a jug, but because the jugs don't have markings on them, you can't partially fill them with a specific amount. You can, however, pour one jug into another, stopping when the seconfd is full or the first is empty." - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "class PourProblem(Problem):\n", - " \"\"\"Problem about pouring water between jugs to achieve some water level.\n", - " Each state is a tuples of levels. In the initialization, provide a tuple of \n", - " capacities, e.g. PourProblem(capacities=(8, 16, 32), initial=(2, 4, 3), goals={7}), \n", - " which means three jugs of capacity 8, 16, 32, currently filled with 2, 4, 3 units of \n", - " water, respectively, and the goal is to get a level of 7 in any one of the jugs.\"\"\"\n", - " \n", - " def actions(self, state):\n", - " \"\"\"The actions executable in this state.\"\"\"\n", - " jugs = range(len(state))\n", - " return ([('Fill', i) for i in jugs if state[i] != self.capacities[i]] +\n", - " [('Dump', i) for i in jugs if state[i] != 0] +\n", - " [('Pour', i, j) for i in jugs for j in jugs if i != j])\n", - "\n", - " def result(self, state, action):\n", - " \"\"\"The state that results from executing this action in this state.\"\"\"\n", - " result = list(state)\n", - " act, i, j = action[0], action[1], action[-1]\n", - " if act == 'Fill': # Fill i to capacity\n", - " result[i] = self.capacities[i]\n", - " elif act == 'Dump': # Empty i\n", - " result[i] = 0\n", - " elif act == 'Pour':\n", - " a, b = state[i], state[j]\n", - " result[i], result[j] = ((0, a + b) \n", - " if (a + b <= self.capacities[j]) else\n", - " (a + b - self.capacities[j], self.capacities[j]))\n", - " else:\n", - " raise ValueError('unknown action', action)\n", - " return tuple(result)\n", - "\n", - " def is_goal(self, state):\n", - " \"\"\"True if any of the jugs has a level equal to one of the goal levels.\"\"\"\n", - " return any(level in self.goals for level in state)" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "(2, 13)" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "p7 = PourProblem(initial=(2, 0), capacities=(5, 13), goals={7})\n", - "p7.result((2, 0), ('Fill', 1))" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "[('Pour', 0, 1), ('Fill', 0), ('Pour', 0, 1)]" - ] - }, - "execution_count": 32, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "result = uniform_cost_search(p7)\n", - "action_sequence(result)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# Visualization Output" - ] - }, - { - "cell_type": "code", - "execution_count": 33, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "def showpath(searcher, problem):\n", - " \"Show what happens when searcvher solves problem.\"\n", - " problem = Instrumented(problem)\n", - " print('\\n{}:'.format(searcher.__name__))\n", - " result = searcher(problem)\n", - " if result:\n", - " actions = action_sequence(result)\n", - " state = problem.initial\n", - " path_cost = 0\n", - " for steps, action in enumerate(actions, 1):\n", - " path_cost += problem.step_cost(state, action, 0)\n", - " result = problem.result(state, action)\n", - " print(' {} =={}==> {}; cost {} after {} steps'\n", - " .format(state, action, result, path_cost, steps,\n", - " '; GOAL!' if problem.is_goal(result) else ''))\n", - " state = result\n", - " msg = 'GOAL FOUND' if result else 'no solution'\n", - " print('{} after {} results and {} goal checks'\n", - " .format(msg, problem._counter['result'], problem._counter['is_goal']))\n", - " \n", - "from collections import Counter\n", - "\n", - "class Instrumented:\n", - " \"Instrument an object to count all the attribute accesses in _counter.\"\n", - " def __init__(self, obj):\n", - " self._object = obj\n", - " self._counter = Counter()\n", - " def __getattr__(self, attr):\n", - " self._counter[attr] += 1\n", - " return getattr(self._object, attr) " - ] - }, - { - "cell_type": "code", - "execution_count": 34, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "uniform_cost_search:\n", - " (2, 0) ==('Pour', 0, 1)==> (0, 2); cost 1 after 1 steps\n", - " (0, 2) ==('Fill', 0)==> (5, 2); cost 2 after 2 steps\n", - " (5, 2) ==('Pour', 0, 1)==> (0, 7); cost 3 after 3 steps\n", - "GOAL FOUND after 83 results and 22 goal checks\n" - ] - } - ], - "source": [ - "showpath(uniform_cost_search, p7)" - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "uniform_cost_search:\n", - " (0, 0) ==('Fill', 0)==> (7, 0); cost 1 after 1 steps\n", - " (7, 0) ==('Pour', 0, 1)==> (0, 7); cost 2 after 2 steps\n", - " (0, 7) ==('Fill', 0)==> (7, 7); cost 3 after 3 steps\n", - " (7, 7) ==('Pour', 0, 1)==> (1, 13); cost 4 after 4 steps\n", - " (1, 13) ==('Dump', 1)==> (1, 0); cost 5 after 5 steps\n", - " (1, 0) ==('Pour', 0, 1)==> (0, 1); cost 6 after 6 steps\n", - " (0, 1) ==('Fill', 0)==> (7, 1); cost 7 after 7 steps\n", - " (7, 1) ==('Pour', 0, 1)==> (0, 8); cost 8 after 8 steps\n", - " (0, 8) ==('Fill', 0)==> (7, 8); cost 9 after 9 steps\n", - " (7, 8) ==('Pour', 0, 1)==> (2, 13); cost 10 after 10 steps\n", - "GOAL FOUND after 110 results and 32 goal checks\n" - ] - } - ], - "source": [ - "p = PourProblem(initial=(0, 0), capacities=(7, 13), goals={2})\n", - "showpath(uniform_cost_search, p)" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "class GreenPourProblem(PourProblem): \n", - " def step_cost(self, state, action, result=None):\n", - " \"The cost is the amount of water used in a fill.\"\n", - " if action[0] == 'Fill':\n", - " i = action[1]\n", - " return self.capacities[i] - state[i]\n", - " return 0" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "uniform_cost_search:\n", - " (0, 0) ==('Fill', 0)==> (7, 0); cost 7 after 1 steps\n", - " (7, 0) ==('Pour', 0, 1)==> (0, 7); cost 7 after 2 steps\n", - " (0, 7) ==('Fill', 0)==> (7, 7); cost 14 after 3 steps\n", - " (7, 7) ==('Pour', 0, 1)==> (1, 13); cost 14 after 4 steps\n", - " (1, 13) ==('Dump', 1)==> (1, 0); cost 14 after 5 steps\n", - " (1, 0) ==('Pour', 0, 1)==> (0, 1); cost 14 after 6 steps\n", - " (0, 1) ==('Fill', 0)==> (7, 1); cost 21 after 7 steps\n", - " (7, 1) ==('Pour', 0, 1)==> (0, 8); cost 21 after 8 steps\n", - " (0, 8) ==('Fill', 0)==> (7, 8); cost 28 after 9 steps\n", - " (7, 8) ==('Pour', 0, 1)==> (2, 13); cost 28 after 10 steps\n", - "GOAL FOUND after 184 results and 48 goal checks\n" - ] - } - ], - "source": [ - "p = GreenPourProblem(initial=(0, 0), capacities=(7, 13), goals={2})\n", - "showpath(uniform_cost_search, p)" - ] - }, - { - "cell_type": "code", - "execution_count": 38, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "def compare_searchers(problem, searchers=None):\n", - " \"Apply each of the search algorithms to the problem, and show results\"\n", - " if searchers is None: \n", - " searchers = (breadth_first_search, uniform_cost_search)\n", - " for searcher in searchers:\n", - " showpath(searcher, problem)" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "breadth_first_search:\n", - " (0, 0) ==('Fill', 0)==> (7, 0); cost 7 after 1 steps\n", - " (7, 0) ==('Pour', 0, 1)==> (0, 7); cost 7 after 2 steps\n", - " (0, 7) ==('Fill', 0)==> (7, 7); cost 14 after 3 steps\n", - " (7, 7) ==('Pour', 0, 1)==> (1, 13); cost 14 after 4 steps\n", - " (1, 13) ==('Dump', 1)==> (1, 0); cost 14 after 5 steps\n", - " (1, 0) ==('Pour', 0, 1)==> (0, 1); cost 14 after 6 steps\n", - " (0, 1) ==('Fill', 0)==> (7, 1); cost 21 after 7 steps\n", - " (7, 1) ==('Pour', 0, 1)==> (0, 8); cost 21 after 8 steps\n", - " (0, 8) ==('Fill', 0)==> (7, 8); cost 28 after 9 steps\n", - " (7, 8) ==('Pour', 0, 1)==> (2, 13); cost 28 after 10 steps\n", - "GOAL FOUND after 100 results and 31 goal checks\n", - "\n", - "uniform_cost_search:\n", - " (0, 0) ==('Fill', 0)==> (7, 0); cost 7 after 1 steps\n", - " (7, 0) ==('Pour', 0, 1)==> (0, 7); cost 7 after 2 steps\n", - " (0, 7) ==('Fill', 0)==> (7, 7); cost 14 after 3 steps\n", - " (7, 7) ==('Pour', 0, 1)==> (1, 13); cost 14 after 4 steps\n", - " (1, 13) ==('Dump', 1)==> (1, 0); cost 14 after 5 steps\n", - " (1, 0) ==('Pour', 0, 1)==> (0, 1); cost 14 after 6 steps\n", - " (0, 1) ==('Fill', 0)==> (7, 1); cost 21 after 7 steps\n", - " (7, 1) ==('Pour', 0, 1)==> (0, 8); cost 21 after 8 steps\n", - " (0, 8) ==('Fill', 0)==> (7, 8); cost 28 after 9 steps\n", - " (7, 8) ==('Pour', 0, 1)==> (2, 13); cost 28 after 10 steps\n", - "GOAL FOUND after 184 results and 48 goal checks\n" - ] - } - ], - "source": [ - "compare_searchers(p)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Random Grid\n", - "\n", - "An environment where you can move in any of 4 directions, unless there is an obstacle there.\n", - "\n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 40, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{(0, 0): [(0, 1), (1, 0)],\n", - " (0, 1): [(0, 2), (0, 0), (1, 1)],\n", - " (0, 2): [(0, 3), (0, 1), (1, 2)],\n", - " (0, 3): [(0, 4), (0, 2), (1, 3)],\n", - " (0, 4): [(0, 3), (1, 4)],\n", - " (1, 0): [(1, 1), (2, 0), (0, 0)],\n", - " (1, 1): [(1, 2), (1, 0), (2, 1), (0, 1)],\n", - " (1, 2): [(1, 3), (1, 1), (2, 2), (0, 2)],\n", - " (1, 3): [(1, 4), (1, 2), (2, 3), (0, 3)],\n", - " (1, 4): [(1, 3), (2, 4), (0, 4)],\n", - " (2, 0): [(2, 1), (3, 0), (1, 0)],\n", - " (2, 1): [(2, 2), (2, 0), (3, 1), (1, 1)],\n", - " (2, 2): [(2, 3), (2, 1), (1, 2)],\n", - " (2, 3): [(2, 4), (2, 2), (3, 3), (1, 3)],\n", - " (2, 4): [(2, 3), (1, 4)],\n", - " (3, 0): [(3, 1), (4, 0), (2, 0)],\n", - " (3, 1): [(3, 0), (4, 1), (2, 1)],\n", - " (3, 2): [(3, 3), (3, 1), (4, 2), (2, 2)],\n", - " (3, 3): [(4, 3), (2, 3)],\n", - " (3, 4): [(3, 3), (4, 4), (2, 4)],\n", - " (4, 0): [(4, 1), (3, 0)],\n", - " (4, 1): [(4, 2), (4, 0), (3, 1)],\n", - " (4, 2): [(4, 3), (4, 1)],\n", - " (4, 3): [(4, 4), (4, 2), (3, 3)],\n", - " (4, 4): [(4, 3)]}" - ] - }, - "execution_count": 40, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import random\n", - "\n", - "N, S, E, W = DIRECTIONS = [(0, 1), (0, -1), (1, 0), (-1, 0)]\n", - "\n", - "def Grid(width, height, obstacles=0.1):\n", - " \"\"\"A 2-D grid, width x height, with obstacles that are either a collection of points,\n", - " or a fraction between 0 and 1 indicating the density of obstacles, chosen at random.\"\"\"\n", - " grid = {(x, y) for x in range(width) for y in range(height)}\n", - " if isinstance(obstacles, (float, int)):\n", - " obstacles = random.sample(grid, int(width * height * obstacles))\n", - " def neighbors(x, y):\n", - " for (dx, dy) in DIRECTIONS:\n", - " (nx, ny) = (x + dx, y + dy)\n", - " if (nx, ny) not in obstacles and 0 <= nx < width and 0 <= ny < height:\n", - " yield (nx, ny)\n", - " return {(x, y): list(neighbors(x, y))\n", - " for x in range(width) for y in range(height)}\n", - "\n", - "Grid(5, 5)" - ] - }, - { - "cell_type": "code", - "execution_count": 41, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "class GridProblem(Problem):\n", - " \"Create with a call like GridProblem(grid=Grid(10, 10), initial=(0, 0), goal=(9, 9))\"\n", - " def actions(self, state): return DIRECTIONS\n", - " def result(self, state, action):\n", - " #print('ask for result of', state, action)\n", - " (x, y) = state\n", - " (dx, dy) = action\n", - " r = (x + dx, y + dy)\n", - " return r if r in self.grid[state] else state" - ] - }, - { - "cell_type": "code", - "execution_count": 42, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "uniform_cost_search:\n", - " (0, 0) ==(0, 1)==> (0, 1); cost 1 after 1 steps\n", - " (0, 1) ==(0, 1)==> (0, 2); cost 2 after 2 steps\n", - " (0, 2) ==(0, 1)==> (0, 3); cost 3 after 3 steps\n", - " (0, 3) ==(1, 0)==> (1, 3); cost 4 after 4 steps\n", - " (1, 3) ==(1, 0)==> (2, 3); cost 5 after 5 steps\n", - " (2, 3) ==(0, 1)==> (2, 4); cost 6 after 6 steps\n", - " (2, 4) ==(1, 0)==> (3, 4); cost 7 after 7 steps\n", - " (3, 4) ==(1, 0)==> (4, 4); cost 8 after 8 steps\n", - "GOAL FOUND after 248 results and 69 goal checks\n" - ] - } - ], - "source": [ - "gp = GridProblem(grid=Grid(5, 5, 0.3), initial=(0, 0), goals={(4, 4)})\n", - "showpath(uniform_cost_search, gp)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# Finding a hard PourProblem\n", - "\n", - "What solvable two-jug PourProblem requires the most steps? We can define the hardness as the number of steps, and then iterate over all PourProblems with capacities up to size M, keeping the hardest one." - ] - }, - { - "cell_type": "code", - "execution_count": 43, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "def hardness(problem):\n", - " L = breadth_first_search(problem)\n", - " #print('hardness', problem.initial, problem.capacities, problem.goals, L)\n", - " return len(action_sequence(L)) if (L is not None) else 0" - ] - }, - { - "cell_type": "code", - "execution_count": 44, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "3" - ] - }, - "execution_count": 44, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hardness(p7)" - ] - }, - { - "cell_type": "code", - "execution_count": 45, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[('Pour', 0, 1), ('Fill', 0), ('Pour', 0, 1)]" - ] - }, - "execution_count": 45, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "action_sequence(breadth_first_search(p7))" - ] - }, - { - "cell_type": "code", - "execution_count": 46, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "((0, 0), (7, 9), {8})" - ] - }, - "execution_count": 46, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "C = 9 # Maximum capacity to consider\n", - "\n", - "phard = max((PourProblem(initial=(a, b), capacities=(A, B), goals={goal})\n", - " for A in range(C+1) for B in range(C+1)\n", - " for a in range(A) for b in range(B)\n", - " for goal in range(max(A, B))),\n", - " key=hardness)\n", - "\n", - "phard.initial, phard.capacities, phard.goals" - ] - }, - { - "cell_type": "code", - "execution_count": 47, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "breadth_first_search:\n", - " (0, 0) ==('Fill', 1)==> (0, 9); cost 1 after 1 steps\n", - " (0, 9) ==('Pour', 1, 0)==> (7, 2); cost 2 after 2 steps\n", - " (7, 2) ==('Dump', 0)==> (0, 2); cost 3 after 3 steps\n", - " (0, 2) ==('Pour', 1, 0)==> (2, 0); cost 4 after 4 steps\n", - " (2, 0) ==('Fill', 1)==> (2, 9); cost 5 after 5 steps\n", - " (2, 9) ==('Pour', 1, 0)==> (7, 4); cost 6 after 6 steps\n", - " (7, 4) ==('Dump', 0)==> (0, 4); cost 7 after 7 steps\n", - " (0, 4) ==('Pour', 1, 0)==> (4, 0); cost 8 after 8 steps\n", - " (4, 0) ==('Fill', 1)==> (4, 9); cost 9 after 9 steps\n", - " (4, 9) ==('Pour', 1, 0)==> (7, 6); cost 10 after 10 steps\n", - " (7, 6) ==('Dump', 0)==> (0, 6); cost 11 after 11 steps\n", - " (0, 6) ==('Pour', 1, 0)==> (6, 0); cost 12 after 12 steps\n", - " (6, 0) ==('Fill', 1)==> (6, 9); cost 13 after 13 steps\n", - " (6, 9) ==('Pour', 1, 0)==> (7, 8); cost 14 after 14 steps\n", - "GOAL FOUND after 150 results and 44 goal checks\n" - ] - } - ], - "source": [ - "showpath(breadth_first_search, PourProblem(initial=(0, 0), capacities=(7, 9), goals={8}))" - ] - }, - { - "cell_type": "code", - "execution_count": 48, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "uniform_cost_search:\n", - " (0, 0) ==('Fill', 1)==> (0, 9); cost 1 after 1 steps\n", - " (0, 9) ==('Pour', 1, 0)==> (7, 2); cost 2 after 2 steps\n", - " (7, 2) ==('Dump', 0)==> (0, 2); cost 3 after 3 steps\n", - " (0, 2) ==('Pour', 1, 0)==> (2, 0); cost 4 after 4 steps\n", - " (2, 0) ==('Fill', 1)==> (2, 9); cost 5 after 5 steps\n", - " (2, 9) ==('Pour', 1, 0)==> (7, 4); cost 6 after 6 steps\n", - " (7, 4) ==('Dump', 0)==> (0, 4); cost 7 after 7 steps\n", - " (0, 4) ==('Pour', 1, 0)==> (4, 0); cost 8 after 8 steps\n", - " (4, 0) ==('Fill', 1)==> (4, 9); cost 9 after 9 steps\n", - " (4, 9) ==('Pour', 1, 0)==> (7, 6); cost 10 after 10 steps\n", - " (7, 6) ==('Dump', 0)==> (0, 6); cost 11 after 11 steps\n", - " (0, 6) ==('Pour', 1, 0)==> (6, 0); cost 12 after 12 steps\n", - " (6, 0) ==('Fill', 1)==> (6, 9); cost 13 after 13 steps\n", - " (6, 9) ==('Pour', 1, 0)==> (7, 8); cost 14 after 14 steps\n", - "GOAL FOUND after 159 results and 45 goal checks\n" - ] - } - ], - "source": [ - "showpath(uniform_cost_search, phard)" - ] - }, - { - "cell_type": "code", - "execution_count": 49, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "class GridProblem(Problem):\n", - " \"\"\"A Grid.\"\"\"\n", - "\n", - " def actions(self, state): return ['N', 'S', 'E', 'W'] \n", - " \n", - " def result(self, state, action):\n", - " \"\"\"The state that results from executing this action in this state.\"\"\" \n", - " (W, H) = self.size\n", - " if action == 'N' and state > W: return state - W\n", - " if action == 'S' and state + W < W * W: return state + W\n", - " if action == 'E' and (state + 1) % W !=0: return state + 1\n", - " if action == 'W' and state % W != 0: return state - 1\n", - " return state" - ] - }, - { - "cell_type": "code", - "execution_count": 50, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "breadth_first_search:\n", - " 0 ==S==> 10; cost 1 after 1 steps\n", - " 10 ==S==> 20; cost 2 after 2 steps\n", - " 20 ==S==> 30; cost 3 after 3 steps\n", - " 30 ==S==> 40; cost 4 after 4 steps\n", - " 40 ==E==> 41; cost 5 after 5 steps\n", - " 41 ==E==> 42; cost 6 after 6 steps\n", - " 42 ==E==> 43; cost 7 after 7 steps\n", - " 43 ==E==> 44; cost 8 after 8 steps\n", - "GOAL FOUND after 135 results and 49 goal checks\n", - "\n", - "uniform_cost_search:\n", - " 0 ==S==> 10; cost 1 after 1 steps\n", - " 10 ==S==> 20; cost 2 after 2 steps\n", - " 20 ==E==> 21; cost 3 after 3 steps\n", - " 21 ==E==> 22; cost 4 after 4 steps\n", - " 22 ==E==> 23; cost 5 after 5 steps\n", - " 23 ==S==> 33; cost 6 after 6 steps\n", - " 33 ==S==> 43; cost 7 after 7 steps\n", - " 43 ==E==> 44; cost 8 after 8 steps\n", - "GOAL FOUND after 1036 results and 266 goal checks\n" - ] - } - ], - "source": [ - "compare_searchers(GridProblem(initial=0, goals={44}, size=(10, 10)))" - ] - }, - { - "cell_type": "code", - "execution_count": 51, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "'test_frontier ok'" - ] - }, - "execution_count": 51, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "def test_frontier():\n", - " \n", - " #### Breadth-first search with FIFO Q\n", - " f = FrontierQ(Node(1), LIFO=False)\n", - " assert 1 in f and len(f) == 1\n", - " f.add(Node(2))\n", - " f.add(Node(3))\n", - " assert 1 in f and 2 in f and 3 in f and len(f) == 3\n", - " assert f.pop().state == 1\n", - " assert 1 not in f and 2 in f and 3 in f and len(f) == 2\n", - " assert f\n", - " assert f.pop().state == 2\n", - " assert f.pop().state == 3\n", - " assert not f\n", - " \n", - " #### Depth-first search with LIFO Q\n", - " f = FrontierQ(Node('a'), LIFO=True)\n", - " for s in 'bcdef': f.add(Node(s))\n", - " assert len(f) == 6 and 'a' in f and 'c' in f and 'f' in f\n", - " for s in 'fedcba': assert f.pop().state == s\n", - " assert not f\n", - "\n", - " #### Best-first search with Priority Q\n", - " f = FrontierPQ(Node(''), lambda node: len(node.state))\n", - " assert '' in f and len(f) == 1 and f\n", - " for s in ['book', 'boo', 'bookie', 'bookies', 'cook', 'look', 'b']:\n", - " assert s not in f\n", - " f.add(Node(s))\n", - " assert s in f\n", - " assert f.pop().state == ''\n", - " assert f.pop().state == 'b'\n", - " assert f.pop().state == 'boo'\n", - " assert {f.pop().state for _ in '123'} == {'book', 'cook', 'look'}\n", - " assert f.pop().state == 'bookie'\n", - " \n", - " #### Romania: Two paths to Bucharest; cheapest one found first\n", - " S = Node('S')\n", - " SF = Node('F', S, 'S->F', 99)\n", - " SFB = Node('B', SF, 'F->B', 211)\n", - " SR = Node('R', S, 'S->R', 80)\n", - " SRP = Node('P', SR, 'R->P', 97)\n", - " SRPB = Node('B', SRP, 'P->B', 101)\n", - " f = FrontierPQ(S)\n", - " f.add(SF); f.add(SR), f.add(SRP), f.add(SRPB); f.add(SFB)\n", - " def cs(n): return (n.path_cost, n.state) # cs: cost and state\n", - " assert cs(f.pop()) == (0, 'S')\n", - " assert cs(f.pop()) == (80, 'R')\n", - " assert cs(f.pop()) == (99, 'F')\n", - " assert cs(f.pop()) == (177, 'P')\n", - " assert cs(f.pop()) == (278, 'B')\n", - " return 'test_frontier ok'\n", - "\n", - "test_frontier()" - ] - }, - { - "cell_type": "code", - "execution_count": 52, - "metadata": { - "button": false, - "collapsed": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "# %matplotlib inline\n", - "import matplotlib.pyplot as plt\n", - "\n", - "p = plt.plot([i**2 for i in range(10)])\n", - "plt.savefig('destination_path.eps', format='eps', dpi=1200)" - ] - }, - { - "cell_type": "code", - "execution_count": 53, - "metadata": { - "button": false, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "image/png": "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\ngg==\n", - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAeYAAAHSCAYAAAA5eGh0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz\nAAALEgAACxIB0t1+/AAAADl0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uIDIuMS4wLCBo\ndHRwOi8vbWF0cGxvdGxpYi5vcmcvpW3flQAAIABJREFUeJzt219Mk/f///9HaxOnNULI1iIWxrSi\nk8URHfFPYjK7g2YaiOhUkkUPdrIlLNmOfX8mduLigYdkMYsHYKIBBDM2o43GGNEjdIZEl2iE6JQ/\nFlw0bI1b4aLfA3/29674nuCAvvrifjuif676fPC6ruvRq0VXMpkUAAAwgzvTAwAAgP8fxQwAgEEo\nZgAADEIxAwBgEIoZAACDUMwAABjEk+kBXseBAwcejo2N+TM9x1Rzu91jY2Nj1r1ZSiaTYy6Xy7pc\nkr1r5vF4xkZHR63LJdm7P86ZM2fMcRzrctl6jEmS2+2OffPNN/kv3p+VxTw2Nubfvn17pseYcm1t\nbW5bc8VisUyPMS38fr+1a1ZbW5vpMaZFJBKxcn/0+/1WrlkkErHyGJOktra2l15gWvkuBACAbEUx\nAwBgEIoZAACDUMwAABiEYgYAwCAUMwAABqGYAQAwCMUMAIBBKGYAAAxCMQMAYBCKGQAAg1DMAAAY\nhGIGAMAgFDMAAAahmAEAMAjFDACAQShmAAAMQjEDAGAQihkAAINQzAAAGGTWFvOVK1dUUVGhzZs3\n6+jRo+Mev3btmnbu3KmysjKdO3cudf+tW7f06aefauvWrdq2bZui0ehMjj0htma7f/++Tpw4oePH\nj+v69evjHu/v79fJkyd15MgR9fT0pO5/9OiRTp06paamJjU3N6u7u3smx34lW9dLkqLRqJYvX65g\nMKhDhw6Ne7yjo0OrV6+Wx+NRa2tr6v6uri6tX79epaWlWrVqlZqbm2dy7FeydV+U7F2zbDrOPNP+\nLxjIcRwdPHhQP/zwg/Lz81VdXa1NmzZp6dKlqecsWrRIBw4cUGNjY9q2b7zxhr777ju9/fbbGhwc\n1K5du7RhwwYtXLhwpmO8lK3ZxsbGdPnyZVVUVMjr9aqtrU3FxcXKy8tLPWfBggUKhULq6upK29bj\n8SgUCik3N1fxeFytra0qLCzU3LlzZzrGOLaul/QsW01Njc6fP69AIKDy8nJVVlZq5cqVqecUFRWp\noaFBhw8fTtt2/vz5OnbsmJYtW6b+/n6tWbNG4XBYubm5Mx1jHFv3RcneNcu242xWFvONGzdUVFSk\nwsJCSdLHH3+sixcvpi3S4sWLJUkulytt2+Li4tTPPp9PeXl5evz4sTEnQ1uzDQ4OKicnJzVLMBjU\nvXv30k6Gzx97Mdd/nxi8Xq/mzZunp0+fGnEytHW9JKmzs1PBYFBLliyRJFVXV6u9vT3tJP88g9ud\n/uFdSUlJ6ueCggL5fD4NDQ0ZcZK3dV+U7F2zbDvOZuVH2YODg8rPz0/d9vv9isVik36dGzduaGRk\nJLXYJrA1Wzwel9frTd32er2Kx+OTfp1YLCbHcZSTkzOV4702W9dLkvr6+tLmCQQC6uvrm/TrdHZ2\nKpFIpJ1EM8nWfVGyd82y7TiblVfMyWRy3H0vvkt6laGhIe3du1d1dXXj3jlmks3Z/q14PK4LFy4o\nFApN+ncyXWxer6nINjAwoN27d6uxsdGobP+WifuiZO+aZdtxZsZvbYb5/X49fPgwdTsWi8nn8014\n+z///FM1NTX68ssv9f7770/HiK/N1mwvXpW8eNXyKolEQmfOnNHatWvT3jlnmq3rJT272nrw4EHq\ndm9vrwoKCia8/fDwsLZs2aK6ujqtW7duOkZ8Lbbui5K9a5Ztx9msLOb33ntPv/32m3p7ezUyMqKz\nZ8/qww8/nNC2IyMj+vrrr1VRUaFwODy9g74GW7P5fD49efJEw8PDchxH3d3dad/9/BPHcRSNRlVS\nUmLMR2vP2bpeklReXq47d+7o7t27SiQSampqUmVl5YS2TSQSqqqq0p49e7Rjx45pnnRybN0XJXvX\nLNuOs1n5UbbH49HevXv1xRdfyHEcVVVVKRgMqr6+XqWlpdq0aZNu3rypr776Sn/88YcuXbqk77//\nXj/++KOi0ah++eUXPXnyRO3t7ZKkuro6rVixIsOpnrE1m9vt1saNG3X69Gklk0mtWLFCeXl56uzs\n1FtvvaV33nlHg4ODikaj+vvvv3Xv3j1dvXpV1dXV6unp0cDAgP766y/dvn1bkhQKhfTmm29mOJW9\n6yU9y1ZfX69wOCzHcfTZZ5+ptLRU+/bt0wcffKDKykpdvXpVVVVVevz4sX7++WfV1tbq119/VUtL\nizo6OvT777+roaFBktTQ0KCysrLMhpK9+6Jk75pl23Hmetln76aLRCLJ7du3Z3qMKdfW1iZbc73O\nH1pkA7/fb+2a1dbWZnqMaRGJRKzcH/1+v5VrFolErDzGpNRxNu7L7ln5UTYAAKaimAEAMAjFDACA\nQShmAAAMQjEDAGAQihkAAINQzAAAGIRiBgDAIBQzAAAGoZgBADAIxQwAgEEoZgAADEIxAwBgEIoZ\nAACDUMwAABiEYgYAwCAUMwAABqGYAQAwCMUMAIBBKGYAAAxCMQMAYBCKGQAAg1DMAAAYxJVMJjM9\nw6QdPHjQGR0dte5Nhcfj0ejoaKbHmHLJZFIulyvTY0yLOXPmyHGcTI8x5WzdFyV7s7ndbo2NjWV6\njCln63pJksfjGfvPf/4zZ9z9mRjm3xodHXXX1tZmeowpF4lEZGuuWCyW6TGmhd/vt3bNbMwl2Zst\nEolo+/btmR5jyrW1tVm5XpIUiUReeoFp3VUnAADZjGIGAMAgFDMAAAahmAEAMAjFDACAQShmAAAM\nQjEDAGAQihkAAINQzAAAGIRiBgDAIBQzAAAGoZgBADAIxQwAgEEoZgAADEIxAwBgEIoZAACDUMwA\nABiEYgYAwCAUMwAABqGYAQAwCMUMAIBBZm0xR6NRLV++XMFgUIcOHRr3eEdHh1avXi2Px6PW1tbU\n/V1dXVq/fr1KS0u1atUqNTc3z+TYE2Jrtvv37+vEiRM6fvy4rl+/Pu7x/v5+nTx5UkeOHFFPT0/q\n/kePHunUqVNqampSc3Ozuru7Z3LsV7J1vSR7s9maS5KuXLmiiooKbd68WUePHh33+LVr17Rz506V\nlZXp3Llzqftv3bqlTz/9VFu3btW2bdsUjUZncuxXyqY180z7v2Agx3FUU1Oj8+fPKxAIqLy8XJWV\nlVq5cmXqOUVFRWpoaNDhw4fTtp0/f76OHTumZcuWqb+/X2vWrFE4HFZubu5Mx3gpW7ONjY3p8uXL\nqqiokNfrVVtbm4qLi5WXl5d6zoIFCxQKhdTV1ZW2rcfjUSgUUm5uruLxuFpbW1VYWKi5c+fOdIxx\nbF0vyd5stuaSnmU7ePCgfvjhB+Xn56u6ulqbNm3S0qVLU89ZtGiRDhw4oMbGxrRt33jjDX333Xd6\n++23NTg4qF27dmnDhg1auHDhTMcYJ9vWbFYWc2dnp4LBoJYsWSJJqq6uVnt7e9oiFRcXS5Lc7vQP\nFUpKSlI/FxQUyOfzaWhoyJgDy9Zsg4ODysnJSR3kwWBQ9+7dSyvm54+5XK60bf97fq/Xq3nz5unp\n06dGFLOt6yXZm83WXJJ048YNFRUVqbCwUJL08ccf6+LFi2nFvHjxYknjj7PnmSXJ5/MpLy9Pjx8/\nNqKYs23NZuVH2X19fakdT5ICgYD6+vom/TqdnZ1KJBJpO22m2ZotHo/L6/Wmbnu9XsXj8Um/TiwW\nk+M4ysnJmcrxXput6yXZm83WXNKzN8D5+fmp236/X7FYbNKvc+PGDY2MjKT9njIp29ZsVl4xJ5PJ\ncfe9+O7vVQYGBrR79241NjaOe4eVSTZn+7fi8bguXLigUCg06d/JdLF5vWzNZmsuaWqyDQ0Nae/e\nvaqrqzMmW7atmRm/tRkWCAT04MGD1O3e3l4VFBRMePvh4WFt2bJFdXV1Wrdu3XSM+NpszfbiFfKL\nV9CvkkgkdObMGa1duzbtiiDTbF0vyd5stuaSnl0hP3z4MHU7FovJ5/NNePs///xTNTU1+vLLL/X+\n++9Px4ivJdvWbFYWc3l5ue7cuaO7d+8qkUioqalJlZWVE9o2kUioqqpKe/bs0Y4dO6Z50smzNZvP\n59OTJ080PDwsx3HU3d2d9p3WP3EcR9FoVCUlJUZ9bCjZu16SvdlszSVJ7733nn777Tf19vZqZGRE\nZ8+e1YcffjihbUdGRvT111+roqJC4XB4egedpGxbs1lZzB6PR/X19QqHw3r33Xe1c+dOlZaWat++\nffrpp58kSVevXlUgENDJkyf1+eefq7S0VJLU0tKijo4ONTQ0qKysTGVlZeP+CjiTbM3mdru1ceNG\nnT59Wk1NTVq6dKny8vLU2dmpu3fvSnr2/dixY8fU09OjS5cuqampSZLU09OjgYEB3b59Wy0tLWpp\nadGjR48yGSfF1vWS7M1may7pWba9e/fqiy++UGVlpcLhsILBoOrr63Xx4kVJ0s2bN/XRRx/p/Pnz\n+vbbb7V161ZJz/470i+//KL29nZ98skn+uSTT3Tr1q1MxknJtjVzveyzd9NFIpFkbW1tpseYcpFI\nRLbmep0/IMkGfr/f2jWzMZdkb7ZIJKLt27dneowp19bWZuV6Sal9cdyX3bPyihkAAFNRzAAAGIRi\nBgDAIBQzAAAGoZgBADAIxQwAgEEoZgAADEIxAwBgEIoZAACDUMwAABiEYgYAwCAUMwAABqGYAQAw\nCMUMAIBBKGYAAAxCMQMAYBCKGQAAg1DMAAAYhGIGAMAgFDMAAAahmAEAMAjFDACAQShmAAAM4kom\n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\nAINQzAAAGIRiBgDAIBQzAAAGoZgBADAIxQwAgEEoZgAADEIxAwBgEIoZAACDUMwAABiEYgYAwCAU\nMwAABqGYAQAwCMUMAIBBKGYAAAwya4v5/v37OnHihI4fP67r16+Pe7y/v18nT57UkSNH1NPTk7r/\n0aNHOnXqlJqamtTc3Kzu7u6ZHHtCotGoli9frmAwqEOHDo17vKOjQ6tXr5bH41Fra2vq/q6uLq1f\nv16lpaVatWqVmpubZ3LsVyJXduWS7D3ObM0l2bs/ZtOaeab9XzDQ2NiYLl++rIqKCnm9XrW1tam4\nuFh5eXmp5yxYsEChUEhdXV1p23o8HoVCIeXm5ioej6u1tVWFhYWaO3fuTMd4KcdxVFNTo/PnzysQ\nCKi8vFyVlZVauXJl6jlFRUVqaGjQ4cOH07adP3++jh07pmXLlqm/v19r1qxROBxWbm7uTMcYh1zZ\nlUuy9zizNZdk7/6YbWs2K4t5cHBQOTk5WrhwoSQpGAzq3r17aYv0/DGXy5W27X/vZF6vV/PmzdPT\np0+NObA6OzsVDAa1ZMkSSVJ1dbXa29vTDqzi4mJJktud/oFJSUlJ6ueCggL5fD4NDQ0ZcWCRK7ty\nSfYeZ7bmkuzdH7NtzWblR9nxeFxerzd12+v1Kh6PT/p1YrGYHMdRTk7OVI73r/T19amwsDB1OxAI\nqK+vb9Kv09nZqUQioaVLl07leK+NXP/MtFySvceZrbkke/fHbFuzWXnFPBXi8bguXLigUCg07h1W\nJiWTyXH3TXa+gYEB7d69W42NjePeFWcKuf43E3NNFVOPs3/L1Fzsj//bTK6ZPb+1SXjx3dKL76Ze\nJZFI6MyZM1q7dq3y8/OnY8TXFggE9ODBg9Tt3t5eFRQUTHj74eFhbdmyRXV1dVq3bt10jPhayPVy\npuaS7D3ObM0l2bs/Ztuazcpi9vl8evLkiYaHh+U4jrq7u1Pfm7yK4ziKRqMqKSkx5mOa/1ZeXq47\nd+7o7t27SiQSampqUmVl5YS2TSQSqqqq0p49e7Rjx45pnnRyyDWeybkke48zW3NJ9u6P2bZms/Kj\nbLfbrY0bN+r06dNKJpNasWKF8vLy1NnZqbfeekvvvPOOBgcHFY1G9ffff+vevXu6evWqqqur1dPT\no4GBAf3111+6ffu2JCkUCunNN9/McKpnPB6P6uvrFQ6H5TiOPvvsM5WWlmrfvn364IMPVFlZqatX\n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\nzZoNDQ1p7969qqurM2bN2Bf/mYn7Y7atmV17xAS9+C7wxXeJr5JIJHTmzBmtXbs27YrABLZmCwQC\nevDgQep2b2+vCgoKJrz98PCwtmzZorq6Oq1bt246Rnwttq6X9OwK+eHDh6nbsVhMPp9vwtv/+eef\nqqmp0Zdffqn3339/OkZ8Lbbui5K9+2O2rdmsLGafz6cnT55oeHhYjuOou7s77Tutf+I4jqLRqEpK\nSoz6COo5W7OVl5frzp07unv3rhKJhJqamlRZWTmhbROJhKqqqrRnzx7t2LFjmiedHFvXS5Lee+89\n/fbbb+rt7dXIyIjOnj2rDz/8cELbjoyM6Ouvv1ZFRYXC4fD0DjpJtu6Lkr37Y7at2az8KNvtdmvj\nxo06ffq0ksmkVqxYoby8PHV2duqtt97SO++8o8HBQUWjUf3999+6d++erl69qurqavX09GhgYEB/\n/fWXbt++LUkKhUJ68803M5zqGVuzeTwe1dfXKxwOy3EcffbZZyotLdW+ffv0wQcfqLKyUlevXlVV\nVZUeP36sn3/+WbW1tfr111/V0tKijo4O/f7772poaJAkNTQ0qKysLLOhZO96Sc/WbO/evfriiy/k\nOI6qqqoUDAZVX1+v0tJSbdq0STdv3tRXX32lP/74Q5cuXdL333+vH3/8UdFoVL/88ouePHmi9vZ2\nSVJdXZ1WrFiR4VT27ouSvftjtq2Z62WfvZsuEokkX+ePSEz3un8cYzq/36/a2tpMjzEtIpGItWu2\nffv2TI8xLdra2qzcH23eF21cL+nZmtXW1o77sntWfpQNAICpKGYAAAxCMQMAYBCKGQAAg1DMAAAY\nhGIGAMAgFDMAAAahmAEAMAjFDACAQShmAAAMQjEDAGAQihkAAINQzAAAGIRiBgDAIBQzAAAGoZgB\nADAIxQwAgEEoZgAADEIxAwBgEIoZAACDUMwAABiEYgYAwCAUMwAABnElk8lMzzBp+/fvf+hyufyZ\nnmOqJZPJMZfLZd2bpTlz5ow5jmNdLsneNXO73WNjY2PW5ZIkj8czNjo6al02W/dFm88fHo8n9p//\n/Cf/xfuzspgBALCVle9CAADIVhQzAAAGoZgBADAIxQwAgEEoZgAADEIxAwBgEIoZAACDUMwAABiE\nYgYAwCAUMwAABqGYAQAwCMUMAIBBKGYAAAxCMQMAYBCKGQAAg1DMAAAYhGIGAMAgFDMAAAahmAEA\nMAjFDACAQShmAAAMQjEDAGCQ/wdJuZEoaHGMKwAAAABJRU5ErkJggg==\n", - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "import itertools\n", - "import random\n", - "# http://stackoverflow.com/questions/10194482/custom-matplotlib-plot-chess-board-like-table-with-colored-cells\n", - "\n", - "from matplotlib.table import Table\n", - "\n", - "def main():\n", - " grid_table(8, 8)\n", - " plt.axis('scaled')\n", - " plt.show()\n", - "\n", - "def grid_table(nrows, ncols):\n", - " fig, ax = plt.subplots()\n", - " ax.set_axis_off()\n", - " colors = ['white', 'lightgrey', 'dimgrey']\n", - " tb = Table(ax, bbox=[0,0,2,2])\n", - " for i,j in itertools.product(range(ncols), range(nrows)):\n", - " tb.add_cell(i, j, 2./ncols, 2./nrows, text='{:0.2f}'.format(0.1234), \n", - " loc='center', facecolor=random.choice(colors), edgecolor='grey') # facecolors=\n", - " ax.add_table(tb)\n", - " #ax.plot([0, .3], [.2, .2])\n", - " #ax.add_line(plt.Line2D([0.3, 0.5], [0.7, 0.7], linewidth=2, color='blue'))\n", - " return fig\n", - "\n", - "main()" - ] - }, - { - "cell_type": "code", - "execution_count": 54, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "import collections\n", - "class defaultkeydict(collections.defaultdict):\n", - " \"\"\"Like defaultdict, but the default_factory is a function of the key.\n", - " >>> d = defaultkeydict(abs); d[-42]\n", - " 42\n", - " \"\"\"\n", - " def __missing__(self, key):\n", - " self[key] = self.default_factory(key)\n", - " return self[key]" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "collapsed": true - }, - "source": [ - "# Simulated Annealing visualisation using TSP\n", - "\n", - "Applying simulated annealing in traveling salesman problem to find the shortest tour to travel all cities in Romania. Distance between two cities is taken as the euclidean distance." - ] - }, - { - "cell_type": "code", - "execution_count": 60, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "class TSP_problem(Problem):\n", - "\n", - " '''\n", - " subclass of Problem to define various functions \n", - " '''\n", - "\n", - " def two_opt(self, state):\n", - " '''\n", - " Neighbour generating function for Traveling Salesman Problem\n", - " '''\n", - " state2 = state[:]\n", - " l = random.randint(0, len(state2) - 1)\n", - " r = random.randint(0, len(state2) - 1)\n", - " if l > r:\n", - " l, r = r,l\n", - " state2[l : r + 1] = reversed(state2[l : r + 1])\n", - " return state2\n", - "\n", - " def actions(self, state):\n", - " '''\n", - " action that can be excuted in given state\n", - " '''\n", - " return [self.two_opt]\n", - " \n", - " def result(self, state, action):\n", - " '''\n", - " result after applying the given action on the given state\n", - " '''\n", - " return action(state)\n", - "\n", - " def path_cost(self, c, state1, action, state2):\n", - " '''\n", - " total distance for the Traveling Salesman to be covered if in state2\n", - " '''\n", - " cost = 0\n", - " for i in range(len(state2) - 1):\n", - " cost += distances[state2[i]][state2[i + 1]]\n", - " cost += distances[state2[0]][state2[-1]]\n", - " return cost\n", - " \n", - " def value(self, state):\n", - " '''\n", - " value of path cost given negative for the given state\n", - " '''\n", - " return -1 * self.path_cost(None, None, None, state)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 61, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "def init():\n", - " ''' \n", - " Initialisation function for matplotlib animation\n", - " '''\n", - " line.set_data([], [])\n", - " for name, coordinates in romania_map.locations.items():\n", - " ax.annotate(\n", - " name,\n", - " xy=coordinates, xytext=(-10, 5), textcoords='offset points', size = 10)\n", - " text.set_text(\"Cost = 0 i = 0\" )\n", - "\n", - " return line, \n", - "\n", - "def animate(i):\n", - " '''\n", - " Animation function to set next path and print its cost.\n", - " '''\n", - " x, y = [], []\n", - " for name in states[i]:\n", - " x.append(romania_map.locations[name][0])\n", - " y.append(romania_map.locations[name][1])\n", - " x.append(romania_map.locations[states[i][0]][0])\n", - " y.append(romania_map.locations[states[i][0]][1])\n", - " line.set_data(x,y) \n", - " text.set_text(\"Cost = \" + str('{:.2f}'.format(TSP_problem.path_cost(None, None, None, None, states[i]))))\n", - " return line," - ] - }, - { - "cell_type": "code", - "execution_count": 62, - "metadata": {}, - "outputs": [ - { - "data": { - "application/javascript": [ - "/* Put everything inside the global mpl namespace */\n", - "window.mpl = {};\n", - "\n", - "\n", - "mpl.get_websocket_type = function() {\n", - " if (typeof(WebSocket) !== 'undefined') {\n", - " return WebSocket;\n", - " } else if (typeof(MozWebSocket) !== 'undefined') {\n", - " return MozWebSocket;\n", - " } else {\n", - " alert('Your browser does not have WebSocket support.' +\n", - " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", - " 'Firefox 4 and 5 are also supported but you ' +\n", - " 'have to enable WebSockets in about:config.');\n", - " };\n", - "}\n", - "\n", - "mpl.figure = function(figure_id, websocket, ondownload, parent_element) {\n", - " this.id = figure_id;\n", - "\n", - " this.ws = websocket;\n", - "\n", - " this.supports_binary = (this.ws.binaryType != undefined);\n", - "\n", - " if (!this.supports_binary) {\n", - " var warnings = document.getElementById(\"mpl-warnings\");\n", - " if (warnings) {\n", - " warnings.style.display = 'block';\n", - " warnings.textContent = (\n", - " \"This browser does not support binary websocket messages. \" +\n", - " \"Performance may be slow.\");\n", - " }\n", - " }\n", - "\n", - " this.imageObj = new Image();\n", - "\n", - " this.context = undefined;\n", - " this.message = undefined;\n", - " this.canvas = undefined;\n", - " this.rubberband_canvas = undefined;\n", - " this.rubberband_context = undefined;\n", - " this.format_dropdown = undefined;\n", - "\n", - " this.image_mode = 'full';\n", - "\n", - " this.root = $('
');\n", - " this._root_extra_style(this.root)\n", - " this.root.attr('style', 'display: inline-block');\n", - "\n", - " $(parent_element).append(this.root);\n", - "\n", - " this._init_header(this);\n", - " this._init_canvas(this);\n", - " this._init_toolbar(this);\n", - "\n", - " var fig = this;\n", - "\n", - " this.waiting = false;\n", - "\n", - " this.ws.onopen = function () {\n", - " fig.send_message(\"supports_binary\", {value: fig.supports_binary});\n", - " fig.send_message(\"send_image_mode\", {});\n", - " if (mpl.ratio != 1) {\n", - " fig.send_message(\"set_dpi_ratio\", {'dpi_ratio': mpl.ratio});\n", - " }\n", - " fig.send_message(\"refresh\", {});\n", - " }\n", - "\n", - " this.imageObj.onload = function() {\n", - " if (fig.image_mode == 'full') {\n", - " // Full images could contain transparency (where diff images\n", - " // almost always do), so we need to clear the canvas so that\n", - " // there is no ghosting.\n", - " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", - " }\n", - " fig.context.drawImage(fig.imageObj, 0, 0);\n", - " };\n", - "\n", - " this.imageObj.onunload = function() {\n", - " fig.ws.close();\n", - " }\n", - "\n", - " this.ws.onmessage = this._make_on_message_function(this);\n", - "\n", - " this.ondownload = ondownload;\n", - "}\n", - "\n", - "mpl.figure.prototype._init_header = function() {\n", - " var titlebar = $(\n", - " '
');\n", - " var titletext = $(\n", - " '
');\n", - " titlebar.append(titletext)\n", - " this.root.append(titlebar);\n", - " this.header = titletext[0];\n", - "}\n", - "\n", - "\n", - "\n", - "mpl.figure.prototype._canvas_extra_style = function(canvas_div) {\n", - "\n", - "}\n", - "\n", - "\n", - "mpl.figure.prototype._root_extra_style = function(canvas_div) {\n", - "\n", - "}\n", - "\n", - "mpl.figure.prototype._init_canvas = function() {\n", - " var fig = this;\n", - "\n", - " var canvas_div = $('
');\n", - "\n", - " canvas_div.attr('style', 'position: relative; clear: both; outline: 0');\n", - "\n", - " function canvas_keyboard_event(event) {\n", - " return fig.key_event(event, event['data']);\n", - " }\n", - "\n", - " canvas_div.keydown('key_press', canvas_keyboard_event);\n", - " canvas_div.keyup('key_release', canvas_keyboard_event);\n", - " this.canvas_div = canvas_div\n", - " this._canvas_extra_style(canvas_div)\n", - " this.root.append(canvas_div);\n", - "\n", - " var canvas = $('');\n", - " canvas.addClass('mpl-canvas');\n", - " canvas.attr('style', \"left: 0; top: 0; z-index: 0; outline: 0\")\n", - "\n", - " this.canvas = canvas[0];\n", - " this.context = canvas[0].getContext(\"2d\");\n", - "\n", - " var backingStore = this.context.backingStorePixelRatio ||\n", - "\tthis.context.webkitBackingStorePixelRatio ||\n", - "\tthis.context.mozBackingStorePixelRatio ||\n", - "\tthis.context.msBackingStorePixelRatio ||\n", - "\tthis.context.oBackingStorePixelRatio ||\n", - "\tthis.context.backingStorePixelRatio || 1;\n", - "\n", - " mpl.ratio = (window.devicePixelRatio || 1) / backingStore;\n", - "\n", - " var rubberband = $('');\n", - " rubberband.attr('style', \"position: absolute; left: 0; top: 0; z-index: 1;\")\n", - "\n", - " var pass_mouse_events = true;\n", - "\n", - " canvas_div.resizable({\n", - " start: function(event, ui) {\n", - " pass_mouse_events = false;\n", - " },\n", - " resize: function(event, ui) {\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", - " stop: function(event, ui) {\n", - " pass_mouse_events = true;\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", - " });\n", - "\n", - " function mouse_event_fn(event) {\n", - " if (pass_mouse_events)\n", - " return fig.mouse_event(event, event['data']);\n", - " }\n", - "\n", - " rubberband.mousedown('button_press', mouse_event_fn);\n", - " rubberband.mouseup('button_release', mouse_event_fn);\n", - " // Throttle sequential mouse events to 1 every 20ms.\n", - " rubberband.mousemove('motion_notify', mouse_event_fn);\n", - "\n", - " rubberband.mouseenter('figure_enter', mouse_event_fn);\n", - " rubberband.mouseleave('figure_leave', mouse_event_fn);\n", - "\n", - " canvas_div.on(\"wheel\", function (event) {\n", - " event = event.originalEvent;\n", - " event['data'] = 'scroll'\n", - " if (event.deltaY < 0) {\n", - " event.step = 1;\n", - " } else {\n", - " event.step = -1;\n", - " }\n", - " mouse_event_fn(event);\n", - " });\n", - "\n", - " canvas_div.append(canvas);\n", - " canvas_div.append(rubberband);\n", - "\n", - " this.rubberband = rubberband;\n", - " this.rubberband_canvas = rubberband[0];\n", - " this.rubberband_context = rubberband[0].getContext(\"2d\");\n", - " this.rubberband_context.strokeStyle = \"#000000\";\n", - "\n", - " this._resize_canvas = function(width, height) {\n", - " // Keep the size of the canvas, canvas container, and rubber band\n", - " // canvas in synch.\n", - " canvas_div.css('width', width)\n", - " canvas_div.css('height', height)\n", - "\n", - " canvas.attr('width', width * mpl.ratio);\n", - " canvas.attr('height', height * mpl.ratio);\n", - " canvas.attr('style', 'width: ' + width + 'px; height: ' + height + 'px;');\n", - "\n", - " rubberband.attr('width', width);\n", - " rubberband.attr('height', height);\n", - " }\n", - "\n", - " // Set the figure to an initial 600x600px, this will subsequently be updated\n", - " // upon first draw.\n", - " this._resize_canvas(600, 600);\n", - "\n", - " // Disable right mouse context menu.\n", - " $(this.rubberband_canvas).bind(\"contextmenu\",function(e){\n", - " return false;\n", - " });\n", - "\n", - " function set_focus () {\n", - " canvas.focus();\n", - " canvas_div.focus();\n", - " }\n", - "\n", - " window.setTimeout(set_focus, 100);\n", - "}\n", - "\n", - "mpl.figure.prototype._init_toolbar = function() {\n", - " var fig = this;\n", - "\n", - " var nav_element = $('
')\n", - " nav_element.attr('style', 'width: 100%');\n", - " this.root.append(nav_element);\n", - "\n", - " // Define a callback function for later on.\n", - " function toolbar_event(event) {\n", - " return fig.toolbar_button_onclick(event['data']);\n", - " }\n", - " function toolbar_mouse_event(event) {\n", - " return fig.toolbar_button_onmouseover(event['data']);\n", - " }\n", - "\n", - " for(var toolbar_ind in mpl.toolbar_items) {\n", - " var name = mpl.toolbar_items[toolbar_ind][0];\n", - " var tooltip = mpl.toolbar_items[toolbar_ind][1];\n", - " var image = mpl.toolbar_items[toolbar_ind][2];\n", - " var method_name = mpl.toolbar_items[toolbar_ind][3];\n", - "\n", - " if (!name) {\n", - " // put a spacer in here.\n", - " continue;\n", - " }\n", - " var button = $('');\n", - " button.click(method_name, toolbar_event);\n", - " button.mouseover(tooltip, toolbar_mouse_event);\n", - " nav_element.append(button);\n", - " }\n", - "\n", - " // Add the status bar.\n", - " var status_bar = $('');\n", - " nav_element.append(status_bar);\n", - " this.message = status_bar[0];\n", - "\n", - " // Add the close button to the window.\n", - " var buttongrp = $('
');\n", - " var button = $('');\n", - " button.click(function (evt) { fig.handle_close(fig, {}); } );\n", - " button.mouseover('Stop Interaction', toolbar_mouse_event);\n", - " buttongrp.append(button);\n", - " var titlebar = this.root.find($('.ui-dialog-titlebar'));\n", - " titlebar.prepend(buttongrp);\n", - "}\n", - "\n", - "mpl.figure.prototype._root_extra_style = function(el){\n", - " var fig = this\n", - " el.on(\"remove\", function(){\n", - "\tfig.close_ws(fig, {});\n", - " });\n", - "}\n", - "\n", - "mpl.figure.prototype._canvas_extra_style = function(el){\n", - " // this is important to make the div 'focusable\n", - " el.attr('tabindex', 0)\n", - " // reach out to IPython and tell the keyboard manager to turn it's self\n", - " // off when our div gets focus\n", - "\n", - " // location in version 3\n", - " if (IPython.notebook.keyboard_manager) {\n", - " IPython.notebook.keyboard_manager.register_events(el);\n", - " }\n", - " else {\n", - " // location in version 2\n", - " IPython.keyboard_manager.register_events(el);\n", - " }\n", - "\n", - "}\n", - "\n", - "mpl.figure.prototype._key_event_extra = function(event, name) {\n", - " var manager = IPython.notebook.keyboard_manager;\n", - " if (!manager)\n", - " manager = IPython.keyboard_manager;\n", - "\n", - " // Check for shift+enter\n", - " if (event.shiftKey && event.which == 13) {\n", - " this.canvas_div.blur();\n", - " event.shiftKey = false;\n", - " // Send a \"J\" for go to next cell\n", - " event.which = 74;\n", - " event.keyCode = 74;\n", - " manager.command_mode();\n", - " manager.handle_keydown(event);\n", - " }\n", - "}\n", - "\n", - "mpl.figure.prototype.handle_save = function(fig, msg) {\n", - " fig.ondownload(fig, null);\n", - "}\n", - "\n", - "\n", - "mpl.find_output_cell = function(html_output) {\n", - " // Return the cell and output element which can be found *uniquely* in the notebook.\n", - " // Note - this is a bit hacky, but it is done because the \"notebook_saving.Notebook\"\n", - " // IPython event is triggered only after the cells have been serialised, which for\n", - " // our purposes (turning an active figure into a static one), is too late.\n", - " var cells = IPython.notebook.get_cells();\n", - " var ncells = cells.length;\n", - " for (var i=0; i= 3 moved mimebundle to data attribute of output\n", - " data = data.data;\n", - " }\n", - " if (data['text/html'] == html_output) {\n", - " return [cell, data, j];\n", - " }\n", - " }\n", - " }\n", - " }\n", - "}\n", - "\n", - "// Register the function which deals with the matplotlib target/channel.\n", - "// The kernel may be null if the page has been refreshed.\n", - "if (IPython.notebook.kernel != null) {\n", - " IPython.notebook.kernel.comm_manager.register_target('matplotlib', mpl.mpl_figure_comm);\n", - "}\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "next_state = cities\n", - "states = []\n", - "\n", - "# creating plotting area\n", - "fig = plt.figure(figsize = (8,6))\n", - "ax = plt.axes(xlim=(60, 600), ylim=(245, 600))\n", - "line, = ax.plot([], [], c=\"b\",linewidth = 1.5, marker = 'o', markerfacecolor = 'r', markeredgecolor = 'r',markersize = 10)\n", - "text = ax.text(450, 565, \"\", fontdict = font)\n", - "\n", - "# to plot only the final states of every simulated annealing iteration\n", - "for iterations in range(100):\n", - " tsp_problem = TSP_problem(next_state) \n", - " states.append(simulated_annealing(tsp_problem))\n", - " next_state = states[-1]\n", - " \n", - "anim = animation.FuncAnimation(fig, animate, init_func=init,\n", - " frames=len(states),interval=len(states), blit=True, repeat = False)\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.1" - }, - "widgets": { - "state": {}, - "version": "1.1.1" - } - }, - "nbformat": 4, - "nbformat_minor": 1 -} diff --git a/probability4e.ipynb b/probability4e.ipynb deleted file mode 100644 index e148e929e..000000000 --- a/probability4e.ipynb +++ /dev/null @@ -1,1381 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "button": false, - "deletable": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# Probability and Bayesian Networks\n", - "\n", - "Probability theory allows us to compute the likelihood of certain events, given assumptioons about the components of the event. A Bayesian network, or Bayes net for short, is a data structure to represent a joint probability distribution over several random variables, and do inference on it. \n", - "\n", - "As an example, here is a network with five random variables, each with its conditional probability table, and with arrows from parent to child variables. The story, from Judea Pearl, is that there is a house burglar alarm, which can be triggered by either a burglary or an earthquake. If the alarm sounds, one or both of the neighbors, John and Mary, might call the owwner to say the alarm is sounding.\n", - "\n", - "

\n", - "\n", - "We implement this with the help of seven Python classes:\n", - "\n", - "\n", - "## `BayesNet()`\n", - "\n", - "A `BayesNet` is a graph (as in the diagram above) where each node represents a random variable, and the edges are parent→child links. You can construct an empty graph with `BayesNet()`, then add variables one at a time with the method call `.add(`*variable_name, parent_names, cpt*`)`, where the names are strings, and each of the `parent_names` must already have been `.add`ed.\n", - "\n", - "## `Variable(`*name, cpt, parents*`)`\n", - "\n", - "A random variable; the ovals in the diagram above. The value of a variable depends on the value of the parents, in a probabilistic way specified by the variable's conditional probability table (CPT). Given the parents, the variable is independent of all the other variables. For example, if I know whether *Alarm* is true or false, then I know the probability of *JohnCalls*, and evidence about the other variables won't give me any more information about *JohnCalls*. Each row of the CPT uses the same order of variables as the list of parents.\n", - "We will only allow variables with a finite discrete domain; not continuous values. \n", - "\n", - "## `ProbDist(`*mapping*`)`
`Factor(`*mapping*`)`\n", - "\n", - "A probability distribution is a mapping of `{outcome: probability}` for every outcome of a random variable. \n", - "You can give `ProbDist` the same arguments that you would give to the `dict` initializer, for example\n", - "`ProbDist(sun=0.6, rain=0.1, cloudy=0.3)`.\n", - "As a shortcut for Boolean Variables, you can say `ProbDist(0.95)` instead of `ProbDist({T: 0.95, F: 0.05})`. \n", - "In a probability distribution, every value is between 0 and 1, and the values sum to 1.\n", - "A `Factor` is similar to a probability distribution, except that the values need not sum to 1. Factors\n", - "are used in the variable elimination inference method.\n", - "\n", - "## `Evidence(`*mapping*`)`\n", - "\n", - "A mapping of `{Variable: value, ...}` pairs, describing the exact values for a set of variables—the things we know for sure.\n", - "\n", - "## `CPTable(`*rows, parents*`)`\n", - "\n", - "A conditional probability table (or *CPT*) describes the probability of each possible outcome value of a random variable, given the values of the parent variables. A `CPTable` is a a mapping, `{tuple: probdist, ...}`, where each tuple lists the values of each of the parent variables, in order, and each probability distribution says what the possible outcomes are, given those values of the parents. The `CPTable` for *Alarm* in the diagram above would be represented as follows:\n", - "\n", - " CPTable({(T, T): .95,\n", - " (T, F): .94,\n", - " (F, T): .29,\n", - " (F, F): .001},\n", - " [Burglary, Earthquake])\n", - " \n", - "How do you read this? Take the second row, \"`(T, F): .94`\". This means that when the first parent (`Burglary`) is true, and the second parent (`Earthquake`) is fale, then the probability of `Alarm` being true is .94. Note that the .94 is an abbreviation for `ProbDist({T: .94, F: .06})`.\n", - " \n", - "## `T = Bool(True); F = Bool(False)`\n", - "\n", - "When I used `bool` values (`True` and `False`), it became hard to read rows in CPTables, because the columns didn't line up:\n", - "\n", - " (True, True, False, False, False)\n", - " (False, False, False, False, True)\n", - " (True, False, False, True, True)\n", - " \n", - "Therefore, I created the `Bool` class, with constants `T` and `F` such that `T == True` and `F == False`, and now rows are easier to read:\n", - "\n", - " (T, T, F, F, F)\n", - " (F, F, F, F, T)\n", - " (T, F, F, T, T)\n", - " \n", - "Here is the code for these classes:" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "button": false, - "collapsed": true, - "deletable": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "from collections import defaultdict, Counter\n", - "import itertools\n", - "import math\n", - "import random\n", - "\n", - "class BayesNet(object):\n", - " \"Bayesian network: a graph of variables connected by parent links.\"\n", - " \n", - " def __init__(self): \n", - " self.variables = [] # List of variables, in parent-first topological sort order\n", - " self.lookup = {} # Mapping of {variable_name: variable} pairs\n", - " \n", - " def add(self, name, parentnames, cpt):\n", - " \"Add a new Variable to the BayesNet. Parentnames must have been added previously.\"\n", - " parents = [self.lookup[name] for name in parentnames]\n", - " var = Variable(name, cpt, parents)\n", - " self.variables.append(var)\n", - " self.lookup[name] = var\n", - " return self\n", - " \n", - "class Variable(object):\n", - " \"A discrete random variable; conditional on zero or more parent Variables.\"\n", - " \n", - " def __init__(self, name, cpt, parents=()):\n", - " \"A variable has a name, list of parent variables, and a Conditional Probability Table.\"\n", - " self.__name__ = name\n", - " self.parents = parents\n", - " self.cpt = CPTable(cpt, parents)\n", - " self.domain = set(itertools.chain(*self.cpt.values())) # All the outcomes in the CPT\n", - " \n", - " def __repr__(self): return self.__name__\n", - " \n", - "class Factor(dict): \"An {outcome: frequency} mapping.\"\n", - "\n", - "class ProbDist(Factor):\n", - " \"\"\"A Probability Distribution is an {outcome: probability} mapping. \n", - " The values are normalized to sum to 1.\n", - " ProbDist(0.75) is an abbreviation for ProbDist({T: 0.75, F: 0.25}).\"\"\"\n", - " def __init__(self, mapping=(), **kwargs):\n", - " if isinstance(mapping, float):\n", - " mapping = {T: mapping, F: 1 - mapping}\n", - " self.update(mapping, **kwargs)\n", - " normalize(self)\n", - " \n", - "class Evidence(dict): \n", - " \"A {variable: value} mapping, describing what we know for sure.\"\n", - " \n", - "class CPTable(dict):\n", - " \"A mapping of {row: ProbDist, ...} where each row is a tuple of values of the parent variables.\"\n", - " \n", - " def __init__(self, mapping, parents=()):\n", - " \"\"\"Provides two shortcuts for writing a Conditional Probability Table. \n", - " With no parents, CPTable(dist) means CPTable({(): dist}).\n", - " With one parent, CPTable({val: dist,...}) means CPTable({(val,): dist,...}).\"\"\"\n", - " if len(parents) == 0 and not (isinstance(mapping, dict) and set(mapping.keys()) == {()}):\n", - " mapping = {(): mapping}\n", - " for (row, dist) in mapping.items():\n", - " if len(parents) == 1 and not isinstance(row, tuple): \n", - " row = (row,)\n", - " self[row] = ProbDist(dist)\n", - "\n", - "class Bool(int):\n", - " \"Just like `bool`, except values display as 'T' and 'F' instead of 'True' and 'False'\"\n", - " __str__ = __repr__ = lambda self: 'T' if self else 'F'\n", - " \n", - "T = Bool(True)\n", - "F = Bool(False)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "And here are some associated functions:" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "def P(var, evidence={}):\n", - " \"The probability distribution for P(variable | evidence), when all parent variables are known (in evidence).\"\n", - " row = tuple(evidence[parent] for parent in var.parents)\n", - " return var.cpt[row]\n", - "\n", - "def normalize(dist):\n", - " \"Normalize a {key: value} distribution so values sum to 1.0. Mutates dist and returns it.\"\n", - " total = sum(dist.values())\n", - " for key in dist:\n", - " dist[key] = dist[key] / total\n", - " assert 0 <= dist[key] <= 1, \"Probabilities must be between 0 and 1.\"\n", - " return dist\n", - "\n", - "def sample(probdist):\n", - " \"Randomly sample an outcome from a probability distribution.\"\n", - " r = random.random() # r is a random point in the probability distribution\n", - " c = 0.0 # c is the cumulative probability of outcomes seen so far\n", - " for outcome in probdist:\n", - " c += probdist[outcome]\n", - " if r <= c:\n", - " return outcome\n", - " \n", - "def globalize(mapping):\n", - " \"Given a {name: value} mapping, export all the names to the `globals()` namespace.\"\n", - " globals().update(mapping)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Sample Usage\n", - "\n", - "Here are some examples of using the classes:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "collapsed": false - }, - "outputs": [], - "source": [ - "# Example random variable: Earthquake:\n", - "# An earthquake occurs on 0.002 of days, independent of any other variables.\n", - "Earthquake = Variable('Earthquake', 0.002)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.998, T: 0.002}" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# The probability distribution for Earthquake\n", - "P(Earthquake)" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0.002" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Get the probability of a specific outcome by subscripting the probability distribution\n", - "P(Earthquake)[T]" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "F" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Randomly sample from the distribution:\n", - "sample(P(Earthquake))" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "Counter({F: 99793, T: 207})" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Randomly sample 100,000 times, and count up the results:\n", - "Counter(sample(P(Earthquake)) for i in range(100000))" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": { - "collapsed": false - }, - "outputs": [], - "source": [ - "# Two equivalent ways of specifying the same Boolean probability distribution:\n", - "assert ProbDist(0.75) == ProbDist({T: 0.75, F: 0.25})" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'lose': 0.15, 'tie': 0.1, 'win': 0.75}" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Two equivalent ways of specifying the same non-Boolean probability distribution:\n", - "assert ProbDist(win=15, lose=3, tie=2) == ProbDist({'win': 15, 'lose': 3, 'tie': 2})\n", - "ProbDist(win=15, lose=3, tie=2)" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'a': 1, 'b': 2, 'c': 3, 'd': 4}" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# The difference between a Factor and a ProbDist--the ProbDist is normalized:\n", - "Factor(a=1, b=2, c=3, d=4)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'a': 0.1, 'b': 0.2, 'c': 0.3, 'd': 0.4}" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "ProbDist(a=1, b=2, c=3, d=4)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Example: Alarm Bayes Net\n", - "\n", - "Here is how we define the Bayes net from the diagram above:" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": { - "collapsed": false - }, - "outputs": [], - "source": [ - "alarm_net = (BayesNet()\n", - " .add('Burglary', [], 0.001)\n", - " .add('Earthquake', [], 0.002)\n", - " .add('Alarm', ['Burglary', 'Earthquake'], {(T, T): 0.95, (T, F): 0.94, (F, T): 0.29, (F, F): 0.001})\n", - " .add('JohnCalls', ['Alarm'], {T: 0.90, F: 0.05})\n", - " .add('MaryCalls', ['Alarm'], {T: 0.70, F: 0.01})) " - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "[Burglary, Earthquake, Alarm, JohnCalls, MaryCalls]" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Make Burglary, Earthquake, etc. be global variables\n", - "globalize(alarm_net.lookup) \n", - "alarm_net.variables" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.999, T: 0.001}" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Probability distribution of a Burglary\n", - "P(Burglary)" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.06000000000000005, T: 0.94}" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Probability of Alarm going off, given a Burglary and not an Earthquake:\n", - "P(Alarm, {Burglary: T, Earthquake: F})" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{(F, F): {F: 0.999, T: 0.001},\n", - " (F, T): {F: 0.71, T: 0.29},\n", - " (T, F): {F: 0.06000000000000005, T: 0.94},\n", - " (T, T): {F: 0.050000000000000044, T: 0.95}}" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Where that came from: the (T, F) row of Alarm's CPT:\n", - "Alarm.cpt" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "deletable": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# Bayes Nets as Joint Probability Distributions\n", - "\n", - "A Bayes net is a compact way of specifying a full joint distribution over all the variables in the network. Given a set of variables {*X*1, ..., *X**n*}, the full joint distribution is:\n", - "\n", - "P(*X*1=*x*1, ..., *X**n*=*x**n*) = Π*i* P(*X**i* = *x**i* | parents(*X**i*))\n", - "\n", - "For a network with *n* variables, each of which has *b* values, there are *bn* rows in the joint distribution (for example, a billion rows for 30 Boolean variables), making it impractical to explicitly create the joint distribution for large networks. But for small networks, the function `joint_distribution` creates the distribution, which can be instructive to look at, and can be used to do inference. " - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "def joint_distribution(net):\n", - " \"Given a Bayes net, create the joint distribution over all variables.\"\n", - " return ProbDist({row: prod(P_xi_given_parents(var, row, net)\n", - " for var in net.variables)\n", - " for row in all_rows(net)})\n", - "\n", - "def all_rows(net): return itertools.product(*[var.domain for var in net.variables])\n", - "\n", - "def P_xi_given_parents(var, row, net):\n", - " \"The probability that var = xi, given the values in this row.\"\n", - " dist = P(var, Evidence(zip(net.variables, row)))\n", - " xi = row[net.variables.index(var)]\n", - " return dist[xi]\n", - "\n", - "def prod(numbers):\n", - " \"The product of numbers: prod([2, 3, 5]) == 30. Analogous to `sum([2, 3, 5]) == 10`.\"\n", - " result = 1\n", - " for x in numbers:\n", - " result *= x\n", - " return result" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{(F, F, F, F, F),\n", - " (F, F, F, F, T),\n", - " (F, F, F, T, F),\n", - " (F, F, F, T, T),\n", - " (F, F, T, F, F),\n", - " (F, F, T, F, T),\n", - " (F, F, T, T, F),\n", - " (F, F, T, T, T),\n", - " (F, T, F, F, F),\n", - " (F, T, F, F, T),\n", - " (F, T, F, T, F),\n", - " (F, T, F, T, T),\n", - " (F, T, T, F, F),\n", - " (F, T, T, F, T),\n", - " (F, T, T, T, F),\n", - " (F, T, T, T, T),\n", - " (T, F, F, F, F),\n", - " (T, F, F, F, T),\n", - " (T, F, F, T, F),\n", - " (T, F, F, T, T),\n", - " (T, F, T, F, F),\n", - " (T, F, T, F, T),\n", - " (T, F, T, T, F),\n", - " (T, F, T, T, T),\n", - " (T, T, F, F, F),\n", - " (T, T, F, F, T),\n", - " (T, T, F, T, F),\n", - " (T, T, F, T, T),\n", - " (T, T, T, F, F),\n", - " (T, T, T, F, T),\n", - " (T, T, T, T, F),\n", - " (T, T, T, T, T)}" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# All rows in the joint distribution (2**5 == 32 rows)\n", - "set(all_rows(alarm_net))" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": { - "collapsed": false - }, - "outputs": [], - "source": [ - "# Let's work through just one row of the table:\n", - "row = (F, F, F, F, F)" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.999, T: 0.001}" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# This is the probability distribution for Alarm\n", - "P(Alarm, {Burglary: F, Earthquake: F})" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0.999" - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Here's the probability that Alarm is false, given the parent values in this row:\n", - "P_xi_given_parents(Alarm, row, alarm_net)" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{(F, F, F, F, F): 0.9367427006190001,\n", - " (F, F, F, F, T): 0.009462047481000001,\n", - " (F, F, F, T, F): 0.04930224740100002,\n", - " (F, F, F, T, T): 0.0004980024990000002,\n", - " (F, F, T, F, F): 2.9910060000000004e-05,\n", - " (F, F, T, F, T): 6.979013999999999e-05,\n", - " (F, F, T, T, F): 0.00026919054000000005,\n", - " (F, F, T, T, T): 0.00062811126,\n", - " (F, T, F, F, F): 0.0013341744900000002,\n", - " (F, T, F, F, T): 1.3476510000000005e-05,\n", - " (F, T, F, T, F): 7.021971000000001e-05,\n", - " (F, T, F, T, T): 7.092900000000001e-07,\n", - " (F, T, T, F, F): 1.7382600000000002e-05,\n", - " (F, T, T, F, T): 4.0559399999999997e-05,\n", - " (F, T, T, T, F): 0.00015644340000000006,\n", - " (F, T, T, T, T): 0.00036503460000000007,\n", - " (T, F, F, F, F): 5.631714000000006e-05,\n", - " (T, F, F, F, T): 5.688600000000006e-07,\n", - " (T, F, F, T, F): 2.9640600000000033e-06,\n", - " (T, F, F, T, T): 2.9940000000000035e-08,\n", - " (T, F, T, F, F): 2.8143600000000003e-05,\n", - " (T, F, T, F, T): 6.56684e-05,\n", - " (T, F, T, T, F): 0.0002532924000000001,\n", - " (T, F, T, T, T): 0.0005910156000000001,\n", - " (T, T, F, F, F): 9.40500000000001e-08,\n", - " (T, T, F, F, T): 9.50000000000001e-10,\n", - " (T, T, F, T, F): 4.9500000000000054e-09,\n", - " (T, T, F, T, T): 5.0000000000000066e-11,\n", - " (T, T, T, F, F): 5.7e-08,\n", - " (T, T, T, F, T): 1.3299999999999996e-07,\n", - " (T, T, T, T, F): 5.130000000000002e-07,\n", - " (T, T, T, T, T): 1.1970000000000001e-06}" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# The full joint distribution:\n", - "joint_distribution(alarm_net)" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[Burglary, Earthquake, Alarm, JohnCalls, MaryCalls]\n" - ] - }, - { - "data": { - "text/plain": [ - "0.00062811126" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Probability that \"the alarm has sounded, but neither a burglary nor an earthquake has occurred, \n", - "# and both John and Mary call\" (page 514 says it should be 0.000628)\n", - "\n", - "print(alarm_net.variables)\n", - "joint_distribution(alarm_net)[F, F, T, T, T]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Inference by Querying the Joint Distribution\n", - "\n", - "We can use `P(variable, evidence)` to get the probability of aa variable, if we know the vaues of all the parent variables. But what if we don't know? Bayes nets allow us to calculate the probability, but the calculation is not just a lookup in the CPT; it is a global calculation across the whole net. One inefficient but straightforward way of doing the calculation is to create the joint probability distribution, then pick out just the rows that\n", - "match the evidence variables, and for each row check what the value of the query variable is, and increment the probability for that value accordningly:" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": { - "collapsed": false - }, - "outputs": [], - "source": [ - "def enumeration_ask(X, evidence, net):\n", - " \"The probability distribution for query variable X in a belief net, given evidence.\"\n", - " i = net.variables.index(X) # The index of the query variable X in the row\n", - " dist = defaultdict(float) # The resulting probability distribution over X\n", - " for (row, p) in joint_distribution(net).items():\n", - " if matches_evidence(row, evidence, net):\n", - " dist[row[i]] += p\n", - " return ProbDist(dist)\n", - "\n", - "def matches_evidence(row, evidence, net):\n", - " \"Does the tuple of values for this row agree with the evidence?\"\n", - " return all(evidence[v] == row[net.variables.index(v)]\n", - " for v in evidence)" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.9931237539265789, T: 0.006876246073421024}" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# The probability of a Burgalry, given that John calls but Mary does not: \n", - "enumeration_ask(Burglary, {JohnCalls: F, MaryCalls: T}, alarm_net)" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.03368899586522123, T: 0.9663110041347788}" - ] - }, - "execution_count": 26, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# The probability of an Alarm, given that there is an Earthquake and Mary calls:\n", - "enumeration_ask(Alarm, {MaryCalls: T, Earthquake: T}, alarm_net)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Variable Elimination\n", - "\n", - "The `enumeration_ask` algorithm takes time and space that is exponential in the number of variables. That is, first it creates the joint distribution, of size *bn*, and then it sums out the values for the rows that match the evidence. We can do better than that if we interleave the joining of variables with the summing out of values.\n", - "This approach is called *variable elimination*. The key insight is that\n", - "when we compute\n", - "\n", - "P(*X*1=*x*1, ..., *X**n*=*x**n*) = Π*i* P(*X**i* = *x**i* | parents(*X**i*))\n", - "\n", - "we are repeating the calculation of, say, P(*X**3* = *x**4* | parents(*X**3*))\n", - "multiple times, across multiple rows of the joint distribution.\n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "metadata": { - "collapsed": false - }, - "outputs": [], - "source": [ - "# TODO: Copy over and update Variable Elimination algorithm. Also, sampling algorithms." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "button": false, - "deletable": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "source": [ - "# Example: Flu Net\n", - "\n", - "In this net, whether a patient gets the flu is dependent on whether they were vaccinated, and having the flu influences whether they get a fever or headache. Here `Fever` is a non-Boolean variable, with three values, `no`, `mild`, and `high`." - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": { - "button": false, - "collapsed": false, - "deletable": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [], - "source": [ - "flu_net = (BayesNet()\n", - " .add('Vaccinated', [], 0.60)\n", - " .add('Flu', ['Vaccinated'], {T: 0.002, F: 0.02})\n", - " .add('Fever', ['Flu'], {T: ProbDist(no=25, mild=25, high=50),\n", - " F: ProbDist(no=97, mild=2, high=1)})\n", - " .add('Headache', ['Flu'], {T: 0.5, F: 0.03}))\n", - "\n", - "globalize(flu_net.lookup)" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.9616440110625343, T: 0.03835598893746573}" - ] - }, - "execution_count": 29, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# If you just have a headache, you probably don't have the Flu.\n", - "enumeration_ask(Flu, {Headache: T, Fever: 'no'}, flu_net)" - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "metadata": { - "button": false, - "collapsed": false, - "deletable": true, - "new_sheet": false, - "run_control": { - "read_only": false - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.9914651882096696, T: 0.008534811790330398}" - ] - }, - "execution_count": 30, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Even more so if you were vaccinated.\n", - "enumeration_ask(Flu, {Headache: T, Fever: 'no', Vaccinated: T}, flu_net)" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.9194016377587207, T: 0.08059836224127925}" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# But if you were not vaccinated, there is a higher chance you have the flu.\n", - "enumeration_ask(Flu, {Headache: T, Fever: 'no', Vaccinated: F}, flu_net)" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.1904145077720207, T: 0.8095854922279793}" - ] - }, - "execution_count": 32, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# And if you have both headache and fever, and were not vaccinated, \n", - "# then the flu is very likely, especially if it is a high fever.\n", - "enumeration_ask(Flu, {Headache: T, Fever: 'mild', Vaccinated: F}, flu_net)" - ] - }, - { - "cell_type": "code", - "execution_count": 33, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{F: 0.055534567434831886, T: 0.9444654325651682}" - ] - }, - "execution_count": 33, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "enumeration_ask(Flu, {Headache: T, Fever: 'high', Vaccinated: F}, flu_net)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Entropy\n", - "\n", - "We can compute the entropy of a probability distribution:" - ] - }, - { - "cell_type": "code", - "execution_count": 34, - "metadata": { - "collapsed": false - }, - "outputs": [], - "source": [ - "def entropy(probdist):\n", - " \"The entropy of a probability distribution.\"\n", - " return - sum(p * math.log(p, 2)\n", - " for p in probdist.values())" - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "1.0" - ] - }, - "execution_count": 35, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "entropy(ProbDist(heads=0.5, tails=0.5))" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0.011397802630112312" - ] - }, - "execution_count": 36, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "entropy(ProbDist(yes=1000, no=1))" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0.8687212463394045" - ] - }, - "execution_count": 37, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "entropy(P(Alarm, {Earthquake: T, Burglary: F}))" - ] - }, - { - "cell_type": "code", - "execution_count": 38, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0.011407757737461138" - ] - }, - "execution_count": 38, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "entropy(P(Alarm, {Earthquake: F, Burglary: F}))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For non-Boolean variables, the entropy can be greater than 1 bit:" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "1.5" - ] - }, - "execution_count": 39, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "entropy(P(Fever, {Flu: T}))" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "collapsed": false - }, - "source": [ - "# Unknown Outcomes: Smoothing\n", - "\n", - "So far we have dealt with discrete distributions where we know all the possible outcomes in advance. For Boolean variables, the only outcomes are `T` and `F`. For `Fever`, we modeled exactly three outcomes. However, in some applications we will encounter new, previously unknown outcomes over time. For example, we could train a model on the distribution of words in English, and then somebody could coin a brand new word. To deal with this, we introduce\n", - "the `DefaultProbDist` distribution, which uses the key `None` to stand as a placeholder for any unknown outcome(s)." - ] - }, - { - "cell_type": "code", - "execution_count": 40, - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "class DefaultProbDist(ProbDist):\n", - " \"\"\"A Probability Distribution that supports smoothing for unknown outcomes (keys).\n", - " The default_value represents the probability of an unknown (previously unseen) key. \n", - " The key `None` stands for unknown outcomes.\"\"\"\n", - " def __init__(self, default_value, mapping=(), **kwargs):\n", - " self[None] = default_value\n", - " self.update(mapping, **kwargs)\n", - " normalize(self)\n", - " \n", - " def __missing__(self, key): return self[None] " - ] - }, - { - "cell_type": "code", - "execution_count": 41, - "metadata": { - "collapsed": false - }, - "outputs": [], - "source": [ - "import re\n", - "\n", - "def words(text): return re.findall(r'\\w+', text.lower())\n", - "\n", - "english = words('''This is a sample corpus of English prose. To get a better model, we would train on much\n", - "more text. But this should give you an idea of the process. So far we have dealt with discrete \n", - "distributions where we know all the possible outcomes in advance. For Boolean variables, the only \n", - "outcomes are T and F. For Fever, we modeled exactly three outcomes. However, in some applications we \n", - "will encounter new, previously unknown outcomes over time. For example, when we could train a model on the \n", - "words in this text, we get a distribution, but somebody could coin a brand new word. To deal with this, \n", - "we introduce the DefaultProbDist distribution, which uses the key `None` to stand as a placeholder for any \n", - "unknown outcomes. Probability theory allows us to compute the likelihood of certain events, given \n", - "assumptions about the components of the event. A Bayesian network, or Bayes net for short, is a data \n", - "structure to represent a joint probability distribution over several random variables, and do inference on it.''')\n", - "\n", - "E = DefaultProbDist(0.1, Counter(english))" - ] - }, - { - "cell_type": "code", - "execution_count": 42, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0.052295177222545036" - ] - }, - "execution_count": 42, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# 'the' is a common word:\n", - "E['the']" - ] - }, - { - "cell_type": "code", - "execution_count": 43, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0.005810575246949448" - ] - }, - "execution_count": 43, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# 'possible' is a less-common word:\n", - "E['possible']" - ] - }, - { - "cell_type": "code", - "execution_count": 44, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0.0005810575246949449" - ] - }, - "execution_count": 44, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# 'impossible' was not seen in the training data, but still gets a non-zero probability ...\n", - "E['impossible']" - ] - }, - { - "cell_type": "code", - "execution_count": 45, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0.0005810575246949449" - ] - }, - "execution_count": 45, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# ... as do other rare, previously unseen words:\n", - "E['llanfairpwllgwyngyll']" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Note that this does not mean that 'impossible' and 'llanfairpwllgwyngyll' and all the other unknown words\n", - "*each* have probability 0.004.\n", - "Rather, it means that together, all the unknown words total probability 0.004. With that\n", - "interpretation, the sum of all the probabilities is still 1, as it should be. In the `DefaultProbDist`, the\n", - "unknown words are all represented by the key `None`:" - ] - }, - { - "cell_type": "code", - "execution_count": 46, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0.0005810575246949449" - ] - }, - "execution_count": 46, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "E[None]" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.5.1" - } - }, - "nbformat": 4, - "nbformat_minor": 0 -} diff --git a/probability4e.py b/probability4e.py deleted file mode 100644 index d413a55ae..000000000 --- a/probability4e.py +++ /dev/null @@ -1,776 +0,0 @@ -"""Probability models (Chapter 12-13)""" - -import copy -import random -from collections import defaultdict -from functools import reduce - -import numpy as np - -from utils4e import product, probability, extend - - -# ______________________________________________________________________________ -# Chapter 12 Qualifying Uncertainty -# 12.1 Acting Under Uncertainty - - -def DTAgentProgram(belief_state): - """A decision-theoretic agent. [Figure 12.1]""" - - def program(percept): - belief_state.observe(program.action, percept) - program.action = max(belief_state.actions(), key=belief_state.expected_outcome_utility) - return program.action - - program.action = None - return program - - -# ______________________________________________________________________________ -# 12.2 Basic Probability Notation - - -class ProbDist: - """A discrete probability distribution. You name the random variable - in the constructor, then assign and query probability of values. - >>> P = ProbDist('Flip'); P['H'], P['T'] = 0.25, 0.75; P['H'] - 0.25 - >>> P = ProbDist('X', {'lo': 125, 'med': 375, 'hi': 500}) - >>> P['lo'], P['med'], P['hi'] - (0.125, 0.375, 0.5) - """ - - def __init__(self, varname='?', freqs=None): - """If freqs is given, it is a dictionary of values - frequency pairs, - then ProbDist is normalized.""" - self.prob = {} - self.varname = varname - self.values = [] - if freqs: - for (v, p) in freqs.items(): - self[v] = p - self.normalize() - - def __getitem__(self, val): - """Given a value, return P(value).""" - try: - return self.prob[val] - except KeyError: - return 0 - - def __setitem__(self, val, p): - """Set P(val) = p.""" - if val not in self.values: - self.values.append(val) - self.prob[val] = p - - def normalize(self): - """Make sure the probabilities of all values sum to 1. - Returns the normalized distribution. - Raises a ZeroDivisionError if the sum of the values is 0.""" - total = sum(self.prob.values()) - if not np.isclose(total, 1.0): - for val in self.prob: - self.prob[val] /= total - return self - - def show_approx(self, numfmt='{:.3g}'): - """Show the probabilities rounded and sorted by key, for the - sake of portable doctests.""" - return ', '.join([('{}: ' + numfmt).format(v, p) - for (v, p) in sorted(self.prob.items())]) - - def __repr__(self): - return "P({})".format(self.varname) - - -# ______________________________________________________________________________ -# 12.3 Inference Using Full Joint Distributions - - -class JointProbDist(ProbDist): - """A discrete probability distribute over a set of variables. - >>> P = JointProbDist(['X', 'Y']); P[1, 1] = 0.25 - >>> P[1, 1] - 0.25 - >>> P[dict(X=0, Y=1)] = 0.5 - >>> P[dict(X=0, Y=1)] - 0.5""" - - def __init__(self, variables): - self.prob = {} - self.variables = variables - self.vals = defaultdict(list) - - def __getitem__(self, values): - """Given a tuple or dict of values, return P(values).""" - values = event_values(values, self.variables) - return ProbDist.__getitem__(self, values) - - def __setitem__(self, values, p): - """Set P(values) = p. Values can be a tuple or a dict; it must - have a value for each of the variables in the joint. Also keep track - of the values we have seen so far for each variable.""" - values = event_values(values, self.variables) - self.prob[values] = p - for var, val in zip(self.variables, values): - if val not in self.vals[var]: - self.vals[var].append(val) - - def values(self, var): - """Return the set of possible values for a variable.""" - return self.vals[var] - - def __repr__(self): - return "P({})".format(self.variables) - - -def event_values(event, variables): - """Return a tuple of the values of variables in event. - >>> event_values ({'A': 10, 'B': 9, 'C': 8}, ['C', 'A']) - (8, 10) - >>> event_values ((1, 2), ['C', 'A']) - (1, 2) - """ - if isinstance(event, tuple) and len(event) == len(variables): - return event - else: - return tuple([event[var] for var in variables]) - - -def enumerate_joint_ask(X, e, P): - """Return a probability distribution over the values of the variable X, - given the {var:val} observations e, in the JointProbDist P. [Section 12.3] - >>> P = JointProbDist(['X', 'Y']) - >>> P[0,0] = 0.25; P[0,1] = 0.5; P[1,1] = P[2,1] = 0.125 - >>> enumerate_joint_ask('X', dict(Y=1), P).show_approx() - '0: 0.667, 1: 0.167, 2: 0.167' - """ - assert X not in e, "Query variable must be distinct from evidence" - Q = ProbDist(X) # probability distribution for X, initially empty - Y = [v for v in P.variables if v != X and v not in e] # hidden variables. - for xi in P.values(X): - Q[xi] = enumerate_joint(Y, extend(e, X, xi), P) - return Q.normalize() - - -def enumerate_joint(variables, e, P): - """Return the sum of those entries in P consistent with e, - provided variables is P's remaining variables (the ones not in e).""" - if not variables: - return P[e] - Y, rest = variables[0], variables[1:] - return sum([enumerate_joint(rest, extend(e, Y, y), P) - for y in P.values(Y)]) - - -# ______________________________________________________________________________ -# 12.4 Independence - - -def is_independent(variables, P): - """ - Return whether a list of variables are independent given their distribution P - P is an instance of JoinProbDist - >>> P = JointProbDist(['X', 'Y']) - >>> P[0,0] = 0.25; P[0,1] = 0.5; P[1,1] = P[1,0] = 0.125 - >>> is_independent(['X', 'Y'], P) - False - """ - for var in variables: - event_vars = variables[:] - event_vars.remove(var) - event = {} - distribution = enumerate_joint_ask(var, event, P) - events = gen_possible_events(event_vars, P) - for e in events: - conditional_distr = enumerate_joint_ask(var, e, P) - if conditional_distr.prob != distribution.prob: - return False - return True - - -def gen_possible_events(vars, P): - """Generate all possible events of a collection of vars according to distribution of P""" - events = [] - - def backtrack(vars, P, temp): - if not vars: - events.append(temp) - return - var = vars[0] - for val in P.values(var): - temp[var] = val - backtrack([v for v in vars if v != var], P, copy.copy(temp)) - - backtrack(vars, P, {}) - return events - - -# ______________________________________________________________________________ -# Chapter 13 Probabilistic Reasoning -# 13.1 Representing Knowledge in an Uncertain Domain - - -class BayesNet: - """Bayesian network containing only boolean-variable nodes.""" - - def __init__(self, node_specs=None): - """ - Nodes must be ordered with parents before children. - :param node_specs: an nested iterable object, each element contains (variable name, parents name, cpt) - for each node - """ - - self.nodes = [] - self.variables = [] - node_specs = node_specs or [] - for node_spec in node_specs: - self.add(node_spec) - - def add(self, node_spec): - """ - Add a node to the net. Its parents must already be in the - net, and its variable must not. - Initialize Bayes nodes by detecting the length of input node specs - """ - if len(node_spec) >= 5: - node = ContinuousBayesNode(*node_spec) - else: - node = BayesNode(*node_spec) - assert node.variable not in self.variables - assert all((parent in self.variables) for parent in node.parents) - self.nodes.append(node) - self.variables.append(node.variable) - for parent in node.parents: - self.variable_node(parent).children.append(node) - - def variable_node(self, var): - """ - Return the node for the variable named var. - >>> burglary.variable_node('Burglary').variable - 'Burglary' - """ - for n in self.nodes: - if n.variable == var: - return n - raise Exception("No such variable: {}".format(var)) - - def variable_values(self, var): - """Return the domain of var.""" - return [True, False] - - def __repr__(self): - return 'BayesNet({0!r})'.format(self.nodes) - - -class BayesNode: - """ - A conditional probability distribution for a boolean variable, - P(X | parents). Part of a BayesNet. - """ - - def __init__(self, X, parents, cpt): - """ - :param X: variable name, - :param parents: a sequence of variable names or a space-separated string. Representing the names of parent nodes - :param cpt: the conditional probability table, takes one of these forms: - - * A number, the unconditional probability P(X=true). You can - use this form when there are no parents. - - * A dict {v: p, ...}, the conditional probability distribution - P(X=true | parent=v) = p. When there's just one parent. - - * A dict {(v1, v2, ...): p, ...}, the distribution P(X=true | - parent1=v1, parent2=v2, ...) = p. Each key must have as many - values as there are parents. You can use this form always; - the first two are just conveniences. - - In all cases the probability of X being false is left implicit, - since it follows from P(X=true). - - >>> X = BayesNode('X', '', 0.2) - >>> Y = BayesNode('Y', 'P', {T: 0.2, F: 0.7}) - >>> Z = BayesNode('Z', 'P Q', - ... {(T, T): 0.2, (T, F): 0.3, (F, T): 0.5, (F, F): 0.7}) - """ - if isinstance(parents, str): - parents = parents.split() - - # We store the table always in the third form above. - if isinstance(cpt, (float, int)): # no parents, 0-tuple - cpt = {(): cpt} - elif isinstance(cpt, dict): - # one parent, 1-tuple - if cpt and isinstance(list(cpt.keys())[0], bool): - cpt = {(v,): p for v, p in cpt.items()} - - assert isinstance(cpt, dict) - for vs, p in cpt.items(): - assert isinstance(vs, tuple) and len(vs) == len(parents) - assert all(isinstance(v, bool) for v in vs) - assert 0 <= p <= 1 - - self.variable = X - self.parents = parents - self.cpt = cpt - self.children = [] - - def p(self, value, event): - """ - Return the conditional probability - P(X=value | parents=parent_values), where parent_values - are the values of parents in event. (event must assign each - parent a value.) - >>> bn = BayesNode('X', 'Burglary', {T: 0.2, F: 0.625}) - >>> bn.p(False, {'Burglary': False, 'Earthquake': True}) - 0.375 - """ - assert isinstance(value, bool) - ptrue = self.cpt[event_values(event, self.parents)] - return ptrue if value else 1 - ptrue - - def sample(self, event): - """ - Sample from the distribution for this variable conditioned - on event's values for parent_variables. That is, return True/False - at random according with the conditional probability given the - parents. - """ - return probability(self.p(True, event)) - - def __repr__(self): - return repr((self.variable, ' '.join(self.parents))) - - -# Burglary example [Figure 13 .2] - - -T, F = True, False - -burglary = BayesNet([ - ('Burglary', '', 0.001), - ('Earthquake', '', 0.002), - ('Alarm', 'Burglary Earthquake', - {(T, T): 0.95, (T, F): 0.94, (F, T): 0.29, (F, F): 0.001}), - ('JohnCalls', 'Alarm', {T: 0.90, F: 0.05}), - ('MaryCalls', 'Alarm', {T: 0.70, F: 0.01}) -]) - - -# ______________________________________________________________________________ -# Section 13.2. The Semantics of Bayesian Networks -# Bayesian nets with continuous variables - - -def gaussian_probability(param, event, value): - """ - Gaussian probability of a continuous Bayesian network node on condition of - certain event and the parameters determined by the event - :param param: parameters determined by discrete parent events of current node - :param event: a dict, continuous event of current node, the values are used - as parameters in calculating distribution - :param value: float, the value of current continuous node - :return: float, the calculated probability - >>> param = {'sigma':0.5, 'b':1, 'a':{'h1':0.5, 'h2': 1.5}} - >>> event = {'h1':0.6, 'h2': 0.3} - >>> gaussian_probability(param, event, 1) - 0.2590351913317835 - """ - - assert isinstance(event, dict) - assert isinstance(param, dict) - buff = 0 - for k, v in event.items(): - # buffer varianle to calculate h1*a_h1 + h2*a_h2 - buff += param['a'][k] * v - res = 1 / (param['sigma'] * np.sqrt(2 * np.pi)) * np.exp(-0.5 * ((value - buff - param['b']) / param['sigma']) ** 2) - return res - - -def logistic_probability(param, event, value): - """ - Logistic probability of a discrete node in Bayesian network with continuous parents, - :param param: a dict, parameters determined by discrete parents of current node - :param event: a dict, names and values of continuous parent variables of current node - :param value: boolean, True or False - :return: int, probability - """ - - buff = 1 - for _, v in event.items(): - # buffer variable to calculate (value-mu)/sigma - - buff *= (v - param['mu']) / param['sigma'] - p = 1 - 1 / (1 + np.exp(-4 / np.sqrt(2 * np.pi) * buff)) - return p if value else 1 - p - - -class ContinuousBayesNode: - """ A Bayesian network node with continuous distribution or with continuous distributed parents """ - - def __init__(self, name, d_parents, c_parents, parameters, type): - """ - A continuous Bayesian node has two types of parents: discrete and continuous. - :param d_parents: str, name of discrete parents, value of which determines distribution parameters - :param c_parents: str, name of continuous parents, value of which is used to calculate distribution - :param parameters: a dict, parameters for distribution of current node, keys corresponds to discrete parents - :param type: str, type of current node's value, either 'd' (discrete) or 'c'(continuous) - """ - - self.parameters = parameters - self.type = type - self.d_parents = d_parents.split() - self.c_parents = c_parents.split() - self.parents = self.d_parents + self.c_parents - self.variable = name - self.children = [] - - def continuous_p(self, value, c_event, d_event): - """ - Probability given the value of current node and its parents - :param c_event: event of continuous nodes - :param d_event: event of discrete nodes - """ - assert isinstance(c_event, dict) - assert isinstance(d_event, dict) - - d_event_vals = event_values(d_event, self.d_parents) - if len(d_event_vals) == 1: - d_event_vals = d_event_vals[0] - param = self.parameters[d_event_vals] - if self.type == "c": - p = gaussian_probability(param, c_event, value) - if self.type == "d": - p = logistic_probability(param, c_event, value) - return p - - -# harvest-buy example. Figure 13.5 - - -harvest_buy = BayesNet([ - ('Subsidy', '', 0.001), - ('Harvest', '', 0.002), - ('Cost', 'Subsidy', 'Harvest', - {True: {'sigma': 0.5, 'b': 1, 'a': {'Harvest': 0.5}}, - False: {'sigma': 0.6, 'b': 1, 'a': {'Harvest': 0.5}}}, 'c'), - ('Buys', '', 'Cost', {T: {'mu': 0.5, 'sigma': 0.5}, F: {'mu': 0.6, 'sigma': 0.6}}, 'd')]) - - -# ______________________________________________________________________________ -# 13.3 Exact Inference in Bayesian Networks -# 13.3.1 Inference by enumeration - - -def enumeration_ask(X, e, bn): - """ - Return the conditional probability distribution of variable X - given evidence e, from BayesNet bn. [Figure 13.10] - >>> enumeration_ask('Burglary', dict(JohnCalls=T, MaryCalls=T), burglary - ... ).show_approx() - 'False: 0.716, True: 0.284' - """ - - assert X not in e, "Query variable must be distinct from evidence" - Q = ProbDist(X) - for xi in bn.variable_values(X): - Q[xi] = enumerate_all(bn.variables, extend(e, X, xi), bn) - return Q.normalize() - - -def enumerate_all(variables, e, bn): - """ - Return the sum of those entries in P(variables | e{others}) - consistent with e, where P is the joint distribution represented - by bn, and e{others} means e restricted to bn's other variables - (the ones other than variables). Parents must precede children in variables. - """ - - if not variables: - return 1.0 - Y, rest = variables[0], variables[1:] - Ynode = bn.variable_node(Y) - if Y in e: - return Ynode.p(e[Y], e) * enumerate_all(rest, e, bn) - else: - return sum(Ynode.p(y, e) * enumerate_all(rest, extend(e, Y, y), bn) - for y in bn.variable_values(Y)) - - -# ______________________________________________________________________________ -# 13.3.2 The variable elimination algorithm - - -def elimination_ask(X, e, bn): - """ - Compute bn's P(X|e) by variable elimination. [Figure 13.12] - >>> elimination_ask('Burglary', dict(JohnCalls=T, MaryCalls=T), burglary - ... ).show_approx() - 'False: 0.716, True: 0.284' - """ - assert X not in e, "Query variable must be distinct from evidence" - factors = [] - for var in reversed(bn.variables): - factors.append(make_factor(var, e, bn)) - if is_hidden(var, X, e): - factors = sum_out(var, factors, bn) - return pointwise_product(factors, bn).normalize() - - -def is_hidden(var, X, e): - """Is var a hidden variable when querying P(X|e)?""" - return var != X and var not in e - - -def make_factor(var, e, bn): - """ - Return the factor for var in bn's joint distribution given e. - That is, bn's full joint distribution, projected to accord with e, - is the pointwise product of these factors for bn's variables. - """ - node = bn.variable_node(var) - variables = [X for X in [var] + node.parents if X not in e] - cpt = {event_values(e1, variables): node.p(e1[var], e1) - for e1 in all_events(variables, bn, e)} - return Factor(variables, cpt) - - -def pointwise_product(factors, bn): - return reduce(lambda f, g: f.pointwise_product(g, bn), factors) - - -def sum_out(var, factors, bn): - """Eliminate var from all factors by summing over its values.""" - result, var_factors = [], [] - for f in factors: - (var_factors if var in f.variables else result).append(f) - result.append(pointwise_product(var_factors, bn).sum_out(var, bn)) - return result - - -class Factor: - """A factor in a joint distribution.""" - - def __init__(self, variables, cpt): - self.variables = variables - self.cpt = cpt - - def pointwise_product(self, other, bn): - """Multiply two factors, combining their variables.""" - variables = list(set(self.variables) | set(other.variables)) - cpt = {event_values(e, variables): self.p(e) * other.p(e) - for e in all_events(variables, bn, {})} - return Factor(variables, cpt) - - def sum_out(self, var, bn): - """Make a factor eliminating var by summing over its values.""" - variables = [X for X in self.variables if X != var] - cpt = {event_values(e, variables): sum(self.p(extend(e, var, val)) - for val in bn.variable_values(var)) - for e in all_events(variables, bn, {})} - return Factor(variables, cpt) - - def normalize(self): - """Return my probabilities; must be down to one variable.""" - assert len(self.variables) == 1 - return ProbDist(self.variables[0], - {k: v for ((k,), v) in self.cpt.items()}) - - def p(self, e): - """Look up my value tabulated for e.""" - return self.cpt[event_values(e, self.variables)] - - -def all_events(variables, bn, e): - """Yield every way of extending e with values for all variables.""" - if not variables: - yield e - else: - X, rest = variables[0], variables[1:] - for e1 in all_events(rest, bn, e): - for x in bn.variable_values(X): - yield extend(e1, X, x) - - -# ______________________________________________________________________________ -# 13.3.4 Clustering algorithms -# [Figure 13.14a]: sprinkler network - - -sprinkler = BayesNet([ - ('Cloudy', '', 0.5), - ('Sprinkler', 'Cloudy', {T: 0.10, F: 0.50}), - ('Rain', 'Cloudy', {T: 0.80, F: 0.20}), - ('WetGrass', 'Sprinkler Rain', - {(T, T): 0.99, (T, F): 0.90, (F, T): 0.90, (F, F): 0.00})]) - - -# ______________________________________________________________________________ -# 13.4 Approximate Inference for Bayesian Networks -# 13.4.1 Direct sampling methods - - -def prior_sample(bn): - """ - Randomly sample from bn's full joint distribution. The result - is a {variable: value} dict. [Figure 13.15] - """ - event = {} - for node in bn.nodes: - event[node.variable] = node.sample(event) - return event - - -# _________________________________________________________________________ - - -def rejection_sampling(X, e, bn, N=10000): - """ - [Figure 13.16] - Estimate the probability distribution of variable X given - evidence e in BayesNet bn, using N samples. - Raises a ZeroDivisionError if all the N samples are rejected, - i.e., inconsistent with e. - >>> random.seed(47) - >>> rejection_sampling('Burglary', dict(JohnCalls=T, MaryCalls=T), - ... burglary, 10000).show_approx() - 'False: 0.7, True: 0.3' - """ - counts = {x: 0 for x in bn.variable_values(X)} # bold N in [Figure 13.16] - for j in range(N): - sample = prior_sample(bn) # boldface x in [Figure 13.16] - if consistent_with(sample, e): - counts[sample[X]] += 1 - return ProbDist(X, counts) - - -def consistent_with(event, evidence): - """Is event consistent with the given evidence?""" - return all(evidence.get(k, v) == v - for k, v in event.items()) - - -# _________________________________________________________________________ - - -def likelihood_weighting(X, e, bn, N=10000): - """ - [Figure 13.17] - Estimate the probability distribution of variable X given - evidence e in BayesNet bn. - >>> random.seed(1017) - >>> likelihood_weighting('Burglary', dict(JohnCalls=T, MaryCalls=T), - ... burglary, 10000).show_approx() - 'False: 0.702, True: 0.298' - """ - - W = {x: 0 for x in bn.variable_values(X)} - for j in range(N): - sample, weight = weighted_sample(bn, e) # boldface x, w in [Figure 14.15] - W[sample[X]] += weight - return ProbDist(X, W) - - -def weighted_sample(bn, e): - """ - Sample an event from bn that's consistent with the evidence e; - return the event and its weight, the likelihood that the event - accords to the evidence. - """ - - w = 1 - event = dict(e) # boldface x in [Figure 13.17] - for node in bn.nodes: - Xi = node.variable - if Xi in e: - w *= node.p(e[Xi], event) - else: - event[Xi] = node.sample(event) - return event, w - - -# _________________________________________________________________________ -# 13.4.2 Inference by Markov chain simulation - - -def gibbs_ask(X, e, bn, N=1000): - """[Figure 13.19]""" - assert X not in e, "Query variable must be distinct from evidence" - counts = {x: 0 for x in bn.variable_values(X)} # bold N in [Figure 14.16] - Z = [var for var in bn.variables if var not in e] - state = dict(e) # boldface x in [Figure 14.16] - for Zi in Z: - state[Zi] = random.choice(bn.variable_values(Zi)) - for j in range(N): - for Zi in Z: - state[Zi] = markov_blanket_sample(Zi, state, bn) - counts[state[X]] += 1 - return ProbDist(X, counts) - - -def markov_blanket_sample(X, e, bn): - """ - Return a sample from P(X | mb) where mb denotes that the - variables in the Markov blanket of X take their values from event - e (which must assign a value to each). The Markov blanket of X is - X's parents, children, and children's parents. - """ - Xnode = bn.variable_node(X) - Q = ProbDist(X) - for xi in bn.variable_values(X): - ei = extend(e, X, xi) - # [Equation 13.12:] - Q[xi] = Xnode.p(xi, e) * product(Yj.p(ei[Yj.variable], ei) - for Yj in Xnode.children) - # (assuming a Boolean variable here) - return probability(Q.normalize()[True]) - - -# _________________________________________________________________________ -# 13.4.3 Compiling approximate inference - - -class complied_burglary: - """compiled version of burglary network""" - - def Burglary(self, sample): - if sample['Alarm']: - if sample['Earthquake']: - return probability(0.00327) - else: - return probability(0.485) - else: - if sample['Earthquake']: - return probability(7.05e-05) - else: - return probability(6.01e-05) - - def Earthquake(self, sample): - if sample['Alarm']: - if sample['Burglary']: - return probability(0.0020212) - else: - return probability(0.36755) - else: - if sample['Burglary']: - return probability(0.0016672) - else: - return probability(0.0014222) - - def MaryCalls(self, sample): - if sample['Alarm']: - return probability(0.7) - else: - return probability(0.01) - - def JongCalls(self, sample): - if sample['Alarm']: - return probability(0.9) - else: - return probability(0.05) - - def Alarm(self, sample): - raise NotImplementedError diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..4ccb1fefa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "aima" +version = "4.0.0" +description = "Python implementations of the algorithms from Artificial Intelligence: A Modern Approach (4th edition)" +readme = "README.md" +requires-python = ">=3.9" +license = { file = "LICENSE" } +authors = [{ name = "aima-python contributors" }] +urls = { Homepage = "https://github.com/aimacode/aima-python", Documentation = "https://aimacode.github.io/aima-python/" } +dynamic = ["dependencies"] + +[tool.setuptools.dynamic] +dependencies = { file = ["requirements.txt"] } + +[tool.setuptools.packages.find] +include = ["aima*"] diff --git a/reinforcement_learning4e.py b/reinforcement_learning4e.py deleted file mode 100644 index eaaba3e5a..000000000 --- a/reinforcement_learning4e.py +++ /dev/null @@ -1,353 +0,0 @@ -"""Reinforcement Learning (Chapter 21)""" - -import random -from collections import defaultdict - -from mdp4e import MDP, policy_evaluation - - -# _________________________________________ -# 21.2 Passive Reinforcement Learning -# 21.2.1 Direct utility estimation - - -class PassiveDUEAgent: - """ - Passive (non-learning) agent that uses direct utility estimation - on a given MDP and policy. - - import sys - from mdp import sequential_decision_environment - north = (0, 1) - south = (0,-1) - west = (-1, 0) - east = (1, 0) - policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, - (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} - agent = PassiveDUEAgent(policy, sequential_decision_environment) - for i in range(200): - run_single_trial(agent,sequential_decision_environment) - agent.estimate_U() - agent.U[(0, 0)] > 0.2 - True - """ - - def __init__(self, pi, mdp): - self.pi = pi - self.mdp = mdp - self.U = {} - self.s = None - self.a = None - self.s_history = [] - self.r_history = [] - self.init = mdp.init - - def __call__(self, percept): - s1, r1 = percept - self.s_history.append(s1) - self.r_history.append(r1) - ## - ## - if s1 in self.mdp.terminals: - self.s = self.a = None - else: - self.s, self.a = s1, self.pi[s1] - return self.a - - def estimate_U(self): - # this function can be called only if the MDP has reached a terminal state - # it will also reset the mdp history - assert self.a is None, 'MDP is not in terminal state' - assert len(self.s_history) == len(self.r_history) - # calculating the utilities based on the current iteration - U2 = {s: [] for s in set(self.s_history)} - for i in range(len(self.s_history)): - s = self.s_history[i] - U2[s] += [sum(self.r_history[i:])] - U2 = {k: sum(v) / max(len(v), 1) for k, v in U2.items()} - # resetting history - self.s_history, self.r_history = [], [] - # setting the new utilities to the average of the previous - # iteration and this one - for k in U2.keys(): - if k in self.U.keys(): - self.U[k] = (self.U[k] + U2[k]) / 2 - else: - self.U[k] = U2[k] - return self.U - - def update_state(self, percept): - """To be overridden in most cases. The default case - assumes the percept to be of type (state, reward)""" - return percept - - -# 21.2.2 Adaptive dynamic programming - - -class PassiveADPAgent: - """ - [Figure 21.2] - Passive (non-learning) agent that uses adaptive dynamic programming - on a given MDP and policy. - - import sys - from mdp import sequential_decision_environment - north = (0, 1) - south = (0,-1) - west = (-1, 0) - east = (1, 0) - policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, - (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} - agent = PassiveADPAgent(policy, sequential_decision_environment) - for i in range(100): - run_single_trial(agent,sequential_decision_environment) - - agent.U[(0, 0)] > 0.2 - True - agent.U[(0, 1)] > 0.2 - True - """ - - class ModelMDP(MDP): - """Class for implementing modified Version of input MDP with - an editable transition model P and a custom function T.""" - - def __init__(self, init, actlist, terminals, gamma, states): - super().__init__(init, actlist, terminals, states=states, gamma=gamma) - nested_dict = lambda: defaultdict(nested_dict) - # StackOverflow:whats-the-best-way-to-initialize-a-dict-of-dicts-in-python - self.P = nested_dict() - - def T(self, s, a): - """Return a list of tuples with probabilities for states - based on the learnt model P.""" - return [(prob, res) for (res, prob) in self.P[(s, a)].items()] - - def __init__(self, pi, mdp): - self.pi = pi - self.mdp = PassiveADPAgent.ModelMDP(mdp.init, mdp.actlist, - mdp.terminals, mdp.gamma, mdp.states) - self.U = {} - self.Nsa = defaultdict(int) - self.Ns1_sa = defaultdict(int) - self.s = None - self.a = None - self.visited = set() # keeping track of visited states - - def __call__(self, percept): - s1, r1 = percept - mdp = self.mdp - R, P, terminals, pi = mdp.reward, mdp.P, mdp.terminals, self.pi - s, a, Nsa, Ns1_sa, U = self.s, self.a, self.Nsa, self.Ns1_sa, self.U - - if s1 not in self.visited: # Reward is only known for visited state. - U[s1] = R[s1] = r1 - self.visited.add(s1) - if s is not None: - Nsa[(s, a)] += 1 - Ns1_sa[(s1, s, a)] += 1 - # for each t such that Ns′|sa [t, s, a] is nonzero - for t in [res for (res, state, act), freq in Ns1_sa.items() - if (state, act) == (s, a) and freq != 0]: - P[(s, a)][t] = Ns1_sa[(t, s, a)] / Nsa[(s, a)] - - self.U = policy_evaluation(pi, U, mdp) - ## - ## - self.Nsa, self.Ns1_sa = Nsa, Ns1_sa - if s1 in terminals: - self.s = self.a = None - else: - self.s, self.a = s1, self.pi[s1] - return self.a - - def update_state(self, percept): - """To be overridden in most cases. The default case - assumes the percept to be of type (state, reward).""" - return percept - - -# 21.2.3 Temporal-difference learning - - -class PassiveTDAgent: - """ - [Figure 21.4] - The abstract class for a Passive (non-learning) agent that uses - temporal differences to learn utility estimates. Override update_state - method to convert percept to state and reward. The mdp being provided - should be an instance of a subclass of the MDP Class. - - import sys - from mdp import sequential_decision_environment - north = (0, 1) - south = (0,-1) - west = (-1, 0) - east = (1, 0) - policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, - (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} - agent = PassiveTDAgent(policy, sequential_decision_environment, alpha=lambda n: 60./(59+n)) - for i in range(200): - run_single_trial(agent,sequential_decision_environment) - - agent.U[(0, 0)] > 0.2 - True - agent.U[(0, 1)] > 0.2 - True - """ - - def __init__(self, pi, mdp, alpha=None): - - self.pi = pi - self.U = {s: 0. for s in mdp.states} - self.Ns = {s: 0 for s in mdp.states} - self.s = None - self.a = None - self.r = None - self.gamma = mdp.gamma - self.terminals = mdp.terminals - - if alpha: - self.alpha = alpha - else: - self.alpha = lambda n: 1 / (1 + n) # udacity video - - def __call__(self, percept): - s1, r1 = self.update_state(percept) - pi, U, Ns, s, r = self.pi, self.U, self.Ns, self.s, self.r - alpha, gamma, terminals = self.alpha, self.gamma, self.terminals - if not Ns[s1]: - U[s1] = r1 - if s is not None: - Ns[s] += 1 - U[s] += alpha(Ns[s]) * (r + gamma * U[s1] - U[s]) - if s1 in terminals: - self.s = self.a = self.r = None - else: - self.s, self.a, self.r = s1, pi[s1], r1 - return self.a - - def update_state(self, percept): - """To be overridden in most cases. The default case - assumes the percept to be of type (state, reward).""" - return percept - - -# __________________________________________ -# 21.3. Active Reinforcement Learning -# 21.3.2 Learning an action-utility function - - -class QLearningAgent: - """ - [Figure 21.8] - An exploratory Q-learning agent. It avoids having to learn the transition - model because the Q-value of a state can be related directly to those of - its neighbors. - - import sys - from mdp import sequential_decision_environment - north = (0, 1) - south = (0,-1) - west = (-1, 0) - east = (1, 0) - policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, (0, 1): north, (2, 1): north, - (3, 1): None, (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west,} - q_agent = QLearningAgent(sequential_decision_environment, Ne=5, Rplus=2, alpha=lambda n: 60./(59+n)) - for i in range(200): - run_single_trial(q_agent,sequential_decision_environment) - - q_agent.Q[((0, 1), (0, 1))] >= -0.5 - True - q_agent.Q[((1, 0), (0, -1))] <= 0.5 - True - """ - - def __init__(self, mdp, Ne, Rplus, alpha=None): - - self.gamma = mdp.gamma - self.terminals = mdp.terminals - self.all_act = mdp.actlist - self.Ne = Ne # iteration limit in exploration function - self.Rplus = Rplus # large value to assign before iteration limit - self.Q = defaultdict(float) - self.Nsa = defaultdict(float) - self.s = None - self.a = None - self.r = None - - if alpha: - self.alpha = alpha - else: - self.alpha = lambda n: 1. / (1 + n) # udacity video - - def f(self, u, n): - """Exploration function. Returns fixed Rplus until - agent has visited state, action a Ne number of times. - Same as ADP agent in book.""" - if n < self.Ne: - return self.Rplus - else: - return u - - def actions_in_state(self, state): - """Return actions possible in given state. - Useful for max and argmax.""" - if state in self.terminals: - return [None] - else: - return self.all_act - - def __call__(self, percept): - s1, r1 = self.update_state(percept) - Q, Nsa, s, a, r = self.Q, self.Nsa, self.s, self.a, self.r - alpha, gamma, terminals = self.alpha, self.gamma, self.terminals, - actions_in_state = self.actions_in_state - - if s in terminals: - Q[s, None] = r1 - if s is not None: - Nsa[s, a] += 1 - Q[s, a] += alpha(Nsa[s, a]) * (r + gamma * max(Q[s1, a1] - for a1 in actions_in_state(s1)) - Q[s, a]) - if s in terminals: - self.s = self.a = self.r = None - else: - self.s, self.r = s1, r1 - self.a = max(actions_in_state(s1), key=lambda a1: self.f(Q[s1, a1], Nsa[s1, a1])) - return self.a - - def update_state(self, percept): - """To be overridden in most cases. The default case - assumes the percept to be of type (state, reward).""" - return percept - - -def run_single_trial(agent_program, mdp): - """Execute trial for given agent_program - and mdp. mdp should be an instance of subclass - of mdp.MDP """ - - def take_single_action(mdp, s, a): - """ - Select outcome of taking action a - in state s. Weighted Sampling. - """ - x = random.uniform(0, 1) - cumulative_probability = 0.0 - for probability_state in mdp.T(s, a): - probability, state = probability_state - cumulative_probability += probability - if x < cumulative_probability: - break - return state - - current_state = mdp.init - while True: - current_reward = mdp.R(current_state) - percept = (current_state, current_reward) - next_action = agent_program(percept) - if next_action is None: - break - current_state = take_single_action(mdp, current_state, next_action) diff --git a/requirements.txt b/requirements.txt index dd6b1be8a..007c678c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cvxopt -image +graphviz ipython ipythonblocks ipywidgets @@ -8,11 +8,12 @@ keras matplotlib networkx numpy -opencv-python +opencv-contrib-python pandas pillow +pytest pytest-cov qpsolvers scipy sortedcontainers -tensorflow \ No newline at end of file +tensorflow diff --git a/search4e.ipynb b/search4e.ipynb deleted file mode 100644 index 7c636f2e7..000000000 --- a/search4e.ipynb +++ /dev/null @@ -1,2652 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Search for AIMA 4th edition\n", - "\n", - "Implementation of search algorithms and search problems for AIMA.\n", - "\n", - "# Problems and Nodes\n", - "\n", - "We start by defining the abstract class for a `Problem`; specific problem domains will subclass this. To make it easier for algorithms that use a heuristic evaluation function, `Problem` has a default `h` function (uniformly zero), and subclasses can define their own default `h` function.\n", - "\n", - "We also define a `Node` in a search tree, and some functions on nodes: `expand` to generate successors; `path_actions` and `path_states` to recover aspects of the path from the node. " - ] - }, - { - "cell_type": "code", - "execution_count": 93, - "metadata": {}, - "outputs": [], - "source": [ - "%matplotlib inline\n", - "import matplotlib.pyplot as plt\n", - "import random\n", - "import heapq\n", - "import math\n", - "import sys\n", - "from collections import defaultdict, deque, Counter\n", - "from itertools import combinations\n", - "\n", - "\n", - "class Problem(object):\n", - " \"\"\"The abstract class for a formal problem. A new domain subclasses this,\n", - " overriding `actions` and `results`, and perhaps other methods.\n", - " The default heuristic is 0 and the default action cost is 1 for all states.\n", - " When yiou create an instance of a subclass, specify `initial`, and `goal` states \n", - " (or give an `is_goal` method) and perhaps other keyword args for the subclass.\"\"\"\n", - "\n", - " def __init__(self, initial=None, goal=None, **kwds): \n", - " self.__dict__.update(initial=initial, goal=goal, **kwds) \n", - " \n", - " def actions(self, state): raise NotImplementedError\n", - " def result(self, state, action): raise NotImplementedError\n", - " def is_goal(self, state): return state == self.goal\n", - " def action_cost(self, s, a, s1): return 1\n", - " def h(self, node): return 0\n", - " \n", - " def __str__(self):\n", - " return '{}({!r}, {!r})'.format(\n", - " type(self).__name__, self.initial, self.goal)\n", - " \n", - "\n", - "class Node:\n", - " \"A Node in a search tree.\"\n", - " def __init__(self, state, parent=None, action=None, path_cost=0):\n", - " self.__dict__.update(state=state, parent=parent, action=action, path_cost=path_cost)\n", - "\n", - " def __repr__(self): return '<{}>'.format(self.state)\n", - " def __len__(self): return 0 if self.parent is None else (1 + len(self.parent))\n", - " def __lt__(self, other): return self.path_cost < other.path_cost\n", - " \n", - " \n", - "failure = Node('failure', path_cost=math.inf) # Indicates an algorithm couldn't find a solution.\n", - "cutoff = Node('cutoff', path_cost=math.inf) # Indicates iterative deepening search was cut off.\n", - " \n", - " \n", - "def expand(problem, node):\n", - " \"Expand a node, generating the children nodes.\"\n", - " s = node.state\n", - " for action in problem.actions(s):\n", - " s1 = problem.result(s, action)\n", - " cost = node.path_cost + problem.action_cost(s, action, s1)\n", - " yield Node(s1, node, action, cost)\n", - " \n", - "\n", - "def path_actions(node):\n", - " \"The sequence of actions to get to this node.\"\n", - " if node.parent is None:\n", - " return [] \n", - " return path_actions(node.parent) + [node.action]\n", - "\n", - "\n", - "def path_states(node):\n", - " \"The sequence of states to get to this node.\"\n", - " if node in (cutoff, failure, None): \n", - " return []\n", - " return path_states(node.parent) + [node.state]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Queues\n", - "\n", - "First-in-first-out and Last-in-first-out queues, and a `PriorityQueue`, which allows you to keep a collection of items, and continually remove from it the item with minimum `f(item)` score." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "FIFOQueue = deque\n", - "\n", - "LIFOQueue = list\n", - "\n", - "class PriorityQueue:\n", - " \"\"\"A queue in which the item with minimum f(item) is always popped first.\"\"\"\n", - "\n", - " def __init__(self, items=(), key=lambda x: x): \n", - " self.key = key\n", - " self.items = [] # a heap of (score, item) pairs\n", - " for item in items:\n", - " self.add(item)\n", - " \n", - " def add(self, item):\n", - " \"\"\"Add item to the queuez.\"\"\"\n", - " pair = (self.key(item), item)\n", - " heapq.heappush(self.items, pair)\n", - "\n", - " def pop(self):\n", - " \"\"\"Pop and return the item with min f(item) value.\"\"\"\n", - " return heapq.heappop(self.items)[1]\n", - " \n", - " def top(self): return self.items[0][1]\n", - "\n", - " def __len__(self): return len(self.items)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Search Algorithms: Best-First\n", - "\n", - "Best-first search with various *f(n)* functions gives us different search algorithms. Note that A\\*, weighted A\\* and greedy search can be given a heuristic function, `h`, but if `h` is not supplied they use the problem's default `h` function (if the problem does not define one, it is taken as *h(n)* = 0)." - ] - }, - { - "cell_type": "code", - "execution_count": 356, - "metadata": {}, - "outputs": [], - "source": [ - "def best_first_search(problem, f):\n", - " \"Search nodes with minimum f(node) value first.\"\n", - " node = Node(problem.initial)\n", - " frontier = PriorityQueue([node], key=f)\n", - " reached = {problem.initial: node}\n", - " while frontier:\n", - " node = frontier.pop()\n", - " if problem.is_goal(node.state):\n", - " return node\n", - " for child in expand(problem, node):\n", - " s = child.state\n", - " if s not in reached or child.path_cost < reached[s].path_cost:\n", - " reached[s] = child\n", - " frontier.add(child)\n", - " return failure\n", - "\n", - "\n", - "def best_first_tree_search(problem, f):\n", - " \"A version of best_first_search without the `reached` table.\"\n", - " frontier = PriorityQueue([Node(problem.initial)], key=f)\n", - " while frontier:\n", - " node = frontier.pop()\n", - " if problem.is_goal(node.state):\n", - " return node\n", - " for child in expand(problem, node):\n", - " if not is_cycle(child):\n", - " frontier.add(child)\n", - " return failure\n", - "\n", - "\n", - "def g(n): return n.path_cost\n", - "\n", - "\n", - "def astar_search(problem, h=None):\n", - " \"\"\"Search nodes with minimum f(n) = g(n) + h(n).\"\"\"\n", - " h = h or problem.h\n", - " return best_first_search(problem, f=lambda n: g(n) + h(n))\n", - "\n", - "\n", - "def astar_tree_search(problem, h=None):\n", - " \"\"\"Search nodes with minimum f(n) = g(n) + h(n), with no `reached` table.\"\"\"\n", - " h = h or problem.h\n", - " return best_first_tree_search(problem, f=lambda n: g(n) + h(n))\n", - "\n", - "\n", - "def weighted_astar_search(problem, h=None, weight=1.4):\n", - " \"\"\"Search nodes with minimum f(n) = g(n) + weight * h(n).\"\"\"\n", - " h = h or problem.h\n", - " return best_first_search(problem, f=lambda n: g(n) + weight * h(n))\n", - "\n", - " \n", - "def greedy_bfs(problem, h=None):\n", - " \"\"\"Search nodes with minimum h(n).\"\"\"\n", - " h = h or problem.h\n", - " return best_first_search(problem, f=h)\n", - "\n", - "\n", - "def uniform_cost_search(problem):\n", - " \"Search nodes with minimum path cost first.\"\n", - " return best_first_search(problem, f=g)\n", - "\n", - "\n", - "def breadth_first_bfs(problem):\n", - " \"Search shallowest nodes in the search tree first; using best-first.\"\n", - " return best_first_search(problem, f=len)\n", - "\n", - "\n", - "def depth_first_bfs(problem):\n", - " \"Search deepest nodes in the search tree first; using best-first.\"\n", - " return best_first_search(problem, f=lambda n: -len(n))\n", - "\n", - "\n", - "def is_cycle(node, k=30):\n", - " \"Does this node form a cycle of length k or less?\"\n", - " def find_cycle(ancestor, k):\n", - " return (ancestor is not None and k > 0 and\n", - " (ancestor.state == node.state or find_cycle(ancestor.parent, k - 1)))\n", - " return find_cycle(node.parent, k)\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Other Search Algorithms\n", - "\n", - "Here are the other search algorithms:" - ] - }, - { - "cell_type": "code", - "execution_count": 234, - "metadata": {}, - "outputs": [], - "source": [ - "def breadth_first_search(problem):\n", - " \"Search shallowest nodes in the search tree first.\"\n", - " node = Node(problem.initial)\n", - " if problem.is_goal(problem.initial):\n", - " return node\n", - " frontier = FIFOQueue([node])\n", - " reached = {problem.initial}\n", - " while frontier:\n", - " node = frontier.pop()\n", - " for child in expand(problem, node):\n", - " s = child.state\n", - " if problem.is_goal(s):\n", - " return child\n", - " if s not in reached:\n", - " reached.add(s)\n", - " frontier.appendleft(child)\n", - " return failure\n", - "\n", - "\n", - "def iterative_deepening_search(problem):\n", - " \"Do depth-limited search with increasing depth limits.\"\n", - " for limit in range(1, sys.maxsize):\n", - " result = depth_limited_search(problem, limit)\n", - " if result != cutoff:\n", - " return result\n", - " \n", - " \n", - "def depth_limited_search(problem, limit=10):\n", - " \"Search deepest nodes in the search tree first.\"\n", - " frontier = LIFOQueue([Node(problem.initial)])\n", - " result = failure\n", - " while frontier:\n", - " node = frontier.pop()\n", - " if problem.is_goal(node.state):\n", - " return node\n", - " elif len(node) >= limit:\n", - " result = cutoff\n", - " elif not is_cycle(node):\n", - " for child in expand(problem, node):\n", - " frontier.append(child)\n", - " return result\n", - "\n", - "\n", - "def depth_first_recursive_search(problem, node=None):\n", - " if node is None: \n", - " node = Node(problem.initial)\n", - " if problem.is_goal(node.state):\n", - " return node\n", - " elif is_cycle(node):\n", - " return failure\n", - " else:\n", - " for child in expand(problem, node):\n", - " result = depth_first_recursive_search(problem, child)\n", - " if result:\n", - " return result\n", - " return failure" - ] - }, - { - "cell_type": "code", - "execution_count": 236, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['N', 'I', 'V', 'U', 'B', 'F', 'S', 'O', 'Z', 'A', 'T', 'L']" - ] - }, - "execution_count": 236, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "path_states(depth_first_recursive_search(r2))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Bidirectional Best-First Search" - ] - }, - { - "cell_type": "code", - "execution_count": 412, - "metadata": {}, - "outputs": [], - "source": [ - "def bidirectional_best_first_search(problem_f, f_f, problem_b, f_b, terminated):\n", - " node_f = Node(problem_f.initial)\n", - " node_b = Node(problem_f.goal)\n", - " frontier_f, reached_f = PriorityQueue([node_f], key=f_f), {node_f.state: node_f}\n", - " frontier_b, reached_b = PriorityQueue([node_b], key=f_b), {node_b.state: node_b}\n", - " solution = failure\n", - " while frontier_f and frontier_b and not terminated(solution, frontier_f, frontier_b):\n", - " def S1(node, f):\n", - " return str(int(f(node))) + ' ' + str(path_states(node))\n", - " print('Bi:', S1(frontier_f.top(), f_f), S1(frontier_b.top(), f_b))\n", - " if f_f(frontier_f.top()) < f_b(frontier_b.top()):\n", - " solution = proceed('f', problem_f, frontier_f, reached_f, reached_b, solution)\n", - " else:\n", - " solution = proceed('b', problem_b, frontier_b, reached_b, reached_f, solution)\n", - " return solution\n", - "\n", - "def inverse_problem(problem):\n", - " if isinstance(problem, CountCalls):\n", - " return CountCalls(inverse_problem(problem._object))\n", - " else:\n", - " inv = copy.copy(problem)\n", - " inv.initial, inv.goal = inv.goal, inv.initial\n", - " return inv" - ] - }, - { - "cell_type": "code", - "execution_count": 413, - "metadata": {}, - "outputs": [], - "source": [ - "def bidirectional_uniform_cost_search(problem_f):\n", - " def terminated(solution, frontier_f, frontier_b):\n", - " n_f, n_b = frontier_f.top(), frontier_b.top()\n", - " return g(n_f) + g(n_b) > g(solution)\n", - " return bidirectional_best_first_search(problem_f, g, inverse_problem(problem_f), g, terminated)\n", - "\n", - "def bidirectional_astar_search(problem_f):\n", - " def terminated(solution, frontier_f, frontier_b):\n", - " nf, nb = frontier_f.top(), frontier_b.top()\n", - " return g(nf) + g(nb) > g(solution)\n", - " problem_f = inverse_problem(problem_f)\n", - " return bidirectional_best_first_search(problem_f, lambda n: g(n) + problem_f.h(n),\n", - " problem_b, lambda n: g(n) + problem_b.h(n), \n", - " terminated)\n", - " \n", - "\n", - "def proceed(direction, problem, frontier, reached, reached2, solution):\n", - " node = frontier.pop()\n", - " for child in expand(problem, node):\n", - " s = child.state\n", - " print('proceed', direction, S(child))\n", - " if s not in reached or child.path_cost < reached[s].path_cost:\n", - " frontier.add(child)\n", - " reached[s] = child\n", - " if s in reached2: # Frontiers collide; solution found\n", - " solution2 = (join_nodes(child, reached2[s]) if direction == 'f' else\n", - " join_nodes(reached2[s], child))\n", - " #print('solution', path_states(solution2), solution2.path_cost, \n", - " # path_states(child), path_states(reached2[s]))\n", - " if solution2.path_cost < solution.path_cost:\n", - " solution = solution2\n", - " return solution\n", - "\n", - "S = path_states\n", - "\n", - "#A-S-R + B-P-R => A-S-R-P + B-P\n", - "def join_nodes(nf, nb):\n", - " \"\"\"Join the reverse of the backward node nb to the forward node nf.\"\"\"\n", - " #print('join', S(nf), S(nb))\n", - " join = nf\n", - " while nb.parent is not None:\n", - " cost = join.path_cost + nb.path_cost - nb.parent.path_cost\n", - " join = Node(nb.parent.state, join, nb.action, cost)\n", - " nb = nb.parent\n", - " #print(' now join', S(join), 'with nb', S(nb), 'parent', S(nb.parent))\n", - " return join\n", - " \n", - " " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "#A , B = uniform_cost_search(r1), uniform_cost_search(r2)\n", - "#path_states(A), path_states(B)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "#path_states(append_nodes(A, B))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# TODO: RBFS" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Problem Domains\n", - "\n", - "Now we turn our attention to defining some problem domains as subclasses of `Problem`." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Route Finding Problems\n", - "\n", - "![](romania.png)\n", - "\n", - "In a `RouteProblem`, the states are names of \"cities\" (or other locations), like `'A'` for Arad. The actions are also city names; `'Z'` is the action to move to city `'Z'`. The layout of cities is given by a separate data structure, a `Map`, which is a graph where there are vertexes (cities), links between vertexes, distances (costs) of those links (if not specified, the default is 1 for every link), and optionally the 2D (x, y) location of each city can be specified. A `RouteProblem` takes this `Map` as input and allows actions to move between linked cities. The default heuristic is straight-line distance to the goal, or is uniformly zero if locations were not given." - ] - }, - { - "cell_type": "code", - "execution_count": 398, - "metadata": {}, - "outputs": [], - "source": [ - "class RouteProblem(Problem):\n", - " \"\"\"A problem to find a route between locations on a `Map`.\n", - " Create a problem with RouteProblem(start, goal, map=Map(...)}).\n", - " States are the vertexes in the Map graph; actions are destination states.\"\"\"\n", - " \n", - " def actions(self, state): \n", - " \"\"\"The places neighboring `state`.\"\"\"\n", - " return self.map.neighbors[state]\n", - " \n", - " def result(self, state, action):\n", - " \"\"\"Go to the `action` place, if the map says that is possible.\"\"\"\n", - " return action if action in self.map.neighbors[state] else state\n", - " \n", - " def action_cost(self, s, action, s1):\n", - " \"\"\"The distance (cost) to go from s to s1.\"\"\"\n", - " return self.map.distances[s, s1]\n", - " \n", - " def h(self, node):\n", - " \"Straight-line distance between state and the goal.\"\n", - " locs = self.map.locations\n", - " return straight_line_distance(locs[node.state], locs[self.goal])\n", - " \n", - " \n", - "def straight_line_distance(A, B):\n", - " \"Straight-line distance between two points.\"\n", - " return sum(abs(a - b)**2 for (a, b) in zip(A, B)) ** 0.5" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [], - "source": [ - "class Map:\n", - " \"\"\"A map of places in a 2D world: a graph with vertexes and links between them. \n", - " In `Map(links, locations)`, `links` can be either [(v1, v2)...] pairs, \n", - " or a {(v1, v2): distance...} dict. Optional `locations` can be {v1: (x, y)} \n", - " If `directed=False` then for every (v1, v2) link, we add a (v2, v1) link.\"\"\"\n", - "\n", - " def __init__(self, links, locations=None, directed=False):\n", - " if not hasattr(links, 'items'): # Distances are 1 by default\n", - " links = {link: 1 for link in links}\n", - " if not directed:\n", - " for (v1, v2) in list(links):\n", - " links[v2, v1] = links[v1, v2]\n", - " self.distances = links\n", - " self.neighbors = multimap(links)\n", - " self.locations = locations or defaultdict(lambda: (0, 0))\n", - "\n", - " \n", - "def multimap(pairs) -> dict:\n", - " \"Given (key, val) pairs, make a dict of {key: [val,...]}.\"\n", - " result = defaultdict(list)\n", - " for key, val in pairs:\n", - " result[key].append(val)\n", - " return result" - ] - }, - { - "cell_type": "code", - "execution_count": 400, - "metadata": {}, - "outputs": [], - "source": [ - "# Some specific RouteProblems\n", - "\n", - "romania = Map(\n", - " {('O', 'Z'): 71, ('O', 'S'): 151, ('A', 'Z'): 75, ('A', 'S'): 140, ('A', 'T'): 118, \n", - " ('L', 'T'): 111, ('L', 'M'): 70, ('D', 'M'): 75, ('C', 'D'): 120, ('C', 'R'): 146, \n", - " ('C', 'P'): 138, ('R', 'S'): 80, ('F', 'S'): 99, ('B', 'F'): 211, ('B', 'P'): 101, \n", - " ('B', 'G'): 90, ('B', 'U'): 85, ('H', 'U'): 98, ('E', 'H'): 86, ('U', 'V'): 142, \n", - " ('I', 'V'): 92, ('I', 'N'): 87, ('P', 'R'): 97},\n", - " {'A': ( 76, 497), 'B': (400, 327), 'C': (246, 285), 'D': (160, 296), 'E': (558, 294), \n", - " 'F': (285, 460), 'G': (368, 257), 'H': (548, 355), 'I': (488, 535), 'L': (162, 379),\n", - " 'M': (160, 343), 'N': (407, 561), 'O': (117, 580), 'P': (311, 372), 'R': (227, 412),\n", - " 'S': (187, 463), 'T': ( 83, 414), 'U': (471, 363), 'V': (535, 473), 'Z': (92, 539)})\n", - "\n", - "\n", - "r0 = RouteProblem('A', 'A', map=romania)\n", - "r1 = RouteProblem('A', 'B', map=romania)\n", - "r2 = RouteProblem('N', 'L', map=romania)\n", - "r3 = RouteProblem('E', 'T', map=romania)\n", - "r4 = RouteProblem('O', 'M', map=romania)" - ] - }, - { - "cell_type": "code", - "execution_count": 232, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['A', 'S', 'R', 'P', 'B']" - ] - }, - "execution_count": 232, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "path_states(uniform_cost_search(r1)) # Lowest-cost path from Arab to Bucharest" - ] - }, - { - "cell_type": "code", - "execution_count": 233, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['A', 'S', 'F', 'B']" - ] - }, - "execution_count": 233, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "path_states(breadth_first_search(r1)) # Breadth-first: fewer steps, higher path cost" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Grid Problems\n", - "\n", - "A `GridProblem` involves navigating on a 2D grid, with some cells being impassible obstacles. By default you can move to any of the eight neighboring cells that are not obstacles (but in a problem instance you can supply a `directions=` keyword to change that). Again, the default heuristic is straight-line distance to the goal. States are `(x, y)` cell locations, such as `(4, 2)`, and actions are `(dx, dy)` cell movements, such as `(0, -1)`, which means leave the `x` coordinate alone, and decrement the `y` coordinate by 1." - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": {}, - "outputs": [], - "source": [ - "class GridProblem(Problem):\n", - " \"\"\"Finding a path on a 2D grid with obstacles. Obstacles are (x, y) cells.\"\"\"\n", - "\n", - " def __init__(self, initial=(15, 30), goal=(130, 30), obstacles=(), **kwds):\n", - " Problem.__init__(self, initial=initial, goal=goal, \n", - " obstacles=set(obstacles) - {initial, goal}, **kwds)\n", - "\n", - " directions = [(-1, -1), (0, -1), (1, -1),\n", - " (-1, 0), (1, 0),\n", - " (-1, +1), (0, +1), (1, +1)]\n", - " \n", - " def action_cost(self, s, action, s1): return straight_line_distance(s, s1)\n", - " \n", - " def h(self, node): return straight_line_distance(node.state, self.goal)\n", - " \n", - " def result(self, state, action): \n", - " \"Both states and actions are represented by (x, y) pairs.\"\n", - " return action if action not in self.obstacles else state\n", - " \n", - " def actions(self, state):\n", - " \"\"\"You can move one cell in any of `directions` to a non-obstacle cell.\"\"\"\n", - " x, y = state\n", - " return {(x + dx, y + dy) for (dx, dy) in self.directions} - self.obstacles\n", - " \n", - "class ErraticVacuum(Problem):\n", - " def actions(self, state): \n", - " return ['suck', 'forward', 'backward']\n", - " \n", - " def results(self, state, action): return self.table[action][state]\n", - " \n", - " table = dict(suck= {1:{5,7}, 2:{4,8}, 3:{7}, 4:{2,4}, 5:{1,5}, 6:{8}, 7:{3,7}, 8:{6,8}},\n", - " forward= {1:{2}, 2:{2}, 3:{4}, 4:{4}, 5:{6}, 6:{6}, 7:{8}, 8:{8}},\n", - " backward={1:{1}, 2:{1}, 3:{3}, 4:{3}, 5:{5}, 6:{5}, 7:{7}, 8:{7}})" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": {}, - "outputs": [], - "source": [ - "# Some grid routing problems\n", - "\n", - "# The following can be used to create obstacles:\n", - " \n", - "def random_lines(X=range(15, 130), Y=range(60), N=150, lengths=range(6, 12)):\n", - " \"\"\"The set of cells in N random lines of the given lengths.\"\"\"\n", - " result = set()\n", - " for _ in range(N):\n", - " x, y = random.choice(X), random.choice(Y)\n", - " dx, dy = random.choice(((0, 1), (1, 0)))\n", - " result |= line(x, y, dx, dy, random.choice(lengths))\n", - " return result\n", - "\n", - "def line(x, y, dx, dy, length):\n", - " \"\"\"A line of `length` cells starting at (x, y) and going in (dx, dy) direction.\"\"\"\n", - " return {(x + i * dx, y + i * dy) for i in range(length)}\n", - "\n", - "random.seed(42) # To make this reproducible\n", - "\n", - "frame = line(-10, 20, 0, 1, 20) | line(150, 20, 0, 1, 20)\n", - "cup = line(102, 44, -1, 0, 15) | line(102, 20, -1, 0, 20) | line(102, 44, 0, -1, 24)\n", - "\n", - "d1 = GridProblem(obstacles=random_lines(N=100) | frame)\n", - "d2 = GridProblem(obstacles=random_lines(N=150) | frame)\n", - "d3 = GridProblem(obstacles=random_lines(N=200) | frame)\n", - "d4 = GridProblem(obstacles=random_lines(N=250) | frame)\n", - "d5 = GridProblem(obstacles=random_lines(N=300) | frame)\n", - "d6 = GridProblem(obstacles=cup | frame)\n", - "d7 = GridProblem(obstacles=cup | frame | line(50, 35, 0, -1, 10) | line(60, 37, 0, -1, 17) | line(70, 31, 0, -1, 19))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 8 Puzzle Problems\n", - "\n", - "![](https://ece.uwaterloo.ca/~dwharder/aads/Algorithms/N_puzzles/images/puz3.png)\n", - "\n", - "A sliding tile puzzle where you can swap the blank with an adjacent piece, trying to reach a goal configuration. The cells are numbered 0 to 8, starting at the top left and going row by row left to right. The pieces are numebred 1 to 8, with 0 representing the blank. An action is the cell index number that is to be swapped with the blank (*not* the actual number to be swapped but the index into the state). So the diagram above left is the state `(5, 2, 7, 8, 4, 0, 1, 3, 6)`, and the action is `8`, because the cell number 8 (the 9th or last cell, the `6` in the bottom right) is swapped with the blank.\n", - "\n", - "There are two disjoint sets of states that cannot be reached from each other. One set has an even number of \"inversions\"; the other has an odd number. An inversion is when a piece in the state is larger than a piece that follows it.\n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 397, - "metadata": {}, - "outputs": [], - "source": [ - "class EightPuzzle(Problem):\n", - " \"\"\" The problem of sliding tiles numbered from 1 to 8 on a 3x3 board,\n", - " where one of the squares is a blank, trying to reach a goal configuration.\n", - " A board state is represented as a tuple of length 9, where the element at index i \n", - " represents the tile number at index i, or 0 if for the empty square, e.g. the goal:\n", - " 1 2 3\n", - " 4 5 6 ==> (1, 2, 3, 4, 5, 6, 7, 8, 0)\n", - " 7 8 _\n", - " \"\"\"\n", - "\n", - " def __init__(self, initial, goal=(0, 1, 2, 3, 4, 5, 6, 7, 8)):\n", - " assert inversions(initial) % 2 == inversions(goal) % 2 # Parity check\n", - " self.initial, self.goal = initial, goal\n", - " \n", - " def actions(self, state):\n", - " \"\"\"The indexes of the squares that the blank can move to.\"\"\"\n", - " moves = ((1, 3), (0, 2, 4), (1, 5),\n", - " (0, 4, 6), (1, 3, 5, 7), (2, 4, 8),\n", - " (3, 7), (4, 6, 8), (7, 5))\n", - " blank = state.index(0)\n", - " return moves[blank]\n", - " \n", - " def result(self, state, action):\n", - " \"\"\"Swap the blank with the square numbered `action`.\"\"\"\n", - " s = list(state)\n", - " blank = state.index(0)\n", - " s[action], s[blank] = s[blank], s[action]\n", - " return tuple(s)\n", - " \n", - " def h1(self, node):\n", - " \"\"\"The misplaced tiles heuristic.\"\"\"\n", - " return hamming_distance(node.state, self.goal)\n", - " \n", - " def h2(self, node):\n", - " \"\"\"The Manhattan heuristic.\"\"\"\n", - " X = (0, 1, 2, 0, 1, 2, 0, 1, 2)\n", - " Y = (0, 0, 0, 1, 1, 1, 2, 2, 2)\n", - " return sum(abs(X[s] - X[g]) + abs(Y[s] - Y[g])\n", - " for (s, g) in zip(node.state, self.goal) if s != 0)\n", - " \n", - " def h(self, node): return h2(self, node)\n", - " \n", - " \n", - "def hamming_distance(A, B):\n", - " \"Number of positions where vectors A and B are different.\"\n", - " return sum(a != b for a, b in zip(A, B))\n", - " \n", - "\n", - "def inversions(board):\n", - " \"The number of times a piece is a smaller number than a following piece.\"\n", - " return sum((a > b and a != 0 and b != 0) for (a, b) in combinations(board, 2))\n", - " \n", - " \n", - "def board8(board, fmt=(3 * '{} {} {}\\n')):\n", - " \"A string representing an 8-puzzle board\"\n", - " return fmt.format(*board).replace('0', '_')\n", - "\n", - "class Board(defaultdict):\n", - " empty = '.'\n", - " off = '#'\n", - " def __init__(self, board=None, width=8, height=8, to_move=None, **kwds):\n", - " if board is not None:\n", - " self.update(board)\n", - " self.width, self.height = (board.width, board.height) \n", - " else:\n", - " self.width, self.height = (width, height)\n", - " self.to_move = to_move\n", - "\n", - " def __missing__(self, key):\n", - " x, y = key\n", - " if x < 0 or x >= self.width or y < 0 or y >= self.height:\n", - " return self.off\n", - " else:\n", - " return self.empty\n", - " \n", - " def __repr__(self):\n", - " def row(y): return ' '.join(self[x, y] for x in range(self.width))\n", - " return '\\n'.join(row(y) for y in range(self.height))\n", - " \n", - " def __hash__(self): \n", - " return hash(tuple(sorted(self.items()))) + hash(self.to_move)" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": {}, - "outputs": [], - "source": [ - "# Some specific EightPuzzle problems\n", - "\n", - "e1 = EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8))\n", - "e2 = EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0))\n", - "e3 = EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6))\n", - "e4 = EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1))\n", - "e5 = EightPuzzle((8, 6, 7, 2, 5, 4, 3, 0, 1))" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1 4 2\n", - "_ 7 5\n", - "3 6 8\n", - "\n", - "1 4 2\n", - "3 7 5\n", - "_ 6 8\n", - "\n", - "1 4 2\n", - "3 7 5\n", - "6 _ 8\n", - "\n", - "1 4 2\n", - "3 _ 5\n", - "6 7 8\n", - "\n", - "1 _ 2\n", - "3 4 5\n", - "6 7 8\n", - "\n", - "_ 1 2\n", - "3 4 5\n", - "6 7 8\n", - "\n" - ] - } - ], - "source": [ - "# Solve an 8 puzzle problem and print out each state\n", - "\n", - "for s in path_states(astar_search(e1)):\n", - " print(board8(s))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Water Pouring Problems\n", - "\n", - "![](http://puzzles.nigelcoldwell.co.uk/images/water22.png)\n", - "\n", - "In a [water pouring problem](https://en.wikipedia.org/wiki/Water_pouring_puzzle) you are given a collection of jugs, each of which has a size (capacity) in, say, litres, and a current level of water (in litres). The goal is to measure out a certain level of water; it can appear in any of the jugs. For example, in the movie *Die Hard 3*, the heroes were faced with the task of making exactly 4 gallons from jugs of size 5 gallons and 3 gallons.) A state is represented by a tuple of current water levels, and the available actions are:\n", - "- `(Fill, i)`: fill the `i`th jug all the way to the top (from a tap with unlimited water).\n", - "- `(Dump, i)`: dump all the water out of the `i`th jug.\n", - "- `(Pour, i, j)`: pour water from the `i`th jug into the `j`th jug until either the jug `i` is empty, or jug `j` is full, whichever comes first." - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": {}, - "outputs": [], - "source": [ - "class PourProblem(Problem):\n", - " \"\"\"Problem about pouring water between jugs to achieve some water level.\n", - " Each state is a tuples of water levels. In the initialization, also provide a tuple of \n", - " jug sizes, e.g. PourProblem(initial=(0, 0), goal=4, sizes=(5, 3)), \n", - " which means two jugs of sizes 5 and 3, initially both empty, with the goal\n", - " of getting a level of 4 in either jug.\"\"\"\n", - " \n", - " def actions(self, state):\n", - " \"\"\"The actions executable in this state.\"\"\"\n", - " jugs = range(len(state))\n", - " return ([('Fill', i) for i in jugs if state[i] < self.sizes[i]] +\n", - " [('Dump', i) for i in jugs if state[i]] +\n", - " [('Pour', i, j) for i in jugs if state[i] for j in jugs if i != j])\n", - "\n", - " def result(self, state, action):\n", - " \"\"\"The state that results from executing this action in this state.\"\"\"\n", - " result = list(state)\n", - " act, i, *_ = action\n", - " if act == 'Fill': # Fill i to capacity\n", - " result[i] = self.sizes[i]\n", - " elif act == 'Dump': # Empty i\n", - " result[i] = 0\n", - " elif act == 'Pour': # Pour from i into j\n", - " j = action[2]\n", - " amount = min(state[i], self.sizes[j] - state[j])\n", - " result[i] -= amount\n", - " result[j] += amount\n", - " return tuple(result)\n", - "\n", - " def is_goal(self, state):\n", - " \"\"\"True if the goal level is in any one of the jugs.\"\"\"\n", - " return self.goal in state" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In a `GreenPourProblem`, the states and actions are the same, but instead of all actions costing 1, in these problems the cost of an action is the amount of water that flows from the tap. (There is an issue that non-*Fill* actions have 0 cost, which in general can lead to indefinitely long solutions, but in this problem there is a finite number of states, so we're ok.)" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": {}, - "outputs": [], - "source": [ - "class GreenPourProblem(PourProblem): \n", - " \"\"\"A PourProblem in which the cost is the amount of water used.\"\"\"\n", - " def action_cost(self, s, action, s1):\n", - " \"The cost is the amount of water used.\"\n", - " act, i, *_ = action\n", - " return self.sizes[i] - s[i] if act == 'Fill' else 0" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "metadata": {}, - "outputs": [], - "source": [ - "# Some specific PourProblems\n", - "\n", - "p1 = PourProblem((1, 1, 1), 13, sizes=(2, 16, 32))\n", - "p2 = PourProblem((0, 0, 0), 21, sizes=(8, 11, 31))\n", - "p3 = PourProblem((0, 0), 8, sizes=(7,9))\n", - "p4 = PourProblem((0, 0, 0), 21, sizes=(8, 11, 31))\n", - "p5 = PourProblem((0, 0), 4, sizes=(3, 5))\n", - "\n", - "g1 = GreenPourProblem((1, 1, 1), 13, sizes=(2, 16, 32))\n", - "g2 = GreenPourProblem((0, 0, 0), 21, sizes=(8, 11, 31))\n", - "g3 = GreenPourProblem((0, 0), 8, sizes=(7,9))\n", - "g4 = GreenPourProblem((0, 0, 0), 21, sizes=(8, 11, 31))\n", - "g5 = GreenPourProblem((0, 0), 4, sizes=(3, 5))" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "([('Fill', 1), ('Pour', 1, 0), ('Dump', 0), ('Pour', 1, 0)],\n", - " [(1, 1, 1), (1, 16, 1), (2, 15, 1), (0, 15, 1), (2, 13, 1)])" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Solve the PourProblem of getting 13 in some jug, and show the actions and states\n", - "soln = breadth_first_search(p1)\n", - "path_actions(soln), path_states(soln)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Pancake Sorting Problems\n", - "\n", - "Given a stack of pancakes of various sizes, can you sort them into a stack of decreasing sizes, largest on bottom to smallest on top? You have a spatula with which you can flip the top `i` pancakes. This is shown below for `i = 3`; on the top the spatula grabs the first three pancakes; on the bottom we see them flipped:\n", - "\n", - "\n", - "![](https://upload.wikimedia.org/wikipedia/commons/0/0f/Pancake_sort_operation.png)\n", - "\n", - "How many flips will it take to get the whole stack sorted? This is an interesting [problem](https://en.wikipedia.org/wiki/Pancake_sorting) that Bill Gates has [written about](https://people.eecs.berkeley.edu/~christos/papers/Bounds%20For%20Sorting%20By%20Prefix%20Reversal.pdf). A reasonable heuristic for this problem is the *gap heuristic*: if we look at neighboring pancakes, if, say, the 2nd smallest is next to the 3rd smallest, that's good; they should stay next to each other. But if the 2nd smallest is next to the 4th smallest, that's bad: we will require at least one move to separate them and insert the 3rd smallest between them. The gap heuristic counts the number of neighbors that have a gap like this. In our specification of the problem, pancakes are ranked by size: the smallest is `1`, the 2nd smallest `2`, and so on, and the representation of a state is a tuple of these rankings, from the top to the bottom pancake. Thus the goal state is always `(1, 2, ..., `*n*`)` and the initial (top) state in the diagram above is `(2, 1, 4, 6, 3, 5)`.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "metadata": {}, - "outputs": [], - "source": [ - "class PancakeProblem(Problem):\n", - " \"\"\"A PancakeProblem the goal is always `tuple(range(1, n+1))`, where the\n", - " initial state is a permutation of `range(1, n+1)`. An act is the index `i` \n", - " of the top `i` pancakes that will be flipped.\"\"\"\n", - " \n", - " def __init__(self, initial): \n", - " self.initial, self.goal = tuple(initial), tuple(sorted(initial))\n", - " \n", - " def actions(self, state): return range(2, len(state) + 1)\n", - "\n", - " def result(self, state, i): return state[:i][::-1] + state[i:]\n", - " \n", - " def h(self, node):\n", - " \"The gap heuristic.\"\n", - " s = node.state\n", - " return sum(abs(s[i] - s[i - 1]) > 1 for i in range(1, len(s)))" - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "metadata": {}, - "outputs": [], - "source": [ - "c0 = PancakeProblem((2, 1, 4, 6, 3, 5))\n", - "c1 = PancakeProblem((4, 6, 2, 5, 1, 3))\n", - "c2 = PancakeProblem((1, 3, 7, 5, 2, 6, 4))\n", - "c3 = PancakeProblem((1, 7, 2, 6, 3, 5, 4))\n", - "c4 = PancakeProblem((1, 3, 5, 7, 9, 2, 4, 6, 8))" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[(2, 1, 4, 6, 3, 5),\n", - " (6, 4, 1, 2, 3, 5),\n", - " (5, 3, 2, 1, 4, 6),\n", - " (4, 1, 2, 3, 5, 6),\n", - " (3, 2, 1, 4, 5, 6),\n", - " (1, 2, 3, 4, 5, 6)]" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Solve a pancake problem\n", - "path_states(astar_search(c0))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Jumping Frogs Puzzle\n", - "\n", - "In this puzzle (which also can be played as a two-player game), the initial state is a line of squares, with N pieces of one kind on the left, then one empty square, then N pieces of another kind on the right. The diagram below uses 2 blue toads and 2 red frogs; we will represent this as the string `'LL.RR'`. The goal is to swap the pieces, arriving at `'RR.LL'`. An `'L'` piece moves left-to-right, either sliding one space ahead to an empty space, or two spaces ahead if that space is empty and if there is an `'R'` in between to hop over. The `'R'` pieces move right-to-left analogously. An action will be an `(i, j)` pair meaning to swap the pieces at those indexes. The set of actions for the N = 2 position below is `{(1, 2), (3, 2)}`, meaning either the blue toad in position 1 or the red frog in position 3 can swap places with the blank in position 2.\n", - "\n", - "![](https://upload.wikimedia.org/wikipedia/commons/2/2f/ToadsAndFrogs.png)" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "metadata": {}, - "outputs": [], - "source": [ - "class JumpingPuzzle(Problem):\n", - " \"\"\"Try to exchange L and R by moving one ahead or hopping two ahead.\"\"\"\n", - " def __init__(self, N=2):\n", - " self.initial = N*'L' + '.' + N*'R'\n", - " self.goal = self.initial[::-1]\n", - " \n", - " def actions(self, state):\n", - " \"\"\"Find all possible move or hop moves.\"\"\"\n", - " idxs = range(len(state))\n", - " return ({(i, i + 1) for i in idxs if state[i:i+2] == 'L.'} # Slide\n", - " |{(i, i + 2) for i in idxs if state[i:i+3] == 'LR.'} # Hop\n", - " |{(i + 1, i) for i in idxs if state[i:i+2] == '.R'} # Slide\n", - " |{(i + 2, i) for i in idxs if state[i:i+3] == '.LR'}) # Hop\n", - "\n", - " def result(self, state, action):\n", - " \"\"\"An action (i, j) means swap the pieces at positions i and j.\"\"\"\n", - " i, j = action\n", - " result = list(state)\n", - " result[i], result[j] = state[j], state[i]\n", - " return ''.join(result)\n", - " \n", - " def h(self, node): return hamming_distance(node.state, self.goal)" - ] - }, - { - "cell_type": "code", - "execution_count": 33, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{(1, 2), (3, 2)}" - ] - }, - "execution_count": 33, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "JumpingPuzzle(N=2).actions('LL.RR')" - ] - }, - { - "cell_type": "code", - "execution_count": 34, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['LLL.RRR',\n", - " 'LLLR.RR',\n", - " 'LL.RLRR',\n", - " 'L.LRLRR',\n", - " 'LRL.LRR',\n", - " 'LRLRL.R',\n", - " 'LRLRLR.',\n", - " 'LRLR.RL',\n", - " 'LR.RLRL',\n", - " '.RLRLRL',\n", - " 'R.LRLRL',\n", - " 'RRL.LRL',\n", - " 'RRLRL.L',\n", - " 'RRLR.LL',\n", - " 'RR.RLLL',\n", - " 'RRR.LLL']" - ] - }, - "execution_count": 34, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "j3 = JumpingPuzzle(N=3)\n", - "j9 = JumpingPuzzle(N=9)\n", - "path_states(astar_search(j3))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Reporting Summary Statistics on Search Algorithms\n", - "\n", - "Now let's gather some metrics on how well each algorithm does. We'll use `CountCalls` to wrap a `Problem` object in such a way that calls to its methods are delegated to the original problem, but each call increments a counter. Once we've solved the problem, we print out summary statistics." - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "metadata": {}, - "outputs": [], - "source": [ - "class CountCalls:\n", - " \"\"\"Delegate all attribute gets to the object, and count them in ._counts\"\"\"\n", - " def __init__(self, obj):\n", - " self._object = obj\n", - " self._counts = Counter()\n", - " \n", - " def __getattr__(self, attr):\n", - " \"Delegate to the original object, after incrementing a counter.\"\n", - " self._counts[attr] += 1\n", - " return getattr(self._object, attr)\n", - "\n", - " \n", - "def report(searchers, problems, verbose=True):\n", - " \"\"\"Show summary statistics for each searcher (and on each problem unless verbose is false).\"\"\"\n", - " for searcher in searchers:\n", - " print(searcher.__name__ + ':')\n", - " total_counts = Counter()\n", - " for p in problems:\n", - " prob = CountCalls(p)\n", - " soln = searcher(prob)\n", - " counts = prob._counts; \n", - " counts.update(actions=len(soln), cost=soln.path_cost)\n", - " total_counts += counts\n", - " if verbose: report_counts(counts, str(p)[:40])\n", - " report_counts(total_counts, 'TOTAL\\n')\n", - " \n", - "def report_counts(counts, name):\n", - " \"\"\"Print one line of the counts report.\"\"\"\n", - " print('{:9,d} nodes |{:9,d} goal |{:5.0f} cost |{:8,d} actions | {}'.format(\n", - " counts['result'], counts['is_goal'], counts['cost'], counts['actions'], name))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Here's a tiny report for uniform-cost search on the jug pouring problems:" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "uniform_cost_search:\n", - " 948 nodes | 109 goal | 4 cost | 112 actions | PourProblem((1, 1, 1), 13)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 124 nodes | 30 goal | 14 cost | 43 actions | PourProblem((0, 0), 8)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 52 nodes | 14 goal | 6 cost | 19 actions | PourProblem((0, 0), 4)\n", - " 8,122 nodes | 931 goal | 42 cost | 968 actions | TOTAL\n", - "\n" - ] - } - ], - "source": [ - "report([uniform_cost_search], [p1, p2, p3, p4, p5])" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "uniform_cost_search:\n", - " 948 nodes | 109 goal | 4 cost | 112 actions | PourProblem((1, 1, 1), 13)\n", - " 1,696 nodes | 190 goal | 10 cost | 204 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 124 nodes | 30 goal | 14 cost | 43 actions | PourProblem((0, 0), 8)\n", - " 124 nodes | 30 goal | 35 cost | 45 actions | GreenPourProblem((0, 0), 8)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 3,590 nodes | 719 goal | 7 cost | 725 actions | PancakeProblem((4, 6, 2, 5, 1, 3), (1, 2\n", - " 30,204 nodes | 5,035 goal | 8 cost | 5,042 actions | PancakeProblem((1, 3, 7, 5, 2, 6, 4), (1\n", - " 22,068 nodes | 3,679 goal | 6 cost | 3,684 actions | PancakeProblem((1, 7, 2, 6, 3, 5, 4), (1\n", - " 81,467 nodes | 12,321 goal | 174 cost | 12,435 actions | TOTAL\n", - "\n", - "breadth_first_search:\n", - " 596 nodes | 597 goal | 4 cost | 73 actions | PourProblem((1, 1, 1), 13)\n", - " 596 nodes | 597 goal | 15 cost | 73 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 2,618 nodes | 2,619 goal | 9 cost | 302 actions | PourProblem((0, 0, 0), 21)\n", - " 2,618 nodes | 2,619 goal | 32 cost | 302 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 120 nodes | 121 goal | 14 cost | 42 actions | PourProblem((0, 0), 8)\n", - " 120 nodes | 121 goal | 36 cost | 42 actions | GreenPourProblem((0, 0), 8)\n", - " 2,618 nodes | 2,619 goal | 9 cost | 302 actions | PourProblem((0, 0, 0), 21)\n", - " 2,618 nodes | 2,619 goal | 32 cost | 302 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 2,618 nodes | 2,619 goal | 9 cost | 302 actions | PourProblem((0, 0, 0), 21)\n", - " 2,618 nodes | 2,619 goal | 32 cost | 302 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 2,951 nodes | 2,952 goal | 7 cost | 598 actions | PancakeProblem((4, 6, 2, 5, 1, 3), (1, 2\n", - " 25,945 nodes | 25,946 goal | 8 cost | 4,333 actions | PancakeProblem((1, 3, 7, 5, 2, 6, 4), (1\n", - " 5,975 nodes | 5,976 goal | 6 cost | 1,002 actions | PancakeProblem((1, 7, 2, 6, 3, 5, 4), (1\n", - " 52,011 nodes | 52,024 goal | 213 cost | 7,975 actions | TOTAL\n", - "\n" - ] - } - ], - "source": [ - "report((uniform_cost_search, breadth_first_search), \n", - " (p1, g1, p2, g2, p3, g3, p4, g4, p4, g4, c1, c2, c3)) " - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Comparing heuristics\n", - "\n", - "First, let's look at the eight puzzle problems, and compare three different heuristics the Manhattan heuristic, the less informative misplaced tiles heuristic, and the uninformed (i.e. *h* = 0) breadth-first search:" - ] - }, - { - "cell_type": "code", - "execution_count": 38, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "breadth_first_search:\n", - " 81 nodes | 82 goal | 5 cost | 35 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 160,948 nodes | 160,949 goal | 22 cost | 59,960 actions | EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0),\n", - " 218,263 nodes | 218,264 goal | 23 cost | 81,829 actions | EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6),\n", - " 418,771 nodes | 418,772 goal | 26 cost | 156,533 actions | EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1),\n", - " 448,667 nodes | 448,668 goal | 27 cost | 167,799 actions | EightPuzzle((8, 6, 7, 2, 5, 4, 3, 0, 1),\n", - "1,246,730 nodes |1,246,735 goal | 103 cost | 466,156 actions | TOTAL\n", - "\n", - "astar_misplaced_tiles:\n", - " 17 nodes | 7 goal | 5 cost | 11 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 23,407 nodes | 8,726 goal | 22 cost | 8,747 actions | EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0),\n", - " 38,632 nodes | 14,433 goal | 23 cost | 14,455 actions | EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6),\n", - " 124,324 nodes | 46,553 goal | 26 cost | 46,578 actions | EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1),\n", - " 156,111 nodes | 58,475 goal | 27 cost | 58,501 actions | EightPuzzle((8, 6, 7, 2, 5, 4, 3, 0, 1),\n", - " 342,491 nodes | 128,194 goal | 103 cost | 128,292 actions | TOTAL\n", - "\n", - "astar_search:\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 3,614 nodes | 1,349 goal | 22 cost | 1,370 actions | EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0),\n", - " 5,373 nodes | 2,010 goal | 23 cost | 2,032 actions | EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6),\n", - " 10,832 nodes | 4,086 goal | 26 cost | 4,111 actions | EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1),\n", - " 11,669 nodes | 4,417 goal | 27 cost | 4,443 actions | EightPuzzle((8, 6, 7, 2, 5, 4, 3, 0, 1),\n", - " 31,503 nodes | 11,868 goal | 103 cost | 11,966 actions | TOTAL\n", - "\n" - ] - } - ], - "source": [ - "def astar_misplaced_tiles(problem): return astar_search(problem, h=problem.h1)\n", - "\n", - "report([breadth_first_search, astar_misplaced_tiles, astar_search], \n", - " [e1, e2, e3, e4, e5])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We see that all three algorithms get cost-optimal solutions, but the better the heuristic, the fewer nodes explored. \n", - "Compared to the uninformed search, the misplaced tiles heuristic explores about 1/4 the number of nodes, and the Manhattan heuristic needs just 2%.\n", - "\n", - "Next, we can show the value of the gap heuristic for pancake sorting problems:" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "astar_search:\n", - " 1,285 nodes | 258 goal | 7 cost | 264 actions | PancakeProblem((4, 6, 2, 5, 1, 3), (1, 2\n", - " 3,804 nodes | 635 goal | 8 cost | 642 actions | PancakeProblem((1, 3, 7, 5, 2, 6, 4), (1\n", - " 294 nodes | 50 goal | 6 cost | 55 actions | PancakeProblem((1, 7, 2, 6, 3, 5, 4), (1\n", - " 2,256 nodes | 283 goal | 9 cost | 291 actions | PancakeProblem((1, 3, 5, 7, 9, 2, 4, 6, \n", - " 7,639 nodes | 1,226 goal | 30 cost | 1,252 actions | TOTAL\n", - "\n", - "uniform_cost_search:\n", - " 3,590 nodes | 719 goal | 7 cost | 725 actions | PancakeProblem((4, 6, 2, 5, 1, 3), (1, 2\n", - " 30,204 nodes | 5,035 goal | 8 cost | 5,042 actions | PancakeProblem((1, 3, 7, 5, 2, 6, 4), (1\n", - " 22,068 nodes | 3,679 goal | 6 cost | 3,684 actions | PancakeProblem((1, 7, 2, 6, 3, 5, 4), (1\n", - "2,271,792 nodes | 283,975 goal | 9 cost | 283,983 actions | PancakeProblem((1, 3, 5, 7, 9, 2, 4, 6, \n", - "2,327,654 nodes | 293,408 goal | 30 cost | 293,434 actions | TOTAL\n", - "\n" - ] - } - ], - "source": [ - "report([astar_search, uniform_cost_search], [c1, c2, c3, c4])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We need to explore 300 times more nodes without the heuristic.\n", - "\n", - "# Comparing graph search and tree search\n", - "\n", - "Keeping the *reached* table in `best_first_search` allows us to do a graph search, where we notice when we reach a state by two different paths, rather than a tree search, where we have duplicated effort. The *reached* table consumes space and also saves time. How much time? In part it depends on how good the heuristics are at focusing the search. Below we show that on some pancake and eight puzzle problems, the tree search expands roughly twice as many nodes (and thus takes roughly twice as much time):" - ] - }, - { - "cell_type": "code", - "execution_count": 188, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "astar_search:\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 3,614 nodes | 1,349 goal | 22 cost | 1,370 actions | EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0),\n", - " 5,373 nodes | 2,010 goal | 23 cost | 2,032 actions | EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6),\n", - " 10,832 nodes | 4,086 goal | 26 cost | 4,111 actions | EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1),\n", - " 15 nodes | 6 goal | 418 cost | 9 actions | RouteProblem('A', 'B')\n", - " 34 nodes | 15 goal | 910 cost | 23 actions | RouteProblem('N', 'L')\n", - " 33 nodes | 14 goal | 805 cost | 21 actions | RouteProblem('E', 'T')\n", - " 20 nodes | 9 goal | 445 cost | 13 actions | RouteProblem('O', 'M')\n", - " 19,936 nodes | 7,495 goal | 2654 cost | 7,589 actions | TOTAL\n", - "\n", - "astar_tree_search:\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 5,384 nodes | 2,000 goal | 22 cost | 2,021 actions | EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0),\n", - " 9,116 nodes | 3,404 goal | 23 cost | 3,426 actions | EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6),\n", - " 19,084 nodes | 7,185 goal | 26 cost | 7,210 actions | EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1),\n", - " 15 nodes | 6 goal | 418 cost | 9 actions | RouteProblem('A', 'B')\n", - " 47 nodes | 19 goal | 910 cost | 27 actions | RouteProblem('N', 'L')\n", - " 46 nodes | 18 goal | 805 cost | 25 actions | RouteProblem('E', 'T')\n", - " 24 nodes | 10 goal | 445 cost | 14 actions | RouteProblem('O', 'M')\n", - " 33,731 nodes | 12,648 goal | 2654 cost | 12,742 actions | TOTAL\n", - "\n" - ] - } - ], - "source": [ - "report([astar_search, astar_tree_search], [e1, e2, e3, e4, r1, r2, r3, r4])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Comparing different weighted search values\n", - "\n", - "Below we report on problems using these four algorithms:\n", - "\n", - "|Algorithm|*f*|Optimality|\n", - "|:---------|---:|:----------:|\n", - "|Greedy best-first search | *f = h*|nonoptimal|\n", - "|Extra weighted A* search | *f = g + 2 × h*|nonoptimal|\n", - "|Weighted A* search | *f = g + 1.4 × h*|nonoptimal|\n", - "|A* search | *f = g + h*|optimal|\n", - "|Uniform-cost search | *f = g*|optimal|\n", - "\n", - "We will see that greedy best-first search (which ranks nodes solely by the heuristic) explores the fewest number of nodes, but has the highest path costs. Weighted A* search explores twice as many nodes (on this problem set) but gets 10% better path costs. A* is optimal, but explores more nodes, and uniform-cost is also optimal, but explores an order of magnitude more nodes." - ] - }, - { - "cell_type": "code", - "execution_count": 41, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "greedy_bfs:\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 9 nodes | 4 goal | 450 cost | 6 actions | RouteProblem('A', 'B')\n", - " 29 nodes | 12 goal | 910 cost | 20 actions | RouteProblem('N', 'L')\n", - " 19 nodes | 8 goal | 837 cost | 14 actions | RouteProblem('E', 'T')\n", - " 14 nodes | 6 goal | 572 cost | 10 actions | RouteProblem('O', 'M')\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 909 nodes | 138 goal | 136 cost | 258 actions | GridProblem((15, 30), (130, 30))\n", - " 974 nodes | 147 goal | 152 cost | 277 actions | GridProblem((15, 30), (130, 30))\n", - " 5,146 nodes | 4,984 goal | 99 cost | 5,082 actions | JumpingPuzzle('LLLLLLLLL.RRRRRRRRR', 'RR\n", - " 1,569 nodes | 568 goal | 58 cost | 625 actions | EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0),\n", - " 1,424 nodes | 257 goal | 164 cost | 406 actions | GridProblem((15, 30), (130, 30))\n", - " 1,899 nodes | 342 goal | 153 cost | 470 actions | GridProblem((15, 30), (130, 30))\n", - " 18,239 nodes | 2,439 goal | 134 cost | 2,564 actions | GridProblem((15, 30), (130, 30))\n", - " 18,329 nodes | 2,460 goal | 152 cost | 2,594 actions | GridProblem((15, 30), (130, 30))\n", - " 287 nodes | 109 goal | 33 cost | 141 actions | EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6),\n", - " 1,128 nodes | 408 goal | 46 cost | 453 actions | EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1),\n", - " 49,990 nodes | 11,889 goal | 3901 cost | 12,930 actions | TOTAL\n", - "\n", - "extra_weighted_astar_search:\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 9 nodes | 4 goal | 450 cost | 6 actions | RouteProblem('A', 'B')\n", - " 29 nodes | 12 goal | 910 cost | 20 actions | RouteProblem('N', 'L')\n", - " 23 nodes | 9 goal | 805 cost | 16 actions | RouteProblem('E', 'T')\n", - " 18 nodes | 8 goal | 445 cost | 12 actions | RouteProblem('O', 'M')\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 1,575 nodes | 239 goal | 136 cost | 357 actions | GridProblem((15, 30), (130, 30))\n", - " 1,384 nodes | 231 goal | 133 cost | 349 actions | GridProblem((15, 30), (130, 30))\n", - " 10,990 nodes | 10,660 goal | 99 cost | 10,758 actions | JumpingPuzzle('LLLLLLLLL.RRRRRRRRR', 'RR\n", - " 1,720 nodes | 633 goal | 24 cost | 656 actions | EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0),\n", - " 9,282 nodes | 1,412 goal | 163 cost | 1,551 actions | GridProblem((15, 30), (130, 30))\n", - " 1,354 nodes | 228 goal | 134 cost | 345 actions | GridProblem((15, 30), (130, 30))\n", - " 16,024 nodes | 2,098 goal | 129 cost | 2,214 actions | GridProblem((15, 30), (130, 30))\n", - " 16,950 nodes | 2,237 goal | 140 cost | 2,359 actions | GridProblem((15, 30), (130, 30))\n", - " 1,908 nodes | 709 goal | 25 cost | 733 actions | EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6),\n", - " 1,312 nodes | 489 goal | 30 cost | 518 actions | EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1),\n", - " 62,593 nodes | 18,976 goal | 3628 cost | 19,904 actions | TOTAL\n", - "\n", - "weighted_astar_search:\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 9 nodes | 4 goal | 450 cost | 6 actions | RouteProblem('A', 'B')\n", - " 32 nodes | 14 goal | 910 cost | 22 actions | RouteProblem('N', 'L')\n", - " 29 nodes | 12 goal | 805 cost | 19 actions | RouteProblem('E', 'T')\n", - " 18 nodes | 8 goal | 445 cost | 12 actions | RouteProblem('O', 'M')\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 1,631 nodes | 236 goal | 128 cost | 350 actions | GridProblem((15, 30), (130, 30))\n", - " 1,706 nodes | 275 goal | 131 cost | 389 actions | GridProblem((15, 30), (130, 30))\n", - " 10,990 nodes | 10,660 goal | 99 cost | 10,758 actions | JumpingPuzzle('LLLLLLLLL.RRRRRRRRR', 'RR\n", - " 2,082 nodes | 771 goal | 22 cost | 792 actions | EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0),\n", - " 8,385 nodes | 1,266 goal | 154 cost | 1,396 actions | GridProblem((15, 30), (130, 30))\n", - " 1,400 nodes | 229 goal | 133 cost | 344 actions | GridProblem((15, 30), (130, 30))\n", - " 12,122 nodes | 1,572 goal | 124 cost | 1,686 actions | GridProblem((15, 30), (130, 30))\n", - " 24,129 nodes | 3,141 goal | 127 cost | 3,255 actions | GridProblem((15, 30), (130, 30))\n", - " 3,960 nodes | 1,475 goal | 25 cost | 1,499 actions | EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6),\n", - " 1,992 nodes | 748 goal | 26 cost | 773 actions | EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1),\n", - " 68,500 nodes | 20,418 goal | 3585 cost | 21,311 actions | TOTAL\n", - "\n", - "astar_search:\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 15 nodes | 6 goal | 418 cost | 9 actions | RouteProblem('A', 'B')\n", - " 34 nodes | 15 goal | 910 cost | 23 actions | RouteProblem('N', 'L')\n", - " 33 nodes | 14 goal | 805 cost | 21 actions | RouteProblem('E', 'T')\n", - " 20 nodes | 9 goal | 445 cost | 13 actions | RouteProblem('O', 'M')\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 26,711 nodes | 3,620 goal | 127 cost | 3,734 actions | GridProblem((15, 30), (130, 30))\n", - " 12,932 nodes | 1,822 goal | 124 cost | 1,936 actions | GridProblem((15, 30), (130, 30))\n", - " 10,991 nodes | 10,661 goal | 99 cost | 10,759 actions | JumpingPuzzle('LLLLLLLLL.RRRRRRRRR', 'RR\n", - " 3,614 nodes | 1,349 goal | 22 cost | 1,370 actions | EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0),\n", - " 62,509 nodes | 8,729 goal | 154 cost | 8,859 actions | GridProblem((15, 30), (130, 30))\n", - " 15,190 nodes | 2,276 goal | 133 cost | 2,391 actions | GridProblem((15, 30), (130, 30))\n", - " 25,303 nodes | 3,196 goal | 124 cost | 3,310 actions | GridProblem((15, 30), (130, 30))\n", - " 32,572 nodes | 4,149 goal | 127 cost | 4,263 actions | GridProblem((15, 30), (130, 30))\n", - " 5,373 nodes | 2,010 goal | 23 cost | 2,032 actions | EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6),\n", - " 10,832 nodes | 4,086 goal | 26 cost | 4,111 actions | EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1),\n", - " 206,144 nodes | 41,949 goal | 3543 cost | 42,841 actions | TOTAL\n", - "\n", - "uniform_cost_search:\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 30 nodes | 13 goal | 418 cost | 16 actions | RouteProblem('A', 'B')\n", - " 42 nodes | 19 goal | 910 cost | 27 actions | RouteProblem('N', 'L')\n", - " 44 nodes | 20 goal | 805 cost | 27 actions | RouteProblem('E', 'T')\n", - " 30 nodes | 12 goal | 445 cost | 16 actions | RouteProblem('O', 'M')\n", - " 124 nodes | 46 goal | 5 cost | 50 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 355,452 nodes | 44,984 goal | 127 cost | 45,098 actions | GridProblem((15, 30), (130, 30))\n", - " 326,962 nodes | 41,650 goal | 124 cost | 41,764 actions | GridProblem((15, 30), (130, 30))\n", - " 10,992 nodes | 10,662 goal | 99 cost | 10,760 actions | JumpingPuzzle('LLLLLLLLL.RRRRRRRRR', 'RR\n", - " 214,952 nodes | 79,187 goal | 22 cost | 79,208 actions | EightPuzzle((1, 2, 3, 4, 5, 6, 7, 8, 0),\n", - " 558,084 nodes | 70,738 goal | 154 cost | 70,868 actions | GridProblem((15, 30), (130, 30))\n", - " 370,370 nodes | 47,243 goal | 133 cost | 47,358 actions | GridProblem((15, 30), (130, 30))\n", - " 349,062 nodes | 43,693 goal | 124 cost | 43,807 actions | GridProblem((15, 30), (130, 30))\n", - " 366,996 nodes | 45,970 goal | 127 cost | 46,084 actions | GridProblem((15, 30), (130, 30))\n", - " 300,925 nodes | 112,082 goal | 23 cost | 112,104 actions | EightPuzzle((4, 0, 2, 5, 1, 3, 7, 8, 6),\n", - " 457,766 nodes | 171,571 goal | 26 cost | 171,596 actions | EightPuzzle((7, 2, 4, 5, 0, 6, 8, 3, 1),\n", - "3,311,831 nodes | 667,891 goal | 3543 cost | 668,783 actions | TOTAL\n", - "\n" - ] - } - ], - "source": [ - "def extra_weighted_astar_search(problem): return weighted_astar_search(problem, weight=2)\n", - " \n", - "report((greedy_bfs, extra_weighted_astar_search, weighted_astar_search, astar_search, uniform_cost_search), \n", - " (r0, r1, r2, r3, r4, e1, d1, d2, j9, e2, d3, d4, d6, d7, e3, e4))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We see that greedy search expands the fewest nodes, but has the highest path costs. In contrast, A\\* gets optimal path costs, but expands 4 or 5 times more nodes. Weighted A* is a good compromise, using half the compute time as A\\*, and achieving path costs within 1% or 2% of optimal. Uniform-cost is optimal, but is an order of magnitude slower than A\\*.\n", - "\n", - "# Comparing many search algorithms\n", - "\n", - "Finally, we compare a host of algorihms (even the slow ones) on some of the easier problems:" - ] - }, - { - "cell_type": "code", - "execution_count": 42, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "astar_search:\n", - " 948 nodes | 109 goal | 4 cost | 112 actions | PourProblem((1, 1, 1), 13)\n", - " 1,696 nodes | 190 goal | 10 cost | 204 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 124 nodes | 30 goal | 14 cost | 43 actions | PourProblem((0, 0), 8)\n", - " 124 nodes | 30 goal | 35 cost | 45 actions | GreenPourProblem((0, 0), 8)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 15 nodes | 6 goal | 418 cost | 9 actions | RouteProblem('A', 'B')\n", - " 34 nodes | 15 goal | 910 cost | 23 actions | RouteProblem('N', 'L')\n", - " 33 nodes | 14 goal | 805 cost | 21 actions | RouteProblem('E', 'T')\n", - " 20 nodes | 9 goal | 445 cost | 13 actions | RouteProblem('O', 'M')\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 18,151 nodes | 2,096 goal | 2706 cost | 2,200 actions | TOTAL\n", - "\n", - "uniform_cost_search:\n", - " 948 nodes | 109 goal | 4 cost | 112 actions | PourProblem((1, 1, 1), 13)\n", - " 1,696 nodes | 190 goal | 10 cost | 204 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 124 nodes | 30 goal | 14 cost | 43 actions | PourProblem((0, 0), 8)\n", - " 124 nodes | 30 goal | 35 cost | 45 actions | GreenPourProblem((0, 0), 8)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 30 nodes | 13 goal | 418 cost | 16 actions | RouteProblem('A', 'B')\n", - " 42 nodes | 19 goal | 910 cost | 27 actions | RouteProblem('N', 'L')\n", - " 44 nodes | 20 goal | 805 cost | 27 actions | RouteProblem('E', 'T')\n", - " 30 nodes | 12 goal | 445 cost | 16 actions | RouteProblem('O', 'M')\n", - " 124 nodes | 46 goal | 5 cost | 50 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 18,304 nodes | 2,156 goal | 2706 cost | 2,260 actions | TOTAL\n", - "\n", - "breadth_first_search:\n", - " 596 nodes | 597 goal | 4 cost | 73 actions | PourProblem((1, 1, 1), 13)\n", - " 596 nodes | 597 goal | 15 cost | 73 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 2,618 nodes | 2,619 goal | 9 cost | 302 actions | PourProblem((0, 0, 0), 21)\n", - " 2,618 nodes | 2,619 goal | 32 cost | 302 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 120 nodes | 121 goal | 14 cost | 42 actions | PourProblem((0, 0), 8)\n", - " 120 nodes | 121 goal | 36 cost | 42 actions | GreenPourProblem((0, 0), 8)\n", - " 2,618 nodes | 2,619 goal | 9 cost | 302 actions | PourProblem((0, 0, 0), 21)\n", - " 2,618 nodes | 2,619 goal | 32 cost | 302 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 18 nodes | 19 goal | 450 cost | 10 actions | RouteProblem('A', 'B')\n", - " 42 nodes | 43 goal | 1085 cost | 27 actions | RouteProblem('N', 'L')\n", - " 36 nodes | 37 goal | 837 cost | 22 actions | RouteProblem('E', 'T')\n", - " 30 nodes | 31 goal | 445 cost | 16 actions | RouteProblem('O', 'M')\n", - " 81 nodes | 82 goal | 5 cost | 35 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 12,111 nodes | 12,125 goal | 2973 cost | 1,548 actions | TOTAL\n", - "\n", - "breadth_first_bfs:\n", - " 948 nodes | 109 goal | 4 cost | 112 actions | PourProblem((1, 1, 1), 13)\n", - " 1,062 nodes | 124 goal | 15 cost | 127 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 3,757 nodes | 420 goal | 24 cost | 428 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 124 nodes | 30 goal | 14 cost | 43 actions | PourProblem((0, 0), 8)\n", - " 124 nodes | 30 goal | 36 cost | 43 actions | GreenPourProblem((0, 0), 8)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 3,757 nodes | 420 goal | 24 cost | 428 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 28 nodes | 12 goal | 450 cost | 14 actions | RouteProblem('A', 'B')\n", - " 55 nodes | 24 goal | 910 cost | 32 actions | RouteProblem('N', 'L')\n", - " 51 nodes | 22 goal | 837 cost | 28 actions | RouteProblem('E', 'T')\n", - " 40 nodes | 16 goal | 445 cost | 20 actions | RouteProblem('O', 'M')\n", - " 124 nodes | 46 goal | 5 cost | 50 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 17,068 nodes | 2,032 goal | 2782 cost | 2,119 actions | TOTAL\n", - "\n", - "iterative_deepening_search:\n", - " 6,133 nodes | 6,118 goal | 4 cost | 822 actions | PourProblem((1, 1, 1), 13)\n", - " 6,133 nodes | 6,118 goal | 15 cost | 822 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 288,706 nodes | 288,675 goal | 9 cost | 36,962 actions | PourProblem((0, 0, 0), 21)\n", - " 288,706 nodes | 288,675 goal | 62 cost | 36,962 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 3,840 nodes | 3,824 goal | 14 cost | 949 actions | PourProblem((0, 0), 8)\n", - " 3,840 nodes | 3,824 goal | 36 cost | 949 actions | GreenPourProblem((0, 0), 8)\n", - " 288,706 nodes | 288,675 goal | 9 cost | 36,962 actions | PourProblem((0, 0, 0), 21)\n", - " 288,706 nodes | 288,675 goal | 62 cost | 36,962 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 27 nodes | 25 goal | 450 cost | 13 actions | RouteProblem('A', 'B')\n", - " 167 nodes | 173 goal | 910 cost | 82 actions | RouteProblem('N', 'L')\n", - " 117 nodes | 120 goal | 837 cost | 56 actions | RouteProblem('E', 'T')\n", - " 108 nodes | 109 goal | 572 cost | 44 actions | RouteProblem('O', 'M')\n", - " 116 nodes | 118 goal | 5 cost | 47 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - "1,175,305 nodes |1,175,130 goal | 2985 cost | 151,632 actions | TOTAL\n", - "\n", - "depth_limited_search:\n", - " 4,433 nodes | 4,374 goal | 10 cost | 627 actions | PourProblem((1, 1, 1), 13)\n", - " 4,433 nodes | 4,374 goal | 30 cost | 627 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 37,149 nodes | 37,106 goal | 10 cost | 4,753 actions | PourProblem((0, 0, 0), 21)\n", - " 37,149 nodes | 37,106 goal | 54 cost | 4,753 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 452 nodes | 453 goal | inf cost | 110 actions | PourProblem((0, 0), 8)\n", - " 452 nodes | 453 goal | inf cost | 110 actions | GreenPourProblem((0, 0), 8)\n", - " 37,149 nodes | 37,106 goal | 10 cost | 4,753 actions | PourProblem((0, 0, 0), 21)\n", - " 37,149 nodes | 37,106 goal | 54 cost | 4,753 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 17 nodes | 8 goal | 733 cost | 14 actions | RouteProblem('A', 'B')\n", - " 40 nodes | 38 goal | 910 cost | 26 actions | RouteProblem('N', 'L')\n", - " 29 nodes | 23 goal | 992 cost | 20 actions | RouteProblem('E', 'T')\n", - " 35 nodes | 29 goal | 895 cost | 22 actions | RouteProblem('O', 'M')\n", - " 351 nodes | 349 goal | 5 cost | 138 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 158,838 nodes | 158,526 goal | inf cost | 20,706 actions | TOTAL\n", - "\n", - "greedy_bfs:\n", - " 948 nodes | 109 goal | 4 cost | 112 actions | PourProblem((1, 1, 1), 13)\n", - " 1,696 nodes | 190 goal | 10 cost | 204 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 124 nodes | 30 goal | 14 cost | 43 actions | PourProblem((0, 0), 8)\n", - " 124 nodes | 30 goal | 35 cost | 45 actions | GreenPourProblem((0, 0), 8)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 9 nodes | 4 goal | 450 cost | 6 actions | RouteProblem('A', 'B')\n", - " 29 nodes | 12 goal | 910 cost | 20 actions | RouteProblem('N', 'L')\n", - " 19 nodes | 8 goal | 837 cost | 14 actions | RouteProblem('E', 'T')\n", - " 14 nodes | 6 goal | 572 cost | 10 actions | RouteProblem('O', 'M')\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 18,120 nodes | 2,082 goal | 2897 cost | 2,184 actions | TOTAL\n", - "\n", - "weighted_astar_search:\n", - " 948 nodes | 109 goal | 4 cost | 112 actions | PourProblem((1, 1, 1), 13)\n", - " 1,696 nodes | 190 goal | 10 cost | 204 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 124 nodes | 30 goal | 14 cost | 43 actions | PourProblem((0, 0), 8)\n", - " 124 nodes | 30 goal | 35 cost | 45 actions | GreenPourProblem((0, 0), 8)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 9 nodes | 4 goal | 450 cost | 6 actions | RouteProblem('A', 'B')\n", - " 32 nodes | 14 goal | 910 cost | 22 actions | RouteProblem('N', 'L')\n", - " 29 nodes | 12 goal | 805 cost | 19 actions | RouteProblem('E', 'T')\n", - " 18 nodes | 8 goal | 445 cost | 12 actions | RouteProblem('O', 'M')\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 18,137 nodes | 2,090 goal | 2738 cost | 2,193 actions | TOTAL\n", - "\n", - "extra_weighted_astar_search:\n", - " 948 nodes | 109 goal | 4 cost | 112 actions | PourProblem((1, 1, 1), 13)\n", - " 1,696 nodes | 190 goal | 10 cost | 204 actions | GreenPourProblem((1, 1, 1), 13)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 124 nodes | 30 goal | 14 cost | 43 actions | PourProblem((0, 0), 8)\n", - " 124 nodes | 30 goal | 35 cost | 45 actions | GreenPourProblem((0, 0), 8)\n", - " 3,499 nodes | 389 goal | 9 cost | 397 actions | PourProblem((0, 0, 0), 21)\n", - " 4,072 nodes | 454 goal | 21 cost | 463 actions | GreenPourProblem((0, 0, 0), 21)\n", - " 0 nodes | 1 goal | 0 cost | 0 actions | RouteProblem('A', 'A')\n", - " 9 nodes | 4 goal | 450 cost | 6 actions | RouteProblem('A', 'B')\n", - " 29 nodes | 12 goal | 910 cost | 20 actions | RouteProblem('N', 'L')\n", - " 23 nodes | 9 goal | 805 cost | 16 actions | RouteProblem('E', 'T')\n", - " 18 nodes | 8 goal | 445 cost | 12 actions | RouteProblem('O', 'M')\n", - " 15 nodes | 6 goal | 5 cost | 10 actions | EightPuzzle((1, 4, 2, 0, 7, 5, 3, 6, 8),\n", - " 18,128 nodes | 2,085 goal | 2738 cost | 2,188 actions | TOTAL\n", - "\n" - ] - } - ], - "source": [ - "report((astar_search, uniform_cost_search, breadth_first_search, breadth_first_bfs, \n", - " iterative_deepening_search, depth_limited_search, greedy_bfs, \n", - " weighted_astar_search, extra_weighted_astar_search), \n", - " (p1, g1, p2, g2, p3, g3, p4, g4, r0, r1, r2, r3, r4, e1))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This confirms some of the things we already knew: A* and uniform-cost search are optimal, but the others are not. A* explores fewer nodes than uniform-cost. " - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Visualizing Reached States\n", - "\n", - "I would like to draw a picture of the state space, marking the states that have been reached by the search.\n", - "Unfortunately, the *reached* variable is inaccessible inside `best_first_search`, so I will define a new version of `best_first_search` that is identical except that it declares *reached* to be `global`. I can then define `plot_grid_problem` to plot the obstacles of a `GridProblem`, along with the initial and goal states, the solution path, and the states reached during a search." - ] - }, - { - "cell_type": "code", - "execution_count": 45, - "metadata": {}, - "outputs": [], - "source": [ - "def best_first_search(problem, f):\n", - " \"Search nodes with minimum f(node) value first.\"\n", - " global reached # <<<<<<<<<<< Only change here\n", - " node = Node(problem.initial)\n", - " frontier = PriorityQueue([node], key=f)\n", - " reached = {problem.initial: node}\n", - " while frontier:\n", - " node = frontier.pop()\n", - " if problem.is_goal(node.state):\n", - " return node\n", - " for child in expand(problem, node):\n", - " s = child.state\n", - " if s not in reached or child.path_cost < reached[s].path_cost:\n", - " reached[s] = child\n", - " frontier.add(child)\n", - " return failure\n", - "\n", - "\n", - "def plot_grid_problem(grid, solution, reached=(), title='Search', show=True):\n", - " \"Use matplotlib to plot the grid, obstacles, solution, and reached.\"\n", - " reached = list(reached)\n", - " plt.figure(figsize=(16, 10))\n", - " plt.axis('off'); plt.axis('equal')\n", - " plt.scatter(*transpose(grid.obstacles), marker='s', color='darkgrey')\n", - " plt.scatter(*transpose(reached), 1**2, marker='.', c='blue')\n", - " plt.scatter(*transpose(path_states(solution)), marker='s', c='blue')\n", - " plt.scatter(*transpose([grid.initial]), 9**2, marker='D', c='green')\n", - " plt.scatter(*transpose([grid.goal]), 9**2, marker='8', c='red')\n", - " if show: plt.show()\n", - " print('{} {} search: {:.1f} path cost, {:,d} states reached'\n", - " .format(' ' * 10, title, solution.path_cost, len(reached)))\n", - " \n", - "def plots(grid, weights=(1.4, 2)): \n", - " \"\"\"Plot the results of 4 heuristic search algorithms for this grid.\"\"\"\n", - " solution = astar_search(grid)\n", - " plot_grid_problem(grid, solution, reached, 'A* search')\n", - " for weight in weights:\n", - " solution = weighted_astar_search(grid, weight=weight)\n", - " plot_grid_problem(grid, solution, reached, '(b) Weighted ({}) A* search'.format(weight))\n", - " solution = greedy_bfs(grid)\n", - " plot_grid_problem(grid, solution, reached, 'Greedy best-first search')\n", - " \n", - "def transpose(matrix): return list(zip(*matrix))" - ] - }, - { - "cell_type": "code", - "execution_count": 46, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "

" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " A* search search: 154.2 path cost, 7,418 states reached\n" - ] - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA6oAAAJCCAYAAADJHDpFAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADl0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uIDIuMi4zLCBodHRwOi8vbWF0cGxvdGxpYi5vcmcvIxREBQAAIABJREFUeJzt3b+PNMl5H/DqlzxxSfNeWpEdEXBmCBLumNMgQYeODgJmARPCRRSUSP+BQZ4DJc6kRAKh4A0U7AjGwSCc2YQNHaD0DrSVWpFhOJLehemXfOG3Hezue/vOzvR0T1d3PVX9+QCHw9XNj+ruqu55pnq/0/V9nwAAACCKZ6U7AAAAAI8pVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUBSqAAAAhKJQBQAAIBSFKgAAAKF8tXQHAGjTfr9/mVJ6/8j/ut3tds/X7g8AUA8rqgAs5ViROtQOAJBSUqgCAAAQjEIVAACAUBSqAAAAhCJMCXhL+A3EYk7ymPEAbIkVVeAx4TcQiznJY8YDsBkKVQAAAEJRqAIAABCKQhUAAIBQhCkBmyachMeMBwCIwYoqsHXCSXjMeACAABSqAAAAhKJQBQAAIBSFKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCUagCAAAQikIVAACAUL5augMAAMyz3+/7g6bb3W73vEhnADKwogoA0J73S3cAYA6FKgAAAKEoVAEAAAhFoQoAAEAoClUAAABCkfoLAACP7Pf7l+l4IJU0ZViJFVUAAHjXqdRkacqwEoUqAAAAoShUAQAACEWhCgAAQCjClABmGBu4MfC4VggYAQCysaIKMM/YwI2Wi9SU2t8+AGBFClUAAABCUagCAAAQikIVAACAUIQpwUbVEO5Tuo/7/b4/aBIYxGaVno8zmbuBjA2hC9CfU48/vDaUYlzTNCuqsF01fOCM1sc5/bnN1gsoI9p8nKLmvrdobAjdWmodH7X2G0axogqwgjnfegf69h4AYBVWVAEAAAhFoQoAAEAobv2FiSoJFBGwAABAtayownTRi9SU6ujjGNECiEr1J9p+OKaGPjJPzce45r6zvFrHR639hlGsqAJhHVsVHgoW2u123bI9KsPqOBEYh7Sq1LVmi9czmMKKKgAAAKEoVAEAAAjFrb8AwQ0EeAnNasjUoLbMv69rLGUWIXiv1THinAjbYEUVpqshvCB3H2vY5pad+rDbSmgWd0oeT2Mpv9b2aaTtcU6EDbCiChNt4dtaIQ4AAJRkRRUAAIBQFKoAAACE4tZf4CLCLABYWuGQMaAgK6rApYRZQF4lQ8sEpuXX2j4ttT2uKbBRVlQBIAB3IrRlreM5tIJ4LhhvznMBlmZFFQAAgFAUqgAAAITi1l/YgLXDKIRZjDP1uJx4jUv3dejQqxz7hviEssHyCp9PzWUuZkUVtsEH/phKHpc13vtU+MqYUJZoY7a1YJwohLLNN2eesQ2tX2tolBVVABZR87fogmSoRc3zbAm55q47g6A8K6oAAACEolAFAAAgFLf+AkAGgoHWJSDmzlL7YeStr2H2A9AeK6qwDUI1Yip5XKKPiRr3jWCgdQmIuWM/cE6N51OwogpbcOwb76Fvy8eEUcx9/qVaCrgYuxJRal+XZJUGIA/nU2plRRUAAIBQFKoAAACE4tZfIARBNNtVOBQHILQA50jXYYqwogrbdSrgoFTwgSCa7XKMuYSAmDv2Q/tKnyNLvz8bZUUVNsq3o0DNnMPuzNkPWwxqA+phRRUAAIBQFKoAAACE4tZfgIUcua1OIAVULkCwzTHOLUBzrKgCzDMlTCTah9soBLJQk4jzOGKfhkQL84uu9H4p/f5slBVVgBmOrWIMBZTwVCthMI47jGP1dxr7i62yogoAAEAoClUAAABCcesvLGQgcEPoBUAwztkAsVhRheWcCreoLfSC8gSPwPLGnrMjzruIfQKYxYoqQHBWcyAO8xFgHVZUAQAACEWhCgAAQChu/QWyy/17kmNfb8b7NhuWMhAQE02zx2COio7fGI4xZFT4/GA+szgrqgDtFALH1LJttfRzbS3tl5a2pRWC2upWck6ZzyzOiioAwAZZEQMis6IKAABAKApVAAAAQnHrLwAATTgSqif0ByplRRWg7eCQWratln6uraX90tK2UI+WQ39KzinzmcVZUQWy2+123dTnDP20zOPXy/241llJqJvjB5zi/EDrrKgCAAAQikIVAACAUNz6C8Bs+/3+ZQr2t2BDt39fQCDLhmUeS2sxZoGqWVEFanMqwEGwQ1mhitQFtL59tMeYjc/1DAZYUQWqYoUAgBa4nsEwK6oAAACEolAFAAAgFIUqAAAAofgbVdiAgUTWOamQt6deM8jrcULEhN4KGIfUxpgFqqZQhW04VZRcXKzkDoEQKrGqTRSpu92uK90H2mAsAazPrb8AAACEolAFAAAgFIUqAAAAofgbVZgoRzDRfr/vL30uAHVqMMjMtQtYjBVVmC57MNHM58JUW0gDLbGNp95zC/ubcVo716+xPeYPbJQVVYCNsQKyDPsV8ptxpxJQOSuqAAAAhKJQBQAAIBS3/gIXyREqtUJfTj0+5y1iTYSJBAh5qWo/Bthfa6jqmECrIl1vYU1WVIFLLREqdamSBUMrxUrp7Sj9/lPV1t9LlAzKaTVAp7Xtam17oop0vYXVWFEFAIrY2mrQ1rYXYA4rqgAAAISiUAUAACAUt/4CYW0ksIbMjBuiCjo2BfI0JOgYu5SxuXFWVIHIarjYthImUno7cr5/DeOmBqXHRIsijs2IfYqg1qCvlo5nS9vCBayoApyw2+260n1Yi2+tY3s8Fod+XmlLYxaW5JwI5VlRBQAAIBSFKgAAAKG49RegQgOBGcIn2KylgmSGbreGS+UYr8YmLbOiCkRWMrSi1sAM4RPxj91UrW3Pkoz/6YyvcozXYcbmxllRBcKaszIocGa7jBtqZHwRmfFJCVZUAQAACEWhCgAAQChu/YXGLBUmAgBrWyM4TjgdxGRFFdqjSAW2qtbwlVr7vYY1guNKhdPVctxr6SeNsaIKADTB6hc1GTtehbyxVVZUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUOdF3qui592HWpG2oDAACWoVCFpz5IKf37+38PtQEAAAtQqMJTX6SUfvf+30NtAADAAvyO6gj7/f5lOv6jz7d+s609fZ/6lNLnp9r2+xK9gnYNnGNDGfotwzNcKyjOZ5n5Ip6rZpyXTjEeKtTq/LaiOs6pk1KokxXh3JbuAFSi9XNp69tHHVr/LHPqmpvzWtzKvhqyhW1sUZPz24oqHLgPTPogpfTF/UrqO203N+Ne59g3WAt88wkAm1fzqhFwnBVVeEqYEgAAFKRQhaeEKQEAQEFu/YUDwpQAngoQJHNRKEirISMArbOiCutaI+yB8hznaVrfL61sX+lQjkvfv8mQEYpoZS4P2cI2UgkrqmzWsdCkU+2XhCkd49v7bXCcp4m0v4YCz3a7XbdmX4BYxp6rcp9HpryecxgtsaLKlp0KSBKmBAAABSlU2bJTAUnClAAAoCC3/rJZx0KTTrULU2qL37MF+JJzIhCRFVXYtpZDE1reNpYjCOu00vug9PsDsCIrqmzCuYCktcKUSttSkMKx0AurBpwTKdgpGvsGgDVZUWUrpgQkCVMCAICCFKpsxZSAJGFKAABQkFt/2YRzAUlTHjsUpnTk1tJbt8uxthm3ODc7Xvf7/cuU0vul+wHks8afc+R+j5GvN/pcfGn/NnBObPZ6tiVWVGFZLV8EaiSMZVjL47XlbQPassb5qvVzYuvbtwlWVNkEYUqkFC8MRrgTEEHkoD3nSdguK6pshTAlAACohEKVrRCmBAAAlXDrL5uwVpgSMGwDAR6wqoE5JUwGqJoVVZhOIA+5RBtLa/RHkQp5nZpT5lq91jgXR7v+5Nb69m2CFVWaMzY46dIwpb6/+4b63YCl/Zu1to92zFntGAoYiRyMUrMo+1W4DNzJNSdzn09rOD9bbacGVlRp0dgwpLlhSgKWAABgAQpVWjQ2DGlumJKAJQAAWIBbf2nO2OCkS8OUjrUJWAKWMDV8qtBtwUJ7KOLIeDcWBzg/UBsrqgCsqdaAi1L9riEQp4Y+sg2tjsVaz5sptXtMWIEVVZqzfJjS07abm7W2Durmm3WAaU6dNwWr0TorqrRImBIAAFRMoUqLhCkBAEDF3PpLc4QpUbOp4TknXiPS7WCCNDZgxpgLPT5yzEcALmNFFbjUqXCHmkMfImjtQ3Fr27O21udT9PERvX9TOGdTgvHFxayo0hxhSuuIvAoCrZgzz4KtrFNYxHP2lGsz8+12u650H2AKK6q0SJgSAMTn2gqcpFClRcKUACA+11bgJIUqzen71Pd9+vzxLUNj23I8HwA4z7UVGOJvVAFiuU3bCHBheTWMpejjo4Z92LqixyD333ofeb3QyddQkkIVIBAfWMjFWJqvhn3YemjWsWPQ2Db7IgROcOsvVeu61HVd+vA+JXB2W47nAwDvWuLaDLRNoUrt5iT0Sv0FgHUscW0GGqZQpXZzEnql/gLAOpa4NgMN8zeqVO0+FfDzXG1jHrvf71+mlN6/ucmwAZV42OZCby9ogqoVnj9zmHtkc+m1eeo1t7G/X4VNs6IKI3SfdF33Sff97pOuS/E/cJ5K0ZyTrllym6Pvbzin1jFca78PLXFOZD2tjMNTjEM4wYoqVbsPVfggpfTFw2+uzWk71t590nWpT3+WUvpRSumnfd+nroub5WAFBOBLzonrW+LaHMlutxv8EDC0qnvuucCXrKhSu0XDlO5XUP8s9c9+L3WpS/2z3/vp//pp6vuQ104AiGCJazORdN1vpq7754/++c3SXaI9VlSp3XJhSt//yRcppT9LKf0wPXvz9ZRSSs/efP2zf/gspZTSj/7pj0KvrAJAIUtcm4mi6347pfTXKaWvPGr9f6nr/kXq+/9WqFc0SKFK1ZYKU+o+6e6K1F9/4/fTb/zynf//q/5X6T//779JKSlWAVomSO4yS1ybW7T2+BoZNDU87rrut/8hPf/F++nlO7dlvkkp3abnv/hW1/2OYpVc3PoLB97e7pvSDw+L1Ld+45fps3/4LG3oNuCSYQ+CJqhdrWO41n7nJEiuvGjjcEx/xgZ4RTzGp/t0d3vvXx8WqSndFRTvp5cppfTXbgMmFyuqVC13YMPb4KT+2e+9vd33hF/1v0pbuQ241m/1IQLzh5Zlvw4ftD3Mn8ftNzf7N6f6c329ezb1fYZe75Lwo4bn/D9JKX3l1CrXs5RSn9JXfpJ+8r1/26X/UFNAFjFZUaV2uQMbvpdS+tG5IvXBr/pfpZ///c/T3/7yby/oOgBUL/d1+GzQ4YL9YaY36dmzT9NHf5LsazJQqFK73IEN/zWl9NP05tn/HfPmX+u+ln7wj3+Qfusbv3VB1wGgermvw8NBh+dDloQ2FfQsvXnzUfr0j5J9TQZu/aVquQMb+h/3ffdJ9wepe5NSSj9MKf2jU+/9te5r6bvf+m7zt/1OUTh4ZA1Fwk02sF9Tqjg45kHQ41T9fiW27NfhgaDDh/b9Pm9/hl4vl6Dnh6NOhS699xd/kf7VH/5h+uovf3l0petNSull+tb7f5L+6NOUUrr/aHTb9+l52khAFnlZUYUD/Y/7PqX0Bymlv0y//sbxB/36G4rU46q4CM9Qavta368ptbGNEbchYp9qIkiOXKqfi6+/+c30808+SbfpeTr8o977IjV9N32W/j69k6VU/XZTjhVVqpY7xOHL9v6D9P2f/EF6/3/+fvqdv0zvpP/++hsp/eKH6Uc//JeKVICGWY1+19LBSafaDttvbvL2cej1eNfLb387/c2/+zfpBz/+cerefFmu/p9XX0vfTZ+l/55++8lzhClxKSuq1G65wIb/8pMP0s/+PKVf/DC9XVm9L1LTz/5ckQrA1qwRnCRMKbiX3/52+o9/+qfpP/3xH7/955+l/3G0SL1nX3MRK6rUbuHAhi6ln/35Xet3/uJtkZqSIhWAzVkjOEmYUgVef/Ob6fU3v/n2vw9u9z1kX3MRhSpVWzqw4W7R9L5Y/cW/TunvvpcUqQBs0RrBSa2GKY318ccfpVev3hv56D7V8Jnk1DGFc9z6C6N0Kf3d99MFF4SthWG0vr2ltq/1/ZpSG9sYcRsi9gm2aNRcHF+kplRDkZqcg5jBiipVWzqwYWp/drtdFVeNpawRPHIqNv/+/Zvc/wJd6uA40ZJSwUkthSm9+75354dzfUnpSaBubZ6lkccUzrGiSu1KBjYAQKtKBSe1FKYUqS9raX37WJFCldqVDGwAgFaVCk5qKUwpUl/W0vr2sSK3/lK1dcKUlrXf71+m4z+IfetWwu0ZGA+1anIcm7f1aHBOraJUcFJLYUoHnyeejMMWf+VuyjGFc6yoQnmnPkD5YLVNrR331rbngXlbD8eECBYYh+H+3FNwElkpVGlO16Wu69KH93+8P6ltqB0AWjTnurlG21B7zm2ZY4H3eJZS+k5K6dnNzT4d/+evUt+n7vFj+z51Bdue+wxFTgpVWiRMCQDGixScVGuYUu73mPLcSMfKZyiyUajSImFKADBepOCkWsOUcr/HlOdGOlY+Q5GNMCWac2lgw7Ggg6mGfuMzwusdkTX4pXRoSW37i9NKjyVowchz4u39b3yGCE66NEzp448/Sq9evZfSo98hvQ8ruu37NLh9OcKUzgUnzXm9c/2LdKwEJ5GTFVX40uiLytXV6yX7sabchUDrhcUa29daGMWl29P6WGI9rc2p3Gqca0+O6X2RekyJ7Zv7nsYsJCuqNOj+D/g/SCl9cf/N3ui2U25uFsquhwNWbCGv6HNqhTtBzppz3Vyj7Wn73TF93JYeraRO3b6bm7z768zTn43Z5jn9u7Tfl3w+giVZUaVF/uAfAMaLFLwzJUwpd8DSWGv0ZYnPKD4fURWFKi3yB/8AMF6k4J0pYUq5A5bGWqMvS3xG8fmIqnR9b0X/nKHbcna7nd+JakTXjb+95erqdXrx4tMlu0M9BCwtIMLtkJdyXWCKCGO9tjGbIazoIWAppTTvc97Uvtz//ugkEcbIClxLZ2i1VrGiCl8aHV4wENrA9tQYRFIDYSJsRemxXvr9LzH3vJvzvD3ltWrc12txLeUJYUo059KwgIdvV8eGMxxz7Fur8yEJ+5PvketbsI18G7vot4Zb2YclnQtLKR3oMnbewhTnVpFyj8Xr693kMJ/SbYftQ9v3OPzw+np38nFjw4rWCE4CjrOiSouWCGxYoz+wdZHCW8xboigVBBSpbaj9UmsEMTmPwAwKVVq0RGDDGv2BrYsU3mLeEkWpIKBIbUPtl1ojiMl5BGZw6y/Nub+V5vNL2g7buww3kp57772faIWU0ry5u3abectaco/FCPNnSluG4KRT3t4yfX29OxmSePCZYFJfHt+W/XCcHt9m7DwCw6yoAgDUq/WAntGF4dXV68H/HjIyJPHivnBW6+OYC1hRZROWCGyY8z5jQxxKiRxlLtSoXZHCW2qct7Tp/Ph8Gjw2FLAUYf5cEqZ0wtuwomPb/LBC+jhAKk0MSbwkxKlFkT8X0C4rqmzFWoENQlngcpHCW8xbosg9PiPNn7nzbM42j2XeQyEKVbZircAGoSxwuUjhLeYtUeQen5Hmz9x5NmebxzLvoRC3/rIJa4UptRLKcuT22ttzv/UHc52fK/uXKaX3j4WRrN1W0sN+KN2PTJxbzsh9XYkwp77+9Y+e/E3osevtuWvwJds857qeI2ARGM+KKjBGKx+KqZtxeKel/dDSttSoyP4fGVx0jvCd9djXFGFFlU0QpgSxmCvwrjHXpBxzpcY51fepy7XNU9977HMfBzYd79/pgKtzQUVDIYJCjmiZFVW2QpgSxGKuwLumXJNyhynVoNQ25w6pAkZSqLIVwpQgFnMF3jXlmpQ7TKkGpbY5d0gVMJJbf9kEYUoQi7nyVGMhSUw05po0tm18mNK4vn388dPwo7Xl2ualwpS2fv6CJVhRBaAWrQd6bLFIbf2YRjdq/xcuUo0R2CgrqmyWMCUo55K50vd3P2Ny2XPHzsdxgSdD4SacJvjltBJhSmPnVErp5LyYaTCAaMp+KB2m5FoP+VlRZcuEKUE5c+bKGm2wtpJhSqXmxdygQ2FK0DCFKlsmTAnKmTNX1miDtZUMUyo1L+YGHQpTgoa59ZfNmhumdH29e+e/r65eC4iBkS6ZK2u0mY+UUiJM6Vhb16XVQr0uDTo81pY7TOlRgNSo256dWyA/K6owbHSIQ+lERKB6rYfGtL59rVgr1Cv0eJh4TQ+9LVArK6pw4CAQ4UnQRBr4dlWYEoyzZCDSvDClNbb+uN3uLtiG9o0dm1Mee1mY0tHgpCGjwo9yt+Xc5jPbN9bk/eBaD9NZUYWnWgmpgMgiBSeZj6xtiRChNUJ/Ss7HUmFKY1/PuQUyU6jCU62EVEBkkYKTzEfWtkSI0BqhPyXnY6kwpbGv59wCmbn1Fw6MCJoY8va24PvH3d7fPlx9wELB3428dUvkOPv9/lQIytl9OPDcRTy+De5hDhxpu73/nceqw5TW3rcXMs9WlDNE6FjbhGChSWNz2bl3N0+OnRtSOn/OGPL495EPgxAfG/p/j0U5t0DrrKjCdFNCE6J/OK2BfTjeqX01Zh9G3M8R+3SJGrajhj6S35TjvnRg0Cpj8Orq9dyXEJwEK1Gowghdl7quSx92Xer6Pj3v+9Slu/nznXRmHj1+7lDbkn1e6j1gaWPnT+42WNKUcZh7HE8Y72+vcX1/d+1rYe69ePFpurnZv/PPGVn3AzCeQhXGKRVcMYcQB1ogTIkWRQtTmtPH1ude69sHYSlUYZxSwRVzCHGgBcKUaFG0MKU5fWx97rW+fRCWMCUYIWfA0tXV63Rz8+mo950TYOQ322hB7lCWOYEsY+djweAxMlsqCGvM2Pz444/Sq1fvvfO8Y9eaY23X17t0dfU6vXjx5bXm0eud/C3wx5YMTjrWFjVsqPXtg8isqI5z6g/n/UE9D0aPhcMPHpxlnq0j4n6+tE+CgaaLePwjKDaW5l4rDp8/8fVKjIdiY3AgYMm8oBZN1ipWVEcQ2c8x9+EIH6SUvrj/CZp32tLIb63XdH29e5bu+/c4rn+M3W4nDKJhQ+e5g7Hen2qb8tgl21q8m8D8Y2Fvrw2l5u39T1FdfG4ZuqYdmz9z+g3RtFqrWFGFy9UYnBC9f8S0VvCL0BIoI9K8nXtuGcu5BYJTqMLlagxOiN4/Ylor+EVoCZQRad7OPbeM5dwCwSlU4UJ9n/q+T58/vhXoWFsk0ftHTFPG+tjHrtEGjBNp3s49t6yxzcA6/I0qLOc2nQjiuL7enX3yYWJjpv604NR+Db19S6WHTnj/pT9k3Qb6G5mTc69Socd24y4eS8dSe6fpU0rz/jR5zLUmZRxfpc9zp4w8/0U6hx06OQ5PbFvkbYHRFKqwkIeApce6bvw3sq9evSdA5YiKL77hPrxlFmb75oyRoQ+0j+fj2MfNfR/KmjOWrq/nrsB1qe/PV6rHxtJQgTrmNWcIcx64QNi+nxqHA+eRsNsCU7j1FzLqutR1XfrwPiXwZFvu15vTBjmtMWbNC9ZWciy5rgBbpVCFvCKlEkovpIRISaHmBbmUHEuuK8AmKVQhr0iphNILKSFSUqh5QS4lx5LrCrBJXd8LMYO1TPkb1Qluj/097GNRAy5oSwt/W7lC6NTahKqsoOvSIufY2v5Gtfb5s+Q5bIm/S/e37rTOiiqsa4n0zjEfjhSpLE0ybUzm/jqW2M8Xz6mrq9fZX3Okms8DNfcdmiT1FxZ2HzDxQUrpi4eVz4O2/rAtpfRmxns8eb2bm0wbw2ou+TZ86Nv16+vdszQwRnK0LfGaa7eZKxwz4pw95OK5N+axx8bsw0+brTHv323bjbrGLbMf9ievm1YXoU5WVGF5awRSCLjgnLUCVCIFIgmDIZc542atOZX7vSO1TX0s0ACFKixvjUAKARecs1aASqRAJGEw5DJn3Kw1p3K/d6S2qY8FGuDWX1jY/S1Kn09p66bfpPT2lqf75z4ELH2eUkr7/eTXozGXjMOpbWu9z5Jt5grHHJyfJwUnLT2nhsZshDmVo23MY4f2Q+0hT7BVVlQhprmhDocfooREwDitzZXWtieCKcFJ9j/nGCNwghVVKGBEgMTs0KVzARdDwRPHCKOYbk74x9p9Eaa0bhh2ULfIAAAPIUlEQVSM4Je4SgUn5QxTGvvcWtrm7odIzG8Yz4oqlLFG8IvgifIiHYNogSdbaxtqJ5Y1zrtrhQhFmgMl9wNQoa7v3bYPa8v1bXMa/hmbwW/2ragub+2fU/DzNHHbDtutqMa15Hl37Z9lWf/naUquqK53h8ocOef30DnfeYQWKFShYl2Xpkzgh4CllNL0cAkXvfxyf8jwoaUeCxz7UwE/t7vd7vmRdka4IDhp9Xk2ZywNjBsWolCF8dz6C3WbEsLgwwi069T8Nu/naT04yfhYV41jBIoRpgSBXRK6lEYGLNUSPFGz3OEf0UNV1nqfGtumHCvWEyk4qUSYUovj8Nztzm67h3pYUYXY5oZPjHk9lpM7/CP3sS8ZeLK1tqF2ymlpTs3pY0u2uM3QJIUqxPZFSul307vf7I9tG/t6LGfOscr9enPG0pT3XeN9amwbaqeclubUnD62ZIvbDE0SpgSNmRKwdHX1Or148emoxy59S9RGQj3eCbYRpnQn4LFfPIDIsY+nhuCkY8Ye+4DzbBEjAqSamSstbQscY0UV2jM6rOHVq/eyv+YMzX+AStvYxktE2y/R+sM6BCfVr8bjApwgTAkqkztg6ZhjYRQj3/vithZDPY4RplTHsY8WplTjPowmenDSEmFKkSyxwtfK+QY4zooq1Cd3wNLY95j73gIu7uQ+LrUGv0Q/9tHClGrch9FEnz9LhCm1rpXzDXCEQhXqkztgaex7zH1vARd3hClN63cp0cKUatyH0USfP0uEKbWulfMNcIQwJdiAKQFLTNWndCRT5VRQ1UG4iUCdNNzvLdrSsS+l1uCkY2qZP1HGYUtzpaVtgWOsqMI2CJhYzPHPAieCqhyH4+wX1tZ6cFI09iEwmTAlaFTugCWmOxZKtWT4Ua1hSn2/ezI+5wVz7ase2wJi5qsxOGmtMKXWV9rMFWiHFVVol0CJ8kqFt+R+vbXClJboT422uM25RZ8rwpSWY39BIxSq0C6BEuWVCm/J/XprhSkJ5rqzxW3OLfpcEaa0HPsLGiFMCQLa7/eTgj4ucX29W/LlOe724TbslIQpLaWWcJlTajr2a5yr5vr4449O/c34UWsHJ9WwDzO43e12z88/bHktnSdb2hY4xooqxLT4h5arq9cTHl315/5Ilj6upwJLthZksrXtLSl8gTWlSE1lxk74fZjBFrYRyEyYEmzUsZ9OWdK5YKGa2w7b00BQ1bLhR3lDidYLU8rdtvx+GApsGrOSMbQSIkxpVaPOS2M5dgD5WFEF1hIpoGStwJNjagx0WSL4pZW2JUTrT8ty71fHDiAThSqwlkgBJWsFnhxTY6BLrWFKtQY2RetPy3LvV8cOIBO3/gKreHyr5H7/0Pbl/79vu72/bfPzx8+9v10ubNthezd84+fb/XB9vUtXV6+P3oYdafsu3Q8ttz2M4SVE608EUwORxjq/X08GHR0NB3LsAPKxogoxbTUMppXAjdHHb4kP37CiVc5VC82TMX0/dU7Kea7awvl+C9sIZGZFFQKaE+Nf+09zRDIjROhJmE8aCFia896R2qL1Z9kwpSlHc5po/RmS6ydHzm1fmjh/TsganJTLEj/b4mdLgBZYUQU4bYkQodzvHaktWn+EKdVjje1rfR8CNEWhCnDaEiFCud87Ulu0/ghTqsca29f6PgRoilt/AU7IGSI0FLB0fb071vz2Vsdjz43eduaxt/e3R4cLSRrbJkwpr4O5cirAKNt7DLUBEIMVVSCSlgM3Wt62qVoJzZrj1HgwTpYZH/YrQGWsqMKGCNFYzogAnNkBSy2JEIhUMkzpIUAnSn/WMiaE68xLjApEmhIABkBMVlQB8thiAM4ckQKRSh67aP1Z2pQQrrHPb3l/AWyWQhUgjy0G4MwRKRCp5LGL1p+lTQnhGvv8lvcXwGa59Rc4a7/fLxJuMsPtEr89OMclAThDAUsbcBgWVVXAUq7womj9WdrhdkwNTsp9TCMbOO+GO/8NCXj9OGnG75BXdUygFlZUgTGifciI1p9LCXj5UivHlGmmHPetzZdT+6a2uVJbfy+xhW2E1SlUAVbUdanruvRh16Wu79Pzvk9dujsXfyel9KzvU1dr25jHjt03tbRNEa0/S8vQ58Ox9Dz6NgOQj0IVYF2RgoCWCKaZE2ITaVuWCOOJ1p+lze1zjdsMQCYKVYB1RQoCWiKYZk6ITaRtWSKMJ1p/lja3zzVuMwCZCFOCFZUOlZgRFLEpSx6nx793+RCAs0Lbbd/vFg8qOmx/2I+P+3N9vTt8ymPVBCxdEl4UrT9LOwgOezKnzoWJtRCIBMDlrKjCugQu5LF0qEprx6nU9jx536ur17OeT7WmHsutBSe1bAvHstQ2nnrfLexzNsCKKrCK3W4n/KSg+/CZD1JKX9yvSmVvO2x/vJL64MWLT1NKKV1f7549PC49Wkkt0e9L2o5t21CfS/ZnDef6N/Tcvk/dlDFGXfxsy3LsW1pnRRVgG0qGKY3tT6l+5w4vmhs0VWOYkuAkALJSqAJsQ8kwpbH9KdXv3OFFc4OmagxTEpwEQFZu/QXYgDXChg7bhwJ+DoJ2hoQMWBq7baef/zRoak5o1pA1QtQe9+XrX/8ovXr13qTnC04C4JAVVVjXVgMOatvu2vp7TvTtmdK/VgKWWtmOJyYWqdHHJgCFWFGFFa0RfDC0eiLQaJzaAypKhQ0dtk8IHHp+2JaCByzNDVMqHX5U0NsQrXNBUwBsmxVVgPaUDBbKHZI057k1hCltjX0DwCgKVYD2lAwWyh2SNOe5NYQpbY19A8AoXd+7wwZa4tZfopgzFrtu0u2fDwFLq5k7z9YIOBrr44+nhx9dqu9T2HPQQ8BV6X5syG3tf2YBLMuKKgDhXF29nvJwxcUMaxWpKX5wknG0LvsbGKRQBdiorktd16UP74NsZrcNtU997xcvPk03N/t3/llzW6Zs85i+THl+Q56llL6TUnrW96nr+/R8g/sAgAspVAG2q9YwpTmvJ0xpPbmPMQAbolAF2K5aw5TmvJ4wpfXkPsYAbIgwJWiMMKU7DQajVBc8MmcsHnvu9fXu5OPP3Rq8pshhSoKTTosUcLUVI+dKyXN5deddaIkVVaBVLRWpKbW3PVtXJFhIcNKgGvtcs7H7u+S5z3kXCvpq6Q4AENt98M0HKaUv+v7uZ2OOtR2239xc/ppDz41uzL7p+7tVmrH79vz+2r851Z/r692zh8ellE4+Lt19eT27L+fGSFTHVs5K3qHi7hhg66yoAnBODWFKkUzZNyXDncY8bo3+AcATClUAzqkhTCmSKfumZLjTmMet0T8AeEKhCsCgvk9936fPH9++eaxtqH3Oa9Zmyr7J3TalP2Met0b/AOAYf6MKhNVgcu8cmw96ubp6fTIMaCgReG3X1yUKs126unqdXrz49G3LQ8LvyP5sfnydcJuOn4Psr/WcOgZrvTdQiEIViKyKIlWwyToeirDH+7vrrNY9OCzihxJ+a/vpmFL8NEl5jgFsl1t/AahK16Wu69KH96myXODYPrRfAYhEoQpAbaTJzielF4DQFKoA1Eaa7HxSegEIzd+oAlCV+xTZz1NKqXOT6jvGhko93odDbZEJWwNomxVVILIaEhdr6GPL7P/pWtlnilSAhllRBcKS9sgx92E/H6SUvuj79PxIWx+tben3SSm9Gdhlz8b2EQCisKIKQG3GBgFFalvzfQ4JTgKgOgpVAGozNggoUtua73NIcBIA1XHrLwBVGRsEFKlt6fcZCpVqITgJgO2xogrtORWU0kqACvDUFud9y9sGsHlWVKExAohgG+aESrWg9XPdfr9v5lgBXMKKKgDUKXfoEgCEoVAFgDrlDl0CgDDc+gsAA/b7/cuU0vul+3Hgtu93z9OMcCcAiMyKKgAMi1akphSzTwCQjUIVAACAUBSqAAAAhKJQBQAAIBRhSkA2QUNncrpt/bcbAciv8uujax9FWFEFcqr1IjxW69uX2+3E9qgi9jdin8irlfnDnZqvHzX3nYpZUQVgEa18A9/KdlAX4w7YOiuqAAAAhKJQBQAAIBSFKgAAAKEoVIGcWg/5aH37IhEkA7Sk5nNXzX2nYl3f96X7AAAAAG9ZUQUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEMr/B8wj0o1qhF8ZAAAAAElFTkSuQmCC\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " (b) Weighted (1.4) A* search search: 154.2 path cost, 944 states reached\n" - ] - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA6oAAAJCCAYAAADJHDpFAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADl0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uIDIuMi4zLCBodHRwOi8vbWF0cGxvdGxpYi5vcmcvIxREBQAAIABJREFUeJzt3b+PNMl5H/DqVzxxRfNeWpEdEXBmCBSOzGmQkENHBwOzgAniIgpMpP/AIM+BEmdSQoJg8AYKdgzjYAjObMEGD1B6B9pKrcgwHInvwvRLvvA7Dnb3vX1np3u6p6u7nqr+fIADeXXzo7q7qnuerZ7vdIfDIQEAAEAUz0p3AAAAAB5TqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEL5UukOANCm/X7/MqX0/on/dLvb7Z6v3R8AoB5WVAFYyqkidagdACClpFAFAAAgGIUqAAAAoShUAQAACEWYEvCW8BuIxZzkMeMB2BIrqsBjwm8gFnOSx4wHYDMUqgAAAISiUAUAACAUhSoAAAChCFMCNk04CY8ZDwAQgxVVYOuEk/CY8QAAAShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQFKoAAACEolAFAAAgFIUqAAAAoXypdAcAAJhnv98fjppud7vd8yKdAcjAiioAQHveL90BgDkUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIReovAAA8st/vX6bTgVTSlGElVlQBAOBdfanJ0pRhJQpVAAAAQlGoAgAAEIpCFQAAgFCEKQHMMDZwY+BxrRAwAgBkY0UVYJ6xgRstF6kptb99AMCKFKoAAACEolAFAAAgFIUqAAAAoQhTgo2qIdyndB/3+/3hqElgEJtVej7OZO4GMjaELkB/+h5/fG0oxbimaVZUYbtq+MAZrY9z+nObrRdQRrT5OEXNfW/R2BC6tdQ6PmrtN4xiRRVgBXP+6h3or/cAAKuwogoAAEAoClUAAABCcesvTFRJoIiABQAAqmVFFaaLXqSmVEcfx4gWQFSqP9H2wyk19JF5aj7GNfed5dU6PmrtN4xiRRUI69Sq8FCw0G6365btURlWx4nAOKRVpa41W7yewRRWVAEAAAhFoQoAAEAobv0FCG4gwEtoVkOmBrVl/n1dYymzCMF7rY4R50TYBiuqMF0N4QW5+1jDNres78NuK6FZ3Cl5PI2l/Frbp5G2xzkRNsCKKky0hb/WCnEAAKAkK6oAAACEolAFAAAgFLf+AhcRZgHA0gqHjAEFWVEFLiXMAvIqGVomMC2/1vZpqe1xTYGNsqIKAAG4E6Etax3PoRXEc8F4c54LsDQrqgAAAISiUAUAACAUt/7CBqwdRiHMYpypx6XnNS7d16FDr3LsG+ITygbLK3w+NZe5mBVV2AYf+GMqeVzWeO++8JUxoSzRxmxrwThRCGWbb848Yxtav9bQKCuqACyi5r+iC5KhFjXPsyXkmrvuDILyrKgCAAAQikIVAACAUNz6CwAZCAZal4CYO0vth5G3vobZD0B7rKjCNgjViKnkcYk+JmrcN4KB1iUg5o79wDk1nk/Biipswam/eA/9tXxMGMXc51+qpYCLsSsRpfZ1SVZpAPJwPqVWVlQBAAAIRaEKAABAKG79BUIQRLNdhUNxAEILcI50HaYIK6qwXX0BB6WCDwTRbJdjzCUExNyxH9pX+hxZ+v3ZKCuqsFH+OgrUzDnszpz9sMWgNqAeVlQBAAAIRaEKAABAKG79BVjIidvqBFJA5QIE25zi3AI0x4oqwDxTwkSifbiNQiALNYk4jyP2aUi0ML/oSu+X0u/PRllRBZjh1CrGUEAJT7USBuO4wzhWf6exv9gqK6oAAACEolAFAAAgFLf+wkIGAjeEXgAE45wNEIsVVVhOX7hFbaEXlCd4BJY39pwdcd5F7BPALFZUAYKzmgNxmI8A67CiCgAAQCgKVQAAAEJx6y+QXe7fkxz7ejPet9mwlIGAmGiaPQZzVHT8xnCMIaPC5wfzmcVZUQVopxA4pZZtq6Wfa2tpv7S0La0Q1Fa3knPKfGZxVlQBADbIihgQmRVVAAAAQlGoAgAAEIpbfwEAaMKJUD2hP1ApK6oAbQeH1LJttfRzbS3tl5a2hXq0HPpTck6ZzyzOiiqQ3W6366Y+Z+inZR6/Xu7Htc5KQt0cP6CP8wOts6IKAABAKApVAAAAQnHrLwCz7ff7lynYd8GGbv++gECWDcs8ltZizAJVs6IK1KYvwEGwQ1mhitQFtL59tMeYjc/1DAZYUQWqYoUAgBa4nsEwK6oAAACEolAFAAAgFIUqAAAAofiOKmzAQCLrnFTI277XDPJ69IiY0FsB45DaGLNA1RSqsA19RcnFxUruEAihEqvaRJG62+260n2gDcYSwPrc+gsAAEAoClUAAABCUagCAAAQiu+owkQ5gon2+/3h0ucCUKcGg8xcu4DFWFGF6bIHE818Lky1hTTQEtvY955b2N+M09q5fo3tMX9go6yoAmyMFZBl2K+Q34w7lYDKWVEFAAAgFIUqAAAAobj1F7hIjlCpFfrS9/ict4g1ESYSIOSlqv0YYH+toapjAq2KdL2FNVlRBS61RKjUpUoWDK0UK6W3o/T7T1Vbfy9RMiin1QCd1rarte2JKtL1FlZjRRUAKGJrq0Fb216AOayoAgAAEIpCFQAAgFDc+guEtZHAGjIzbogq6NgUyNOQoGPsUsbmxllRBSKr4WLbSphI6e3I+f41jJsalB4TLYo4NiP2KYJag75aOp4tbQsXsKIK0GO323Wl+7AWf7WO7fFYHPp5pS2NWViScyKUZ0UVAACAUBSqAAAAhOLWX4AKDQRmCJ9gs5YKkhm63RoulWO8Gpu0zIoqEFnJ0IpaAzOET8Q/dlO1tj1LMv6nM77KMV6HGZsbZ0UVCGvOyqDAme0ybqiR8UVkxiclWFEFAAAgFIUqAAAAobj1FxqzVJgIAKxtjeA44XQQkxVVaI8iFdiqWsNXau33GtYIjisVTlfLca+lnzTGiioA0ASrX9Rk7HgV8sZWWVEFAAAgFIUqAAAAoShUAQAACEWhCgAAQCgKVQAAAEJRqAIAABCKQhUAAIBQ/I7qCPv9/mU6/aPPt36zDWCegXNsKEO/ZXiGawXF+SwzX8Rz1YzzUh/joUKtzm8rquP0nZRCnawI57Z0B6ASrZ9LW98+6tD6Z5m+a27Oa3Er+2rIFraxRU3ObyuqcKTrUpdS+iCl9PnhkA7HbTc3417n1F+wFvjLJwBsXs2rRsBpVlThqQ9SSv/+/n+H2gAAgAUoVOGpz1NK//L+f4faAACABbj1F47c3+77WV/bfl+iVwBlBQiSuSgUpNWQEYDWWVGFda0R9kB5jvM0re+XVravdCjHpe/fZMgIRbQyl4dsYRuphBVVOJIrTOkUf73fBsd5mkj7ayjwbLfbdWv2BYhl7Lkq93lkyus5h9ESK6rwlDAlAAAoSKEKTwlTAgCAgtz6C0eEKbXP79kCfME5EYjIiipsW8uhCS1vG8sRhNWv9D4o/f4ArMiKKpt1KjSprz1XmFJpWwpSOBV6YdWAcyIFO0Vj38Rx7jrV2rUL2CYrqmxZX0CSMCUAIht7nXLtAqqlUGXL+gKShCkBENnY65RrF1Att/6yWadCk/rax4Ypnbi19Nbtcqxtxi3OzY7X/X7/MqX0ful+QA6Pr0ldl56M7e7uSx63h0N6nhoOAlzj6xy532Pk640+F1/avw2cE5u9nm2JFVVYVssXgRoJYxnW8nhtedvYtr6xbczXa41j1/r4aH37NsGKKpu1xTClrYv211XhTsA5565JY587dO06FbR3SWDTEm3Ok7BdVlTZMmFKAEQ355o059o1J7ApdxuwQQpVtkyYEgDRzbkmzbl2zQlsyt0GbJBbf9msJcKUgGEbCPCArM4FJ53x5uH/XF/v0tXV6/TixSeT37d0G7BNVlRhOoE85BJtLK3RH0UqXG7W/Hn16r1c/WBZa5yLo11/cmt9+zbBiiqbMDbEYdxj7wJ53g2p2L9JMNGccKehgJFTwSjMF2W/CpdZVpQQoVP9Ger3zc0Xt/tcX++q3ea+/XBKrjmZ+3xaw/k5WrggnGJFla2YEtgg8AFgu6KFCK1xrYm0za6tQEpJocp2TAlsEPgAsF3RQoTWuNZE2mbXViCl5NZfNmJKYMMlgQ8CloAlTA2fKnRb8G1LtxGeCy/qTty4uUbbkiIFJ00JUzox3psai7k5P1AbK6oArKnWgItS/a4hfKqGPl6qim27unpdugtRVHG8LlDreTOldo8JK7CiyibkDVN62nZzs+bWQL38ZZ1Izp3bS/btjGcpY5hfpOCkKWFKW9F33hSsRuusqLIVwpQAOFbruT13HyMFJ9Ww/4EVKFTZCmFKAByr9dyeu4+RgpNq2P/ACtz6yyYIU6IWU8Nzel4j0u1ggjQ2YMaYKzo+zgUnRTXndt+e31h9+3r3QU63h0N6noKHKQFts6IKXKov3KHm0IcIqvigPEFr27O21udTpPERqS8p9Xw9c0xwUoZwpWD7goq1fg5jQVZU2QRhSvlZJYPlzZlnwVbWQxobnHRzU9dtMy9efJJSSun6evc2dCk9WjUdQ5hSe3a73co/fATzWFFlK4QpAXCs9fP4nO0TpgQUpVBlK4QpAXCs9fP4nO0TpgQUpVBlEw6HdDgc0mePbyM61TblsX3PB6AOrZ/H52zfnGth7jZgm3xHFSCW29RWkIkgjXJqGEsVjI9m6qVJ46Hrnmz4QxLw2oqO49zf9T7xepLRoYdCFSAQH1jIxVjKpQsdQjO2kBpbZJ4oUB8UKRZPjePGgsKi/zEJinHrL5vQdanruvTN+zTB3rYpj+17PgB1GHsen3NdmHtNmXP9yX09i7YfgLYpVNkKqb8AHBt7Hi+Zdjvn+pP7ehZtPwANU6iyFVJ/ATg29jxeMu12zvUn9/Us2n4AGuY7qmzCfXrgZ+faxjx2v9+/TCm9f3OzWHfDedjmQm8vaIKqFZ4/czQ79z766MP06tV7KaX0Zszjx15DcreNeex+n7ff3fCNtW/31/3jHgKWFtsPU6+5jX1/FTbNiiqM0H3cdd3H3Xe7j7suxf/A2ZeiOSdds+Q2R9/fcE6tY7jWfh97cu67L1Ivfn7jpmzvGmOklXHYZ2vjC0azokpz7oMWPkgpff7wO2xj2061dx93XTqkn6SUfpBS+tnhcEjdmT85l9TqCgjAWO+ex+/OiY/b0vBK6rN04TUkd9uYx+a4u+foPSbtr7X2QyTnUqCHVnUjJ0hDNFZUaVG2wIb7FdSfpMOz76cudenw7Ps/+18/S4dDyGsnAHdqDAxaIkxprOj7i2i67vdT1/3TR//8fuku0R4rqrQoT2DDd3/8eUrpJyml76Vnb34vpZTSsze/9+mvPk0ppfSDf/yD0CurABtWY2DQEmFKY0XfX0TSdd9IKf0ipfQ7j1r/X+q6f5YOh/9WqFc0SKFKc3IEV3Qfd3dF6m+/8sfpd3/9zn//zeE36T//779JKSlWASI6CgeaFGZ1LjzvIbxohbbb3W73fE6Y0ljRA5ZatXbQ2sigqeEQta77xq/S81++n16+c1vmm5TSbXr+y6913R8qVsnFrb9w5O3tvil977hIfet3f50+/dWnaUO3AZcMexA0Qe1qHcO19vvYlELgeJsFyd0pGbAUbRyO6c/YUMNIx/hBf5/ubu/9xXGRmtJdQfF+eplSSr9wGzC5WFGlObNCHB6Ckw7Pvv/2dt8evzn8Jm3lNmABTXA582dZ587vZ54+GJwU4WfI1ghTGvG+xQKWHubPu9u8733v6+vd5DCsode7JPyo4Tn/j1JKv9O3yvUspXRI6Xd+nH78nX/Tpf9QU0AWMVlRpUVzAhu+k1L6wbki9cFvDr9Jf/33f53+9td/O6/HAFxqjSCgkkr1MVLA0lrvzUxv0rNnn6QP/zzZ12SgUKVFcwIb/mtK6WfpzbP/O+aNvtx9Of3RP/yj9Adf+YN5PQbgUmsEAZVUqo+RApbWem9mepbevPkwffKnyb4mA7f+0pw5gQ2HHx0O3cfdD1P3JqWUvpdS+gd97/Pl7svp21/7dvO3/U6xdjBEAcMhEwvZwH5NqdC+zSnocap+v56TKzjpVFuOoKK5SvUxUsDScfvQNl/yGWCN4xz0/HBSX+jSez//efoXf/In6Uu//vXJla43KaWX6Wvv/3n6009SGj72MIYVVThy+NHhkFL6YUrpL9Nvv3L6Qb/9iiL1tCouwjOU2r7W92tKbWxjxG2I2KclzQlOuvQxS4kWInSsZMBSjarfB6+/+tX01x9/nG7T8ydfUL4vUtO306fp79M7WUrVbzflWFGlOXNCHL5oP3yQvvvjH6b3/+cfpz/8y/RO+u9vv5LSL7+XfvC9f65IhQ0pFWozV85gmyXacr7mmV0xOWTncHga5BNt36wUpnSqj6sFLB23D23zJe8Tde5G9PLrX09/82//dfqjH/0odW++ONz/59WX07fTp+m/p288eY4wJS5lRZUW5Qls+C8//iD91U9T+uX30tuV1fsiNf3VTxWpsD21BrLkDrZZKygndyhOpG1eYt/kFmn/r/k+nPHy619P//Ev/iL9pz/7s7f//JP0P04Wqffsay5iRZUWZQxs6FL6q5/etX7r52+L1JQUqbBBtQay5A62WSsoJ3coTqRtXmLf5BZp/6/5Pozw+qtfTa+/+tW3/350u+8x+5qLKFRpzpwwpeP2u0XT+2L1l/8qpb/7TlKkwjadC+25vt71PTMVPm8cB9u8I1Jbjuf3mRW0V6htzGNLhSmdapsYsPSOgbbbwyE9rz1M6ZSPPvowvXr13oxXKH5uGaVvbMM5bv2FUbqU/u676YILQvQwjNxa395S29f6fk2pvm2cEBAS/4PkBtQ2vmq1xH5uJYznyb6ZV6SmVMm5xdzjYlZU2YRLAxumvs9ut6viqrGUNX4Goy82//79m9z/rf+8yINIAT8zQ3so74LgpHhtYx67ZhBQ7oClS9+7ljClc8FcKdO+CWbU3IMxrKiyFUsENgB5RQqxEb5St0hjpNYwpVNKzpUaw5QiHbu1bHGbWYhCla1YIrAByCtSiI3wlbpFGiO1himdUnKu1BimFOnYrWWL28xCusPBCvw5W7zVkDtd13+Lys3N0/SFS8bDfr9/Espy73Yrt3xO0fp8HBgPtbpoHJ8KK4KxDocvvrzX4JwaLcI5ceg6OsfV1ev04sUnT9ovvA4vfl3Zyjnt8dxjPa1+NrKiCuX1Xbiav6BxUmvH/dLtaW0/sJ7j8BZjqaxFwnTmBxGtboFxGG6xSXASWSlU2YSuS13XpW/ef6G/t22oHchnypyM7OZm/+SfdHdt/VZK6dnhkLqHf061a1vkNZ/XOJZqdW4u3/+0zMXHM3d/htrmmPkeg/vh1Hnm7p9/l0rO3RNt5h5ZKVTZCmFKEEvLgRtzQ3G21rbm+5BftGNXaozMeY+5/Ys0n809slGoshXClCCWlgM35obibK1tzfchv2jHrtQYmfMec/sXaT6be2QjTGmEVr+gzLumBh2cClOqUNbApg2EliwecDV0vqnVufNkzSEjuULVWE6Lcyqz0MF9GcKYbh9+37VP7s95U89p525x9jmUc1odI1ZU4QujLypXV6+X7MeachcHVRYbE6yxfa2FUYzZnirHTUPngda1Nqdyiz7/5h6/Ets35T2NT+jxpdIdgDXcf6n/g5TS54fD3V9nT7X1aWT1lApEXtnI6fH8G3qcucdc0edU7Su+Y6+vl7Y9rIYenTPe5OzjzU3ebT7z9GdDzz33eQS2xIoqW+EL/xCL+QdtKBm4lbuPa7yezyMwkkKVrfCFf4jF/IM2lAzcyt3HNV7P5xEYya2/bML9rTSfnWvrc329e+ffr65epxcvPsnWv5Jqv+1sbSf2V+ggkqgez79uIOah5bkHLRh7fZ3Tdtw+dM7o8fZW4fvnPgQsfZZSSvuJ3zA46svU4KRZn0eONXQNdy3lCSuq8IXRgQavXr23ZD+oS/QgkhqYe2xZ6TCd0u9/iUgBS4KT8nAt5QkrqmzCWoEN19e7USEJ74Y47HvfI1ekeEN/cR20ZAT7VvbhWo7mxaS5d8k8y902NG9hirmrSFPPTWv8VEXuuXei/ck549ScPL4jo6+PQ2FKgpOgHCuqbEW0wAbBCWzdGmEka4W8AO9aK0wp9/V67OMEJ8EKFKpsRbTABsEJbN0aYSRrhbwA71orTCn39Xrs4wQnwQrc+ssmrBXYcElYxNQQB2jBuXkxNPfWCG8xb+FyS4cpnWq7YE6+vVX4+nrXG9Q2Jzjp8e3ID/17fJux8wgMs6IKAFAvAT09rq5ej37syKC20UXqlPcmpWQcc4IVVTZrTGBDjtc8H8oyd0vyWyNs41JCjepzybzI/Xr5w5Ry7BmY71QYU8TzZOYwpVFz8mGF9HEAW5oYkjj2vHRz0/byaOTPBbTLiipbJkwJ1pF7XghTgvqUDFNaI3QJyEyhypYJU4J15J4XwpSgPiXDlNYIXQIyc+svmyVMqd+J28Zu5/7WH9t1ybwYmntjA0qWbCtpv99PCnQJzrmloDXH0ok5dXs47J6nFcKUcl3Xpz4XmMeKKjBGKx+KoQUtzceWtqVGJfe/Y18PQUcUYUWVzRKmBOvIHaYEtGHpMKVTz53Tx6HHPQ5sOt2/fW+I07mgoqFwLCFHtMyKKlsmTAnWYV4Ap7QUpuScBpkpVNkyYUqwDvMCOKWlMCXnNMjMrb9sljAlWEfuMKVWNRaSBGeNPTeMeWzpMCXXesjPiioAxLDFIlVIS1kl979jDwyyogpHhClBXrnDlM6FlsxpGxt4MhRuQj/BL7FE/GmgWsOUXOshPyuq8JQwJcgr97yY83rmI8QmTAlIKSlU4RRhSpBX7nkx5/XMR4hNmBKQUnLrLzwxNjjh+nr3zr9fXb0WpgQn5A5TuuT1xraZj1BWlDCljz76ML169V5KKfV+HSBX/4DTrKjCsNFhD/cXNIBLtR4u0/r20ZCJ13RjGxZgRRWOHAUiPD9uSwN/XRWmBE/lDlPKFZwUbT5GDLaBKEqFKfWYHOjmWg/TWVGFp0qFvECrhCkBc5UKU5rTF+cWmEGhCk+VCnmBVglTAuYqFaY0py/OLTCDW3/hyJyQl/TotuD7x93e3z5cfcBCwd+NvHVL5Dj7/f5lSun9E//p7D4ceO5sj295exjv59qOw8oeqy1Macl9m5F5RlHn5sm5c8aQx7+PPHRuGfpvj0U5t0DrrKjCdFNCE6J/OK2BfThe374asw/t5+XUsG9r6CNtW2UMXl29nvsSgpNgJQpVGKHrUtd16Ztdl7rDIT0/HFKX7ubPt9KZefT4uUNtS/Z5qfeAEsbOqTltQJtevPgk3dzs3/nnjLfX+sPh7jOAcwasQ6EK46wR/JKbEAdaJUwJWItzBhSiUIVx1gh+yU2IA60SpgSsxTkDChGmBCPkDFi6unqdbm4+GfW+cwKM/GYbtfjoow/Tq1fvjX78+dCSu1CWS0KchoydjwWDx8iscBCWgKsAxgYnAflZUR2n74vzvlDPg9FjYcoHclJK5tlaiu3niXNiTD8FA01nnp1WcixtbRwXG4MDAUvmBbVoslaxojqCv2hyyn2Iwgcppc/vf4Lmnbb0aCU1iuvr3bN037/Hcf1j7HY7oRENm3qeOxr/h6H2c21peK48G3ruqbYW7yYw/2jd3M9aQ3cynJo/l5yrHp/rIJJWaxUrqnC5GgMWovePevSNpdwBRgKRgCU4t0BwClW4XI0BC9H7Rz36xlLuACOBSMASnFsgOIUqXOhwSIfDIX32+FagU22RRO8f9egbS2PnxdixmPv1AFJyboEa+I4qLOc29YRhXF/vsr3J1dXr9OLFqBThqr9Q/0jffg29fYXTQ9dIoh2VUNp1acp+uPSY9s69SoUe240rOZZGHfeJ55ZFx1Lp81yfkee/yCnLveOwZ9sibwuMplCFhTwELD3Wdfn/Ivvq1XubClqp+OIb7sNbZmO3r/dxh0PKMo7njJGxgSxTg1sufR/KquR80zunCoylms9zYfveNw4HziNhtwWmcOsvZNR1qeu69M37lMDetlLvu0Zf2LY5427OOM7dBgCUpVCFvEolBkovJIpSCb+52wCAghSqkFepxEDphURRKuE3dxsAUJDvqEIuwSwPAAANJ0lEQVRG96mAn/W1dcvdWPjm4f/cv8ft/XdkP0vpi4CLm5vL32CFMJ5q2TdfOBrvk4JVzs2fNdr2+/7+jT3Oc8dD5vEkVAWAKllRhXWtld55XBwIVmBpp8b2lHEn2XYZ5j5rqnke19x3aJIVVVjYfUDLBymlzx+SgI/aDpe2pUcrqUPvO2cllTIuSevMkSQ7Z2wet595q2eXvs+SbeYKXK7k6r0kbWiPFVVYXqngFwExXGLuOKwxOMlcAYBgFKqwvFLBLwJiuMTccVhjcJK5AgDBuPUXFrZk8MuZcKa3twVfX+/S1dXr9OLFJ9M3gE25dGzWGJx0qm0oTAmok8A7qJMVVajb6PCHV6/eW7If0EpwUuS+XaK17YHWmKPQw4oqVOZcOFMaCFi6vt69DbC5udn3Pu4UYRTTnQ/umXYM1u7fJWFKPUIGJ51u22UNPOsLmho69uYatMv8hvGsqEJ9BCzVI3pwz1oBRJFCkkqGM0U69gAQmkIV6iNgqR7Rg3vWCiCKFJJUMpwp0rEHgNDc+guVEbBUj+jBPRcGeD0JTToz5kKEJJVuO27Pfez3+31fmNVtyd+2JLaBcQNQnBVVaI+AJZY09UOtoJB19B0XRQhDjI91OR/CBFZUoQFzApZYzvkwpZK9uywcaOj1DofUxQhEitd23F762EOrzoUVDf1UjaAjiMWKKrQhemjPVkU/LoKT1msbagcAjihUoQ3RQ3u2KvpxEZy0XttQOwBwxK2/0ICZAUshtBjq8fj2zvvwnNv73+msIkzpVHDS3Nc81fZw7E/sr1P7cI22d47TnG3ray997NmeFs+xQNusqAJjrBEAsYUPULVt45T+zhkj0fZLtP5ADlsY18KKoCFWVKFRYwNwTrm+3j17eG5fGEzu0JmthMvM2eY5+zB3cFK6+0NnljES8dgLU4K8BBUBU1lRhXbNCW6ZEgazROhMy3Ifl7Gvl/uYLDFGIhGmBAAFKVShXXOCW6aEwSwROtOy3Mdl7OvlPiZLjJFIhCkBQEHd4dD7c1JAI7oumeiLOaSUnt7RdnX1Or148cmT9se3v835Pb++oKPj9/3oow/Tq1fvDb3URQ6HExt9oaH9sEWX3CLptyG3q5b5E2UctjRXWtoWOMWKKmyDgInFnP4s0FMc5jwOJ4NRjt93iSI15R9Pxie0zRwHJhOmBI06CnR5ftyWUnpTsHubcCqUKlf40bI9f2JwO+YHbu2ejM95wVx7Yxt6WGkDamFFFdpVY4BNa9YIP1rD3MCgUm0AQKUUqtCuGgNsWrNG+NEa5gYGlWoDACrl1l8IaL/fnwzKmeLxbaX7/dO26+vdnJdnnLe3oHZ3N9vd3t+G/VlKXxyXU+5vaf3s/rmTxkPuY/u4L0NtUx67ZNvQfiWvHOeqldzudne3mEez9j4sFL4Udv8DcVlRhZgW/9BydfV6wqOrCJWswaXHtWQhUGMISo19rlUNRWpKsfsZuW+5bGEbgcysqMJGnfrplCWdCxaque24PQ0EVV0SpjS4Yx8FHQ29bxoZiJRzP5Q7LnnDmaYGNo0Jq6nlJ0UAoBQrqsBaIoXsLBHaMyckqdTj1toPLbcBAAtQqAJriRSys0Roz5yQpFKPW2s/tNwGACzArb/AKh7fKnkq3Om+7fb+ts2iYTxT247bu+EbP9/uh+vrXbq6ev3ObdgfffRhevXqvXceN2Ts+5bYDy23CWzahoGgI+FAAAuzogoxbTUMppXAjdHH774o7f33ie/T975bHU8sr5axdWk/+85JOc9VtezDObawjUBmVlQhoDl/qRfSUsZR+M6TMJ80coX0jHOBSIuHCNURprR821AQ1pZYVZxviX04dB0YE/YFEIEVVYA81gjfiRQiJEwJAFiMQhUgjzXCdyKFCAlTAgAW49ZfgAzOhe8MBR1dX++yvEfptmj9EaYEAPWyogpE0nLgxtxta3nfbJHgKwAYYEUVNkSIxrpmBiydC05aJDRLmNI6YUpCiABgmBVVgOXMCeQpFeYjTGlaGwCwAIUqwHLmBPKUCvMRpjStDQBYgFt/gbP2+/3LlPcH7ue6reHWyTkBS1MCjHISpjSuTZgSSxs471Zx/nsQ8PrRa8bvkFd1TKAWVlSBMaJ9yIjWn0sJ1AH69J3najv/1dbfS2xhG2F1ClWAFXVd6roufbPrUnc4pOeHQ+rS3bn4WymlZ4fDXfvjxxXucjrVl77+jX1sK20AwDIUqgDrqjG4R5hSfxsAsACFKsC6agzuEabU3wYALECYEqyodKjEjKCITVnyOD3+/c2HQJ6xbTPMCvq4NEzpYT+e2Jbbw2H3fMxrRm0TpgQAy7KiCusSuJDH0mFDrR2nUtvTShgMcLkthMOV2kaBfDTNiiqwit1uJ4CmIfeBQh+klD6/X2l80v54JXXM82tqG9o24At+tmU59i2ts6IKwCWmhCmNfX6NbQDAAhSqAFxiSpjS2OfX2AYALMCtvwBMNi5Madrza2qbG6a0ZrBaoRC1WQFeAGBFFda11YCD2ra7tv6e09r2tKD1QKnWtw+AhVlRhRWtscIwtHoi0GgcK0HLixCIJEwJAOKyogpACZECkYQpAUAwClUASogUiCRMCQCCcesvAKs7H1Z0Fzb0+Bbb+wCj28Nh93zouWu0zQ1TIp6pAVeFQqpmv3fJfh8RuAUMsqIKQER9BYOQnjrUGOBlbK3L/gYGWVEFYHVzwoqEKS1D2BoAkVhRBaCEOWFFwpQAoHEKVQBKmBNWJEwJABrn1l+gSVODUSrQVPDInLCikaFLack2YB2Fz+VNnXehNlZUgVa1VKSm1N725FLrfokWNhStPyXYB+sau79LzvFazy/QBCuqAFSlhVCj3Ks0Qz85IiRpnFPHpOR+dUyBrbOiCkBthBoBQOMUqgDURqgRADROoQpAVQ6HdDgc0mcPv28KALTHd1SBsBpM7p1D0MtptynWGHGc2tI3vhzn9ZSc444zFKRQBSKLVID0EmxSjp+OYEnGV3mOAWyXW38BAAAIRaEKAABAKApVAAAAQvEdVQCgOsLWANpmRRWIrIbExRr6SPv6xmHL41ORCtAwK6pAWNIeYRxzBYDWWFEFAAAgFIUqAAAAoShUAQAACEWhCu3ZYqgKsD3OaQANE6YEjRGqAmxB6+e6/X5/KN0HgJKsqAIAABCKQhUAAIBQ3PoLAAP2+/3LlNL7pftx5Lb1W18B2DYrqgAwLFqRmlLMPgFANgpVAAAAQlGoAgAAEIpCFQAAgFCEKQHZBA2dyUmADQCTVX59dO2jCCuqQE61XoTHan37crud2B5VxP5G7BN5tTJ/uFPz9aPmvlMxK6oALKKVv8C3sh3UxbgDts6KKgAAAKEoVAEAAAhFoQoAAEAoClUgp9ZDPlrfvkgEyQAtqfncVXPfqVh3OBxK9wEAAADesqIKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACAUhSoAAAChKFQBAAAIRaEKAABAKApVAAAAQlGoAgAAEIpCFQAAgFAUqgAAAISiUAUAACCU/w8XqOddMO5L7QAAAABJRU5ErkJggg==\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " (b) Weighted (2) A* search search: 162.8 path cost, 782 states reached\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Greedy best-first search search: 164.5 path cost, 448 states reached\n" - ] - } - ], - "source": [ - "plots(d3)" - ] - }, - { - "cell_type": "code", - "execution_count": 47, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "data": { - "image/png": "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AABAKIUoAAAAoRSiAAAAhFKIAgAAEEohCgAAQCiFKAAAAKEUogAAAIRSiAIAABBKIQoAAEAohSgAAAChFKIAAACEUogCAAAQSiEKAABAKIUoAAAAoRSiAAAAhFKIAgAAEEohCgAAQCiFKAAAAKEUogAAAIRSiAIAABBKIQoAAEAohSgAAAChFKIAAACEUogCAAAQSiEKAABAKIUoAAAAoRSiAAAAhFKIAgAAEEohCgAAQCiFKAAAAKEUogAAAIRSiAIAABBKIQoAAEAohSgAAAChFKIAAACEUogCAAAQSiEKAABAKIUoAAAAoRSiAAAAhFKIAgAAEEohCgAAQCiFKAAAAKEUogAAAIRSiAIAABBKIQoAAEAohSgAAAChFKIAAACEUogCAAAQSiEKAABAKIUoAAAAoRSiAAAAhFKIAgAAEEohCgAAQCiFKAAAAKEUogAAAIRSiAIAABBKIQoAAEAohSgAAAChFKIAAACEUogCAAAQSiEKAABAKIUoAAAAoRSiAAAAhFKIAgAAEEohCgAAQCiFKAAAAKEUogAAAIRSiAIAABBKIQoAAEAohSgAAAChFKIAAACEUogCAAAQSiEKAABAKIUoAAAAoRSiAAAAhFKIAgAAEEohCgAAQCiFKAAAAKEUogAAAIRSiAIAABBKIQoAAEAohSgAAAChFKIAAACEUogCAAAQSiEKAABAKIUoAAAAoRSiAAAAhFKIAgAAEEohCgAAQCiFKAAAAKEUogAAAIRSiAIAABBKIQoAAEAohSgAAAChFKIAAACEUogCAAAQSiEKAABAKIUoAAAAoRSiAAAAhFKIAgAAEEohCgAAQCiFKAAAAKEUogAAAIRSiAIAABBKIQoAAEAohSgAAAChFKIAAACEUogCAAAQSiEKAABAKIUoAAAAoRSiAAAAhFKIAgAAEOr/A27LKJSZmrwOAAAAAElFTkSuQmCC\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " A* search search: 133.0 path cost, 2,196 states reached\n" - ] - }, - { - "data": { - "image/png": "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AAilEAUAACCUQhQAAIBQClEAAABCKUQBAAAIpRAFAAAglEIUAACAUApRAAAAQilEAQAACKUQBQAAIJRCFAAAgFAKUQAAAEIpRAEAAAilEAUAACCUQhQAAIBQClEAAABCKUQBAAAIpRAFAAAglEIUAACAUApRAAAAQilEAQAACKUQBQAAIJRCFAAAgFAKUQAAAEIpRAEAAAilEAUAACCUQhQAAIBQClEAAABCKUQBAAAIpRAFAAAglEIUAACAUApRAAAAQilEAQAACKUQBQAAIJRCFAAAgFAKUQAAAEIpRAEAAAilEAUAACCUQhQAAIBQClEAAABCKUQBAAAIpRAFAAAglEIUAACAUApRAAAAQilEAQAACKUQBQAAIJRCFAAAgFAKUQAAAEIpRAEAAAilEAUAACCUQhQAAIBQClEAAABCKUQBAAAIpRAFAAAglEIUAACAUApRAAAAQilEAQAACKUQBQAAIJRCFAAAgFAKUQAAAEIpRAEAAAilEAUAACCUQhQAAIBQClEAAABCKUQBAAAIpRAFAAAglEIUAACAUApRAAAAQilEAQAACKUQBQAAIJRCFAAAgFAKUQAAAEIpRAEAAAilEAUAACCUQhQAAIBQClEAAABCKUQBAAAIpRAFAAAglEIUAACAUApRAAAAQilEAQAACKUQBQAAIJRCFAAAgFAKUQAAAEIpRAEAAAilEAUAACCUQhQAAIBQClEAAABCKUQBAAAIpRAFAAAglEIUAACAUApRAAAAQilEAQAACKUQBQAAIJRCFAAAgFAKUQAAAEIpRAEAAAilEAUAACDU/weM6W3qaeGRAwAAAABJRU5ErkJggg==\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " (b) Weighted (1.4) A* search search: 133.0 path cost, 440 states reached\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " (b) Weighted (2) A* search search: 134.2 path cost, 418 states reached\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Greedy best-first search search: 153.0 path cost, 502 states reached\n" - ] - } - ], - "source": [ - "plots(d4)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# The cost of weighted A* search\n", - "\n", - "Now I want to try a much simpler grid problem, `d6`, with only a few obstacles. We see that A* finds the optimal path, skirting below the obstacles. Weighterd A* with a weight of 1.4 finds the same optimal path while exploring only 1/3 the number of states. But weighted A* with weight 2 takes the slightly longer path above the obstacles, because that path allowed it to stay closer to the goal in straight-line distance, which it over-weights. And greedy best-first search has a bad showing, not deviating from its path towards the goal until it is almost inside the cup made by the obstacles." - ] - }, - { - "cell_type": "code", - "execution_count": 48, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "data": { - "image/png": "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KYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAADYcZriAAABrElEQVQAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKYIqAAAAKf8PLmrpHsltQLwAAAAASUVORK5CYII=\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " A* search search: 124.1 path cost, 3,305 states reached\n" - ] - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA6oAAAJCCAYAAADJHDpFAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADl0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uIDIuMi4zLCBodHRwOi8vbWF0cGxvdGxpYi5vcmcvIxREBQAAGO9JREFUeJzt3T+PXNd9x+HfHSuCo0BrqTKQKnGTwhEoNq4cyG8gMAIDq0KQXTnRq7Cod6E4SOFChQkEAeI+kGBXaSIlDuAudZpIJiHFUGDeFLtkqOXu8s7u3HO/58zzNIKPaJ3D+UPNR3Pvb6d5ngsAAABS7LY+AAAAADxNqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABBFqAIAABDlha0PAACwlvv37z+oqpe3PkdnHp6enp5sfQjguPlGFQAYmUjdn8cM2JxQBQCGMk01TVO9Pk01bX0WAG5GqAIAo7lTVf9w/lcAOiRUAYDRfFJVPzj/KwAdEqoAwFDmueZ5ro/nueatzwLAzQhVAGBkD7c+AAD78+NpAIAunA9HulNVnzz+tvT5a2c/ZuVm/99t19be5+c/v//oNs8HwJp8owoA9OKyIUkjr7XcByDKNM9u3wAA8iV92zn6N6qnp6d+tA+wKaEKAHCE7t+/f+WHQKEKbM2lvwAAAEQRqgDApqappmmq188vS7XW+LEBSCRUAYCtJQ0wSlpruQ9AFPeoAgCbShpglLS29j6G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AEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAEQRqgAAAET5Pzxfqg2F7ogAAAAAAElFTkSuQmCC\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " (b) Weighted (1.4) A* search search: 124.1 path cost, 975 states reached\n" - ] - }, - { - "data": { - "image/png": "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hCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABRhCoAAABR/g/qIHhYi9KlRgAAAABJRU5ErkJggg==\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " (b) Weighted (2) A* search search: 128.6 path cost, 879 states reached\n" - ] - }, - { - "data": { - "image/png": "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FKEKADQhaejImBLAsIQqANCKpKEjY0oAAxKqAEArkoaOjCkBDEioAgBNSBo6MqYEMCyhCgAAQBShCgA0IWnoyJgSwLCEKgDQiqShI2NKAAMSqgBAK5KGjowpAQxIqAIATUgaOjKmBDAsoQoAAEAUoQoANCFp6MiYEsCwhCoA0IqkoSNjSgADEqoAQCuSho6MKQEMqOt776W/zmKx2HiT5vO5l84AwBtYLBYnVfXOoa+D1/n+Btox1VbxjCoAcCgiFYC1hCoAAABRhCoAAABRhCoAAABRhCoAAABRhOp2Tnc8DgBcz/+PZvJ5gbZMslX8eBoAAACieEYVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKEIVAACAKH8DxgVAk+apKx8AAAAASUVORK5CYII=\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Greedy best-first search search: 133.9 path cost, 758 states reached\n" - ] - } - ], - "source": [ - "plots(d6)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In the next problem, `d7`, we see a similar story. the optimal path found by A*, and we see that again weighted A* with weight 1.4 does great and with weight 2 ends up erroneously going below the first two barriers, and then makes another mistake by reversing direction back towards the goal and passing above the third barrier. Again, greedy best-first makes bad decisions all around." - ] - }, - { - "cell_type": "code", - "execution_count": 49, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "data": { - "image/png": "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4rm0et20mFvzOD1Lek3TrpEn+V79un5SX/zw+/U//tNP6osf1jD842t/TzjlGdUJer3vO9nxu3P3teVSgj97/mX92Rc/raofVdU/eqe831XVz+oXn39Wv3i+eo0RvQmoJyn31F9fN16ctPUCoqX73dMypZbPJeX9s4fc0sfpcZnSOUmvaco1UvOXLr31z+vv6r/Vv6zv1m9fva67u7t6/Xqo+vuq+tMax7976u/L0/U6qxhUJ+j1xedxb3+S+vtv/+v66Ov3v+D33676nz+q+s//rsodLyzgKYuTzunp88u5wG25DvdreOLSpT8Oqc/qt9+4LfN1Vb2qZ/Xd+ur7htX19fpedusvnHg7pFb96OyQWlX10ddV3/9Z1b/6N1U2snN7FicBsKTZf858r359dkitejNQfKe+qqr6r24D5lYMqkTaainB28VJr+9+XN+83fd9hlVu56aLk87lzlniGGu8T9c4l7V60+K5tHrdtJhb8zhkvfZLXyNTly69m/u39Vd//t367avHhodj/h9U1T+50GaYzKBKqq2WEvygqv6y7l7/w0lVfvR11af/oeqf/XLSl8Mj1ri21zhuT+eyVm9aPJdWr5sWc2seh6zXPu0auf/b+vO/el13ZgdW4xnVCXq97zvZ8TuA97X2ooI//kR1vPvxpGHVs6rcxmLLcyxTysj1dC5L92brepJySx9nL8uUpkp67VOukXdzP6kvfvi8nv/H4fK/ofqqqv5FjeP/ntN7rtPrrGJQnaDXF5/zvvGM6qXbfw2p3MitFied09Pnl3OB23IdMsubZ0//z+uq7537serrqrqr+k1V/UmN46/XLW7fen0v+/E9nBg/H8eq+qyqfla///b5LzKkcjsWJwGQ783w+aev6tl7/7bNH7f+1pt/osaQyk0YVIm09VKCej7e1y8+/6w++vqv682/m/qu39VHX/91vfqnd1XDpAUEPeTS6knKXfnrb7446bFr+1TKUo/Ec1mrNy2eS6vXTYu5NY/DeUnXQ8T7p8b/9d366vt3Vb8Zq179fd39bqx6dVf1G/80DbdmUCXV9ksJfvH8vv74k9XXd/+vqur4359V1WfH/5+yTKGrhQ0N5tLqeazGU632YY1zWas3LZ5Lq9dNi7k1j8N5SddDxvvnzTD6J8/r+V98Wv/9/z6v539Rb273NaRyU55RnaDX+76THb+Ld18BSwneLliq+suq+vc11Gfj5+O4VY1JvZHL741lShm5ns5lT++frXNLH8cypQ9Luh5aeP+wjV5nFYPqBL2++Ex3XLD0g6r65fEZVmhCT59fzgVuy3UIfej1vfytrQuAFhyH019sXQcAAOyBZ1SJtOpigC2XEjSUS6snKZdWz2M1nmq1D2ucy1q9afFcWr1uWsyteRyANAZVUu1nKUE7ubR6knJp9TxW46lW+7DGuazVmxbPpdXrpsXcmscBiOIZ1Ql6ve872fE7vfe186UESbm0epJyafW8m7NMKSPX07ns6f2zdW7p41imBH3odVYxqE7Q64sP9K+nzy/nArflOoQ+9PpedusvAAAAUQyqNKOnxRUt5tLqScql1fNYjada7cMa57JWb1o8l1avmxZzax4HII1BlZb0tLiixVxaPUm5tHoeq/FUq31Y41zW6k2L59LqddNibs3jAETxjOoEvd733Zrjd3/vq+HFFS3n0upJyqXV827OMqWMXE/nsqf3z9a5pY9jmRL0oddZxaA6Qa8vPtC/nj6/nAvclusQ+tDre9mtvwAAAEQxqNKMnhZXtJhLqycpl1bPYzWearUPa5zLWr1p8VxavW5azK15HIA0BlVa0tPiihZzafUk5dLqeazGU632YY1zWas3LZ5Lq9dNi7k1jwMQxTOqE/R633drjt/9va+GF1e0nEurJymXVs+7OcuUMnI9ncue3j9b55Y+jmVK0IdeZxWD6gS9vvhA/3r6/HIucFuuQ+hDr+9lt/4C9O3VzDwAwOYMqjSjp8UVLebS6knKpdXzbu5wODw7HA7Dw8Ph7uHh8OnDw+HucDgMh8PhWat9OCeh10/pTYvn0up102JuzeMApDGo0pKeFle0mEurJymXVk/vfTgnqQ9zetPiubR63bSYW/M4AFE8ozpBr/d9t+b43d/7anhxRcu5tHqScmn19NqHlhcQ9XQurV03LeeWPo5lStCHXmcVg+oEvb74AC3p6bO4p3OhXa5D6EOv72W3/gIAABDFoEozelpc0WIurZ6kXFo9vffhnKQ+zOlNi+fS6nXTYm7N4wCkMajSkp4WV7SYS6snKZdWT+99OCepD3N60+K5tHrdtJhb8zgAUTyjOkGv93235vjd3/tqeHFFy7m0epJyafX02oeWFxD1dC6tXTct55Y+jmVK0IdeZxWD6gS9vvgALenp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AACAKAZVAAAAohhUAQAAiGJQBQAAIIpBFQAAgCgGVQAAAKIYVAEAAIhiUAUAACCKQRUAAIAoBlUAAACiGFQBAACIYlAFAAAgikEVAACAKAZVAAAAohhUAQAAiGJQBQAAIIpBFQAAgCgGVQAAAKIYVAEAAIhiUAUAACCKQRUAAIAoBlUAAACiGFQBAACIYlAFAAAgikEVAACAKAZVAAAAohhUAQAAiGJQBQAAIIpBFQAAgCgGVQAAAKIYVAEAAIhiUAUAACCKQRUAAIAoBlUAAACiGFQBAACIYlAFAAAgikEVAACAKAZVAAAAohhUAQAAiGJQBQAAIIpBFQAAgCgGVQAAAKIYVAEAAIhiUAUAACCKQRUAAIAoBlUAAACiGFQBAACIYlAFAAAgikEVAACAKAZVAAAAohhUAQAAiGJQBQAAIIpBFQAAgCgGVQAAAKIYVAEAAIhiUAUAACCKQRUAAIAoBlUAAACiGFQBAACIYlAFAAAgikEVAACAKAZVAAAAohhUAQAAiGJQBQAAIIpBFQAAgCgGVQAAAKIYVAEAAIhiUAUAACCKQRUAAIAoBlUAAACiGFQBAACIYlAFAAAgikEVAACAKAZVAAAAohhUAQAAiGJQBQAAIIpBFQAAgCgGVQAAAKIYVAEAAIhiUAUAACDK/wcquXLwuqOEZgAAAABJRU5ErkJggg==\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " A* search search: 127.4 path cost, 4,058 states reached\n" - ] - }, - { - "data": { - "image/png": "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iCKoAgAAEEVQBQAAIIqgCgAAQBRBFQAAgCiCKgAAAFEEVQAAAKIIqgAAAEQRVAEAAIgiqAIAABBFUAUAACCKoAoAAEAUQRUAAIAogioAAABRBFUAAACiCKoAAABEEVQBAACIIqgCAAAQRVAFAAAgiqAKAABAFEEVAACAKIIqAAAAUQRVAAAAogiqAAAARBFUAQAAiCKoAgAAEEVQBQAAIIqgCgAAQBRBFQAAgCiCKgAAAFEEVQAAAKIIqgAAAEQRVAEAAIgiqAIAABBFUAUAACCKoAoAAEAUQRUAAIAogioAAABRBFUAAACiCKoAAABEEVQBAACIIqgCAAAQRVAFAAAgiqAKAABAFEEVAACAKIIqAAAAUQRVAAAAogiqAAAARBFUAQAAiCKoAgAAEEVQBQAAIIqgCgAAQBRBFQAAgCiCKgAAAFEEVQAAAKIIqgAAAEQRVAEAAIgiqAIAABBFUAUAACCKoAoAAEAUQRUAAIAogioAAABRBFUAAACiCKoAAABEEVQBAACIIqgCAAAQRVAFAAAgiqAKAABAFEEVAACAKIIqAAAAUQRVAAAAogiqAAAARBFUAQAAiCKoAgAAEEVQBQAAIIqgCgAAQBRBFQAAgCiCKgAAAFEEVQAAAKIIqgAAAEQRVAEAAIgiqAIAABBFUAUAACCKoAoAAEAUQRUAAIAogioAAABRBFUAAACiCKoAAABEEVQBAACIIqgCAAAQRVAFAAAgiqAKAABAFEEVAACAKIIqAAAAUQRVAAAAogiqAAAARBFUAQAAiCKoAgAAEEVQBQAAIIqgCgAAQBRBFQAAgCiCKgAAAFEEVQAAAKIIqgAAAEQRVAEAAIgiqAIAABBFUAUAACCKoAoAAEAUQRUAAIAogioAAABRBFUAAACi/H9tufMPU4+G/wAAAABJRU5ErkJggg==\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " (b) Weighted (1.4) A* search search: 127.4 path cost, 1,289 states reached\n" - ] - }, - { - "data": { - "image/png": "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gioAAABRBFUAAACiCKoAAABEEVQBAACIIqgCAAAQRVAFAAAgiqAKAABAFEEVAACAKIIqAAAAUQRVAAAAogiqAAAARBFUAQAAiCKoAgAAEEVQBQAAIIqgCgAAQBRBFQAAgCiCKgAAAFEEVQAAAKIIqgAAAEQRVAEAAIgiqAIAABBFUAUAACCKoAoAAEAUQRUAAIAogioAAABRBFUAAACiCKoAAABEEVQBAACIIqgCAAAQRVAFAAAgiqAKAABAFEEVAACAKIIqAAAAUQRVAAAAogiqAAAARBFUAQAAiCKoAgAAEEVQBQAAIIqgCgAAQBRBFQAAgCiCKgAAAFEEVQAAAKIIqgAAAEQRVAEAAIgiqAIAABBFUAUAACCKoAoAAEAUQRUAAIAogioAAABRBFUAAACiCKoAAABEEVQBAACIIqgCAAAQRVAFAAAgiqAKAABAFEEVAACAKIIqAAAAUQRVAAAAogiqAAAARBFUAQAAiCKoAgAAEEVQBQAAIIqgCgAAQBRBFQAAgCiCKgAAAFEEVQAAAKIIqgAAAEQRVAEAAIgiqAIAABBFUAUAACCKoAoAAEAUQRUAAIAogioAAABRBFUAAACiCKoAAABEEVQBAACIIqgCAAAQRVAFAAAgiqAKAABAFEEVAACAKIIqAAAAUQRVAAAAogiqAAAARBFUAQAAiCKoAgAAEEVQBQAAIIqgCgAAQBRBFQAAgCiCKgAAAFEEVQAAAKIIqgAAAEQRVAEAAIgiqAIAABBFUAUAACCKoAoAAEAUQRUAAIAogioAAABRBFUAAACiCKoAAABEEVQBAACIIqgCAAAQRVAFAAAgiqAKAABAFEEVAACAKIIqAAAAUQRVAAAAogiqAAAARBFUAQAAiCKoAgAAEEVQBQAAIIqgCgAAQBRBFQAAgCiCKgAAAFEEVQAAAKIIqgAAAEQRVAEAAIgiqAIAABBFUAUAACCKoAoAAEAUQRUAAIAogioAAABRBFUAAACiCKoAAABEEVQBAACIIqgCAAAQRVAFAAAgiqAKAABAlP8DscRvdl+TaAMAAAAASUVORK5CYII=\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " (b) Weighted (2) A* search search: 140.4 path cost, 982 states reached\n" - ] - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA6oAAAJCCAYAAADJHDpFAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADl0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uIDIuMi4zLCBodHRwOi8vbWF0cGxvdGxpYi5vcmcvIxREBQAAG55JREFUeJzt3c+OZHd9xuHvaYw1MfIYVpayilhFCpbxnghxASyIpZ6FE7xIIL4HFh4vuAcCYmFFjjSNIha5ARDsPRZEyjJbbwLMyI5lizlZdM2kpqeq+9TU+fP+Tj2PZBmOq/ucru5q+zPV9XbX930BAABAirOlLwAAAAC2CVUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAAACiCFUAYFW6rrquq292XXUtH0u8HoC5CFUAYG1er6p/3/y95WOJ1wMwi67v+6WvAQBgNJtnAV+vqo/6vvpWjyVeD8BchCoAsFpdVw+q6uWlr6MxD/u+bi99EcBpE6oAwGp1nWcDn0ffe20qsCyvUQUAmmUIaBruV2BpQhUAaJkhoGm4X4FF+dFfAKBZNw0BVdWjBS+vZWdlYAlYkFAFAFbBcNKkDCwBsxKqAMAqrGs4qa8KeymogSVgTi8sfQEAAM/ryo/57nXv3sU8F9S4O3fO9/4zv28VmJMxJQCgZQZ+5mNgCZiNUAUAWvZRVb1ZNzyjyih23dfuf2ASfvQXAGjW5sdN71dVdV5BOant+/q6YwBj8IwqALByXjo51K1bX+z9Z11X/ZW/Hsx4acCJsfoLAKzCdau/FmufdXFxsff+Oj8/f3J/uV+BJXhGFQBoVtdV13X1zc367EG3Sz8253mG3F9Dbzf0bQGuI1QB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AgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEEWoAgAAEOX/AIS6zUzgLYR1AAAAAElFTkSuQmCC\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Greedy best-first search search: 151.6 path cost, 826 states reached\n" - ] - } - ], - "source": [ - "plots(d7)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Nondeterministic Actions\n", - "\n", - "To handle problems with nondeterministic problems, we'll replace the `result` method with `results`, which returns a collection of possible result states. We'll represent the solution to a problem not with a `Node`, but with a plan that consist of two types of component: sequences of actions, like `['forward', 'suck']`, and condition actions, like\n", - "`{5: ['forward', 'suck'], 7: []}`, which says that if we end up in state 5, then do `['forward', 'suck']`, but if we end up in state 7, then do the empty sequence of actions." - ] - }, - { - "cell_type": "code", - "execution_count": 50, - "metadata": {}, - "outputs": [], - "source": [ - "def and_or_search(problem):\n", - " \"Find a plan for a problem that has nondterministic actions.\"\n", - " return or_search(problem, problem.initial, [])\n", - " \n", - "def or_search(problem, state, path):\n", - " \"Find a sequence of actions to reach goal from state, without repeating states on path.\"\n", - " if problem.is_goal(state): return []\n", - " if state in path: return failure # check for loops\n", - " for action in problem.actions(state):\n", - " plan = and_search(problem, problem.results(state, action), [state] + path)\n", - " if plan != failure:\n", - " return [action] + plan\n", - " return failure\n", - "\n", - "def and_search(problem, states, path):\n", - " \"Plan for each of the possible states we might end up in.\"\n", - " if len(states) == 1: \n", - " return or_search(problem, next(iter(states)), path)\n", - " plan = {}\n", - " for s in states:\n", - " plan[s] = or_search(problem, s, path)\n", - " if plan[s] == failure: return failure\n", - " return [plan]" - ] - }, - { - "cell_type": "code", - "execution_count": 51, - "metadata": {}, - "outputs": [], - "source": [ - "class MultiGoalProblem(Problem):\n", - " \"\"\"A version of `Problem` with a colllection of `goals` instead of one `goal`.\"\"\"\n", - " \n", - " def __init__(self, initial=None, goals=(), **kwds): \n", - " self.__dict__.update(initial=initial, goals=goals, **kwds)\n", - " \n", - " def is_goal(self, state): return state in self.goals\n", - " \n", - "class ErraticVacuum(MultiGoalProblem):\n", - " \"\"\"In this 2-location vacuum problem, the suck action in a dirty square will either clean up that square,\n", - " or clean up both squares. A suck action in a clean square will either do nothing, or\n", - " will deposit dirt in that square. Forward and backward actions are deterministic.\"\"\"\n", - " \n", - " def actions(self, state): \n", - " return ['suck', 'forward', 'backward']\n", - " \n", - " def results(self, state, action): return self.table[action][state]\n", - " \n", - " table = {'suck':{1:{5,7}, 2:{4,8}, 3:{7}, 4:{2,4}, 5:{1,5}, 6:{8}, 7:{3,7}, 8:{6,8}},\n", - " 'forward': {1:{2}, 2:{2}, 3:{4}, 4:{4}, 5:{6}, 6:{6}, 7:{8}, 8:{8}},\n", - " 'backward': {1:{1}, 2:{1}, 3:{3}, 4:{3}, 5:{5}, 6:{5}, 7:{7}, 8:{7}}}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's find a plan to get from state 1 to the goal of no dirt (states 7 or 8):" - ] - }, - { - "cell_type": "code", - "execution_count": 52, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['suck', {5: ['forward', 'suck'], 7: []}]" - ] - }, - "execution_count": 52, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "and_or_search(ErraticVacuum(1, {7, 8}))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This plan says \"First suck, and if we end up in state 5, go forward and suck again; if we end up in state 7, do nothing because that is a goal.\"\n", - "\n", - "Here are the plans to get to a goal state starting from any one of the 8 states:" - ] - }, - { - "cell_type": "code", - "execution_count": 53, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{1: ['suck', {5: ['forward', 'suck'], 7: []}],\n", - " 2: ['suck', {8: [], 4: ['backward', 'suck']}],\n", - " 3: ['suck'],\n", - " 4: ['backward', 'suck'],\n", - " 5: ['forward', 'suck'],\n", - " 6: ['suck'],\n", - " 7: [],\n", - " 8: []}" - ] - }, - "execution_count": 53, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "{s: and_or_search(ErraticVacuum(s, {7,8})) \n", - " for s in range(1, 9)}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Comparing Algorithms on EightPuzzle Problems of Different Lengths" - ] - }, - { - "cell_type": "code", - "execution_count": 54, - "metadata": {}, - "outputs": [], - "source": [ - "from functools import lru_cache\n", - "\n", - "def build_table(table, depth, state, problem):\n", - " if depth > 0 and state not in table:\n", - " problem.initial = state\n", - " table[state] = len(astar_search(problem))\n", - " for a in problem.actions(state):\n", - " build_table(table, depth - 1, problem.result(state, a), problem)\n", - " return table\n", - "\n", - "def invert_table(table):\n", - " result = defaultdict(list)\n", - " for key, val in table.items():\n", - " result[val].append(key)\n", - " return result\n", - "\n", - "goal = (0, 1, 2, 3, 4, 5, 6, 7, 8)\n", - "table8 = invert_table(build_table({}, 25, goal, EightPuzzle(goal)))" - ] - }, - { - "cell_type": "code", - "execution_count": 78, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "data": { - "text/plain": [ - "2.6724" - ] - }, - "execution_count": 78, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "def report8(table8, M, Ds=range(2, 25, 2), searchers=(breadth_first_search, astar_misplaced_tiles, astar_search)):\n", - " \"Make a table of average nodes generated and effective branching factor\"\n", - " for d in Ds:\n", - " line = [d]\n", - " N = min(M, len(table8[d]))\n", - " states = random.sample(table8[d], N)\n", - " for searcher in searchers:\n", - " nodes = 0\n", - " for s in states:\n", - " problem = CountCalls(EightPuzzle(s))\n", - " searcher(problem)\n", - " nodes += problem._counts['result']\n", - " nodes = int(round(nodes/N))\n", - " line.append(nodes)\n", - " line.extend([ebf(d, n) for n in line[1:]])\n", - " print('{:2} & {:6} & {:5} & {:5} && {:.2f} & {:.2f} & {:.2f}'\n", - " .format(*line))\n", - "\n", - " \n", - "def ebf(d, N, possible_bs=[b/100 for b in range(100, 300)]):\n", - " \"Effective Branching Factor\"\n", - " return min(possible_bs, key=lambda b: abs(N - sum(b**i for i in range(1, d+1))))\n", - "\n", - "def edepth_reduction(d, N, b=2.67):\n", - " \n", - " \n", - "\n", - "from statistics import mean \n", - "\n", - "def random_state():\n", - " x = list(range(9))\n", - " random.shuffle(x)\n", - " return tuple(x)\n", - "\n", - "meanbf = mean(len(e3.actions(random_state())) for _ in range(10000))\n", - "meanbf" - ] - }, - { - "cell_type": "code", - "execution_count": 72, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: 1,\n", - " 1: 2,\n", - " 2: 4,\n", - " 3: 8,\n", - " 4: 16,\n", - " 5: 20,\n", - " 6: 36,\n", - " 7: 60,\n", - " 8: 87,\n", - " 9: 123,\n", - " 10: 175,\n", - " 11: 280,\n", - " 12: 397,\n", - " 13: 656,\n", - " 14: 898,\n", - " 15: 1452,\n", - " 16: 1670,\n", - " 17: 2677,\n", - " 18: 2699,\n", - " 19: 4015,\n", - " 20: 3472,\n", - " 21: 4672,\n", - " 22: 3311,\n", - " 23: 3898,\n", - " 24: 1945,\n", - " 25: 1796,\n", - " 26: 621,\n", - " 27: 368,\n", - " 28: 63,\n", - " 29: 19,\n", - " 30: 0}" - ] - }, - "execution_count": 72, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "{n: len(v) for (n, v) in table30.items()}" - ] - }, - { - "cell_type": "code", - "execution_count": 67, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CPU times: user 24min 7s, sys: 11.6 s, total: 24min 19s\n", - "Wall time: 24min 44s\n" - ] - } - ], - "source": [ - "%time table30 = invert_table(build_table({}, 30, goal, EightPuzzle(goal)))" - ] - }, - { - "cell_type": "code", - "execution_count": 68, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 2 & 5 & 6 & 6 && 1.79 & 2.00 & 2.00\n", - " 4 & 33 & 12 & 12 && 2.06 & 1.49 & 1.49\n", - " 6 & 128 & 24 & 19 && 2.01 & 1.42 & 1.34\n", - " 8 & 368 & 48 & 31 && 1.91 & 1.40 & 1.30\n", - "10 & 1033 & 116 & 48 && 1.85 & 1.43 & 1.27\n", - "12 & 2672 & 279 & 84 && 1.80 & 1.45 & 1.28\n", - "14 & 6783 & 678 & 174 && 1.77 & 1.47 & 1.31\n", - "16 & 17270 & 1683 & 364 && 1.74 & 1.48 & 1.32\n", - "18 & 41558 & 4102 & 751 && 1.72 & 1.49 & 1.34\n", - "20 & 91493 & 9905 & 1318 && 1.69 & 1.50 & 1.34\n", - "22 & 175921 & 22955 & 2548 && 1.66 & 1.50 & 1.34\n", - "24 & 290082 & 53039 & 5733 && 1.62 & 1.50 & 1.36\n", - "CPU times: user 6min, sys: 3.63 s, total: 6min 4s\n", - "Wall time: 6min 13s\n" - ] - } - ], - "source": [ - "%time report8(table30, 20, range(26, 31, 2))" - ] - }, - { - "cell_type": "code", - "execution_count": 70, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "26 & 395355 & 110372 & 10080 && 1.58 & 1.50 & 1.35\n", - "28 & 463234 & 202565 & 22055 && 1.53 & 1.49 & 1.36\n" - ] - }, - { - "ename": "ZeroDivisionError", - "evalue": "division by zero", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mZeroDivisionError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n", - "\u001b[0;32m\u001b[0m in \u001b[0;36mreport8\u001b[0;34m(table8, M, Ds, searchers)\u001b[0m\n\u001b[1;32m 11\u001b[0m \u001b[0msearcher\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mproblem\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 12\u001b[0m \u001b[0mnodes\u001b[0m \u001b[0;34m+=\u001b[0m \u001b[0mproblem\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_counts\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m'result'\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 13\u001b[0;31m \u001b[0mnodes\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mround\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mnodes\u001b[0m\u001b[0;34m/\u001b[0m\u001b[0mN\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 14\u001b[0m \u001b[0mline\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mappend\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mnodes\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 15\u001b[0m \u001b[0mline\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mextend\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mebf\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0md\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mn\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mn\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mline\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;36m1\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mZeroDivisionError\u001b[0m: division by zero" - ] - } - ], - "source": [ - "%time report8(table30, 20, range(26, 31, 2))" - ] - }, - { - "cell_type": "code", - "execution_count": 315, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0 116 116 ['A']\n", - "140 0 140 ['A', 'S']\n", - "0 83 83 ['A']\n", - "118 0 118 ['A', 'T']\n", - "0 45 45 ['A']\n", - "75 0 75 ['A', 'Z']\n", - "0 176 176 ['B']\n", - "101 92 193 ['B', 'P']\n", - "211 0 211 ['B', 'F']\n", - "0 77 77 ['B']\n", - "90 0 90 ['B', 'G']\n", - "0 100 100 ['B']\n", - "101 0 101 ['B', 'P']\n", - "0 80 80 ['B']\n", - "85 0 85 ['B', 'U']\n", - "0 87 87 ['C']\n", - "120 0 120 ['C', 'D']\n", - "0 109 109 ['C']\n", - "138 0 138 ['C', 'P']\n", - "0 128 128 ['C']\n", - "146 0 146 ['C', 'R']\n", - "0 47 47 ['D']\n", - "75 0 75 ['D', 'M']\n", - "0 62 62 ['E']\n", - "86 0 86 ['E', 'H']\n", - "0 98 98 ['F']\n", - "99 0 99 ['F', 'S']\n", - "0 77 77 ['H']\n", - "98 0 98 ['H', 'U']\n", - "0 85 85 ['I']\n", - "87 0 87 ['I', 'N']\n", - "0 78 78 ['I']\n", - "92 0 92 ['I', 'V']\n", - "0 36 36 ['L']\n", - "70 0 70 ['L', 'M']\n", - "0 86 86 ['L']\n", - "111 0 111 ['L', 'T']\n", - "0 136 136 ['O']\n", - "151 0 151 ['O', 'S']\n", - "0 48 48 ['O']\n", - "71 0 71 ['O', 'Z']\n", - "0 93 93 ['P']\n", - "97 0 97 ['P', 'R']\n", - "0 65 65 ['R']\n", - "80 0 80 ['R', 'S']\n", - "0 127 127 ['U']\n", - "142 0 142 ['U', 'V']\n" - ] - }, - { - "data": { - "text/plain": [ - "(1.2698088530709188, 1.2059558858330393)" - ] - }, - "execution_count": 315, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from itertools import combinations\n", - "from statistics import median, mean\n", - "\n", - "# Detour index for Romania\n", - "\n", - "L = romania.locations\n", - "def ratio(a, b): return astar_search(RouteProblem(a, b, map=romania)).path_cost / sld(L[a], L[b])\n", - "nums = [ratio(a, b) for a,b in combinations(L, 2) if b in r1.actions(a)]\n", - "mean(nums), median(nums) # 1.7, 1.6 # 1.26, 1.2 for adjacent cities" - ] - }, - { - "cell_type": "code", - "execution_count": 300, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 300, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "sld" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.7.0" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/tests/test_agents.py b/tests/test_agents.py index d1a669486..5915f8a51 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -2,7 +2,7 @@ import pytest -from agents import (ReflexVacuumAgent, ModelBasedVacuumAgent, TrivialVacuumEnvironment, compare_agents, +from aima.agents import (ReflexVacuumAgent, ModelBasedVacuumAgent, TrivialVacuumEnvironment, compare_agents, RandomVacuumAgent, TableDrivenVacuumAgent, TableDrivenAgentProgram, RandomAgentProgram, SimpleReflexAgentProgram, ModelBasedReflexAgentProgram, Wall, Gold, Explorer, Thing, Bump, Glitter, WumpusEnvironment, Pit, VacuumEnvironment, Dirt, Direction, Agent) @@ -117,7 +117,7 @@ def test_TableDrivenAgent(): # add agent to the environment environment.add_thing(agent) - # run the environment by single step everytime to check how environment evolves using TableDrivenAgentProgram + # run the environment by single step every time to check how environment evolves using TableDrivenAgentProgram environment.run(steps=1) assert environment.status == {(1, 0): 'Clean', (0, 0): 'Dirty'} diff --git a/tests/test_agents4e.py b/tests/test_agents4e.py deleted file mode 100644 index 295a1ee47..000000000 --- a/tests/test_agents4e.py +++ /dev/null @@ -1,386 +0,0 @@ -import random - -import pytest - -from agents4e import (ReflexVacuumAgent, ModelBasedVacuumAgent, TrivialVacuumEnvironment, compare_agents, - RandomVacuumAgent, TableDrivenVacuumAgent, TableDrivenAgentProgram, RandomAgentProgram, - SimpleReflexAgentProgram, ModelBasedReflexAgentProgram, Wall, Gold, Explorer, Thing, Bump, - Glitter, WumpusEnvironment, Pit, VacuumEnvironment, Dirt, Direction, Agent) - -# random seed may affect the placement -# of things in the environment which may -# lead to failure of tests. Please change -# the seed if the tests are failing with -# current changes in any stochastic method -# function or variable. -random.seed(9) - -def test_move_forward(): - d = Direction("up") - l1 = d.move_forward((0, 0)) - assert l1 == (0, -1) - - d = Direction(Direction.R) - l1 = d.move_forward((0, 0)) - assert l1 == (1, 0) - - d = Direction(Direction.D) - l1 = d.move_forward((0, 0)) - assert l1 == (0, 1) - - d = Direction("left") - l1 = d.move_forward((0, 0)) - assert l1 == (-1, 0) - - l2 = d.move_forward((1, 0)) - assert l2 == (0, 0) - - -def test_add(): - d = Direction(Direction.U) - l1 = d + "right" - l2 = d + "left" - assert l1.direction == Direction.R - assert l2.direction == Direction.L - - d = Direction("right") - l1 = d.__add__(Direction.L) - l2 = d.__add__(Direction.R) - assert l1.direction == "up" - assert l2.direction == "down" - - d = Direction("down") - l1 = d.__add__("right") - l2 = d.__add__("left") - assert l1.direction == Direction.L - assert l2.direction == Direction.R - - d = Direction(Direction.L) - l1 = d + Direction.R - l2 = d + Direction.L - assert l1.direction == Direction.U - assert l2.direction == Direction.D - - -def test_RandomAgentProgram(): - # create a list of all the actions a Vacuum cleaner can perform - list = ['Right', 'Left', 'Suck', 'NoOp'] - # create a program and then an object of the RandomAgentProgram - program = RandomAgentProgram(list) - - agent = Agent(program) - # create an object of TrivialVacuumEnvironment - environment = TrivialVacuumEnvironment() - # add agent to the environment - environment.add_thing(agent) - # run the environment - environment.run() - # check final status of the environment - assert environment.status == {(1, 0): 'Clean', (0, 0): 'Clean'} - - -def test_RandomVacuumAgent(): - # create an object of the RandomVacuumAgent - agent = RandomVacuumAgent() - # create an object of TrivialVacuumEnvironment - environment = TrivialVacuumEnvironment() - # add agent to the environment - environment.add_thing(agent) - # run the environment - environment.run() - # check final status of the environment - assert environment.status == {(1, 0): 'Clean', (0, 0): 'Clean'} - - -def test_TableDrivenAgent(): - random.seed(10) - loc_A, loc_B = (0, 0), (1, 0) - # table defining all the possible states of the agent - table = {((loc_A, 'Clean'),): 'Right', - ((loc_A, 'Dirty'),): 'Suck', - ((loc_B, 'Clean'),): 'Left', - ((loc_B, 'Dirty'),): 'Suck', - ((loc_A, 'Dirty'), (loc_A, 'Clean')): 'Right', - ((loc_A, 'Clean'), (loc_B, 'Dirty')): 'Suck', - ((loc_B, 'Clean'), (loc_A, 'Dirty')): 'Suck', - ((loc_B, 'Dirty'), (loc_B, 'Clean')): 'Left', - ((loc_A, 'Dirty'), (loc_A, 'Clean'), (loc_B, 'Dirty')): 'Suck', - ((loc_B, 'Dirty'), (loc_B, 'Clean'), (loc_A, 'Dirty')): 'Suck'} - - # create an program and then an object of the TableDrivenAgent - program = TableDrivenAgentProgram(table) - agent = Agent(program) - # create an object of TrivialVacuumEnvironment - environment = TrivialVacuumEnvironment() - # initializing some environment status - environment.status = {loc_A: 'Dirty', loc_B: 'Dirty'} - # add agent to the environment - environment.add_thing(agent, location=(1, 0)) - # run the environment by single step everytime to check how environment evolves using TableDrivenAgentProgram - environment.run(steps=1) - assert environment.status == {(1, 0): 'Clean', (0, 0): 'Dirty'} - - environment.run(steps=1) - assert environment.status == {(1, 0): 'Clean', (0, 0): 'Dirty'} - - environment.run(steps=1) - assert environment.status == {(1, 0): 'Clean', (0, 0): 'Clean'} - - -def test_ReflexVacuumAgent(): - # create an object of the ReflexVacuumAgent - agent = ReflexVacuumAgent() - # create an object of TrivialVacuumEnvironment - environment = TrivialVacuumEnvironment() - # add agent to the environment - environment.add_thing(agent) - # run the environment - environment.run() - # check final status of the environment - assert environment.status == {(1, 0): 'Clean', (0, 0): 'Clean'} - - -def test_SimpleReflexAgentProgram(): - class Rule: - - def __init__(self, state, action): - self.__state = state - self.action = action - - def matches(self, state): - return self.__state == state - - loc_A = (0, 0) - loc_B = (1, 0) - - # create rules for a two state Vacuum Environment - rules = [Rule((loc_A, "Dirty"), "Suck"), Rule((loc_A, "Clean"), "Right"), - Rule((loc_B, "Dirty"), "Suck"), Rule((loc_B, "Clean"), "Left")] - - def interpret_input(state): - return state - - # create a program and then an object of the SimpleReflexAgentProgram - program = SimpleReflexAgentProgram(rules, interpret_input) - agent = Agent(program) - # create an object of TrivialVacuumEnvironment - environment = TrivialVacuumEnvironment() - # add agent to the environment - environment.add_thing(agent) - # run the environment - environment.run() - # check final status of the environment - assert environment.status == {(1, 0): 'Clean', (0, 0): 'Clean'} - - -def test_ModelBasedReflexAgentProgram(): - class Rule: - - def __init__(self, state, action): - self.__state = state - self.action = action - - def matches(self, state): - return self.__state == state - - loc_A = (0, 0) - loc_B = (1, 0) - - # create rules for a two-state Vacuum Environment - rules = [Rule((loc_A, "Dirty"), "Suck"), Rule((loc_A, "Clean"), "Right"), - Rule((loc_B, "Dirty"), "Suck"), Rule((loc_B, "Clean"), "Left")] - - def update_state(state, action, percept, transition_model, sensor_model): - return percept - - # create a program and then an object of the ModelBasedReflexAgentProgram class - program = ModelBasedReflexAgentProgram(rules, update_state, None, None) - agent = Agent(program) - # create an object of TrivialVacuumEnvironment - environment = TrivialVacuumEnvironment() - # add agent to the environment - environment.add_thing(agent) - # run the environment - environment.run() - # check final status of the environment - assert environment.status == {(1, 0): 'Clean', (0, 0): 'Clean'} - - -def test_ModelBasedVacuumAgent(): - # create an object of the ModelBasedVacuumAgent - agent = ModelBasedVacuumAgent() - # create an object of TrivialVacuumEnvironment - environment = TrivialVacuumEnvironment() - # add agent to the environment - environment.add_thing(agent) - # run the environment - environment.run() - # check final status of the environment - assert environment.status == {(1, 0): 'Clean', (0, 0): 'Clean'} - - -def test_TableDrivenVacuumAgent(): - # create an object of the TableDrivenVacuumAgent - agent = TableDrivenVacuumAgent() - # create an object of the TrivialVacuumEnvironment - environment = TrivialVacuumEnvironment() - # add agent to the environment - environment.add_thing(agent) - # run the environment - environment.run() - # check final status of the environment - assert environment.status == {(1, 0): 'Clean', (0, 0): 'Clean'} - - -def test_compare_agents(): - environment = TrivialVacuumEnvironment - agents = [ModelBasedVacuumAgent, ReflexVacuumAgent] - - result = compare_agents(environment, agents) - performance_ModelBasedVacuumAgent = result[0][1] - performance_ReflexVacuumAgent = result[1][1] - - # The performance of ModelBasedVacuumAgent will be at least as good as that of - # ReflexVacuumAgent, since ModelBasedVacuumAgent can identify when it has - # reached the terminal state (both locations being clean) and will perform - # NoOp leading to 0 performance change, whereas ReflexVacuumAgent cannot - # identify the terminal state and thus will keep moving, leading to worse - # performance compared to ModelBasedVacuumAgent. - assert performance_ReflexVacuumAgent <= performance_ModelBasedVacuumAgent - - -def test_TableDrivenAgentProgram(): - table = {(('foo', 1),): 'action1', - (('foo', 2),): 'action2', - (('bar', 1),): 'action3', - (('bar', 2),): 'action1', - (('foo', 1), ('foo', 1),): 'action2', - (('foo', 1), ('foo', 2),): 'action3'} - agent_program = TableDrivenAgentProgram(table) - assert agent_program(('foo', 1)) == 'action1' - assert agent_program(('foo', 2)) == 'action3' - assert agent_program(('invalid percept',)) is None - - -def test_Agent(): - def constant_prog(percept): - return percept - - agent = Agent(constant_prog) - result = agent.program(5) - assert result == 5 - - -def test_VacuumEnvironment(): - # initialize Vacuum Environment - v = VacuumEnvironment(6, 6) - # get an agent - agent = ModelBasedVacuumAgent() - agent.direction = Direction(Direction.R) - v.add_thing(agent, location=(1, 1)) - v.add_thing(Dirt(), location=(2, 1)) - - # check if things are added properly - assert len([x for x in v.things if isinstance(x, Wall)]) == 20 - assert len([x for x in v.things if isinstance(x, Dirt)]) == 1 - - # let the action begin! - assert v.percept(agent) == ("Clean", "None") - v.execute_action(agent, "Forward") - assert v.percept(agent) == ("Dirty", "None") - v.execute_action(agent, "TurnLeft") - v.execute_action(agent, "Forward") - assert v.percept(agent) == ("Dirty", "Bump") - v.execute_action(agent, "Suck") - assert v.percept(agent) == ("Clean", "None") - old_performance = agent.performance - v.execute_action(agent, "NoOp") - assert old_performance == agent.performance - - -def test_WumpusEnvironment(): - def constant_prog(percept): - return percept - - # initialize Wumpus Environment - w = WumpusEnvironment(constant_prog) - - # check if things are added properly - assert len([x for x in w.things if isinstance(x, Wall)]) == 20 - assert any(map(lambda x: isinstance(x, Gold), w.things)) - assert any(map(lambda x: isinstance(x, Explorer), w.things)) - assert not any(map(lambda x: not isinstance(x, Thing), w.things)) - - # check that gold and wumpus are not present on (1,1) - assert not any(map(lambda x: isinstance(x, Gold) or isinstance(x, WumpusEnvironment), w.list_things_at((1, 1)))) - - # check if w.get_world() segments objects correctly - assert len(w.get_world()) == 6 - for row in w.get_world(): - assert len(row) == 6 - - # start the game! - agent = [x for x in w.things if isinstance(x, Explorer)][0] - gold = [x for x in w.things if isinstance(x, Gold)][0] - pit = [x for x in w.things if isinstance(x, Pit)][0] - - assert not w.is_done() - - # check Walls - agent.location = (1, 2) - percepts = w.percept(agent) - assert len(percepts) == 5 - assert any(map(lambda x: isinstance(x, Bump), percepts[0])) - - # check Gold - agent.location = gold.location - percepts = w.percept(agent) - assert any(map(lambda x: isinstance(x, Glitter), percepts[4])) - agent.location = (gold.location[0], gold.location[1] + 1) - percepts = w.percept(agent) - assert not any(map(lambda x: isinstance(x, Glitter), percepts[4])) - - # check agent death - agent.location = pit.location - assert w.in_danger(agent) - assert not agent.alive - assert agent.killed_by == Pit.__name__ - assert agent.performance == -1000 - - assert w.is_done() - - -def test_WumpusEnvironmentActions(): - random.seed(9) - def constant_prog(percept): - return percept - - # initialize Wumpus Environment - w = WumpusEnvironment(constant_prog) - - agent = [x for x in w.things if isinstance(x, Explorer)][0] - gold = [x for x in w.things if isinstance(x, Gold)][0] - pit = [x for x in w.things if isinstance(x, Pit)][0] - - agent.location = (1, 1) - assert agent.direction.direction == "right" - w.execute_action(agent, 'TurnRight') - assert agent.direction.direction == "down" - w.execute_action(agent, 'TurnLeft') - assert agent.direction.direction == "right" - w.execute_action(agent, 'Forward') - assert agent.location == (2, 1) - - agent.location = gold.location - w.execute_action(agent, 'Grab') - assert agent.holding == [gold] - - agent.location = (1, 1) - w.execute_action(agent, 'Climb') - assert not any(map(lambda x: isinstance(x, Explorer), w.things)) - - assert w.is_done() - - -if __name__ == "__main__": - pytest.main() diff --git a/tests/test_csp.py b/tests/test_csp.py index a070cd531..929eb719c 100644 --- a/tests/test_csp.py +++ b/tests/test_csp.py @@ -1,6 +1,7 @@ import pytest -from utils import failure_test -from csp import * +from aima.utils import failure_test +from aima.csp import * +from aima.search import depth_first_tree_search import random random.seed("aima-python") @@ -34,6 +35,12 @@ def test_csp_nconflicts(): val = 'B' assert map_coloring_test.nconflicts(var, val, assignment) == 0 +def test_csp_count_lost_values(): + map_coloring_test = MapColoringCSP(list('RGB'), 'A: B C; B: C; C: ') + assignment = {'A': 'G'} + var = 'C' + val = 'R' + assert map_coloring_test.count_lost_values(var, val, assignment) == 1 def test_csp_actions(): map_coloring_test = MapColoringCSP(list('123'), 'A: B C; B: C; C: ') @@ -331,13 +338,13 @@ def test_lcv(): var = 'B' - assert lcv(var, assignment, csp) == [4, 0, 1, 2, 3, 5] - assignment = {'A': 1, 'C': 3} + assert lcv(var, assignment, csp) == [0, 2, 4, 1, 3, 5] + assignment = {'A': 1} constraints = lambda X, x, Y, y: (x + y) % 2 == 0 and (x + y) < 5 csp = CSP(variables=None, domains=domains, neighbors=neighbors, constraints=constraints) - assert lcv(var, assignment, csp) == [1, 3, 0, 2, 4, 5] + assert lcv(var, assignment, csp) == [0, 1, 2, 3, 4, 5] def test_forward_checking(): @@ -516,6 +523,42 @@ def test_ac_search_solver(): 'C1': 1, 'C2': 1, 'C3': 0, 'C4': 1} +def _complete_and_consistent(csp, solution): + """A solver answer is valid if every variable is assigned and the CSP holds.""" + if solution is None: + return False + assignment = {var: (first(val) if isinstance(val, (set, frozenset)) else val) + for var, val in solution.items()} + return set(assignment) == csp.variables and csp.consistent(assignment) + + +def test_nary_csp(): + csp = NaryCSP({'a': {1, 2, 3}, 'b': {1, 2, 3}}, + [Constraint(('a', 'b'), all_diff_constraint)]) + assert csp.variables == {'a', 'b'} + assert csp.consistent({'a': 1, 'b': 2}) + assert not csp.consistent({'a': 1, 'b': 1}) + # each variable is linked back to the constraint over its scope + assert len(csp.var_to_const['a']) == 1 and len(csp.var_to_const['b']) == 1 + + +def test_ac_solver_classes(): + # exercise the ACSolver / ACSearchSolver classes directly (not just the wrappers) + assert _complete_and_consistent(csp_crossword, ACSolver(csp_crossword).domain_splitting()) + solution = depth_first_tree_search(ACSearchSolver(csp_crossword)) + assert solution is not None and _complete_and_consistent(csp_crossword, solution.state) + + +def test_crossword(): + crossword = Crossword(crossword1, words1) + assert _complete_and_consistent(crossword, ac_solver(crossword)) + + +def test_kakuro(): + kakuro = Kakuro(kakuro2) + assert _complete_and_consistent(kakuro, ac_solver(kakuro)) + + def test_different_values_constraint(): assert different_values_constraint('A', 1, 'B', 2) assert not different_values_constraint('A', 1, 'B', 1) @@ -639,7 +682,7 @@ def test_zebra(): ans = algorithm(z, max_steps=10000) assert ans is None or ans == {'Red': 3, 'Yellow': 1, 'Blue': 2, 'Green': 5, 'Ivory': 4, 'Dog': 4, 'Fox': 1, 'Snails': 3, 'Horse': 2, 'Zebra': 5, 'OJ': 4, 'Tea': 2, 'Coffee': 5, 'Milk': 3, - 'Water': 1, 'Englishman': 3, 'Spaniard': 4, 'Norwegian': 1, 'Ukranian': 2, + 'Water': 1, 'Englishman': 3, 'Spaniard': 4, 'Norwegian': 1, 'Ukrainian': 2, 'Japanese': 5, 'Kools': 1, 'Chesterfields': 2, 'Winston': 3, 'LuckyStrike': 4, 'Parliaments': 5} @@ -648,15 +691,30 @@ def test_zebra(): 'Fox': [1, 2], 'Snails': [3], 'Horse': [2], 'Zebra': [5], 'OJ': [1, 2, 3, 4, 5], 'Tea': [1, 2, 3, 4, 5], 'Coffee': [1, 2, 3, 4, 5], 'Milk': [3], 'Water': [1, 2, 3, 4, 5], 'Englishman': [1, 2, 3, 4, 5], 'Spaniard': [1, 2, 3, 4, 5], 'Norwegian': [1], - 'Ukranian': [1, 2, 3, 4, 5], 'Japanese': [1, 2, 3, 4, 5], 'Kools': [1, 2, 3, 4, 5], + 'Ukrainian': [1, 2, 3, 4, 5], 'Japanese': [1, 2, 3, 4, 5], 'Kools': [1, 2, 3, 4, 5], 'Chesterfields': [1, 2, 3, 4, 5], 'Winston': [1, 2, 3, 4, 5], 'LuckyStrike': [1, 2, 3, 4, 5], 'Parliaments': [1, 2, 3, 4, 5]} ans = algorithm(z, max_steps=10000) assert ans == {'Red': 3, 'Yellow': 1, 'Blue': 2, 'Green': 5, 'Ivory': 4, 'Dog': 4, 'Fox': 1, 'Snails': 3, 'Horse': 2, 'Zebra': 5, 'OJ': 4, 'Tea': 2, 'Coffee': 5, 'Milk': 3, 'Water': 1, 'Englishman': 3, - 'Spaniard': 4, 'Norwegian': 1, 'Ukranian': 2, 'Japanese': 5, 'Kools': 1, 'Chesterfields': 2, + 'Spaniard': 4, 'Norwegian': 1, 'Ukrainian': 2, 'Japanese': 5, 'Kools': 1, 'Chesterfields': 2, 'Winston': 3, 'LuckyStrike': 4, 'Parliaments': 5} +def test_constraint_check_count(): + csp = MapColoringCSP(list('RGB'), 'SA: WA NT Q NSW V; NT: WA Q; NSW: Q V; T: ') + # before solving, no assignments or checks have been made + assert csp.nassigns == 0 and csp.nchecks == 0 + solution = backtracking_search(csp) + assert solution is not None + # solving performs both variable assignments and constraint (consistency) checks + assert csp.nassigns > 0 + assert csp.nchecks > 0 + # every constraints() call is tallied + before = csp.nchecks + csp.constraints('WA', 'R', 'NT', 'G') + assert csp.nchecks == before + 1 + + if __name__ == "__main__": pytest.main() diff --git a/tests/test_deep_learning4e.py b/tests/test_deep_learning.py similarity index 96% rename from tests/test_deep_learning4e.py rename to tests/test_deep_learning.py index 34676b02b..53aa5ebaf 100644 --- a/tests/test_deep_learning4e.py +++ b/tests/test_deep_learning.py @@ -1,8 +1,8 @@ import pytest from keras.datasets import imdb -from deep_learning4e import * -from learning4e import DataSet, grade_learner, err_ratio +from aima.deep_learning import * +from aima.learning import DataSet, grade_learner, err_ratio random.seed("aima-python") diff --git a/tests/test_game_theory.py b/tests/test_game_theory.py new file mode 100644 index 000000000..53090c5cb --- /dev/null +++ b/tests/test_game_theory.py @@ -0,0 +1,173 @@ +import numpy as np +import pytest + +from aima.game_theory import (dominates, dominant_strategy, iterated_dominance, pure_nash_equilibria, solve_zero_sum_game, + shapley_value, is_in_core, plurality_winner, borda_winner, condorcet_winner, + vickrey_auction, contract_net, alternating_offers_bargaining) + +# Prisoner's dilemma, payoffs as utilities (= minus the years in prison). +# Rows/cols: 0 = testify, 1 = refuse. Outcome indexed [Ali (row)][Bo (col)]. +ALI = [[-5, 0], [-10, -1]] +BO = [[-5, -10], [0, -1]] + + +def test_dominates(): + # for Ali, testify (row 0) strongly dominates refuse (row 1) + assert dominates(ALI, 0, 1, strongly=True) + assert not dominates(ALI, 1, 0, strongly=True) + + +def test_dominant_strategy(): + # testify (0) is the dominant strategy for both players (transpose for Bo) + assert dominant_strategy(ALI) == 0 + assert dominant_strategy(np.transpose(BO)) == 0 + # matching pennies has no dominant strategy + assert dominant_strategy([[1, -1], [-1, 1]]) is None + + +def test_iterated_dominance(): + # iterated elimination collapses the prisoner's dilemma to (testify, testify) + assert iterated_dominance(ALI, BO) == ([0], [0]) + # matching pennies has no dominated strategies, so nothing is removed + assert iterated_dominance([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) == ([0, 1], [0, 1]) + + +def test_pure_nash_equilibria(): + # the prisoner's dilemma has the unique (testify, testify) equilibrium + assert pure_nash_equilibria(ALI, BO) == [(0, 0)] + # matching pennies has no pure-strategy Nash equilibrium + assert pure_nash_equilibria([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) == [] + # a coordination game has two pure-strategy Nash equilibria + assert pure_nash_equilibria([[2, 0], [0, 1]], [[2, 0], [0, 1]]) == [(0, 0), (1, 1)] + + +def test_solve_zero_sum_game(): + # two-finger Morra: value -1/12 with optimal mixed strategy [7/12, 5/12] + value, row_strategy, col_strategy = solve_zero_sum_game([[2, -3], [-3, 4]]) + assert value == pytest.approx(-1 / 12) + assert row_strategy == pytest.approx([7 / 12, 5 / 12]) + assert col_strategy == pytest.approx([7 / 12, 5 / 12]) + + # matching pennies: value 0, both players randomize uniformly + value, row_strategy, col_strategy = solve_zero_sum_game([[1, -1], [-1, 1]]) + assert value == pytest.approx(0) + assert row_strategy == pytest.approx([0.5, 0.5]) + assert col_strategy == pytest.approx([0.5, 0.5]) + + +def gloves(coalition): + """Glove market: players 1 and 2 hold a left glove, player 3 a right glove; + a coalition is worth the number of complete pairs it can make.""" + lefts = len({1, 2} & coalition) + rights = len({3} & coalition) + return min(lefts, rights) + + +def test_shapley_value(): + phi = shapley_value([1, 2, 3], gloves) + # the scarce right glove (player 3) captures most of the value + assert phi == pytest.approx({1: 1 / 6, 2: 1 / 6, 3: 2 / 3}) + # the Shapley value is efficient: it distributes the whole grand-coalition value + assert sum(phi.values()) == pytest.approx(gloves(frozenset({1, 2, 3}))) + + +def test_is_in_core(): + # giving the whole value to the indispensable player 3 is in the core + assert is_in_core([1, 2, 3], gloves, {1: 0, 2: 0, 3: 1}) + # splitting it with player 1 lets coalition {2, 3} object (they can make a pair) + assert not is_in_core([1, 2, 3], gloves, {1: 0.5, 2: 0, 3: 0.5}) + + +# Condorcet's paradox (Equation 18.2): pairwise majority preference is cyclic +CONDORCET_PARADOX = [['a', 'b', 'c'], ['c', 'a', 'b'], ['b', 'c', 'a']] +# an election where plurality, Borda and Condorcet disagree +ELECTION = [['a', 'b', 'c']] * 4 + [['b', 'c', 'a']] * 3 + [['c', 'b', 'a']] * 2 + + +def test_plurality_winner(): + assert plurality_winner(ELECTION) == 'a' # 4 first-place votes + + +def test_borda_winner(): + assert borda_winner(ELECTION) == 'b' # compromise candidate wins on points + + +def test_condorcet_winner(): + assert condorcet_winner(ELECTION) == 'b' # b beats both a and c pairwise + assert condorcet_winner(CONDORCET_PARADOX) is None # cyclic majority: no winner + + +def test_vickrey_auction(): + winner, price = vickrey_auction({'a': 10, 'b': 8, 'c': 5}) + assert winner == 'a' # highest bidder wins + assert price == 8 # but pays the second-highest bid + + +# --------------------------------------------------------------------------- +# Additional cases taken from the book and the 3rd-edition solutions manual +# --------------------------------------------------------------------------- + +def test_zero_sum_game_solutions_manual_17_17(): + # Exercise 17.17 (3rd-edition solutions manual): a 5-move rock-paper-scissors- + # fire-water zero-sum game. The worked solution gives the optimal mixed + # strategy r = p = s = 1/9, f = w = 1/3 with game value 0. + payoff = [[0, -1, 1, -1, 1], + [1, 0, -1, -1, 1], + [-1, 1, 0, -1, 1], + [1, 1, 1, 0, -1], + [-1, -1, -1, 1, 0]] + value, row_strategy, col_strategy = solve_zero_sum_game(payoff) + assert value == pytest.approx(0, abs=1e-9) + assert row_strategy == pytest.approx([1 / 9, 1 / 9, 1 / 9, 1 / 3, 1 / 3]) + assert col_strategy == pytest.approx([1 / 9, 1 / 9, 1 / 9, 1 / 3, 1 / 3]) + + +def test_zero_sum_game_with_saddle_point(): + # a zero-sum game whose maximin and minimax coincide in pure strategies has a + # saddle point: here the value is 3, attained by row 0 against column 1 + value, row_strategy, col_strategy = solve_zero_sum_game([[4, 3], [2, 1]]) + assert value == pytest.approx(3) + assert row_strategy == pytest.approx([1, 0]) + assert col_strategy == pytest.approx([0, 1]) + + +def test_battle_of_the_sexes(): + # a coordination game with two pure-strategy Nash equilibria, one favouring + # each player, at the two matching outcomes + a = [[2, 0], [0, 1]] + b = [[1, 0], [0, 2]] + assert pure_nash_equilibria(a, b) == [(0, 0), (1, 1)] + + +def test_stag_hunt(): + # the stag hunt also has two pure Nash equilibria: both hunt the stag (the + # payoff-dominant one) or both forage alone (the risk-dominant one) + assert pure_nash_equilibria([[3, 0], [2, 2]], [[3, 2], [0, 2]]) == [(0, 0), (1, 1)] + + +def test_contract_net(): + # each agent's cost for each task; None means the agent cannot do the task + costs = {('painter', 'paint'): 10, ('painter', 'wire'): None, + ('cheap_painter', 'paint'): 7, ('cheap_painter', 'wire'): None, + ('electrician', 'paint'): None, ('electrician', 'wire'): 5} + allocation = contract_net(['paint', 'wire'], ['painter', 'cheap_painter', 'electrician'], + bid=lambda agent, task: costs[(agent, task)]) + # the manager awards each task to the lowest-cost capable agent + assert allocation == {'paint': ('cheap_painter', 7), 'wire': ('electrician', 5)} + # a task nobody can do stays unallocated + assert contract_net(['fly'], ['painter'], bid=lambda a, t: None) == {'fly': None} + + +def test_alternating_offers_bargaining(): + # with equal discount factors the first mover keeps 1 / (1 + gamma) + assert alternating_offers_bargaining(0.5, 0.5) == pytest.approx((1 / 1.5, 0.5 / 1.5)) + # a totally impatient responder concedes everything (ultimatum game) + assert alternating_offers_bargaining(0.0, 0.0) == pytest.approx((1, 0)) + # the more patient agent secures the larger share + share_a, share_b = alternating_offers_bargaining(0.9, 0.5) + assert share_a > share_b + assert share_a + share_b == pytest.approx(1) + + +if __name__ == "__main__": + pytest.main() diff --git a/tests/test_games.py b/tests/test_games.py index b7541ee93..81301a70c 100644 --- a/tests/test_games.py +++ b/tests/test_games.py @@ -1,10 +1,11 @@ import pytest -from games import * +from aima.games import * # Creating the game instances f52 = Fig52Game() ttt = TicTacToe() +con4 = ConnectFour() random.seed("aima-python") @@ -55,6 +56,32 @@ def test_alpha_beta_search(): assert alpha_beta_search(state, ttt) == (1, 3) +def test_monte_carlo_tree_search(): + state = gen_state(to_move='X', x_positions=[(1, 1), (3, 3)], + o_positions=[(1, 2), (3, 2)]) + assert monte_carlo_tree_search(state, ttt) == (2, 2) + + state = gen_state(to_move='O', x_positions=[(1, 1), (3, 1), (3, 3)], + o_positions=[(1, 2), (3, 2)]) + assert monte_carlo_tree_search(state, ttt) == (2, 2) + + # uncomment the following when removing the 3rd edition + # state = gen_state(to_move='O', x_positions=[(1, 1)], + # o_positions=[]) + # assert monte_carlo_tree_search(state, ttt) == (2, 2) + + state = gen_state(to_move='X', x_positions=[(1, 1), (3, 1)], + o_positions=[(2, 2), (3, 1)]) + assert monte_carlo_tree_search(state, ttt) == (1, 3) + + # should never lose to a random or alpha_beta player in a ttt game + assert ttt.play_game(mcts_player, random_player) >= 0 + assert ttt.play_game(mcts_player, alpha_beta_player) >= 0 + + # should never lose to a random player in a connect four game + assert con4.play_game(mcts_player, random_player) >= 0 + + def test_random_tests(): assert Fig52Game().play_game(alpha_beta_player, alpha_beta_player) == 3 diff --git a/tests/test_games4e.py b/tests/test_games4e.py deleted file mode 100644 index 7dfa47f11..000000000 --- a/tests/test_games4e.py +++ /dev/null @@ -1,96 +0,0 @@ -import pytest - -from games4e import * - -# Creating the game instances -f52 = Fig52Game() -ttt = TicTacToe() -con4 = ConnectFour() - -random.seed("aima-python") - - -def gen_state(to_move='X', x_positions=[], o_positions=[], h=3, v=3): - """Given whose turn it is to move, the positions of X's on the board, the - positions of O's on the board, and, (optionally) number of rows, columns - and how many consecutive X's or O's required to win, return the corresponding - game state""" - - moves = set([(x, y) for x in range(1, h + 1) for y in range(1, v + 1)]) - set(x_positions) - set(o_positions) - moves = list(moves) - board = {} - for pos in x_positions: - board[pos] = 'X' - for pos in o_positions: - board[pos] = 'O' - return GameState(to_move=to_move, utility=0, board=board, moves=moves) - - -def test_minmax_decision(): - assert minmax_decision('A', f52) == 'a1' - assert minmax_decision('B', f52) == 'b1' - assert minmax_decision('C', f52) == 'c1' - assert minmax_decision('D', f52) == 'd3' - - -def test_alpha_beta_search(): - assert alpha_beta_search('A', f52) == 'a1' - assert alpha_beta_search('B', f52) == 'b1' - assert alpha_beta_search('C', f52) == 'c1' - assert alpha_beta_search('D', f52) == 'd3' - - state = gen_state(to_move='X', x_positions=[(1, 1), (3, 3)], - o_positions=[(1, 2), (3, 2)]) - assert alpha_beta_search(state, ttt) == (2, 2) - - state = gen_state(to_move='O', x_positions=[(1, 1), (3, 1), (3, 3)], - o_positions=[(1, 2), (3, 2)]) - assert alpha_beta_search(state, ttt) == (2, 2) - - state = gen_state(to_move='O', x_positions=[(1, 1)], - o_positions=[]) - assert alpha_beta_search(state, ttt) == (2, 2) - - state = gen_state(to_move='X', x_positions=[(1, 1), (3, 1)], - o_positions=[(2, 2), (3, 1)]) - assert alpha_beta_search(state, ttt) == (1, 3) - - -def test_monte_carlo_tree_search(): - state = gen_state(to_move='X', x_positions=[(1, 1), (3, 3)], - o_positions=[(1, 2), (3, 2)]) - assert monte_carlo_tree_search(state, ttt) == (2, 2) - - state = gen_state(to_move='O', x_positions=[(1, 1), (3, 1), (3, 3)], - o_positions=[(1, 2), (3, 2)]) - assert monte_carlo_tree_search(state, ttt) == (2, 2) - - # uncomment the following when removing the 3rd edition - # state = gen_state(to_move='O', x_positions=[(1, 1)], - # o_positions=[]) - # assert monte_carlo_tree_search(state, ttt) == (2, 2) - - state = gen_state(to_move='X', x_positions=[(1, 1), (3, 1)], - o_positions=[(2, 2), (3, 1)]) - assert monte_carlo_tree_search(state, ttt) == (1, 3) - - # should never lose to a random or alpha_beta player in a ttt game - assert ttt.play_game(mcts_player, random_player) >= 0 - assert ttt.play_game(mcts_player, alpha_beta_player) >= 0 - - # should never lose to a random player in a connect four game - assert con4.play_game(mcts_player, random_player) >= 0 - - -def test_random_tests(): - assert Fig52Game().play_game(alpha_beta_player, alpha_beta_player) == 3 - - # The player 'X' (one who plays first) in TicTacToe never loses: - assert ttt.play_game(alpha_beta_player, alpha_beta_player) >= 0 - - # The player 'X' (one who plays first) in TicTacToe never loses: - assert ttt.play_game(alpha_beta_player, random_player) >= 0 - - -if __name__ == "__main__": - pytest.main() diff --git a/tests/test_graphplan.py b/tests/test_graphplan.py new file mode 100644 index 000000000..476a2d573 --- /dev/null +++ b/tests/test_graphplan.py @@ -0,0 +1,196 @@ +from multiprocessing import Process, Queue + +import pytest + +from aima.planning import * + + +def test_blocksworld_manual(): + sbw = simple_blocks_world() + assert sbw.goal_test() is False + sbw.act(expr('ToTable(A, B)')) + sbw.act(expr('FromTable(B, A)')) + assert sbw.goal_test() is False + sbw.act(expr('FromTable(C, B)')) + assert sbw.goal_test() is True + + +def test_logistics_manual(): + init = 'In(C1, R1) & In(C2, D1) & In(C3, D2) & In(R1, D1) & Holding(R1)' + goal_state = 'In(C2, D3) & In(C3, D3)' + p = logistics_problem(init, goal_state) + assert p.goal_test() is False + p.act(expr('PutDown(R1, C1, D1)')) + p.act(expr('PickUp(R1, C2, D1)')) + p.act(expr('Move(R1, D1, D3)')) + p.act(expr('PutDown(R1, C2, D3)')) + p.act(expr('Move(R1, D3, D2)')) + p.act(expr('PickUp(R1, C3, D2)')) + p.act(expr('Move(R1, D2, D3)')) + assert p.goal_test() is False + p.act(expr('PutDown(R1, C3, D3)')) + assert p.goal_test() is True + + +def test_generalized_blocksworld_manual(): + """ + Manual test for the generalized blocks world problem constructor. + This test case involves stacking four blocks (A, B, C, D) into a single tower. + """ + initial_state = ('On(A, Table) & On(B, Table) & On(C, Table) & On(D, Table) & ' + 'Clear(A) & Clear(B) & Clear(C) & Clear(D)') + goal_state = 'On(A, B) & On(B, C) & On(C, D)' + bw_problem = blocks_world(initial_state, goal_state, ['A', 'B', 'C', 'D']) + assert bw_problem.goal_test() is False + bw_problem.act(expr('Move(C, Table, D)')) + assert bw_problem.goal_test() is False + bw_problem.act(expr('Move(B, Table, C)')) + assert bw_problem.goal_test() is False + bw_problem.act(expr('Move(A, Table, B)')) + assert bw_problem.goal_test() is True + + +def verify_solution(p): + sol = Linearize(p).execute() + assert p.goal_test() is False + for act in sol: + p.act(expr(act)) + assert p.goal_test() is True + + +def test_air_cargo(): + verify_solution(air_cargo()) + + +def test_spare_tire(): + verify_solution(spare_tire()) + + +def test_three_block_tower(): + verify_solution(three_block_tower()) + + +def test_simple_blocks_world(): + verify_solution(simple_blocks_world()) + + +def test_shopping_problem(): + verify_solution(shopping_problem()) + + +def test_socks_and_shoes(): + verify_solution(socks_and_shoes()) + + +def test_have_cake_and_eat_cake_too(): + verify_solution(have_cake_and_eat_cake_too()) + + +@pytest.mark.parametrize('goal_state', [ + 'In(C1, D1)', + 'In(C1, D2)', + 'In(C1, D1) & In(R1, D2)', + 'In(R1, D2) & In(C1, D1)', + 'In(C1, D1) & In(C3, R1)', + 'In(C1, D1) & In(C3, R1) & In(R1, D3)', + 'In(C1, D1) & In(R1, D3) & In(C3, R1)', + 'In(C1, D1) & In(C3, D3)', + 'In(C1, D1) & In(R1, D2) & In(C3, R1)', + 'In(C1, D1) & In(C3, R1) & In(R1, D3)', + 'In(C1, D1) & In(C2, D3)', + 'In(C3, D1)', + 'In(C2, D3)', + 'In(C2, D3) & In(C3, D3)', + 'In(C3, D3) & In(C2, D3)', + 'In(C1, D2) & In(C3, D3)', + 'In(C1, D3) & In(C2, D3) & In(C3, D3)', + 'In(C1, D2) & In(C3, D3) & In(C2, D1)', + 'In(C3, D3)', + 'In(C1, D2) & In(C3, D3) & In(C2, D3) & In(R1, D1)' +]) +def test_logistics_plan_valid(goal_state): + """These should yield a valid (non-crashing) plan, even if empty.""" + init = 'In(C1, R1) & In(C2, D1) & In(C3, D2) & In(R1, D1) & Holding(R1)' + verify_solution(logistics_problem(init, goal_state)) + + +def test_rush_hour_manual_alt_sequence(): + """ + Provides an alternative manual test for the Rush Hour problem. + + This solution is less efficient but still valid. It interleaves the + movements of different vehicles and includes an unnecessary move to verify + that the actions correctly modify the game state without breaking the rules. + """ + problem = rush_hour() + assert not problem.goal_test() + + # Make an unnecessary move with the BlueCar to show it works. + problem.act(expr('MoveUpCar(BlueCar, R4, R5, R6, C2)')) + assert not problem.goal_test(), 'Moving the BlueCar should not solve the puzzle.' + + # Start clearing the main path by moving the GreenTruck down. + problem.act(expr('MoveDownTruck(GreenTruck, R1, R2, R3, R4, C4)')) + + # Move the RedCar into the newly available space. + problem.act(expr('MoveRightCar(RedCar, R3, C1, C2, C3)')) + assert not problem.goal_test() + + # Continue clearing the path. + problem.act(expr('MoveDownTruck(GreenTruck, R2, R3, R4, R5, C4)')) + problem.act(expr('MoveRightCar(RedCar, R3, C2, C3, C4)')) + assert not problem.goal_test() + + # Final moves to solve the puzzle. + problem.act(expr('MoveDownTruck(GreenTruck, R3, R4, R5, R6, C4)')) + problem.act(expr('MoveRightCar(RedCar, R3, C3, C4, C5)')) + problem.act(expr('MoveRightCar(RedCar, R3, C4, C5, C6)')) + assert problem.goal_test() + + +def test_rush_hour_optimized(): + verify_solution(rush_hour_optimized()) + + +def test_planner_leveloff(): + def run_planner_in_queue(problem, queue): + queue.put(Linearize(problem).execute()) + + p = blocks_world( + 'On(A, Table) & On(B, Table) & On(C, Table) & Clear(A) & Clear(B) & Clear(C)', + 'On(A, B) & On(B, C) & On(C, A)', + ['A', 'B', 'C']) + + result_queue = Queue() + proc = Process(target=run_planner_in_queue, args=(p, result_queue)) + proc.start() + proc.join(timeout=3) + + if proc.is_alive(): + proc.terminate() + proc.join() + assert False # ran for 3 seconds and did not exit in level-off + else: + result = result_queue.get() + assert result is None or result == [] or result == [[]] + + +def test_impossible_cake_exits_via_leveloff(): + """Verify that GraphPlan terminates and returns None for the impossible cake problem.""" + + def impossible_cake_problem(): + """ + An impossible planning problem to demonstrate GraphPlan's level-off detection. + + The goal is to both Have(Cake) and Eaten(Cake). However, the only available + action, Eat(Cake), has the effect of ~Have(Cake). The propositions + Have(Cake) and Eaten(Cake) become mutually exclusive at the first level, + and the graph quickly levels off, proving the goal is unreachable. + """ + return PlanningProblem(initial='Have(Cake) & ~Eaten(Cake)', + goals='Have(Cake) & Eaten(Cake)', + actions=[Action('Eat(Cake)', + precond='Have(Cake)', + effect='Eaten(Cake) & ~Have(Cake)')]) + + assert Linearize(impossible_cake_problem()).execute() is None diff --git a/tests/test_knowledge.py b/tests/test_knowledge.py index d3829de02..701bb839a 100644 --- a/tests/test_knowledge.py +++ b/tests/test_knowledge.py @@ -1,7 +1,7 @@ import pytest -from knowledge import * -from utils import expr +from aima.knowledge import * +from aima.utils import expr import random random.seed("aima-python") diff --git a/tests/test_learning.py b/tests/test_learning.py index 63a7fd9aa..fbc0ed23b 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -1,6 +1,6 @@ import pytest -from learning import * +from aima.learning import * random.seed("aima-python") @@ -114,6 +114,14 @@ def test_neural_network_learner(): assert err_ratio(nnl, iris) < 0.21 +def test_linear_learner(): + iris = DataSet(name="iris") + iris.classes_to_numbers() + # both linear learners should train and produce a numeric prediction without shape errors + assert isinstance(float(LinearLearner(iris)(iris.examples[0][:-1])), float) + assert isinstance(float(LogisticLinearLeaner(iris)(iris.examples[0][:-1])), float) + + def test_perceptron(): iris = DataSet(name='iris') iris.classes_to_numbers() @@ -153,5 +161,77 @@ def test_ada_boost(): assert err_ratio(ab, iris) < 0.25 +def test_gaussian_mixture_em(): + np.random.seed(42) + # two well-separated 2-D Gaussian blobs around (0, 0) and (10, 10) + blob1 = np.random.randn(100, 2) + [0, 0] + blob2 = np.random.randn(100, 2) + [10, 10] + data = np.vstack([blob1, blob2]) + + model = gaussian_mixture_em(data, k=2) + + # the two recovered means should match the two true cluster centers (in some order) + means = sorted(model['means'].tolist()) + assert np.allclose(means[0], [0, 0], atol=0.5) + assert np.allclose(means[1], [10, 10], atol=0.5) + # the mixture weights are roughly balanced and sum to 1 + assert np.isclose(model['weights'].sum(), 1) + assert np.allclose(model['weights'], 0.5, atol=0.1) + # every point is assigned (with highest responsibility) to its own cluster + labels = model['responsibilities'].argmax(axis=1) + assert labels[0] != labels[-1] + assert len(set(labels[:100])) == 1 and len(set(labels[100:])) == 1 + + +def test_naive_bayes_em(): + np.random.seed(42) + # the 'two bags of candy' example (Section 20.3.2): bag 1 mostly has cherry + # flavour, red wrapper and a hole (each feature true with prob 0.8), bag 2 is + # the opposite (each feature true with prob 0.3); the bag is hidden + bag1 = (np.random.rand(1000, 3) < 0.8).astype(int) + bag2 = (np.random.rand(1000, 3) < 0.3).astype(int) + candies = np.vstack([bag1, bag2]) + + model = naive_bayes_em(candies, k=2) + + # recover the two bags (sorted by how 'cherry/red/holed' they are to undo the + # arbitrary labelling of the hidden classes) + components = sorted(model['probabilities'].tolist(), key=lambda p: sum(p)) + assert np.allclose(components[0], [0.3, 0.3, 0.3], atol=0.1) # bag 2 + assert np.allclose(components[1], [0.8, 0.8, 0.8], atol=0.1) # bag 1 + # the two bags were mixed in equal proportions and the priors sum to 1 + assert np.isclose(model['weights'].sum(), 1) + assert np.allclose(model['weights'], 0.5, atol=0.1) + + +def test_decision_list_learner(): + restaurant = DataSet(name="restaurant") + dll = DecisionListLearner(restaurant) + # the learned decision list is consistent with the (discrete) training data + assert err_ratio(dll, restaurant) == 0 + assert all(dll(example) == example[restaurant.target] for example in restaurant.examples) + # each rule is a (conjunctive test, outcome) pair, ending in the empty catch-all + assert all(isinstance(test, tuple) for test, _ in dll.decision_list) + assert dll.decision_list[-1][0] == () + + +def test_cross_validation(): + random.seed("aima-python") + iris = DataSet(name="iris") + + # adapt k-NN to the (dataset, size) interface cross_validation expects, + # cross-validating over the neighbourhood size + def knn(dataset, size): + return NearestNeighborLearner(dataset, k=size or 1) + + err_train, err_val = cross_validation(knn, iris, size=3, k=5) + # both are error ratios in [0, 1]; on iris k-NN generalizes well, so the + # validation error stays well below chance (~0.67 for 3 classes). Loose + # bounds are used on purpose -- the exact value depends on the shuffle. + assert 0.0 <= err_train <= 1.0 + assert 0.0 <= err_val < 0.5 + assert err_val >= err_train # validation error should not beat training error + + if __name__ == "__main__": pytest.main() diff --git a/tests/test_learning4e.py b/tests/test_learning4e.py deleted file mode 100644 index b345efad7..000000000 --- a/tests/test_learning4e.py +++ /dev/null @@ -1,127 +0,0 @@ -import pytest - -from deep_learning4e import PerceptronLearner -from learning4e import * - -random.seed("aima-python") - - -def test_exclude(): - iris = DataSet(name='iris', exclude=[3]) - assert iris.inputs == [0, 1, 2] - - -def test_parse_csv(): - iris = open_data('iris.csv').read() - assert parse_csv(iris)[0] == [5.1, 3.5, 1.4, 0.2, 'setosa'] - - -def test_weighted_mode(): - assert weighted_mode('abbaa', [1, 2, 3, 1, 2]) == 'b' - - -def test_weighted_replicate(): - assert weighted_replicate('ABC', [1, 2, 1], 4) == ['A', 'B', 'B', 'C'] - - -def test_means_and_deviation(): - iris = DataSet(name='iris') - means, deviations = iris.find_means_and_deviations() - assert round(means['setosa'][0], 3) == 5.006 - assert round(means['versicolor'][0], 3) == 5.936 - assert round(means['virginica'][0], 3) == 6.588 - assert round(deviations['setosa'][0], 3) == 0.352 - assert round(deviations['versicolor'][0], 3) == 0.516 - assert round(deviations['virginica'][0], 3) == 0.636 - - -def test_plurality_learner(): - zoo = DataSet(name='zoo') - pl = PluralityLearner(zoo) - assert pl.predict([1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 4, 1, 0, 1]) == 'mammal' - - -def test_k_nearest_neighbors(): - iris = DataSet(name='iris') - knn = NearestNeighborLearner(iris, k=3) - assert knn.predict([5, 3, 1, 0.1]) == 'setosa' - assert knn.predict([6, 5, 3, 1.5]) == 'versicolor' - assert knn.predict([7.5, 4, 6, 2]) == 'virginica' - - -def test_decision_tree_learner(): - iris = DataSet(name='iris') - dtl = DecisionTreeLearner(iris) - assert dtl.predict([5, 3, 1, 0.1]) == 'setosa' - assert dtl.predict([6, 5, 3, 1.5]) == 'versicolor' - assert dtl.predict([7.5, 4, 6, 2]) == 'virginica' - - -def test_svc(): - iris = DataSet(name='iris') - classes = ['setosa', 'versicolor', 'virginica'] - iris.classes_to_numbers(classes) - n_samples, n_features = len(iris.examples), iris.target - X, y = (np.array([x[:n_features] for x in iris.examples]), - np.array([x[n_features] for x in iris.examples])) - svm = MultiClassLearner(SVC()).fit(X, y) - assert svm.predict([[5.0, 3.1, 0.9, 0.1]]) == 0 - assert svm.predict([[5.1, 3.5, 1.0, 0.0]]) == 0 - assert svm.predict([[4.9, 3.3, 1.1, 0.1]]) == 0 - assert svm.predict([[6.0, 3.0, 4.0, 1.1]]) == 1 - assert svm.predict([[6.1, 2.2, 3.5, 1.0]]) == 1 - assert svm.predict([[5.9, 2.5, 3.3, 1.1]]) == 1 - assert svm.predict([[7.5, 4.1, 6.2, 2.3]]) == 2 - assert svm.predict([[7.3, 4.0, 6.1, 2.4]]) == 2 - assert svm.predict([[7.0, 3.3, 6.1, 2.5]]) == 2 - - -def test_information_content(): - assert information_content([]) == 0 - assert information_content([4]) == 0 - assert information_content([5, 4, 0, 2, 5, 0]) > 1.9 - assert information_content([5, 4, 0, 2, 5, 0]) < 2 - assert information_content([1.5, 2.5]) > 0.9 - assert information_content([1.5, 2.5]) < 1.0 - - -def test_random_forest(): - iris = DataSet(name='iris') - rf = RandomForest(iris) - tests = [([5.0, 3.0, 1.0, 0.1], 'setosa'), - ([5.1, 3.3, 1.1, 0.1], 'setosa'), - ([6.0, 5.0, 3.0, 1.0], 'versicolor'), - ([6.1, 2.2, 3.5, 1.0], 'versicolor'), - ([7.5, 4.1, 6.2, 2.3], 'virginica'), - ([7.3, 3.7, 6.1, 2.5], 'virginica')] - assert grade_learner(rf, tests) >= 1 / 3 - - -def test_random_weights(): - min_value = -0.5 - max_value = 0.5 - num_weights = 10 - test_weights = random_weights(min_value, max_value, num_weights) - assert len(test_weights) == num_weights - for weight in test_weights: - assert min_value <= weight <= max_value - - -def test_ada_boost(): - iris = DataSet(name='iris') - classes = ['setosa', 'versicolor', 'virginica'] - iris.classes_to_numbers(classes) - wl = WeightedLearner(PerceptronLearner(iris)) - ab = ada_boost(iris, wl, 5) - tests = [([5, 3, 1, 0.1], 0), - ([5, 3.5, 1, 0], 0), - ([6, 3, 4, 1.1], 1), - ([6, 2, 3.5, 1], 1), - ([7.5, 4, 6, 2], 2), - ([7, 3, 6, 2.5], 2)] - assert grade_learner(ab, tests) > 2 / 3 - assert err_ratio(ab, iris) < 0.25 - - -if __name__ == "__main__": - pytest.main() diff --git a/tests/test_logic.py b/tests/test_logic.py index 2ead21746..4746ff659 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -1,7 +1,7 @@ import pytest -from logic import * -from utils import expr_handle_infix_ops, count +from aima.logic import * +from aima.utils import expr_handle_infix_ops, count random.seed("aima-python") @@ -158,6 +158,26 @@ def test_cdcl_satisfiable(): assert cdcl_satisfiable(P & ~P) is False +def test_dpll_branching_heuristics(): + # every branching heuristic must still return a satisfying model on a SAT + # instance and report UNSAT on an unsatisfiable one + sat = (A | B | C) & (~A | ~B) & (~B | ~C) & (A | C) + for heuristic in (no_branching_heuristic, moms, momsf, posit, dlis, dlcs, jw, jw2, zm): + model = dpll_satisfiable(sat, branching_heuristic=heuristic) + assert model and pl_true(sat, model) + assert dpll_satisfiable(P & ~P, branching_heuristic=heuristic) is False + + +def test_cdcl_restart_strategies(): + # every restart strategy must still return a satisfying model on a SAT + # instance and report UNSAT on an unsatisfiable one + sat = (A | B | C) & (~A | ~B) & (~B | ~C) & (A | C) & (D | ~A) & (~D | B) + for restart_strategy in (no_restart, luby, glucose): + model = cdcl_satisfiable(sat, restart_strategy=restart_strategy) + assert model and pl_true(sat, model) + assert cdcl_satisfiable(P & ~P, restart_strategy=restart_strategy) is False + + def test_find_pure_symbol(): assert find_pure_symbol([A, B, C], [A | ~B, ~B | ~C, C | A]) == (A, True) assert find_pure_symbol([A, B, C], [~A | ~B, ~B | ~C, C | A]) == (B, False) @@ -385,5 +405,49 @@ def test_SAT_plan(): assert SAT_plan((0, 0), transition, (1, 1), 4) == ['Right', 'Down'] +def test_hybrid_wumpus_agent_make_percept_sentence(): + # a single square can yield several percepts at once (Stench *and* Breeze); + # the old elif-chain recorded only the first one + from aima.agents import Stench, Breeze + kb = WumpusKB(2) + kb.make_percept_sentence([Stench(), Breeze()], 0) + assert percept_stench(0) in kb.clauses + assert percept_breeze(0) in kb.clauses + assert ~percept_glitter(0) in kb.clauses + + +def test_hybrid_wumpus_agent_plan_shot(): + # plan_shot lines up with a possible wumpus and ends by shooting, using the + # same UP/DOWN/LEFT/RIGHT orientations as PlanRoute (it used to use EAST/WEST/…) + agent = HybridWumpusAgent(2) + actions = agent.plan_shot(WumpusPosition(1, 1, 'RIGHT'), [[2, 2]], + [[1, 1], [2, 1], [1, 2], [2, 2]]) + assert actions and actions[-1] == 'Shoot' + + +def test_hybrid_wumpus_agent_plan_route_no_path(): + # an unreachable / empty goal set yields an empty plan instead of crashing + agent = HybridWumpusAgent(2) + assert agent.plan_route(WumpusPosition(1, 1, 'RIGHT'), [], [[1, 1]]) == [] + + +def test_hybrid_wumpus_agent_first_action(): + # end-to-end first step: from [1,1] (provably safe at t=0) the agent returns a + # legal action and records its pose. (Only t=0 is exercised: the propositional + # Fig 7.20 inference grows expensive as the temporal KB accumulates.) + agent = HybridWumpusAgent(2) + action = agent.program(None) + assert action in {'Forward', 'TurnLeft', 'TurnRight', 'Grab', 'Shoot', 'Climb'} + assert agent.current_position.get_location() == (1, 1) + + +def test_gensym(): + s1, s2, s3 = gensym(), gensym(), gensym() + assert len({s1, s2, s3}) == 3 # all distinct + assert all(is_symbol(s.op) for s in (s1, s2, s3)) # genuine symbols + assert gensym('v_').op.startswith('v_') # custom prefix + assert variables(s1 | s2) == {s1, s2} # usable in expressions + + if __name__ == '__main__': pytest.main() diff --git a/tests/test_logic4e.py b/tests/test_logic4e.py deleted file mode 100644 index 5a7399281..000000000 --- a/tests/test_logic4e.py +++ /dev/null @@ -1,359 +0,0 @@ -import pytest - -from logic4e import * -from utils4e import expr_handle_infix_ops, count - -definite_clauses_KB = PropDefiniteKB() -for clause in ['(B & F)==>E', - '(A & E & F)==>G', - '(B & C)==>F', - '(A & B)==>D', - '(E & F)==>H', - '(H & I)==>J', - 'A', 'B', 'C']: - definite_clauses_KB.tell(expr(clause)) - - -def test_is_symbol(): - assert is_symbol('x') - assert is_symbol('X') - assert is_symbol('N245') - assert not is_symbol('') - assert not is_symbol('1L') - assert not is_symbol([1, 2, 3]) - - -def test_is_var_symbol(): - assert is_var_symbol('xt') - assert not is_var_symbol('Txt') - assert not is_var_symbol('') - assert not is_var_symbol('52') - - -def test_is_prop_symbol(): - assert not is_prop_symbol('xt') - assert is_prop_symbol('Txt') - assert not is_prop_symbol('') - assert not is_prop_symbol('52') - - -def test_variables(): - assert variables(expr('F(x, x) & G(x, y) & H(y, z) & R(A, z, 2)')) == {x, y, z} - assert variables(expr('(x ==> y) & B(x, y) & A')) == {x, y} - - -def test_expr(): - assert repr(expr('P <=> Q(1)')) == '(P <=> Q(1))' - assert repr(expr('P & Q | ~R(x, F(x))')) == '((P & Q) | ~R(x, F(x)))' - assert (expr_handle_infix_ops('P & Q ==> R & ~S') == "P & Q |'==>'| R & ~S") - - -def test_extend(): - assert extend({x: 1}, y, 2) == {x: 1, y: 2} - - -def test_subst(): - assert subst({x: 42, y: 0}, F(x) + y) == (F(42) + 0) - - -def test_PropKB(): - kb = PropKB() - assert count(kb.ask(expr) for expr in [A, C, D, E, Q]) is 0 - kb.tell(A & E) - assert kb.ask(A) == kb.ask(E) == {} - kb.tell(E | '==>' | C) - assert kb.ask(C) == {} - kb.retract(E) - assert kb.ask(E) is False - assert kb.ask(C) is False - - -def test_wumpus_kb(): - # Statement: There is no pit in [1,1]. - assert wumpus_kb.ask(~P11) == {} - - # Statement: There is no pit in [1,2]. - assert wumpus_kb.ask(~P12) == {} - - # Statement: There is a pit in [2,2]. - assert wumpus_kb.ask(P22) is False - - # Statement: There is a pit in [3,1]. - assert wumpus_kb.ask(P31) is False - - # Statement: Neither [1,2] nor [2,1] contains a pit. - assert wumpus_kb.ask(~P12 & ~P21) == {} - - # Statement: There is a pit in either [2,2] or [3,1]. - assert wumpus_kb.ask(P22 | P31) == {} - - -def test_is_definite_clause(): - assert is_definite_clause(expr('A & B & C & D ==> E')) - assert is_definite_clause(expr('Farmer(Mac)')) - assert not is_definite_clause(expr('~Farmer(Mac)')) - assert is_definite_clause(expr('(Farmer(f) & Rabbit(r)) ==> Hates(f, r)')) - assert not is_definite_clause(expr('(Farmer(f) & ~Rabbit(r)) ==> Hates(f, r)')) - assert not is_definite_clause(expr('(Farmer(f) | Rabbit(r)) ==> Hates(f, r)')) - - -def test_parse_definite_clause(): - assert parse_definite_clause(expr('A & B & C & D ==> E')) == ([A, B, C, D], E) - assert parse_definite_clause(expr('Farmer(Mac)')) == ([], expr('Farmer(Mac)')) - assert parse_definite_clause(expr('(Farmer(f) & Rabbit(r)) ==> Hates(f, r)')) == ( - [expr('Farmer(f)'), expr('Rabbit(r)')], expr('Hates(f, r)')) - - -def test_pl_true(): - assert pl_true(P, {}) is None - assert pl_true(P, {P: False}) is False - assert pl_true(P | Q, {P: True}) is True - assert pl_true((A | B) & (C | D), {A: False, B: True, D: True}) is True - assert pl_true((A & B) & (C | D), {A: False, B: True, D: True}) is False - assert pl_true((A & B) | (A & C), {A: False, B: True, C: True}) is False - assert pl_true((A | B) & (C | D), {A: True, D: False}) is None - assert pl_true(P | P, {}) is None - - -def test_tt_true(): - assert tt_true(P | ~P) - assert tt_true('~~P <=> P') - assert not tt_true((P | ~Q) & (~P | Q)) - assert not tt_true(P & ~P) - assert not tt_true(P & Q) - assert tt_true((P | ~Q) | (~P | Q)) - assert tt_true('(A & B) ==> (A | B)') - assert tt_true('((A & B) & C) <=> (A & (B & C))') - assert tt_true('((A | B) | C) <=> (A | (B | C))') - assert tt_true('(A ==> B) <=> (~B ==> ~A)') - assert tt_true('(A ==> B) <=> (~A | B)') - assert tt_true('(A <=> B) <=> ((A ==> B) & (B ==> A))') - assert tt_true('~(A & B) <=> (~A | ~B)') - assert tt_true('~(A | B) <=> (~A & ~B)') - assert tt_true('(A & (B | C)) <=> ((A & B) | (A & C))') - assert tt_true('(A | (B & C)) <=> ((A | B) & (A | C))') - - -def test_dpll(): - assert (dpll_satisfiable(A & ~B & C & (A | ~D) & (~E | ~D) & (C | ~D) & (~A | ~F) & (E | ~F) - & (~D | ~F) & (B | ~C | D) & (A | ~E | F) & (~A | E | D)) - == {B: False, C: True, A: True, F: False, D: True, E: False}) - assert dpll_satisfiable(A & B & ~C & D) == {C: False, A: True, D: True, B: True} - assert dpll_satisfiable((A | (B & C)) | '<=>' | ((A | B) & (A | C))) == {C: True, A: True} or {C: True, B: True} - assert dpll_satisfiable(A | '<=>' | B) == {A: True, B: True} - assert dpll_satisfiable(A & ~B) == {A: True, B: False} - assert dpll_satisfiable(P & ~P) is False - - -def test_find_pure_symbol(): - assert find_pure_symbol([A, B, C], [A | ~B, ~B | ~C, C | A]) == (A, True) - assert find_pure_symbol([A, B, C], [~A | ~B, ~B | ~C, C | A]) == (B, False) - assert find_pure_symbol([A, B, C], [~A | B, ~B | ~C, C | A]) == (None, None) - - -def test_unit_clause_assign(): - assert unit_clause_assign(A | B | C, {A: True}) == (None, None) - assert unit_clause_assign(B | C, {A: True}) == (None, None) - assert unit_clause_assign(B | ~A, {A: True}) == (B, True) - - -def test_find_unit_clause(): - assert find_unit_clause([A | B | C, B | ~C, ~A | ~B], {A: True}) == (B, False) - - -def test_unify(): - assert unify(x, x, {}) == {} - assert unify(x, 3, {}) == {x: 3} - assert unify(x & 4 & y, 6 & y & 4, {}) == {x: 6, y: 4} - assert unify(expr('A(x)'), expr('A(B)')) == {x: B} - assert unify(expr('American(x) & Weapon(B)'), expr('American(A) & Weapon(y)')) == {x: A, y: B} - - -def test_pl_fc_entails(): - assert pl_fc_entails(horn_clauses_KB, expr('Q')) - assert pl_fc_entails(definite_clauses_KB, expr('G')) - assert pl_fc_entails(definite_clauses_KB, expr('H')) - assert not pl_fc_entails(definite_clauses_KB, expr('I')) - assert not pl_fc_entails(definite_clauses_KB, expr('J')) - assert not pl_fc_entails(horn_clauses_KB, expr('SomethingSilly')) - - -def test_tt_entails(): - assert tt_entails(P & Q, Q) - assert not tt_entails(P | Q, Q) - assert tt_entails(A & (B | C) & E & F & ~(P | Q), A & E & F & ~P & ~Q) - assert not tt_entails(P | '<=>' | Q, Q) - assert tt_entails((P | '==>' | Q) & P, Q) - assert not tt_entails((P | '<=>' | Q) & ~P, Q) - - -def test_prop_symbols(): - assert prop_symbols(expr('x & y & z | A')) == {A} - assert prop_symbols(expr('(x & B(z)) ==> Farmer(y) | A')) == {A, expr('Farmer(y)'), expr('B(z)')} - - -def test_constant_symbols(): - assert constant_symbols(expr('x & y & z | A')) == {A} - assert constant_symbols(expr('(x & B(z)) & Father(John) ==> Farmer(y) | A')) == {A, expr('John')} - - -def test_predicate_symbols(): - assert predicate_symbols(expr('x & y & z | A')) == set() - assert predicate_symbols(expr('(x & B(z)) & Father(John) ==> Farmer(y) | A')) == { - ('B', 1), - ('Father', 1), - ('Farmer', 1)} - assert predicate_symbols(expr('(x & B(x, y, z)) & F(G(x, y), x) ==> P(Q(R(x, y)), x, y, z)')) == { - ('B', 3), - ('F', 2), - ('G', 2), - ('P', 4), - ('Q', 1), - ('R', 2)} - - -def test_eliminate_implications(): - assert repr(eliminate_implications('A ==> (~B <== C)')) == '((~B | ~C) | ~A)' - assert repr(eliminate_implications(A ^ B)) == '((A & ~B) | (~A & B))' - assert repr(eliminate_implications(A & B | C & ~D)) == '((A & B) | (C & ~D))' - - -def test_dissociate(): - assert dissociate('&', [A & B]) == [A, B] - assert dissociate('|', [A, B, C & D, P | Q]) == [A, B, C & D, P, Q] - assert dissociate('&', [A, B, C & D, P | Q]) == [A, B, C, D, P | Q] - - -def test_associate(): - assert (repr(associate('&', [(A & B), (B | C), (B & C)])) - == '(A & B & (B | C) & B & C)') - assert (repr(associate('|', [A | (B | (C | (A & B)))])) - == '(A | B | C | (A & B))') - - -def test_move_not_inwards(): - assert repr(move_not_inwards(~(A | B))) == '(~A & ~B)' - assert repr(move_not_inwards(~(A & B))) == '(~A | ~B)' - assert repr(move_not_inwards(~(~(A | ~B) | ~~C))) == '((A | ~B) & ~C)' - - -def test_distribute_and_over_or(): - def test_entailment(s, has_and=False): - result = distribute_and_over_or(s) - if has_and: - assert result.op == '&' - assert tt_entails(s, result) - assert tt_entails(result, s) - - test_entailment((A & B) | C, True) - test_entailment((A | B) & C, True) - test_entailment((A | B) | C, False) - test_entailment((A & B) | (C | D), True) - - -def test_to_cnf(): - assert (repr(to_cnf(wumpus_world_inference & ~expr('~P12'))) == - "((~P12 | B11) & (~P21 | B11) & (P12 | P21 | ~B11) & ~B11 & P12)") - assert repr(to_cnf((P & Q) | (~P & ~Q))) == '((~P | P) & (~Q | P) & (~P | Q) & (~Q | Q))' - assert repr(to_cnf('A <=> B')) == '((A | ~B) & (B | ~A))' - assert repr(to_cnf("B <=> (P1 | P2)")) == '((~P1 | B) & (~P2 | B) & (P1 | P2 | ~B))' - assert repr(to_cnf('A <=> (B & C)')) == '((A | ~B | ~C) & (B | ~A) & (C | ~A))' - assert repr(to_cnf("a | (b & c) | d")) == '((b | a | d) & (c | a | d))' - assert repr(to_cnf("A & (B | (D & E))")) == '(A & (D | B) & (E | B))' - assert repr(to_cnf("A | (B | (C | (D & E)))")) == '((D | A | B | C) & (E | A | B | C))' - assert repr(to_cnf( - '(A <=> ~B) ==> (C | ~D)')) == '((B | ~A | C | ~D) & (A | ~A | C | ~D) & (B | ~B | C | ~D) & (A | ~B | C | ~D))' - - -def test_pl_resolution(): - assert pl_resolution(wumpus_kb, ~P11) - assert pl_resolution(wumpus_kb, ~B11) - assert not pl_resolution(wumpus_kb, P22) - assert pl_resolution(horn_clauses_KB, A) - assert pl_resolution(horn_clauses_KB, B) - assert not pl_resolution(horn_clauses_KB, P) - assert not pl_resolution(definite_clauses_KB, P) - - -def test_standardize_variables(): - e = expr('F(a, b, c) & G(c, A, 23)') - assert len(variables(standardize_variables(e))) == 3 - # assert variables(e).intersection(variables(standardize_variables(e))) == {} - assert is_variable(standardize_variables(expr('x'))) - - -def test_fol_bc_ask(): - def test_ask(query, kb=None): - q = expr(query) - test_variables = variables(q) - answers = fol_bc_ask(kb or test_kb, q) - return sorted( - [dict((x, v) for x, v in list(a.items()) if x in test_variables) - for a in answers], key=repr) - - assert repr(test_ask('Farmer(x)')) == '[{x: Mac}]' - assert repr(test_ask('Human(x)')) == '[{x: Mac}, {x: MrsMac}]' - assert repr(test_ask('Rabbit(x)')) == '[{x: MrsRabbit}, {x: Pete}]' - assert repr(test_ask('Criminal(x)', crime_kb)) == '[{x: West}]' - - -def test_fol_fc_ask(): - def test_ask(query, kb=None): - q = expr(query) - test_variables = variables(q) - answers = fol_fc_ask(kb or test_kb, q) - return sorted( - [dict((x, v) for x, v in list(a.items()) if x in test_variables) - for a in answers], key=repr) - - assert repr(test_ask('Criminal(x)', crime_kb)) == '[{x: West}]' - assert repr(test_ask('Enemy(x, America)', crime_kb)) == '[{x: Nono}]' - assert repr(test_ask('Farmer(x)')) == '[{x: Mac}]' - assert repr(test_ask('Human(x)')) == '[{x: Mac}, {x: MrsMac}]' - assert repr(test_ask('Rabbit(x)')) == '[{x: MrsRabbit}, {x: Pete}]' - - -def test_d(): - assert d(x * x - x, x) == 2 * x - 1 - - -def test_WalkSAT(): - def check_SAT(clauses, single_solution={}): - # Make sure the solution is correct if it is returned by WalkSat - # Sometimes WalkSat may run out of flips before finding a solution - soln = WalkSAT(clauses) - if soln: - assert all(pl_true(x, soln) for x in clauses) - if single_solution: # Cross check the solution if only one exists - assert all(pl_true(x, single_solution) for x in clauses) - assert soln == single_solution - - # Test WalkSat for problems with solution - check_SAT([A & B, A & C]) - check_SAT([A | B, P & Q, P & B]) - check_SAT([A & B, C | D, ~(D | P)], {A: True, B: True, C: True, D: False, P: False}) - check_SAT([A, B, ~C, D], {C: False, A: True, B: True, D: True}) - # Test WalkSat for problems without solution - assert WalkSAT([A & ~A], 0.5, 100) is None - assert WalkSAT([A & B, C | D, ~(D | B)], 0.5, 100) is None - assert WalkSAT([A | B, ~A, ~(B | C), C | D, P | Q], 0.5, 100) is None - assert WalkSAT([A | B, B & C, C | D, D & A, P, ~P], 0.5, 100) is None - - -def test_SAT_plan(): - transition = {'A': {'Left': 'A', 'Right': 'B'}, - 'B': {'Left': 'A', 'Right': 'C'}, - 'C': {'Left': 'B', 'Right': 'C'}} - assert SAT_plan('A', transition, 'C', 2) is None - assert SAT_plan('A', transition, 'B', 3) == ['Right'] - assert SAT_plan('C', transition, 'A', 3) == ['Left', 'Left'] - - transition = {(0, 0): {'Right': (0, 1), 'Down': (1, 0)}, - (0, 1): {'Left': (1, 0), 'Down': (1, 1)}, - (1, 0): {'Right': (1, 0), 'Up': (1, 0), 'Left': (1, 0), 'Down': (1, 0)}, - (1, 1): {'Left': (1, 0), 'Up': (0, 1)}} - assert SAT_plan((0, 0), transition, (1, 1), 4) == ['Right', 'Down'] - - -if __name__ == '__main__': - pytest.main() diff --git a/tests/test_mdp.py b/tests/test_mdp.py index 979b4ba85..bcd10c75b 100644 --- a/tests/test_mdp.py +++ b/tests/test_mdp.py @@ -1,6 +1,6 @@ import pytest -from mdp import * +from aima.mdp import * random.seed("aima-python") @@ -22,32 +22,55 @@ terminals=[(2, 2), (3, 2), (0, 4), (5, 0)]) +def test_gen_grid(): + # the default reproduces the canonical 4x3 Figure 17.1 grid + assert gen_grid() == [[-0.04, -0.04, -0.04, 1], + [-0.04, None, -0.04, -1], + [-0.04, -0.04, -0.04, -0.04]] + # and feeds GridMDP to the same optimal policy as the shipped environment + generated = GridMDP(gen_grid(), terminals=[(3, 2), (3, 1)]) + assert (best_policy(generated, value_iteration(generated, .001)) == + best_policy(sequential_decision_environment, + value_iteration(sequential_decision_environment, .001))) + # an arbitrary larger world places terminals and obstacles correctly + big = gen_grid(n_rows=5, n_cols=5, terminals=[(4, 3), (4, 2)], main_reward=0.04, + terminal_rewards=[1, -1], block_coords=[(0, 3), (2, 3), (3, 1)]) + assert big == [[0.04, 0.04, 0.04, 0.04, 0.04], + [None, 0.04, None, 0.04, 1], + [0.04, 0.04, 0.04, 0.04, -1], + [0.04, 0.04, 0.04, None, 0.04], + [0.04, 0.04, 0.04, 0.04, 0.04]] + + def test_value_iteration(): - assert value_iteration(sequential_decision_environment, .01) == { + # exact float equality on the value function is brittle across numpy/BLAS + # versions (the values can differ in the last decimal), so compare with a + # tolerance via pytest.approx + assert value_iteration(sequential_decision_environment, .01) == pytest.approx({ (3, 2): 1.0, (3, 1): -1.0, (3, 0): 0.12958868267972745, (0, 1): 0.39810203830605462, (0, 2): 0.50928545646220924, (1, 0): 0.25348746162470537, (0, 0): 0.29543540628363629, (1, 2): 0.64958064617168676, (2, 0): 0.34461306281476806, (2, 1): 0.48643676237737926, - (2, 2): 0.79536093684710951} + (2, 2): 0.79536093684710951}) - assert value_iteration(sequential_decision_environment_1, .01) == { + assert value_iteration(sequential_decision_environment_1, .01) == pytest.approx({ (3, 2): 1.0, (3, 1): -1.0, (3, 0): -0.0897388258468311, (0, 1): 0.146419707398967840, (0, 2): 0.30596200514385086, (1, 0): 0.010092796415625799, (0, 0): 0.00633408092008296, (1, 2): 0.507390193380827400, (2, 0): 0.15072242145212010, (2, 1): 0.358309043654212570, - (2, 2): 0.71675493618997840} + (2, 2): 0.71675493618997840}) - assert value_iteration(sequential_decision_environment_2, .01) == { + assert value_iteration(sequential_decision_environment_2, .01) == pytest.approx({ (3, 2): 1.0, (3, 1): -1.0, (3, 0): -3.5141584808407855, (0, 1): -7.8000009574737180, (0, 2): -6.1064293596058830, (1, 0): -7.1012549580376760, (0, 0): -8.5872244532783200, (1, 2): -3.9653547121245810, (2, 0): -5.3099468802901630, (2, 1): -3.3543366255753995, - (2, 2): -1.7383376462930498} + (2, 2): -1.7383376462930498}) - assert value_iteration(sequential_decision_environment_3, .01) == { + assert value_iteration(sequential_decision_environment_3, .01) == pytest.approx({ (0, 0): 4.350592130345558, (0, 1): 3.640700980321895, (0, 2): 3.0734806370346943, (0, 3): 2.5754335063434937, (0, 4): -1.0, (1, 0): 3.640700980321895, (1, 1): 3.129579352304856, (1, 4): 2.0787517066719916, @@ -55,7 +78,7 @@ def test_value_iteration(): (3, 0): 2.5336747364500076, (3, 2): 3.0, (3, 3): 2.292172805400873, (3, 4): 2.996383110867515, (4, 0): 2.1014575936349886, (4, 3): 3.1297590518608907, (4, 4): 3.6408806798779287, (5, 0): -1.0, (5, 1): 2.5756132058995282, (5, 2): 3.0736603365907276, (5, 3): 3.6408806798779287, - (5, 4): 4.350771829901593} + (5, 4): 4.350771829901593}) def test_policy_iteration(): @@ -123,6 +146,10 @@ def test_transition_model(): assert mdp.T("b", "plan2") == [(0.6, 'a'), (0.2, 'b'), (0.1, 'c'), (0.1, 'd')] assert mdp.T("c", "plan1") == [(0.3, 'a'), (0.5, 'b'), (0.1, 'c'), (0.1, 'd')] + # a set actlist must be accepted and yield indexable per-state actions + assert set(mdp.actions("a")) == {"plan1", "plan2", "plan3"} + assert mdp.actions("d") == [None] + def test_pomdp_value_iteration(): t_prob = [[[0.65, 0.35], [0.65, 0.35]], [[0.65, 0.35], [0.65, 0.35]], [[1.0, 0.0], [0.0, 1.0]]] @@ -141,7 +168,7 @@ def test_pomdp_value_iteration(): for element in v: sum_ += sum(element) - assert -9.76 < sum_ < -9.70 or 246.5 < sum_ < 248.5 or 0 < sum_ < 1 + assert -12.77 < sum_ < -12.75 def test_pomdp_value_iteration2(): @@ -164,5 +191,35 @@ def test_pomdp_value_iteration2(): assert -77.31 < sum_ < -77.25 or 799 < sum_ < 800 +def test_update_belief(): + # action '2' keeps the state (identity transition) and gives an informative + # observation through the sensor model [[0.8, 0.2], [0.3, 0.7]] + t_prob = [[[0.65, 0.35], [0.65, 0.35]], [[0.65, 0.35], [0.65, 0.35]], [[1.0, 0.0], [0.0, 1.0]]] + e_prob = [[[0.5, 0.5], [0.5, 0.5]], [[0.5, 0.5], [0.5, 0.5]], [[0.8, 0.2], [0.3, 0.7]]] + rewards = [[5, -10], [-20, 5], [-1, -1]] + pomdp = POMDP(('0', '1', '2'), t_prob, e_prob, rewards, ('0', '1'), gamma=0.95) + + # from a uniform belief, observation 0 (more likely in state 0) shifts the + # belief towards state 0: b'(s') ~ [0.8 * 0.5, 0.3 * 0.5] normalized + belief = update_belief(pomdp, [0.5, 0.5], '2', 0) + assert belief == pytest.approx([0.8 / 1.1, 0.3 / 1.1]) + assert sum(belief) == pytest.approx(1) + + +def test_pomdp_lookahead(): + t_prob = [[[0.65, 0.35], [0.65, 0.35]], [[0.65, 0.35], [0.65, 0.35]], [[1.0, 0.0], [0.0, 1.0]]] + e_prob = [[[0.5, 0.5], [0.5, 0.5]], [[0.5, 0.5], [0.5, 0.5]], [[0.8, 0.2], [0.3, 0.7]]] + rewards = [[5, -10], [-20, 5], [-1, -1]] + pomdp = POMDP(('0', '1', '2'), t_prob, e_prob, rewards, ('0', '1'), gamma=0.95) + + # when the state is (almost) known, commit to the rewarding action: action 0 + # pays off in state 0 (reward 5), action 1 pays off in state 1 (reward 5) + assert pomdp_lookahead(pomdp, [0.9, 0.1], depth=1) == '0' + assert pomdp_lookahead(pomdp, [0.1, 0.9], depth=1) == '1' + # when the state is unknown, the DDN look-ahead prefers to gather information + # first (the sensing action 2) rather than commit blindly + assert pomdp_lookahead(pomdp, [0.5, 0.5], depth=2) == '2' + + if __name__ == "__main__": pytest.main() diff --git a/tests/test_mdp4e.py b/tests/test_mdp4e.py deleted file mode 100644 index e51bda5d6..000000000 --- a/tests/test_mdp4e.py +++ /dev/null @@ -1,176 +0,0 @@ -import pytest - -from mdp4e import * - -random.seed("aima-python") - -sequential_decision_environment_1 = GridMDP([[-0.1, -0.1, -0.1, +1], - [-0.1, None, -0.1, -1], - [-0.1, -0.1, -0.1, -0.1]], - terminals=[(3, 2), (3, 1)]) - -sequential_decision_environment_2 = GridMDP([[-2, -2, -2, +1], - [-2, None, -2, -1], - [-2, -2, -2, -2]], - terminals=[(3, 2), (3, 1)]) - -sequential_decision_environment_3 = GridMDP([[-1.0, -0.1, -0.1, -0.1, -0.1, 0.5], - [-0.1, None, None, -0.5, -0.1, -0.1], - [-0.1, None, 1.0, 3.0, None, -0.1], - [-0.1, -0.1, -0.1, None, None, -0.1], - [0.5, -0.1, -0.1, -0.1, -0.1, -1.0]], - terminals=[(2, 2), (3, 2), (0, 4), (5, 0)]) - - -def test_value_iteration(): - ref1 = { - (3, 2): 1.0, (3, 1): -1.0, - (3, 0): 0.12958868267972745, (0, 1): 0.39810203830605462, - (0, 2): 0.50928545646220924, (1, 0): 0.25348746162470537, - (0, 0): 0.29543540628363629, (1, 2): 0.64958064617168676, - (2, 0): 0.34461306281476806, (2, 1): 0.48643676237737926, - (2, 2): 0.79536093684710951} - assert sum(value_iteration(sequential_decision_environment, .01).values()) - sum(ref1.values()) < 0.0001 - - ref2 = { - (3, 2): 1.0, (3, 1): -1.0, - (3, 0): -0.0897388258468311, (0, 1): 0.146419707398967840, - (0, 2): 0.30596200514385086, (1, 0): 0.010092796415625799, - (0, 0): 0.00633408092008296, (1, 2): 0.507390193380827400, - (2, 0): 0.15072242145212010, (2, 1): 0.358309043654212570, - (2, 2): 0.71675493618997840} - assert sum(value_iteration(sequential_decision_environment_1, .01).values()) - sum(ref2.values()) < 0.0001 - - ref3 = { - (3, 2): 1.0, (3, 1): -1.0, - (3, 0): -3.5141584808407855, (0, 1): -7.8000009574737180, - (0, 2): -6.1064293596058830, (1, 0): -7.1012549580376760, - (0, 0): -8.5872244532783200, (1, 2): -3.9653547121245810, - (2, 0): -5.3099468802901630, (2, 1): -3.3543366255753995, - (2, 2): -1.7383376462930498} - assert sum(value_iteration(sequential_decision_environment_2, .01).values()) - sum(ref3.values()) < 0.0001 - - ref4 = { - (0, 0): 4.350592130345558, (0, 1): 3.640700980321895, (0, 2): 3.0734806370346943, (0, 3): 2.5754335063434937, - (0, 4): -1.0, - (1, 0): 3.640700980321895, (1, 1): 3.129579352304856, (1, 4): 2.0787517066719916, - (2, 0): 3.0259220379893352, (2, 1): 2.5926103577982897, (2, 2): 1.0, (2, 4): 2.507774181360808, - (3, 0): 2.5336747364500076, (3, 2): 3.0, (3, 3): 2.292172805400873, (3, 4): 2.996383110867515, - (4, 0): 2.1014575936349886, (4, 3): 3.1297590518608907, (4, 4): 3.6408806798779287, - (5, 0): -1.0, (5, 1): 2.5756132058995282, (5, 2): 3.0736603365907276, (5, 3): 3.6408806798779287, - (5, 4): 4.350771829901593} - assert sum(value_iteration(sequential_decision_environment_3, .01).values()) - sum(ref4.values()) < 0.001 - - -def test_policy_iteration(): - assert policy_iteration(sequential_decision_environment) == { - (0, 0): (0, 1), (0, 1): (0, 1), (0, 2): (1, 0), - (1, 0): (1, 0), (1, 2): (1, 0), (2, 0): (0, 1), - (2, 1): (0, 1), (2, 2): (1, 0), (3, 0): (-1, 0), - (3, 1): None, (3, 2): None} - - assert policy_iteration(sequential_decision_environment_1) == { - (0, 0): (0, 1), (0, 1): (0, 1), (0, 2): (1, 0), - (1, 0): (1, 0), (1, 2): (1, 0), (2, 0): (0, 1), - (2, 1): (0, 1), (2, 2): (1, 0), (3, 0): (-1, 0), - (3, 1): None, (3, 2): None} - - assert policy_iteration(sequential_decision_environment_2) == { - (0, 0): (1, 0), (0, 1): (0, 1), (0, 2): (1, 0), - (1, 0): (1, 0), (1, 2): (1, 0), (2, 0): (1, 0), - (2, 1): (1, 0), (2, 2): (1, 0), (3, 0): (0, 1), - (3, 1): None, (3, 2): None} - - -def test_best_policy(): - pi = best_policy(sequential_decision_environment, - value_iteration(sequential_decision_environment, .01)) - assert sequential_decision_environment.to_arrows(pi) == [['>', '>', '>', '.'], - ['^', None, '^', '.'], - ['^', '>', '^', '<']] - - pi_1 = best_policy(sequential_decision_environment_1, - value_iteration(sequential_decision_environment_1, .01)) - assert sequential_decision_environment_1.to_arrows(pi_1) == [['>', '>', '>', '.'], - ['^', None, '^', '.'], - ['^', '>', '^', '<']] - - pi_2 = best_policy(sequential_decision_environment_2, - value_iteration(sequential_decision_environment_2, .01)) - assert sequential_decision_environment_2.to_arrows(pi_2) == [['>', '>', '>', '.'], - ['^', None, '>', '.'], - ['>', '>', '>', '^']] - - pi_3 = best_policy(sequential_decision_environment_3, - value_iteration(sequential_decision_environment_3, .01)) - assert sequential_decision_environment_3.to_arrows(pi_3) == [['.', '>', '>', '>', '>', '>'], - ['v', None, None, '>', '>', '^'], - ['v', None, '.', '.', None, '^'], - ['v', '<', 'v', None, None, '^'], - ['<', '<', '<', '<', '<', '.']] - - -def test_transition_model(): - transition_model = {'a': {'plan1': [(0.2, 'a'), (0.3, 'b'), (0.3, 'c'), (0.2, 'd')], - 'plan2': [(0.4, 'a'), (0.15, 'b'), (0.45, 'c')], - 'plan3': [(0.2, 'a'), (0.5, 'b'), (0.3, 'c')], - }, - 'b': {'plan1': [(0.2, 'a'), (0.6, 'b'), (0.2, 'c'), (0.1, 'd')], - 'plan2': [(0.6, 'a'), (0.2, 'b'), (0.1, 'c'), (0.1, 'd')], - 'plan3': [(0.3, 'a'), (0.3, 'b'), (0.4, 'c')], - }, - 'c': {'plan1': [(0.3, 'a'), (0.5, 'b'), (0.1, 'c'), (0.1, 'd')], - 'plan2': [(0.5, 'a'), (0.3, 'b'), (0.1, 'c'), (0.1, 'd')], - 'plan3': [(0.1, 'a'), (0.3, 'b'), (0.1, 'c'), (0.5, 'd')], - }} - - mdp = MDP(init="a", actlist={"plan1", "plan2", "plan3"}, terminals={"d"}, states={"a", "b", "c", "d"}, - transitions=transition_model) - - assert mdp.T("a", "plan3") == [(0.2, 'a'), (0.5, 'b'), (0.3, 'c')] - assert mdp.T("b", "plan2") == [(0.6, 'a'), (0.2, 'b'), (0.1, 'c'), (0.1, 'd')] - assert mdp.T("c", "plan1") == [(0.3, 'a'), (0.5, 'b'), (0.1, 'c'), (0.1, 'd')] - - -def test_pomdp_value_iteration(): - t_prob = [[[0.65, 0.35], [0.65, 0.35]], [[0.65, 0.35], [0.65, 0.35]], [[1.0, 0.0], [0.0, 1.0]]] - e_prob = [[[0.5, 0.5], [0.5, 0.5]], [[0.5, 0.5], [0.5, 0.5]], [[0.8, 0.2], [0.3, 0.7]]] - rewards = [[5, -10], [-20, 5], [-1, -1]] - - gamma = 0.95 - actions = ('0', '1', '2') - states = ('0', '1') - - pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma) - utility = pomdp_value_iteration(pomdp, epsilon=5) - - for _, v in utility.items(): - sum_ = 0 - for element in v: - sum_ += sum(element) - - assert -9.76 < sum_ < -9.70 or 246.5 < sum_ < 248.5 or 0 < sum_ < 1 - - -def test_pomdp_value_iteration2(): - t_prob = [[[0.5, 0.5], [0.5, 0.5]], [[0.5, 0.5], [0.5, 0.5]], [[1.0, 0.0], [0.0, 1.0]]] - e_prob = [[[0.5, 0.5], [0.5, 0.5]], [[0.5, 0.5], [0.5, 0.5]], [[0.85, 0.15], [0.15, 0.85]]] - rewards = [[-100, 10], [10, -100], [-1, -1]] - - gamma = 0.95 - actions = ('0', '1', '2') - states = ('0', '1') - - pomdp = POMDP(actions, t_prob, e_prob, rewards, states, gamma) - utility = pomdp_value_iteration(pomdp, epsilon=100) - - for _, v in utility.items(): - sum_ = 0 - for element in v: - sum_ += sum(element) - - assert -77.31 < sum_ < -77.25 or 799 < sum_ < 800 - - -if __name__ == "__main__": - pytest.main() diff --git a/tests/test_nlp.py b/tests/test_nlp.py index 85d246dfa..b00d77218 100644 --- a/tests/test_nlp.py +++ b/tests/test_nlp.py @@ -1,13 +1,13 @@ import random import pytest -import nlp +from aima import nlp -from nlp import loadPageHTML, stripRawHTML, findOutlinks, onlyWikipediaURLS -from nlp import expand_pages, relevant_pages, normalize, ConvergenceDetector, getInLinks -from nlp import getOutLinks, Page, determineInlinks, HITS -from nlp import Rules, Lexicon, Grammar, ProbRules, ProbLexicon, ProbGrammar -from nlp import Chart, CYK_parse +from aima.nlp import load_page_html, strip_raw_html, find_outlinks, only_wikipedia_urls +from aima.nlp import expand_pages, relevant_pages, normalize, ConvergenceDetector, get_in_links +from aima.nlp import get_out_links, Page, determine_inlinks, HITS +from aima.nlp import Rules, Lexicon, Grammar, ProbRules, ProbLexicon, ProbGrammar +from aima.nlp import Chart, CYK_parse, subspan, Tree, astar_search_parsing, beam_search_parsing # Clumsy imports because we want to access certain nlp.py globals explicitly, because # they are accessed by functions within nlp.py @@ -123,18 +123,37 @@ def test_CYK_parse(): assert len(P) == 32 +def test_subspan(): + spans = subspan(3) + assert spans.__next__() == (1, 1, 2) + assert spans.__next__() == (2, 2, 3) + assert spans.__next__() == (1, 1, 3) + assert spans.__next__() == (1, 2, 3) + + +def test_text_parsing(): + # NB: canonical nlp.E0 has the adjective "smelly" (not "dead" as in the 4e grammar) + words = ["the", "wumpus", "is", "smelly"] + grammar = nlp.E0 + assert astar_search_parsing(words, grammar) == 'S' + assert beam_search_parsing(words, grammar) == 'S' + words = ["the", "is", "wupus", "smelly"] + assert astar_search_parsing(words, grammar) is False + assert beam_search_parsing(words, grammar) is False + + # ______________________________________________________________________________ # Data Setup -testHTML = """Keyword String 1: A man is a male human. +test_html = """Keyword String 1: A man is a male human. Keyword String 2: Like most other male mammals, a man inherits an X from his mom and a Y from his dad. Links: href="https://google.com.au" < href="/wiki/TestThing" > href="/wiki/TestBoy" href="/wiki/TestLiving" href="/wiki/TestMan" >""" -testHTML2 = "a mom and a dad" -testHTML3 = """ +test_html2 = "a mom and a dad" +test_html3 = """ @@ -154,44 +173,44 @@ def test_CYK_parse(): pD = Page("D", ["A", "B", "C", "E"], [], 4, 3) pE = Page("E", [], ["A", "B", "C", "D", "F"], 5, 2) pF = Page("F", ["E"], [], 6, 1) -pageDict = {pA.address: pA, pB.address: pB, pC.address: pC, +page_dict = {pA.address: pA, pB.address: pB, pC.address: pC, pD.address: pD, pE.address: pE, pF.address: pF} -nlp.pagesIndex = pageDict -nlp.pagesContent = {pA.address: testHTML, pB.address: testHTML2, - pC.address: testHTML, pD.address: testHTML2, - pE.address: testHTML, pF.address: testHTML2} +nlp.pages_index = page_dict +nlp.pages_content = {pA.address: test_html, pB.address: test_html2, + pC.address: test_html, pD.address: test_html2, + pE.address: test_html, pF.address: test_html2} # This test takes a long time (> 60 secs) -# def test_loadPageHTML(): +# def test_load_page_html(): # # first format all the relative URLs with the base URL -# addresses = [examplePagesSet[0] + x for x in examplePagesSet[1:]] -# loadedPages = loadPageHTML(addresses) -# relURLs = ['Ancient_Greek','Ethics','Plato','Theology'] -# fullURLs = ["https://en.wikipedia.org/wiki/"+x for x in relURLs] -# assert all(x in loadedPages for x in fullURLs) -# assert all(loadedPages.get(key,"") != "" for key in addresses) +# addresses = [example_pages_set[0] + x for x in example_pages_set[1:]] +# loaded_pages = load_page_html(addresses) +# rel_urls = ['Ancient_Greek','Ethics','Plato','Theology'] +# full_urls = ["https://en.wikipedia.org/wiki/"+x for x in rel_urls] +# assert all(x in loaded_pages for x in full_urls) +# assert all(loaded_pages.get(key,"") != "" for key in addresses) -@patch('urllib.request.urlopen', return_value=BytesIO(testHTML3.encode())) -def test_stripRawHTML(html_mock): +@patch('urllib.request.urlopen', return_value=BytesIO(test_html3.encode())) +def test_strip_raw_html(html_mock): addr = "https://en.wikipedia.org/wiki/Ethics" - aPage = loadPageHTML([addr]) - someHTML = aPage[addr] - strippedHTML = stripRawHTML(someHTML) - assert "" not in strippedHTML and "" not in strippedHTML - assert "AIMA book" in someHTML and "AIMA book" in strippedHTML + a_page = load_page_html([addr]) + some_html = a_page[addr] + stripped_html = strip_raw_html(some_html) + assert "" not in stripped_html and "" not in stripped_html + assert "AIMA book" in some_html and "AIMA book" in stripped_html -def test_determineInlinks(): - assert set(determineInlinks(pA)) == set(['B', 'C', 'E']) - assert set(determineInlinks(pE)) == set([]) - assert set(determineInlinks(pF)) == set(['E']) +def test_determine_inlinks(): + assert set(determine_inlinks(pA)) == set(['B', 'C', 'E']) + assert set(determine_inlinks(pE)) == set([]) + assert set(determine_inlinks(pF)) == set(['E']) -def test_findOutlinks_wiki(): - testPage = pageDict[pA.address] - outlinks = findOutlinks(testPage, handleURLs=onlyWikipediaURLS) +def test_find_outlinks_wiki(): + test_page = page_dict[pA.address] + outlinks = find_outlinks(test_page, handle_urls=only_wikipedia_urls) assert "https://en.wikipedia.org/wiki/TestThing" in outlinks assert "https://en.wikipedia.org/wiki/TestThing" in outlinks assert "https://google.com.au" not in outlinks @@ -202,12 +221,12 @@ def test_findOutlinks_wiki(): def test_expand_pages(): - pages = {k: pageDict[k] for k in ('F')} - pagesTwo = {k: pageDict[k] for k in ('A', 'E')} + pages = {k: page_dict[k] for k in ('F')} + pages_two = {k: page_dict[k] for k in ('A', 'E')} expanded_pages = expand_pages(pages) assert all(x in expanded_pages for x in ['F', 'E']) assert all(x not in expanded_pages for x in ['A', 'B', 'C', 'D']) - expanded_pages = expand_pages(pagesTwo) + expanded_pages = expand_pages(pages_two) print(expanded_pages) assert all(x in expanded_pages for x in ['A', 'B', 'C', 'D', 'E', 'F']) @@ -223,42 +242,42 @@ def test_relevant_pages(): def test_normalize(): - normalize(pageDict) - print(page.hub for addr, page in nlp.pagesIndex.items()) + normalize(page_dict) + print(page.hub for addr, page in nlp.pages_index.items()) expected_hub = [1 / 91 ** 0.5, 2 / 91 ** 0.5, 3 / 91 ** 0.5, 4 / 91 ** 0.5, 5 / 91 ** 0.5, 6 / 91 ** 0.5] # Works only for sample data above expected_auth = list(reversed(expected_hub)) - assert len(expected_hub) == len(expected_auth) == len(nlp.pagesIndex) - assert expected_hub == [page.hub for addr, page in sorted(nlp.pagesIndex.items())] - assert expected_auth == [page.authority for addr, page in sorted(nlp.pagesIndex.items())] + assert len(expected_hub) == len(expected_auth) == len(nlp.pages_index) + assert expected_hub == [page.hub for addr, page in sorted(nlp.pages_index.items())] + assert expected_auth == [page.authority for addr, page in sorted(nlp.pages_index.items())] -def test_detectConvergence(): - # run detectConvergence once to initialise history +def test_detect_convergence(): + # run detect_convergence once to initialise history convergence = ConvergenceDetector() convergence() assert convergence() # values haven't changed so should return True # make tiny increase/decrease to all values - for _, page in nlp.pagesIndex.items(): + for _, page in nlp.pages_index.items(): page.hub += 0.0003 page.authority += 0.0004 # retest function with values. Should still return True assert convergence() - for _, page in nlp.pagesIndex.items(): + for _, page in nlp.pages_index.items(): page.hub += 3000000 page.authority += 3000000 # retest function with values. Should now return false assert not convergence() -def test_getInlinks(): - inlnks = getInLinks(pageDict['A']) - assert sorted(inlnks) == pageDict['A'].inlinks +def test_get_inlinks(): + inlnks = get_in_links(page_dict['A']) + assert sorted(inlnks) == page_dict['A'].inlinks -def test_getOutlinks(): - outlnks = getOutLinks(pageDict['A']) - assert sorted(outlnks) == pageDict['A'].outlinks +def test_get_outlinks(): + outlnks = get_out_links(page_dict['A']) + assert sorted(outlnks) == page_dict['A'].outlinks def test_HITS(): diff --git a/tests/test_nlp4e.py b/tests/test_nlp4e.py deleted file mode 100644 index 2d16a3196..000000000 --- a/tests/test_nlp4e.py +++ /dev/null @@ -1,139 +0,0 @@ -import random - -import pytest -import nlp - -from nlp4e import Rules, Lexicon, Grammar, ProbRules, ProbLexicon, ProbGrammar, E0 -from nlp4e import Chart, CYK_parse, subspan, astar_search_parsing, beam_search_parsing - -# Clumsy imports because we want to access certain nlp.py globals explicitly, because -# they are accessed by functions within nlp.py - -random.seed("aima-python") - - -def test_rules(): - check = {'A': [['B', 'C'], ['D', 'E']], 'B': [['E'], ['a'], ['b', 'c']]} - assert Rules(A="B C | D E", B="E | a | b c") == check - - -def test_lexicon(): - check = {'Article': ['the', 'a', 'an'], 'Pronoun': ['i', 'you', 'he']} - lexicon = Lexicon(Article="the | a | an", Pronoun="i | you | he") - assert lexicon == check - - -def test_grammar(): - rules = Rules(A="B C | D E", B="E | a | b c") - lexicon = Lexicon(Article="the | a | an", Pronoun="i | you | he") - grammar = Grammar("Simplegram", rules, lexicon) - - assert grammar.rewrites_for('A') == [['B', 'C'], ['D', 'E']] - assert grammar.isa('the', 'Article') - - grammar = nlp.E_Chomsky - for rule in grammar.cnf_rules(): - assert len(rule) == 3 - - -def test_generation(): - lexicon = Lexicon(Article="the | a | an", - Pronoun="i | you | he") - - rules = Rules( - S="Article | More | Pronoun", - More="Article Pronoun | Pronoun Pronoun" - ) - - grammar = Grammar("Simplegram", rules, lexicon) - - sentence = grammar.generate_random('S') - for token in sentence.split(): - found = False - for non_terminal, terminals in grammar.lexicon.items(): - if token in terminals: - found = True - assert found - - -def test_prob_rules(): - check = {'A': [(['B', 'C'], 0.3), (['D', 'E'], 0.7)], - 'B': [(['E'], 0.1), (['a'], 0.2), (['b', 'c'], 0.7)]} - rules = ProbRules(A="B C [0.3] | D E [0.7]", B="E [0.1] | a [0.2] | b c [0.7]") - assert rules == check - - -def test_prob_lexicon(): - check = {'Article': [('the', 0.5), ('a', 0.25), ('an', 0.25)], - 'Pronoun': [('i', 0.4), ('you', 0.3), ('he', 0.3)]} - lexicon = ProbLexicon(Article="the [0.5] | a [0.25] | an [0.25]", - Pronoun="i [0.4] | you [0.3] | he [0.3]") - assert lexicon == check - - -def test_prob_grammar(): - rules = ProbRules(A="B C [0.3] | D E [0.7]", B="E [0.1] | a [0.2] | b c [0.7]") - lexicon = ProbLexicon(Article="the [0.5] | a [0.25] | an [0.25]", - Pronoun="i [0.4] | you [0.3] | he [0.3]") - grammar = ProbGrammar("Simplegram", rules, lexicon) - - assert grammar.rewrites_for('A') == [(['B', 'C'], 0.3), (['D', 'E'], 0.7)] - assert grammar.isa('the', 'Article') - - grammar = nlp.E_Prob_Chomsky - for rule in grammar.cnf_rules(): - assert len(rule) == 4 - - -def test_prob_generation(): - lexicon = ProbLexicon(Verb="am [0.5] | are [0.25] | is [0.25]", - Pronoun="i [0.4] | you [0.3] | he [0.3]") - - rules = ProbRules( - S="Verb [0.5] | More [0.3] | Pronoun [0.1] | nobody is here [0.1]", - More="Pronoun Verb [0.7] | Pronoun Pronoun [0.3]") - - grammar = ProbGrammar("Simplegram", rules, lexicon) - - sentence = grammar.generate_random('S') - assert len(sentence) == 2 - - -def test_chart_parsing(): - chart = Chart(nlp.E0) - parses = chart.parses('the stench is in 2 2') - assert len(parses) == 1 - - -def test_CYK_parse(): - grammar = nlp.E_Prob_Chomsky - words = ['the', 'robot', 'is', 'good'] - P = CYK_parse(words, grammar) - assert len(P) == 5 - - grammar = nlp.E_Prob_Chomsky_ - words = ['astronomers', 'saw', 'stars'] - P = CYK_parse(words, grammar) - assert len(P) == 3 - - -def test_subspan(): - spans = subspan(3) - assert spans.__next__() == (1, 1, 2) - assert spans.__next__() == (2, 2, 3) - assert spans.__next__() == (1, 1, 3) - assert spans.__next__() == (1, 2, 3) - - -def test_text_parsing(): - words = ["the", "wumpus", "is", "dead"] - grammer = E0 - assert astar_search_parsing(words, grammer) == 'S' - assert beam_search_parsing(words, grammer) == 'S' - words = ["the", "is", "wupus", "dead"] - assert astar_search_parsing(words, grammer) is False - assert beam_search_parsing(words, grammer) is False - - -if __name__ == '__main__': - pytest.main() diff --git a/tests/test_perception4e.py b/tests/test_perception.py similarity index 86% rename from tests/test_perception4e.py rename to tests/test_perception.py index 46d534523..0c20c4045 100644 --- a/tests/test_perception4e.py +++ b/tests/test_perception.py @@ -2,7 +2,7 @@ import pytest -from perception4e import * +from aima.perception import * from PIL import Image import numpy as np import os @@ -83,5 +83,15 @@ def test_ROIPoolingLayer(): [1, 1, 1, 1, 1, 1, 50]] +def test_hog(): + # a 16x16 image split by a vertical edge; HOG yields one L2-normalized 2x2 block + image = np.zeros((16, 16)) + image[:, 8:] = 255.0 + features = hog(image, cell_size=8, bins=9, block_size=2) + assert features.shape == (2 * 2 * 9,) # one block of 2x2 cells x 9 bins + assert np.isfinite(features).all() + assert abs(np.linalg.norm(features) - 1) < 1e-3 # the single block is L2-normalized + + if __name__ == '__main__': pytest.main() diff --git a/tests/test_planning.py b/tests/test_planning.py index a39152adc..b81a2f224 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -2,10 +2,10 @@ import pytest -from planning import * -from search import astar_search -from utils import expr -from logic import FolKB, conjuncts +from aima.planning import * +from aima.search import astar_search +from aima.utils import expr +from aima.logic import FolKB, conjuncts random.seed('aima-python') @@ -194,19 +194,19 @@ def test_graph_call(): assert levels_size == len(graph.levels) - 1 -def test_graphPlan(): - spare_tire_solution = spare_tire_graphPlan() +def test_graph_plan(): + spare_tire_solution = spare_tire_graph_plan() spare_tire_solution = linearize(spare_tire_solution) assert expr('Remove(Flat, Axle)') in spare_tire_solution assert expr('Remove(Spare, Trunk)') in spare_tire_solution assert expr('PutOn(Spare, Axle)') in spare_tire_solution - cake_solution = have_cake_and_eat_cake_too_graphPlan() + cake_solution = have_cake_and_eat_cake_too_graph_plan() cake_solution = linearize(cake_solution) assert expr('Eat(Cake)') in cake_solution assert expr('Bake(Cake)') in cake_solution - air_cargo_solution = air_cargo_graphPlan() + air_cargo_solution = air_cargo_graph_plan() air_cargo_solution = linearize(air_cargo_solution) assert expr('Load(C1, P1, SFO)') in air_cargo_solution assert expr('Load(C2, P2, JFK)') in air_cargo_solution @@ -215,28 +215,32 @@ def test_graphPlan(): assert expr('Unload(C1, P1, JFK)') in air_cargo_solution assert expr('Unload(C2, P2, SFO)') in air_cargo_solution - sussman_anomaly_solution = three_block_tower_graphPlan() + sussman_anomaly_solution = three_block_tower_graph_plan() sussman_anomaly_solution = linearize(sussman_anomaly_solution) assert expr('MoveToTable(C, A)') in sussman_anomaly_solution assert expr('Move(B, Table, C)') in sussman_anomaly_solution assert expr('Move(A, Table, B)') in sussman_anomaly_solution - blocks_world_solution = simple_blocks_world_graphPlan() + blocks_world_solution = simple_blocks_world_graph_plan() blocks_world_solution = linearize(blocks_world_solution) assert expr('ToTable(A, B)') in blocks_world_solution assert expr('FromTable(B, A)') in blocks_world_solution assert expr('FromTable(C, B)') in blocks_world_solution - shopping_problem_solution = shopping_graphPlan() + shopping_problem_solution = shopping_graph_plan() shopping_problem_solution = linearize(shopping_problem_solution) - assert expr('Go(Home, HW)') in shopping_problem_solution - assert expr('Go(Home, SM)') in shopping_problem_solution + # The plan must visit both stores; the route may be reached either directly + # from Home or by hopping between the stores (e.g. Home -> SM -> HW). + assert (expr('Go(Home, HW)') in shopping_problem_solution or + expr('Go(SM, HW)') in shopping_problem_solution) + assert (expr('Go(Home, SM)') in shopping_problem_solution or + expr('Go(HW, SM)') in shopping_problem_solution) assert expr('Buy(Drill, HW)') in shopping_problem_solution assert expr('Buy(Banana, SM)') in shopping_problem_solution assert expr('Buy(Milk, SM)') in shopping_problem_solution -def test_forwardPlan(): +def test_forward_plan(): spare_tire_solution = astar_search(ForwardPlan(spare_tire())).solution() spare_tire_solution = list(map(lambda action: Expr(action.name, *action.args), spare_tire_solution)) assert expr('Remove(Flat, Axle)') in spare_tire_solution @@ -253,9 +257,9 @@ def test_forwardPlan(): assert expr('Load(C2, P2, JFK)') in air_cargo_solution assert expr('Fly(P2, JFK, SFO)') in air_cargo_solution assert expr('Unload(C2, P2, SFO)') in air_cargo_solution - assert expr('Load(C1, P2, SFO)') in air_cargo_solution - assert expr('Fly(P2, SFO, JFK)') in air_cargo_solution - assert expr('Unload(C1, P2, JFK)') in air_cargo_solution + assert expr('Load(C1, P1, SFO)') in air_cargo_solution + assert expr('Fly(P1, SFO, JFK)') in air_cargo_solution + assert expr('Unload(C1, P1, JFK)') in air_cargo_solution sussman_anomaly_solution = astar_search(ForwardPlan(three_block_tower())).solution() sussman_anomaly_solution = list(map(lambda action: Expr(action.name, *action.args), sussman_anomaly_solution)) @@ -271,14 +275,15 @@ def test_forwardPlan(): shopping_problem_solution = astar_search(ForwardPlan(shopping_problem())).solution() shopping_problem_solution = list(map(lambda action: Expr(action.name, *action.args), shopping_problem_solution)) - assert expr('Go(Home, SM)') in shopping_problem_solution assert expr('Buy(Banana, SM)') in shopping_problem_solution assert expr('Buy(Milk, SM)') in shopping_problem_solution - assert expr('Go(SM, HW)') in shopping_problem_solution assert expr('Buy(Drill, HW)') in shopping_problem_solution + # the plan must reach both stores; the exact route may vary by tie-breaking + assert expr('Go(Home, SM)') in shopping_problem_solution or expr('Go(HW, SM)') in shopping_problem_solution + assert expr('Go(Home, HW)') in shopping_problem_solution or expr('Go(SM, HW)') in shopping_problem_solution -def test_backwardPlan(): +def test_backward_plan(): spare_tire_solution = astar_search(BackwardPlan(spare_tire())).solution() spare_tire_solution = list(map(lambda action: Expr(action.name, *action.args), spare_tire_solution)) assert expr('Remove(Flat, Axle)') in spare_tire_solution @@ -510,6 +515,25 @@ def test_partial_order_planner(): assert list(plan[3])[0].name == 'Finish' +def test_partial_order_planner_solves_standard_problems(): + # the planner must return a valid, executable plan (one that reaches the goal) + # for every standard problem, deterministically (not just the threat-free + # socks_and_shoes case) + for factory in (spare_tire, socks_and_shoes, have_cake_and_eat_cake_too, + three_block_tower, air_cargo, shopping_problem, simple_blocks_world): + problem = factory() # fresh problem instance to simulate the plan on + pop = PartialOrderPlanner(factory()) + pop.execute(display=False) + levels = list(reversed(list(pop.toposort(pop.convert(pop.constraints))))) + plan = [action for level in levels for action in level + if action.name not in ('Start', 'Finish')] + assert plan, 'no plan found for ' + factory.__name__ + for action in plan: + act_expr = expr(action.name)(*action.args) if action.args else expr(action.name) + problem.act(act_expr) # raises if a precondition is unmet + assert problem.goal_test(), 'plan does not reach the goal for ' + factory.__name__ + + def test_double_tennis(): p = double_tennis_problem() assert not goal_test(p.goals, p.initial) @@ -582,7 +606,7 @@ def test_job_shop_problem(): prob_1 = RealWorldPlanningProblem('At(Home) & Have(Cash) & Have(Car) ', 'At(SFO) & Have(Cash)', [go_SFO, taxi_SFO, drive_SFOLongTermParking, shuttle_SFO]) -initialPlan = [AngelicNode(prob_1.initial, None, [angelic_opt_description], [angelic_pes_description])] +initial_plan = [AngelicNode(prob_1.initial, None, [angelic_opt_description], [angelic_pes_description])] def test_refinements(): @@ -630,6 +654,25 @@ def test_hierarchical_search(): assert (solution_2[1].args == (expr('MetroStop'), expr('SFO'))) +def test_hierarchical_search_max_depth(): + # a recursive hierarchy: Walk refines to [Step, Walk] (recursive) or [] (base); + # without a bound, hierarchical_search loops forever when the goal is unreachable + recursive_library = { + 'HLA': ['Walk()', 'Walk()', 'Step()'], + 'steps': [['Step()', 'Walk()'], [], []], + 'precond': [['At(Home)'], ['At(Home)'], ['At(Home)']], + 'effect': [['At(Home)'], ['At(Home)'], ['At(Home)']]} + walk = HLA('Walk()', precond='At(Home)', effect='At(Home)') + + # unreachable goal: max_depth guarantees termination, returning None + prob = RealWorldPlanningProblem('At(Home)', 'At(Mars)', [walk]) + assert RealWorldPlanningProblem.hierarchical_search(prob, recursive_library, max_depth=8) is None + + # reachable goal: a solution is still found within the bound + prob_ok = RealWorldPlanningProblem('At(Home)', 'At(Home)', [walk]) + assert RealWorldPlanningProblem.hierarchical_search(prob_ok, recursive_library, max_depth=8) is not None + + def test_convert_angelic_HLA(): """ Converts angelic HLA's into expressions that correspond to their actions @@ -740,15 +783,15 @@ def test_making_progress(): plan_1 = AngelicNode(prob_1.initial, None, [angelic_opt_description], [angelic_pes_description]) - assert (not RealWorldPlanningProblem.making_progress(plan_1, initialPlan)) + assert (not RealWorldPlanningProblem.making_progress(plan_1, initial_plan)) def test_angelic_search(): """ - Test angelic search for problem, hierarchy, initialPlan + Test angelic search for problem, hierarchy, initial_plan """ # test_1 - solution = RealWorldPlanningProblem.angelic_search(prob_1, library_1, initialPlan) + solution = RealWorldPlanningProblem.angelic_search(prob_1, library_1, initial_plan) assert (len(solution) == 2) @@ -759,7 +802,7 @@ def test_angelic_search(): assert (solution[1].args == shuttle_SFO.args) # test_2 - solution_2 = RealWorldPlanningProblem.angelic_search(prob_1, library_2, initialPlan) + solution_2 = RealWorldPlanningProblem.angelic_search(prob_1, library_2, initial_plan) assert (len(solution_2) == 2) diff --git a/tests/test_probabilistic_learning.py b/tests/test_probabilistic_learning.py index bd37b6ebb..afe3aefc5 100644 --- a/tests/test_probabilistic_learning.py +++ b/tests/test_probabilistic_learning.py @@ -2,8 +2,8 @@ import pytest -from learning import DataSet -from probabilistic_learning import * +from aima.learning import DataSet +from aima.probabilistic_learning import * random.seed("aima-python") diff --git a/tests/test_probability.py b/tests/test_probability.py index 8def79c68..bbadfae5f 100644 --- a/tests/test_probability.py +++ b/tests/test_probability.py @@ -1,7 +1,7 @@ import pytest -from probability import * -from utils import rounder +from aima.probability import * +from aima.utils import rounder random.seed("aima-python") @@ -112,6 +112,27 @@ def test_enumerate_joint_ask(): 'X', dict(Y=1), P).show_approx() == '0: 0.667, 1: 0.167, 2: 0.167' +def test_is_independent(): + P = JointProbDist(['X', 'Y']) + P[0, 0] = P[0, 1] = P[1, 1] = P[1, 0] = 0.25 + assert enumerate_joint_ask( + 'X', dict(Y=1), P).show_approx() == '0: 0.5, 1: 0.5' + assert is_independent(['X', 'Y'], P) + + +def test_gaussian_probability(): + param = {'sigma': 0.5, 'b': 1, 'a': {'h': 0.5}} + event = {'h': 0.6} + assert gaussian_probability(param, event, 1) == 0.6664492057835993 + + +def test_logistic_probability(): + param = {'mu': 0.5, 'sigma': 0.1} + event = {'h': 0.6} + assert logistic_probability(param, event, True) == 0.16857376940725355 + assert logistic_probability(param, event, False) == 0.8314262305927465 + + def test_bayesnode_p(): bn = BayesNode('X', 'Burglary', {T: 0.2, F: 0.625}) assert bn.p(True, {'Burglary': True, 'Earthquake': False}) == 0.2 @@ -305,7 +326,7 @@ def test_fixed_lag_smoothing(): umbrellaHMM = HiddenMarkovModel(umbrella_transition, umbrella_sensor) d = 2 - assert rounder(fixed_lag_smoothing(e_t, umbrellaHMM, d, umbrella_evidence, t)) == [0.1111, 0.8889] + assert rounder(fixed_lag_smoothing(e_t, umbrellaHMM, d, umbrella_evidence, t)) == [0.2259, 0.7741] d = 5 assert fixed_lag_smoothing(e_t, umbrellaHMM, d, umbrella_evidence, t) is None @@ -314,7 +335,7 @@ def test_fixed_lag_smoothing(): e_t = T d = 1 - assert rounder(fixed_lag_smoothing(e_t, umbrellaHMM, d, umbrella_evidence, t)) == [0.9939, 0.0061] + assert rounder(fixed_lag_smoothing(e_t, umbrellaHMM, d, umbrella_evidence, t)) == [0.2839, 0.7161] def test_particle_filtering(): @@ -329,6 +350,108 @@ def test_particle_filtering(): # XXX 'A' and 'B' are really arbitrary names, but I'm letting it stand for now +def test_kalman_filter(): + # one-dimensional random walk (Section 15.4 example): x_{t+1} = x_t + noise, + # z_t = x_t + noise. With prior N(0, 1), transition variance 2 and sensor + # variance 1, a single observation z = 2.5 has the closed-form posterior + # mean ((cov0 + Sigma_x) * z + Sigma_z * mean0) / (cov0 + Sigma_x + Sigma_z) + # and variance (cov0 + Sigma_x) * Sigma_z / (cov0 + Sigma_x + Sigma_z) + kf = KalmanFilter(transition_model=[[1]], sensor_model=[[1]], + transition_noise=[[2]], sensor_noise=[[1]]) + (mean, cov), = kalman_filter(kf, mean0=[0], cov0=[[1]], observations=[2.5]) + assert np.isclose(mean[0], 1.875) + assert np.isclose(cov[0, 0], 0.75) + # conditioning on the observation never increases the predicted uncertainty + assert cov[0, 0] < 1 + 2 + + # two-dimensional constant-velocity model: the state is [position, velocity] + # and only the position is observed; from a sequence of steadily increasing + # position measurements the filter should infer a positive velocity + kf = KalmanFilter(transition_model=[[1, 1], [0, 1]], sensor_model=[[1, 0]], + transition_noise=[[0.01, 0], [0, 0.01]], sensor_noise=[[1]]) + estimates = kalman_filter(kf, mean0=[0, 0], cov0=[[1, 0], [0, 1]], + observations=[1, 2, 3, 4, 5]) + assert len(estimates) == 5 + final_mean, final_cov = estimates[-1] + assert final_mean[1] > 0 # inferred velocity is positive + assert final_cov.shape == (2, 2) + + +def test_kalman_filter_steady_state(): + # [Section 15.4] for the 1-D random walk with unit transition and sensor + # variance the filtered variance converges to the fixed point of + # s = (s + 1) / (s + 2), i.e. s^2 + s - 1 = 0, so s = (sqrt(5) - 1) / 2 + kf = KalmanFilter(transition_model=[[1]], sensor_model=[[1]], + transition_noise=[[1]], sensor_noise=[[1]]) + estimates = kalman_filter(kf, mean0=[0], cov0=[[1]], observations=[0.0] * 60) + steady_state = (5 ** 0.5 - 1) / 2 + assert estimates[-1][1][0, 0] == pytest.approx(steady_state, abs=1e-6) + + +def test_baum_welch(): + def sequence_log_likelihood(hmm, obs): + """Log probability of the observation sequence under hmm (scaled forward pass).""" + A = np.array(hmm.transition_model) + sensor = np.array(hmm.sensor_model) + B = np.array([sensor[0] if e else sensor[1] for e in obs]) + alpha = np.array(hmm.prior) * B[0] + ll = np.log(alpha.sum()) + alpha = alpha / alpha.sum() + for t in range(1, len(obs)): + alpha = B[t] * (alpha @ A) + ll += np.log(alpha.sum()) + alpha = alpha / alpha.sum() + return ll + + umbrella_transition = [[0.7, 0.3], [0.3, 0.7]] + umbrella_sensor = [[0.9, 0.2], [0.1, 0.8]] + umbrellaHMM = HiddenMarkovModel(umbrella_transition, umbrella_sensor) + observations = [T, T, F, T, T, F, F, F, T, F, T, T] + + # Baum-Welch (EM) must never decrease the data log likelihood + log_likelihoods = [sequence_log_likelihood(baum_welch(umbrellaHMM, observations, iterations=k), observations) + for k in (1, 2, 5, 10, 20)] + assert all(later >= earlier - 1e-9 for earlier, later in zip(log_likelihoods, log_likelihoods[1:])) + assert log_likelihoods[-1] >= sequence_log_likelihood(umbrellaHMM, observations) - 1e-9 + + # the learned parameters are still valid probability distributions + learned = baum_welch(umbrellaHMM, observations, iterations=20) + assert np.allclose(np.sum(learned.transition_model, axis=1), 1) + assert np.isclose(sum(learned.prior), 1) + assert np.allclose(np.sum(learned.sensor_model, axis=0), 1) + + +def test_dynamic_bayes_net(): + # the umbrella world as a DBN: hidden Rain with a 0.7 self-transition, observed + # through Umbrella with sensor probabilities 0.9 / 0.2 + umbrella_dbn = DynamicBayesNet(prior=[('Rain', '', 0.5)], + transition=[('Rain', 'Rain_prev', {T: 0.7, F: 0.3})], + sensors=[('Umbrella', 'Rain', {T: 0.9, F: 0.2})]) + + # unrolling spans slices 0..steps with evidence variables at slices 1..steps + assert umbrella_dbn.unroll(2).variables == ['Rain_0', 'Rain_1', 'Umbrella_1', 'Rain_2', 'Umbrella_2'] + + # filtering by exact inference matches the canonical umbrella values, which in + # turn agree with the HMM forward algorithm + assert umbrella_dbn.filter([{'Umbrella': True}], 'Rain')[True] == pytest.approx(0.8182, abs=1e-4) + assert umbrella_dbn.filter([{'Umbrella': True}, {'Umbrella': True}], 'Rain')[True] == pytest.approx(0.8834, + abs=1e-4) + + umbrellaHMM = HiddenMarkovModel([[0.7, 0.3], [0.3, 0.7]], [[0.9, 0.2], [0.1, 0.8]]) + hmm_belief = forward(umbrellaHMM, umbrellaHMM.prior, True) + assert umbrella_dbn.filter([{'Umbrella': True}], 'Rain')[True] == pytest.approx(hmm_belief[0]) + + # over the book's umbrella evidence sequence [T, T, F, T, T], exact DBN + # filtering agrees step for step with the HMM forward algorithm + sequence = [T, T, F, T, T] + fv = umbrellaHMM.prior + for e in sequence: + fv = forward(umbrellaHMM, fv, e) + dbn_belief = umbrella_dbn.filter([{'Umbrella': e} for e in sequence], 'Rain') + assert dbn_belief[True] == pytest.approx(fv[0]) + assert dbn_belief[True] == pytest.approx(0.8673, abs=1e-4) + + def test_monte_carlo_localization(): # TODO: Add tests for random motion/inaccurate sensors random.seed('aima-python') @@ -367,7 +490,7 @@ def P_sensor(x, y): else: return 0 - from utils import print_table + from aima.utils import print_table a = {'v': (0, 0), 'w': 0} z = (2, 4, 1, 6) S = monte_carlo_localization(a, z, 1000, P_motion_sample, P_sensor, m) @@ -391,10 +514,60 @@ def P_sensor(x, y): assert grid[6][7] > 700 +def test_continuous_mcl(): + import math + from aima.probability import ContinuousMCLmap + + # a 10 x 10 arena with a central 4x4..6x6 square obstacle (Fig 25.10 style) + m = ContinuousMCLmap(10, 10, obstacles=[(4, 4, 6, 6)]) + + # ray casting returns continuous distances, not grid steps + assert m.ray_cast(0, (2.0, 2.0, 0.0)) == 8.0 # +x -> right wall + assert m.ray_cast(1, (2.0, 2.0, 0.0)) == 8.0 # +y -> top wall + assert m.ray_cast(2, (2.0, 2.0, 0.0)) == 2.0 # -x -> left wall + assert m.ray_cast(3, (2.0, 2.0, 0.0)) == 2.0 # -y -> bottom wall + assert m.ray_cast(0, (1.0, 5.0, 0.0)) == 3.0 # +x -> obstacle left edge at x=4 + assert m.in_free_space(2, 2) and not m.in_free_space(5, 5) + + # sample() always lands in free space + random.seed('aima-python') + for _ in range(100): + x, y, _ = m.sample() + assert m.in_free_space(x, y) + + # the particle filter localizes the robot on the continuous map + true = (2.0, 2.0, 0.0) + z = [m.ray_cast(s, true) for s in range(len(m.sensors))] + + def P_motion_sample(kin_state, v, w): + """Near-static motion with small Gaussian jitter to keep diversity.""" + x, y, h = kin_state + return (x + random.gauss(0, 0.1), y + random.gauss(0, 0.1), + (h + random.gauss(0, 0.05)) % (2 * math.pi)) + + def P_sensor(observed, expected): + """Gaussian range-sensor likelihood (need not be normalized).""" + if expected == math.inf: + return 1e-6 + return math.exp(-((observed - expected) ** 2) / (2 * 0.5 ** 2)) + 1e-6 + + random.seed('aima-python') + a = {'v': 0, 'w': 0} + N, S = 2000, None + for _ in range(6): + S = monte_carlo_localization(a, z, N, P_motion_sample, P_sensor, m, S) + + assert len(S) == N + near = sum(1 for x, y, _ in S if math.hypot(x - 2, y - 2) < 1.5) + assert near > N / 2 # most particles concentrate around the true pose + + def test_gibbs_ask(): - possible_solutions = ['False: 0.16, True: 0.84', 'False: 0.17, True: 0.83', 'False: 0.15, True: 0.85'] - g_solution = gibbs_ask('Cloudy', dict(Rain=True), sprinkler, 200).show_approx() - assert g_solution in possible_solutions + # exact posterior P(Cloudy | Rain=True) = 0.8; seed + tolerance keep this + # Monte Carlo test deterministic and independent of execution order + random.seed('aima-python') + p = gibbs_ask('Cloudy', dict(Rain=True), sprinkler, 2000)[True] + assert abs(p - 0.8) < 0.05 # The following should probably go in .ipynb: @@ -433,5 +606,50 @@ def test_gibbs_ask(): True """ + +def test_discrete_bayes_net_inference(): + # a small multi-valued net; both exact-inference engines must agree with the + # hand-computed posterior P(Rain | Traffic=high) + net = DiscreteBayesNet([ + ('Rain', '', ['none', 'light', 'heavy'], {(): [0.6, 0.3, 0.1]}), + ('Traffic', 'Rain', ['low', 'high'], + {('none',): [0.9, 0.1], ('light',): [0.6, 0.4], ('heavy',): [0.2, 0.8]}), + ]) + expected = {'none': 0.06 / 0.26, 'light': 0.12 / 0.26, 'heavy': 0.08 / 0.26} + for ask in (enumeration_ask, elimination_ask): + q = ask('Rain', {'Traffic': 'high'}, net) + for value, p in expected.items(): + assert abs(q[value] - p) < 1e-9 + + +def test_read_bif(): + bif = """ + network n { } + variable A { type discrete [ 2 ] { yes, no }; } + variable B { type discrete [ 3 ] { lo, mid, hi }; } + probability ( A ) { table 0.3, 0.7; } + probability ( B | A ) { + (yes) 0.2, 0.3, 0.5; + (no) 0.1, 0.1, 0.8; + } + """ + net = read_bif(bif) + assert set(net.variables) == {'A', 'B'} + assert net.variable_values('B') == ['lo', 'mid', 'hi'] + assert net.variable_node('B').parents == ['A'] + assert net.variable_node('A').p('yes', {}) == 0.3 + assert net.variable_node('B').p('hi', {'A': 'no'}) == 0.8 + + +def test_insurance_bayes_net(): + # the car-insurance ("Insurance") network loads from aima-data and supports + # exact inference (AIMA 4e §16 case study, issue #1285) + net = insurance() + assert len(net.variables) == 27 + assert net.variable_values('Age') == ['Adolescent', 'Adult', 'Senior'] + age = elimination_ask('Age', {}, net) + assert (round(age['Adolescent'], 3), round(age['Adult'], 3), round(age['Senior'], 3)) == (0.2, 0.6, 0.2) + + if __name__ == '__main__': pytest.main() diff --git a/tests/test_probability4e.py b/tests/test_probability4e.py deleted file mode 100644 index d07954e0a..000000000 --- a/tests/test_probability4e.py +++ /dev/null @@ -1,349 +0,0 @@ -import pytest - -from probability4e import * - -random.seed("aima-python") - - -def tests(): - cpt = burglary.variable_node('Alarm') - event = {'Burglary': True, 'Earthquake': True} - assert cpt.p(True, event) == 0.95 - event = {'Burglary': False, 'Earthquake': True} - assert cpt.p(False, event) == 0.71 - # enumeration_ask('Earthquake', {}, burglary) - - s = {'A': True, 'B': False, 'C': True, 'D': False} - assert consistent_with(s, {}) - assert consistent_with(s, s) - assert not consistent_with(s, {'A': False}) - assert not consistent_with(s, {'D': True}) - - random.seed(21) - p = rejection_sampling('Earthquake', {}, burglary, 1000) - assert p[True], p[False] == (0.001, 0.999) - - random.seed(71) - p = likelihood_weighting('Earthquake', {}, burglary, 1000) - assert p[True], p[False] == (0.002, 0.998) - - -# test ProbDist - - -def test_probdist_basic(): - P = ProbDist('Flip') - P['H'], P['T'] = 0.25, 0.75 - assert P['H'] == 0.25 - assert P['T'] == 0.75 - assert P['X'] == 0.00 - - P = ProbDist('BiasedDie') - P['1'], P['2'], P['3'], P['4'], P['5'], P['6'] = 10, 15, 25, 30, 40, 80 - P.normalize() - assert P['2'] == 0.075 - assert P['4'] == 0.15 - assert P['6'] == 0.4 - - -def test_probdist_frequency(): - P = ProbDist('X', {'lo': 125, 'med': 375, 'hi': 500}) - assert (P['lo'], P['med'], P['hi']) == (0.125, 0.375, 0.5) - - P = ProbDist('Pascal-5', {'x1': 1, 'x2': 5, 'x3': 10, 'x4': 10, 'x5': 5, 'x6': 1}) - assert (P['x1'], P['x2'], P['x3'], P['x4'], P['x5'], P['x6']) == ( - 0.03125, 0.15625, 0.3125, 0.3125, 0.15625, 0.03125) - - -def test_probdist_normalize(): - P = ProbDist('Flip') - P['H'], P['T'] = 35, 65 - P = P.normalize() - assert (P.prob['H'], P.prob['T']) == (0.350, 0.650) - - P = ProbDist('BiasedDie') - P['1'], P['2'], P['3'], P['4'], P['5'], P['6'] = 10, 15, 25, 30, 40, 80 - P = P.normalize() - assert (P.prob['1'], P.prob['2'], P.prob['3'], P.prob['4'], P.prob['5'], P.prob['6']) == ( - 0.05, 0.075, 0.125, 0.15, 0.2, 0.4) - - -# test JoinProbDist - - -def test_jointprob(): - P = JointProbDist(['X', 'Y']) - P[1, 1] = 0.25 - assert P[1, 1] == 0.25 - P[dict(X=0, Y=1)] = 0.5 - assert P[dict(X=0, Y=1)] == 0.5 - - -def test_event_values(): - assert event_values({'A': 10, 'B': 9, 'C': 8}, ['C', 'A']) == (8, 10) - assert event_values((1, 2), ['C', 'A']) == (1, 2) - - -def test_enumerate_joint(): - P = JointProbDist(['X', 'Y']) - P[0, 0] = 0.25 - P[0, 1] = 0.5 - P[1, 1] = P[2, 1] = 0.125 - assert enumerate_joint(['Y'], dict(X=0), P) == 0.75 - assert enumerate_joint(['X'], dict(Y=2), P) == 0 - assert enumerate_joint(['X'], dict(Y=1), P) == 0.75 - - Q = JointProbDist(['W', 'X', 'Y', 'Z']) - Q[0, 1, 1, 0] = 0.12 - Q[1, 0, 1, 1] = 0.4 - Q[0, 0, 1, 1] = 0.5 - Q[0, 0, 1, 0] = 0.05 - Q[0, 0, 0, 0] = 0.675 - Q[1, 1, 1, 0] = 0.3 - assert enumerate_joint(['W'], dict(X=0, Y=0, Z=1), Q) == 0 - assert enumerate_joint(['W'], dict(X=0, Y=0, Z=0), Q) == 0.675 - assert enumerate_joint(['W'], dict(X=0, Y=1, Z=1), Q) == 0.9 - assert enumerate_joint(['Y'], dict(W=1, X=0, Z=1), Q) == 0.4 - assert enumerate_joint(['Z'], dict(W=0, X=0, Y=0), Q) == 0.675 - assert enumerate_joint(['Z'], dict(W=1, X=1, Y=1), Q) == 0.3 - - -def test_enumerate_joint_ask(): - P = JointProbDist(['X', 'Y']) - P[0, 0] = 0.25 - P[0, 1] = 0.5 - P[1, 1] = P[2, 1] = 0.125 - assert enumerate_joint_ask( - 'X', dict(Y=1), P).show_approx() == '0: 0.667, 1: 0.167, 2: 0.167' - - -def test_is_independent(): - P = JointProbDist(['X', 'Y']) - P[0, 0] = P[0, 1] = P[1, 1] = P[1, 0] = 0.25 - assert enumerate_joint_ask( - 'X', dict(Y=1), P).show_approx() == '0: 0.5, 1: 0.5' - assert is_independent(['X', 'Y'], P) - - -# test BayesNode - - -def test_bayesnode_p(): - bn = BayesNode('X', 'Burglary', {T: 0.2, F: 0.625}) - assert bn.p(True, {'Burglary': True, 'Earthquake': False}) == 0.2 - assert bn.p(False, {'Burglary': False, 'Earthquake': True}) == 0.375 - assert BayesNode('W', '', 0.75).p(False, {'Random': True}) == 0.25 - - -def test_bayesnode_sample(): - X = BayesNode('X', 'Burglary', {T: 0.2, F: 0.625}) - assert X.sample({'Burglary': False, 'Earthquake': True}) in [True, False] - Z = BayesNode('Z', 'P Q', {(True, True): 0.2, (True, False): 0.3, - (False, True): 0.5, (False, False): 0.7}) - assert Z.sample({'P': True, 'Q': False}) in [True, False] - - -# test continuous variable bayesian net - - -def test_gaussian_probability(): - param = {'sigma': 0.5, 'b': 1, 'a': {'h': 0.5}} - event = {'h': 0.6} - assert gaussian_probability(param, event, 1) == 0.6664492057835993 - - -def test_logistic_probability(): - param = {'mu': 0.5, 'sigma': 0.1} - event = {'h': 0.6} - assert logistic_probability(param, event, True) == 0.16857376940725355 - assert logistic_probability(param, event, False) == 0.8314262305927465 - - -def test_enumeration_ask(): - assert enumeration_ask( - 'Burglary', dict(JohnCalls=T, MaryCalls=T), - burglary).show_approx() == 'False: 0.716, True: 0.284' - assert enumeration_ask( - 'Burglary', dict(JohnCalls=T, MaryCalls=F), - burglary).show_approx() == 'False: 0.995, True: 0.00513' - assert enumeration_ask( - 'Burglary', dict(JohnCalls=F, MaryCalls=T), - burglary).show_approx() == 'False: 0.993, True: 0.00688' - assert enumeration_ask( - 'Burglary', dict(JohnCalls=T), - burglary).show_approx() == 'False: 0.984, True: 0.0163' - assert enumeration_ask( - 'Burglary', dict(MaryCalls=T), - burglary).show_approx() == 'False: 0.944, True: 0.0561' - - -def test_elimination_ask(): - assert elimination_ask( - 'Burglary', dict(JohnCalls=T, MaryCalls=T), - burglary).show_approx() == 'False: 0.716, True: 0.284' - assert elimination_ask( - 'Burglary', dict(JohnCalls=T, MaryCalls=F), - burglary).show_approx() == 'False: 0.995, True: 0.00513' - assert elimination_ask( - 'Burglary', dict(JohnCalls=F, MaryCalls=T), - burglary).show_approx() == 'False: 0.993, True: 0.00688' - assert elimination_ask( - 'Burglary', dict(JohnCalls=T), - burglary).show_approx() == 'False: 0.984, True: 0.0163' - assert elimination_ask( - 'Burglary', dict(MaryCalls=T), - burglary).show_approx() == 'False: 0.944, True: 0.0561' - - -# test sampling - - -def test_prior_sample(): - random.seed(42) - all_obs = [prior_sample(burglary) for x in range(1000)] - john_calls_true = [observation for observation in all_obs if observation['JohnCalls'] is True] - mary_calls_true = [observation for observation in all_obs if observation['MaryCalls'] is True] - burglary_and_john = [observation for observation in john_calls_true if observation['Burglary'] is True] - burglary_and_mary = [observation for observation in mary_calls_true if observation['Burglary'] is True] - assert len(john_calls_true) / 1000 == 46 / 1000 - assert len(mary_calls_true) / 1000 == 13 / 1000 - assert len(burglary_and_john) / len(john_calls_true) == 1 / 46 - assert len(burglary_and_mary) / len(mary_calls_true) == 1 / 13 - - -def test_prior_sample2(): - random.seed(128) - all_obs = [prior_sample(sprinkler) for x in range(1000)] - rain_true = [observation for observation in all_obs if observation['Rain'] is True] - sprinkler_true = [observation for observation in all_obs if observation['Sprinkler'] is True] - rain_and_cloudy = [observation for observation in rain_true if observation['Cloudy'] is True] - sprinkler_and_cloudy = [observation for observation in sprinkler_true if observation['Cloudy'] is True] - assert len(rain_true) / 1000 == 0.476 - assert len(sprinkler_true) / 1000 == 0.291 - assert len(rain_and_cloudy) / len(rain_true) == 376 / 476 - assert len(sprinkler_and_cloudy) / len(sprinkler_true) == 39 / 291 - - -def test_rejection_sampling(): - random.seed(47) - assert rejection_sampling( - 'Burglary', dict(JohnCalls=T, MaryCalls=T), - burglary, 10000).show_approx() == 'False: 0.7, True: 0.3' - assert rejection_sampling( - 'Burglary', dict(JohnCalls=T, MaryCalls=F), - burglary, 10000).show_approx() == 'False: 1, True: 0' - assert rejection_sampling( - 'Burglary', dict(JohnCalls=F, MaryCalls=T), - burglary, 10000).show_approx() == 'False: 0.987, True: 0.0128' - assert rejection_sampling( - 'Burglary', dict(JohnCalls=T), - burglary, 10000).show_approx() == 'False: 0.982, True: 0.0183' - assert rejection_sampling( - 'Burglary', dict(MaryCalls=T), - burglary, 10000).show_approx() == 'False: 0.965, True: 0.0348' - - -def test_rejection_sampling2(): - random.seed(42) - assert rejection_sampling( - 'Cloudy', dict(Rain=T, Sprinkler=T), - sprinkler, 10000).show_approx() == 'False: 0.56, True: 0.44' - assert rejection_sampling( - 'Cloudy', dict(Rain=T, Sprinkler=F), - sprinkler, 10000).show_approx() == 'False: 0.119, True: 0.881' - assert rejection_sampling( - 'Cloudy', dict(Rain=F, Sprinkler=T), - sprinkler, 10000).show_approx() == 'False: 0.951, True: 0.049' - assert rejection_sampling( - 'Cloudy', dict(Rain=T), - sprinkler, 10000).show_approx() == 'False: 0.205, True: 0.795' - assert rejection_sampling( - 'Cloudy', dict(Sprinkler=T), - sprinkler, 10000).show_approx() == 'False: 0.835, True: 0.165' - - -def test_likelihood_weighting(): - random.seed(1017) - assert likelihood_weighting( - 'Burglary', dict(JohnCalls=T, MaryCalls=T), - burglary, 10000).show_approx() == 'False: 0.702, True: 0.298' - assert likelihood_weighting( - 'Burglary', dict(JohnCalls=T, MaryCalls=F), - burglary, 10000).show_approx() == 'False: 0.993, True: 0.00656' - assert likelihood_weighting( - 'Burglary', dict(JohnCalls=F, MaryCalls=T), - burglary, 10000).show_approx() == 'False: 0.996, True: 0.00363' - assert likelihood_weighting( - 'Burglary', dict(JohnCalls=F, MaryCalls=F), - burglary, 10000).show_approx() == 'False: 1, True: 0.000126' - assert likelihood_weighting( - 'Burglary', dict(JohnCalls=T), - burglary, 10000).show_approx() == 'False: 0.979, True: 0.0205' - assert likelihood_weighting( - 'Burglary', dict(MaryCalls=T), - burglary, 10000).show_approx() == 'False: 0.94, True: 0.0601' - - -def test_likelihood_weighting2(): - random.seed(42) - assert likelihood_weighting( - 'Cloudy', dict(Rain=T, Sprinkler=T), - sprinkler, 10000).show_approx() == 'False: 0.559, True: 0.441' - assert likelihood_weighting( - 'Cloudy', dict(Rain=T, Sprinkler=F), - sprinkler, 10000).show_approx() == 'False: 0.12, True: 0.88' - assert likelihood_weighting( - 'Cloudy', dict(Rain=F, Sprinkler=T), - sprinkler, 10000).show_approx() == 'False: 0.951, True: 0.0486' - assert likelihood_weighting( - 'Cloudy', dict(Rain=T), - sprinkler, 10000).show_approx() == 'False: 0.198, True: 0.802' - assert likelihood_weighting( - 'Cloudy', dict(Sprinkler=T), - sprinkler, 10000).show_approx() == 'False: 0.833, True: 0.167' - - -def test_gibbs_ask(): - g_solution = gibbs_ask('Cloudy', dict(Rain=True), sprinkler, 1000) - assert abs(g_solution.prob[False] - 0.2) < 0.05 - assert abs(g_solution.prob[True] - 0.8) < 0.05 - - -# The following should probably go in .ipynb: - -""" -# We can build up a probability distribution like this (p. 469): ->>> P = ProbDist() ->>> P['sunny'] = 0.7 ->>> P['rain'] = 0.2 ->>> P['cloudy'] = 0.08 ->>> P['snow'] = 0.02 - -# and query it like this: (Never mind this ELLIPSIS option -# added to make the doctest portable.) ->>> P['rain'] #doctest:+ELLIPSIS -0.2... - -# A Joint Probability Distribution is dealt with like this [Figure 13.3]: ->>> P = JointProbDist(['Toothache', 'Cavity', 'Catch']) ->>> T, F = True, False ->>> P[T, T, T] = 0.108; P[T, T, F] = 0.012; P[F, T, T] = 0.072; P[F, T, F] = 0.008 ->>> P[T, F, T] = 0.016; P[T, F, F] = 0.064; P[F, F, T] = 0.144; P[F, F, F] = 0.576 - ->>> P[T, T, T] -0.108 - -# Ask for P(Cavity|Toothache=T) ->>> PC = enumerate_joint_ask('Cavity', {'Toothache': T}, P) ->>> PC.show_approx() -'False: 0.4, True: 0.6' - ->>> 0.6-epsilon < PC[T] < 0.6+epsilon -True - ->>> 0.4-epsilon < PC[F] < 0.4+epsilon -True -""" - -if __name__ == '__main__': - pytest.main() diff --git a/tests/test_reinforcement_learning.py b/tests/test_reinforcement_learning.py index d80ad3baf..713c3532d 100644 --- a/tests/test_reinforcement_learning.py +++ b/tests/test_reinforcement_learning.py @@ -1,7 +1,7 @@ import pytest -from reinforcement_learning import * -from mdp import sequential_decision_environment +from aima.reinforcement_learning import * +from aima.mdp import sequential_decision_environment random.seed("aima-python") @@ -67,5 +67,17 @@ def test_QLearning(): assert q_agent.Q[((1, 0), (0, -1))] <= 0.5 # In reality around -0.1 +def test_SARSA(): + sarsa_agent = SARSALearningAgent(sequential_decision_environment, Ne=5, Rplus=2, alpha=lambda n: 60. / (59 + n)) + + for i in range(200): + run_single_trial(sarsa_agent, sequential_decision_environment) + + # the on-policy agent does not always produce the same results; check the + # learned Q-values are in a sensible range, as for the Q-learning agent + assert sarsa_agent.Q[((0, 1), (0, 1))] >= -0.5 # In reality around 0.1 + assert sarsa_agent.Q[((1, 0), (0, -1))] <= 0.5 # In reality around -0.1 + + if __name__ == '__main__': pytest.main() diff --git a/tests/test_reinforcement_learning4e.py b/tests/test_reinforcement_learning4e.py deleted file mode 100644 index 287ec397b..000000000 --- a/tests/test_reinforcement_learning4e.py +++ /dev/null @@ -1,69 +0,0 @@ -import pytest - -from mdp4e import sequential_decision_environment -from reinforcement_learning4e import * - -random.seed("aima-python") - -north = (0, 1) -south = (0, -1) -west = (-1, 0) -east = (1, 0) - -policy = {(0, 2): east, (1, 2): east, (2, 2): east, (3, 2): None, - (0, 1): north, (2, 1): north, (3, 1): None, - (0, 0): north, (1, 0): west, (2, 0): west, (3, 0): west} - - -def test_PassiveDUEAgent(): - agent = PassiveDUEAgent(policy, sequential_decision_environment) - for i in range(200): - run_single_trial(agent, sequential_decision_environment) - agent.estimate_U() - # Agent does not always produce same results. - # Check if results are good enough. - # print(agent.U[(0, 0)], agent.U[(0,1)], agent.U[(1,0)]) - assert agent.U[(0, 0)] > 0.15 # In reality around 0.3 - assert agent.U[(0, 1)] > 0.15 # In reality around 0.4 - assert agent.U[(1, 0)] > 0 # In reality around 0.2 - - -def test_PassiveADPAgent(): - agent = PassiveADPAgent(policy, sequential_decision_environment) - for i in range(100): - run_single_trial(agent, sequential_decision_environment) - - # Agent does not always produce same results. - # Check if results are good enough. - # print(agent.U[(0, 0)], agent.U[(0,1)], agent.U[(1,0)]) - assert agent.U[(0, 0)] > 0.15 # In reality around 0.3 - assert agent.U[(0, 1)] > 0.15 # In reality around 0.4 - assert agent.U[(1, 0)] > 0 # In reality around 0.2 - - -def test_PassiveTDAgent(): - agent = PassiveTDAgent(policy, sequential_decision_environment, alpha=lambda n: 60. / (59 + n)) - for i in range(200): - run_single_trial(agent, sequential_decision_environment) - - # Agent does not always produce same results. - # Check if results are good enough. - assert agent.U[(0, 0)] > 0.15 # In reality around 0.3 - assert agent.U[(0, 1)] > 0.15 # In reality around 0.35 - assert agent.U[(1, 0)] > 0.15 # In reality around 0.25 - - -def test_QLearning(): - q_agent = QLearningAgent(sequential_decision_environment, Ne=5, Rplus=2, alpha=lambda n: 60. / (59 + n)) - - for i in range(200): - run_single_trial(q_agent, sequential_decision_environment) - - # Agent does not always produce same results. - # Check if results are good enough. - assert q_agent.Q[((0, 1), (0, 1))] >= -0.5 # In reality around 0.1 - assert q_agent.Q[((1, 0), (0, -1))] <= 0.5 # In reality around -0.1 - - -if __name__ == '__main__': - pytest.main() diff --git a/tests/test_search.py b/tests/test_search.py index 9be3e4a47..4139873f4 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,5 +1,6 @@ import pytest -from search import * +from aima.search import * +from aima.logic import WumpusPosition random.seed("aima-python") @@ -83,6 +84,55 @@ def test_astar_search(): assert astar_search(n_queens).solution() == [7, 1, 3, 0, 6, 4, 2, 5] +def test_tree_search_variants(): + # n-queens is tree-structured (one queen per column, no repeated states), so + # the tree variants explore exactly like the graph ones and find the same solution + assert astar_tree_search(n_queens).solution() == astar_search(n_queens).solution() + # on Romania (a cyclic graph) cost-bounded tree search still terminates and + # returns the optimal path, matching the graph versions + assert uniform_cost_tree_search(romania_problem).solution() == ['Sibiu', 'Rimnicu', 'Pitesti', 'Bucharest'] + assert astar_tree_search(romania_problem).solution() == ['Sibiu', 'Rimnicu', 'Pitesti', 'Bucharest'] + assert astar_tree_search(romania_problem).path_cost == 418 + # the greedy tree-search alias mirrors greedy_best_first_graph_search + assert greedy_best_first_tree_search is best_first_tree_search + + +def test_iterative_deepening_astar_search(): + # IDA* is optimal, so it returns a solution of the same cost as A*. + assert iterative_deepening_astar_search(romania_problem).solution() == ['Sibiu', 'Rimnicu', 'Pitesti', 'Bucharest'] + assert (iterative_deepening_astar_search(eight_puzzle).path_cost == + astar_search(eight_puzzle).path_cost) + assert iterative_deepening_astar_search(EightPuzzle((1, 2, 3, 4, 5, 6, 0, 7, 8))).solution() == ['RIGHT', 'RIGHT'] + + +def test_traveling_salesman(): + cities = {0: (0, 0), 1: (0, 1), 2: (1, 1), 3: (1, 0), 4: (0.5, 2)} + tsp = TravelingSalesman(cities, initial=(0,)) + solution = astar_search(tsp).state + # a valid tour starts and ends at the start city and visits every city once + assert solution[0] == solution[-1] == 0 + assert set(solution) == set(cities) + # the MST heuristic is admissible, so A* finds the optimal tour cost + assert tsp.value(solution) == pytest.approx(3 + 5 ** 0.5) + + +def test_pour_problem(): + # the classic two-jug puzzle: with jugs of capacity 3 and 5, measure out 4 + problem = PourProblem(initial=(0, 0), goals={4}, capacities=(3, 5)) + solution = breadth_first_graph_search(problem) + assert solution is not None + assert any(level == 4 for level in solution.state) + + +def test_n_puzzle(): + # NPuzzle generalizes EightPuzzle; a one-move-from-goal 3x3 instance is solved by A* + npuzzle = NPuzzle(initial=(1, 2, 3, 4, 5, 6, 0, 7, 8), size=3, shuffle=0) + assert astar_search(npuzzle).solution() == ['RIGHT', 'RIGHT'] + # a shuffled instance is always solvable and reaches the goal + solved = astar_search(NPuzzle(size=3, shuffle=15)) + assert solved is not None + + def test_find_blank_square(): assert eight_puzzle.find_blank_square((0, 1, 2, 3, 4, 5, 6, 7, 8)) == 0 assert eight_puzzle.find_blank_square((6, 3, 5, 1, 8, 4, 2, 0, 7)) == 7 @@ -235,11 +285,26 @@ def run_plan(state, problem, plan): def test_online_dfs_agent(): odfs_agent = OnlineDFSAgent(LRTA_problem) - keys = [key for key in odfs_agent('State_3')] - assert keys[0] in ['Right', 'Left'] - assert keys[1] in ['Right', 'Left'] + # each call returns a single legal action (or None at the goal) + first = odfs_agent('State_3') + assert first in ['Right', 'Left'] assert odfs_agent('State_5') is None + # driving the agent through the environment must reach the goal and stop, + # only ever issuing actions that are legal in the current state + odfs_agent = OnlineDFSAgent(LRTA_problem) + graph_dict = one_dim_state_space.graph_dict + state = LRTA_problem.initial + action = odfs_agent(state) + for _ in range(40): + if action is None: + break + assert action in graph_dict[state] + state = graph_dict[state][action] + action = odfs_agent(state) + assert state == LRTA_problem.goal + assert action is None + def test_LRTAStarAgent(): lrta_agent = LRTAStarAgent(LRTA_problem) @@ -317,7 +382,7 @@ def GA_GraphColoringInts(edges, fitness): return genetic_algorithm(population, fitness) -def test_simpleProblemSolvingAgent(): +def test_simple_problem_solving_agent(): class vacuumAgent(SimpleProblemSolvingAgentProgram): def update_state(self, state, percept): return percept @@ -392,5 +457,84 @@ def search(self, problem): (['E', 'P', 'R', 'D', 'O', 'A', 'G', 'S', 'T'], 123) """ +def test_plan_route(): + dim = 4 + allowed = [[i, j] for i in range(1, dim + 1) for j in range(1, dim + 1)] + start = WumpusPosition(1, 1, 'UP') + + # a route to a goal cell: the planned actions must actually reach it + problem = PlanRoute(start, [[3, 3]], allowed, dim) + state = start + for action in astar_search(problem).solution(): + state = problem.result(state, action) + assert list(state.get_location()) == [3, 3] + # the A* search must not mutate the initial state + assert start.get_location() == (1, 1) and start.get_orientation() == 'UP' + + # a position goal also constrains the final orientation + problem = PlanRoute(start, [WumpusPosition(2, 1, 'RIGHT')], allowed, dim) + state = start + for action in astar_search(problem).solution(): + state = problem.result(state, action) + assert state.get_location() == (2, 1) and state.get_orientation() == 'RIGHT' + + # forward into a cell that is not allowed (unsafe) leaves the agent in place + problem = PlanRoute(start, [[2, 2]], [[1, 1]], dim) + assert problem.result(WumpusPosition(1, 1, 'RIGHT'), 'Forward').get_location() == (1, 1) + + +def test_grid_problem(): + # 5x5 grid with a wall blocking the direct route (gap at y=4) + walls = [(2, y) for y in range(4)] + problem = GridProblem((0, 0), (4, 0), 5, 5, obstacles=walls) + astar = astar_search(problem) + bfs = breadth_first_graph_search(problem) + assert astar is not None + assert astar.path_cost == bfs.path_cost # both optimal + assert all(problem.passable(node.state) for node in astar.path()) # avoids walls + # a goal walled off on every side is unreachable + boxed = GridProblem((0, 0), (4, 4), 5, 5, obstacles=[(3, 4), (4, 3)]) + assert astar_search(boxed) is None + + +def test_grid_search_visualization(): + import matplotlib + matplotlib.use('Agg') + from aima.notebook_utils import grid_search_steps, plot_grid_search + walls = [(3, y) for y in range(7)] + problem = GridProblem((0, 0), (6, 0), 10, 10, obstacles=walls) + expl_bfs, path_bfs = grid_search_steps(problem, 'bfs') + expl_astar, path_astar = grid_search_steps(problem, 'astar') + assert path_bfs[0] == path_astar[0] == (0, 0) + assert path_bfs[-1] == path_astar[-1] == (6, 0) + assert len(path_astar) == len(path_bfs) # same optimal path length + assert len(expl_astar) <= len(expl_bfs) # informed search is more focused + assert plot_grid_search(problem, expl_astar, path_astar) is not None + + +def test_local_search_variants(): + # a unimodal grid (value = x + y + 1, single peak at (2, 2)): every local-search + # variant climbs to it. These algorithms have no pseudocode in the book (#1151). + random.seed(0) + grid = [[1, 2, 3], [2, 3, 4], [3, 4, 5]] + assert stochastic_hill_climbing(PeakFindingProblem((0, 0), grid)) == (2, 2) + assert first_choice_hill_climbing(PeakFindingProblem((0, 0), grid)) == (2, 2) + assert local_beam_search(PeakFindingProblem((0, 0), grid), k=3) == (2, 2) + n, m = len(grid), len(grid[0]) + best = random_restart_hill_climbing( + PeakFindingProblem((0, 0), grid), + lambda: (random.randrange(n), random.randrange(m)), restarts=5) + assert best == (2, 2) + + +def test_node_path_states(): + # the full root->goal path is one call away from the returned Node (#1068) + node = astar_search(GraphProblem('Arad', 'Bucharest', romania_map)) + states = node.path_states() + assert states == [n.state for n in node.path()] + assert states[0] == 'Arad' and states[-1] == 'Bucharest' + assert len(node.solution()) == len(states) - 1 # actions are the path's edges + + if __name__ == '__main__': pytest.main() diff --git a/tests/test_text.py b/tests/test_text.py index 3aaa007f6..38da80b67 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -3,8 +3,8 @@ import numpy as np import pytest -from text import * -from utils import open_data +from aima.text import * +from aima.utils import open_data random.seed("aima-python") diff --git a/tests/test_utils.py b/tests/test_utils.py index 6c2a50808..a0a89fe6c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,5 @@ import pytest -from utils import * +from aima.utils import * import random random.seed("aima-python") @@ -299,5 +299,15 @@ def __eq__(self, other): assert len(queue) == 0 +def test_priority_queue_ties_with_non_comparable_items(): + # Items with equal priority must never be compared with each other + # (these dicts are not orderable); ties keep insertion (FIFO) order. + queue = PriorityQueue(f=lambda d: d['cost']) + queue.append({'cost': 1, 'id': 'a'}) + queue.append({'cost': 1, 'id': 'b'}) + queue.append({'cost': 0, 'id': 'c'}) + assert [queue.pop()['id'] for _ in range(3)] == ['c', 'a', 'b'] + + if __name__ == '__main__': pytest.main() diff --git a/utils4e.py b/utils4e.py deleted file mode 100644 index 65cb9026f..000000000 --- a/utils4e.py +++ /dev/null @@ -1,807 +0,0 @@ -"""Provides some utilities widely used by other modules""" - -import bisect -import collections -import collections.abc -import functools -import heapq -import os.path -import random -from itertools import chain, combinations -from statistics import mean - -import numpy as np - - -# part1. General data structures and their functions -# ______________________________________________________________________________ -# Queues: Stack, FIFOQueue, PriorityQueue -# Stack and FIFOQueue are implemented as list and collection.deque -# PriorityQueue is implemented here - - -class PriorityQueue: - """A Queue in which the minimum (or maximum) element (as determined by f and order) is returned first. - If order is 'min', the item with minimum f(x) is - returned first; if order is 'max', then it is the item with maximum f(x). - Also supports dict-like lookup.""" - - def __init__(self, order='min', f=lambda x: x): - self.heap = [] - - if order == 'min': - self.f = f - elif order == 'max': # now item with max f(x) - self.f = lambda x: -f(x) # will be popped first - else: - raise ValueError("Order must be either 'min' or 'max'.") - - def append(self, item): - """Insert item at its correct position.""" - heapq.heappush(self.heap, (self.f(item), item)) - - def extend(self, items): - """Insert each item in items at its correct position.""" - for item in items: - self.append(item) - - def pop(self): - """Pop and return the item (with min or max f(x) value) - depending on the order.""" - if self.heap: - return heapq.heappop(self.heap)[1] - else: - raise Exception('Trying to pop from empty PriorityQueue.') - - def __len__(self): - """Return current capacity of PriorityQueue.""" - return len(self.heap) - - def __contains__(self, key): - """Return True if the key is in PriorityQueue.""" - return any([item == key for _, item in self.heap]) - - def __getitem__(self, key): - """Returns the first value associated with key in PriorityQueue. - Raises KeyError if key is not present.""" - for value, item in self.heap: - if item == key: - return value - raise KeyError(str(key) + " is not in the priority queue") - - def __delitem__(self, key): - """Delete the first occurrence of key.""" - try: - del self.heap[[item == key for _, item in self.heap].index(True)] - except ValueError: - raise KeyError(str(key) + " is not in the priority queue") - heapq.heapify(self.heap) - - -# ______________________________________________________________________________ -# Functions on Sequences and Iterables - - -def sequence(iterable): - """Converts iterable to sequence, if it is not already one.""" - return (iterable if isinstance(iterable, collections.abc.Sequence) - else tuple([iterable])) - - -def remove_all(item, seq): - """Return a copy of seq (or string) with all occurrences of item removed.""" - if isinstance(seq, str): - return seq.replace(item, '') - elif isinstance(seq, set): - rest = seq.copy() - rest.remove(item) - return rest - else: - return [x for x in seq if x != item] - - -def unique(seq): - """Remove duplicate elements from seq. Assumes hashable elements.""" - return list(set(seq)) - - -def count(seq): - """Count the number of items in sequence that are interpreted as true.""" - return sum(map(bool, seq)) - - -def multimap(items): - """Given (key, val) pairs, return {key: [val, ....], ...}.""" - result = collections.defaultdict(list) - for (key, val) in items: - result[key].append(val) - return dict(result) - - -def multimap_items(mmap): - """Yield all (key, val) pairs stored in the multimap.""" - for (key, vals) in mmap.items(): - for val in vals: - yield key, val - - -def product(numbers): - """Return the product of the numbers, e.g. product([2, 3, 10]) == 60""" - result = 1 - for x in numbers: - result *= x - return result - - -def first(iterable, default=None): - """Return the first element of an iterable; or default.""" - return next(iter(iterable), default) - - -def is_in(elt, seq): - """Similar to (elt in seq), but compares with 'is', not '=='.""" - return any(x is elt for x in seq) - - -def mode(data): - """Return the most common data item. If there are ties, return any one of them.""" - [(item, count)] = collections.Counter(data).most_common(1) - return item - - -def power_set(iterable): - """power_set([1,2,3]) --> (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)""" - s = list(iterable) - return list(chain.from_iterable(combinations(s, r) for r in range(len(s) + 1)))[1:] - - -def extend(s, var, val): - """Copy dict s and extend it by setting var to val; return copy.""" - return {**s, var: val} - - -def flatten(seqs): - return sum(seqs, []) - - -# ______________________________________________________________________________ -# argmin and argmax - - -identity = lambda x: x - - -def argmin_random_tie(seq, key=identity): - """Return a minimum element of seq; break ties at random.""" - return min(shuffled(seq), key=key) - - -def argmax_random_tie(seq, key=identity): - """Return an element with highest fn(seq[i]) score; break ties at random.""" - return max(shuffled(seq), key=key) - - -def shuffled(iterable): - """Randomly shuffle a copy of iterable.""" - items = list(iterable) - random.shuffle(items) - return items - - -# part2. Mathematical and Statistical util functions -# ______________________________________________________________________________ - - -def histogram(values, mode=0, bin_function=None): - """Return a list of (value, count) pairs, summarizing the input values. - Sorted by increasing value, or if mode=1, by decreasing count. - If bin_function is given, map it over values first.""" - if bin_function: - values = map(bin_function, values) - - bins = {} - for val in values: - bins[val] = bins.get(val, 0) + 1 - - if mode: - return sorted(list(bins.items()), key=lambda x: (x[1], x[0]), reverse=True) - else: - return sorted(bins.items()) - - -def element_wise_product(x, y): - if hasattr(x, '__iter__') and hasattr(y, '__iter__'): - assert len(x) == len(y) - return [element_wise_product(_x, _y) for _x, _y in zip(x, y)] - elif hasattr(x, '__iter__') == hasattr(y, '__iter__'): - return x * y - else: - raise Exception('Inputs must be in the same size!') - - -def vector_add(a, b): - """Component-wise addition of two vectors.""" - if not (a and b): - return a or b - if hasattr(a, '__iter__') and hasattr(b, '__iter__'): - assert len(a) == len(b) - return list(map(vector_add, a, b)) - else: - try: - return a + b - except TypeError: - raise Exception('Inputs must be in the same size!') - - -def scalar_vector_product(x, y): - """Return vector as a product of a scalar and a vector recursively.""" - return [scalar_vector_product(x, _y) for _y in y] if hasattr(y, '__iter__') else x * y - - -def map_vector(f, x): - """Apply function f to iterable x.""" - return [map_vector(f, _x) for _x in x] if hasattr(x, '__iter__') else list(map(f, [x]))[0] - - -def probability(p): - """Return true with probability p.""" - return p > random.uniform(0.0, 1.0) - - -def weighted_sample_with_replacement(n, seq, weights): - """Pick n samples from seq at random, with replacement, with the - probability of each element in proportion to its corresponding - weight.""" - sample = weighted_sampler(seq, weights) - - return [sample() for _ in range(n)] - - -def weighted_sampler(seq, weights): - """Return a random-sample function that picks from seq weighted by weights.""" - totals = [] - for w in weights: - totals.append(w + totals[-1] if totals else w) - - return lambda: seq[bisect.bisect(totals, random.uniform(0, totals[-1]))] - - -def weighted_choice(choices): - """A weighted version of random.choice""" - # NOTE: Should be replaced by random.choices if we port to Python 3.6 - - total = sum(w for _, w in choices) - r = random.uniform(0, total) - upto = 0 - for c, w in choices: - if upto + w >= r: - return c, w - upto += w - - -def rounder(numbers, d=4): - """Round a single number, or sequence of numbers, to d decimal places.""" - if isinstance(numbers, (int, float)): - return round(numbers, d) - else: - constructor = type(numbers) # Can be list, set, tuple, etc. - return constructor(rounder(n, d) for n in numbers) - - -def num_or_str(x): # TODO: rename as `atom` - """The argument is a string; convert to a number if - possible, or strip it.""" - try: - return int(x) - except ValueError: - try: - return float(x) - except ValueError: - return str(x).strip() - - -def euclidean_distance(x, y): - return np.sqrt(sum((_x - _y) ** 2 for _x, _y in zip(x, y))) - - -def manhattan_distance(x, y): - return sum(abs(_x - _y) for _x, _y in zip(x, y)) - - -def hamming_distance(x, y): - return sum(_x != _y for _x, _y in zip(x, y)) - - -def rms_error(x, y): - return np.sqrt(ms_error(x, y)) - - -def ms_error(x, y): - return mean((x - y) ** 2 for x, y in zip(x, y)) - - -def mean_error(x, y): - return mean(abs(x - y) for x, y in zip(x, y)) - - -def mean_boolean_error(x, y): - return mean(_x != _y for _x, _y in zip(x, y)) - - -# part3. Neural network util functions -# ______________________________________________________________________________ - - -def cross_entropy_loss(x, y): - """Cross entropy loss function. x and y are 1D iterable objects.""" - return (-1.0 / len(x)) * sum(x * np.log(_y) + (1 - _x) * np.log(1 - _y) for _x, _y in zip(x, y)) - - -def mean_squared_error_loss(x, y): - """Min square loss function. x and y are 1D iterable objects.""" - return (1.0 / len(x)) * sum((_x - _y) ** 2 for _x, _y in zip(x, y)) - - -def normalize(dist): - """Multiply each number by a constant such that the sum is 1.0""" - if isinstance(dist, dict): - total = sum(dist.values()) - for key in dist: - dist[key] = dist[key] / total - assert 0 <= dist[key] <= 1 # probabilities must be between 0 and 1 - return dist - total = sum(dist) - return [(n / total) for n in dist] - - -def random_weights(min_value, max_value, num_weights): - return [random.uniform(min_value, max_value) for _ in range(num_weights)] - - -def conv1D(x, k): - """1D convolution. x: input vector; K: kernel vector.""" - return np.convolve(x, k, mode='same') - - -def gaussian_kernel(size=3): - return [gaussian((size - 1) / 2, 0.1, x) for x in range(size)] - - -def gaussian_kernel_1D(size=3, sigma=0.5): - return [gaussian((size - 1) / 2, sigma, x) for x in range(size)] - - -def gaussian_kernel_2D(size=3, sigma=0.5): - x, y = np.mgrid[-size // 2 + 1:size // 2 + 1, -size // 2 + 1:size // 2 + 1] - g = np.exp(-((x ** 2 + y ** 2) / (2.0 * sigma ** 2))) - return g / g.sum() - - -def step(x): - """Return activation value of x with sign function.""" - return 1 if x >= 0 else 0 - - -def gaussian(mean, st_dev, x): - """Given the mean and standard deviation of a distribution, it returns the probability of x.""" - return 1 / (np.sqrt(2 * np.pi) * st_dev) * np.exp(-0.5 * (float(x - mean) / st_dev) ** 2) - - -def linear_kernel(x, y=None): - if y is None: - y = x - return np.dot(x, y.T) - - -def polynomial_kernel(x, y=None, degree=2.0): - if y is None: - y = x - return (1.0 + np.dot(x, y.T)) ** degree - - -def rbf_kernel(x, y=None, gamma=None): - """Radial-basis function kernel (aka squared-exponential kernel).""" - if y is None: - y = x - if gamma is None: - gamma = 1.0 / x.shape[1] # 1.0 / n_features - return np.exp(-gamma * (-2.0 * np.dot(x, y.T) + - np.sum(x * x, axis=1).reshape((-1, 1)) + np.sum(y * y, axis=1).reshape((1, -1)))) - - -# part4. Self defined data structures -# ______________________________________________________________________________ -# Grid Functions - - -orientations = EAST, NORTH, WEST, SOUTH = [(1, 0), (0, 1), (-1, 0), (0, -1)] -turns = LEFT, RIGHT = (+1, -1) - - -def turn_heading(heading, inc, headings=orientations): - return headings[(headings.index(heading) + inc) % len(headings)] - - -def turn_right(heading): - return turn_heading(heading, RIGHT) - - -def turn_left(heading): - return turn_heading(heading, LEFT) - - -def distance(a, b): - """The distance between two (x, y) points.""" - xA, yA = a - xB, yB = b - return np.hypot((xA - xB), (yA - yB)) - - -def distance_squared(a, b): - """The square of the distance between two (x, y) points.""" - xA, yA = a - xB, yB = b - return (xA - xB) ** 2 + (yA - yB) ** 2 - - -# ______________________________________________________________________________ -# Misc Functions - - -class injection: - """Dependency injection of temporary values for global functions/classes/etc. - E.g., `with injection(DataBase=MockDataBase): ...`""" - - def __init__(self, **kwds): - self.new = kwds - - def __enter__(self): - self.old = {v: globals()[v] for v in self.new} - globals().update(self.new) - - def __exit__(self, type, value, traceback): - globals().update(self.old) - - -def memoize(fn, slot=None, maxsize=32): - """Memoize fn: make it remember the computed value for any argument list. - If slot is specified, store result in that slot of first argument. - If slot is false, use lru_cache for caching the values.""" - if slot: - def memoized_fn(obj, *args): - if hasattr(obj, slot): - return getattr(obj, slot) - else: - val = fn(obj, *args) - setattr(obj, slot, val) - return val - else: - @functools.lru_cache(maxsize=maxsize) - def memoized_fn(*args): - return fn(*args) - - return memoized_fn - - -def name(obj): - """Try to find some reasonable name for the object.""" - return (getattr(obj, 'name', 0) or getattr(obj, '__name__', 0) or - getattr(getattr(obj, '__class__', 0), '__name__', 0) or - str(obj)) - - -def isnumber(x): - """Is x a number?""" - return hasattr(x, '__int__') - - -def issequence(x): - """Is x a sequence?""" - return isinstance(x, collections.abc.Sequence) - - -def print_table(table, header=None, sep=' ', numfmt='{}'): - """Print a list of lists as a table, so that columns line up nicely. - header, if specified, will be printed as the first row. - numfmt is the format for all numbers; you might want e.g. '{:.2f}'. - (If you want different formats in different columns, - don't use print_table.) sep is the separator between columns.""" - justs = ['rjust' if isnumber(x) else 'ljust' for x in table[0]] - - if header: - table.insert(0, header) - - table = [[numfmt.format(x) if isnumber(x) else x for x in row] - for row in table] - sizes = list( - map(lambda seq: max(map(len, seq)), - list(zip(*[map(str, row) for row in table])))) - - for row in table: - print(sep.join(getattr( - str(x), j)(size) for (j, size, x) in zip(justs, sizes, row))) - - -def open_data(name, mode='r'): - aima_root = os.path.dirname(__file__) - aima_file = os.path.join(aima_root, *['aima-data', name]) - - return open(aima_file, mode=mode) - - -def failure_test(algorithm, tests): - """Grades the given algorithm based on how many tests it passes. - Most algorithms have arbitrary output on correct execution, which is difficult - to check for correctness. On the other hand, a lot of algorithms output something - particular on fail (for example, False, or None). - tests is a list with each element in the form: (values, failure_output).""" - return mean(int(algorithm(x) != y) for x, y in tests) - - -# ______________________________________________________________________________ -# Expressions - -# See https://docs.python.org/3/reference/expressions.html#operator-precedence -# See https://docs.python.org/3/reference/datamodel.html#special-method-names - - -class Expr: - """A mathematical expression with an operator and 0 or more arguments. - op is a str like '+' or 'sin'; args are Expressions. - Expr('x') or Symbol('x') creates a symbol (a nullary Expr). - Expr('-', x) creates a unary; Expr('+', x, 1) creates a binary.""" - - def __init__(self, op, *args): - self.op = str(op) - self.args = args - - # Operator overloads - def __neg__(self): - return Expr('-', self) - - def __pos__(self): - return Expr('+', self) - - def __invert__(self): - return Expr('~', self) - - def __add__(self, rhs): - return Expr('+', self, rhs) - - def __sub__(self, rhs): - return Expr('-', self, rhs) - - def __mul__(self, rhs): - return Expr('*', self, rhs) - - def __pow__(self, rhs): - return Expr('**', self, rhs) - - def __mod__(self, rhs): - return Expr('%', self, rhs) - - def __and__(self, rhs): - return Expr('&', self, rhs) - - def __xor__(self, rhs): - return Expr('^', self, rhs) - - def __rshift__(self, rhs): - return Expr('>>', self, rhs) - - def __lshift__(self, rhs): - return Expr('<<', self, rhs) - - def __truediv__(self, rhs): - return Expr('/', self, rhs) - - def __floordiv__(self, rhs): - return Expr('//', self, rhs) - - def __matmul__(self, rhs): - return Expr('@', self, rhs) - - def __or__(self, rhs): - """Allow both P | Q, and P |'==>'| Q.""" - if isinstance(rhs, Expression): - return Expr('|', self, rhs) - else: - return PartialExpr(rhs, self) - - # Reverse operator overloads - def __radd__(self, lhs): - return Expr('+', lhs, self) - - def __rsub__(self, lhs): - return Expr('-', lhs, self) - - def __rmul__(self, lhs): - return Expr('*', lhs, self) - - def __rdiv__(self, lhs): - return Expr('/', lhs, self) - - def __rpow__(self, lhs): - return Expr('**', lhs, self) - - def __rmod__(self, lhs): - return Expr('%', lhs, self) - - def __rand__(self, lhs): - return Expr('&', lhs, self) - - def __rxor__(self, lhs): - return Expr('^', lhs, self) - - def __ror__(self, lhs): - return Expr('|', lhs, self) - - def __rrshift__(self, lhs): - return Expr('>>', lhs, self) - - def __rlshift__(self, lhs): - return Expr('<<', lhs, self) - - def __rtruediv__(self, lhs): - return Expr('/', lhs, self) - - def __rfloordiv__(self, lhs): - return Expr('//', lhs, self) - - def __rmatmul__(self, lhs): - return Expr('@', lhs, self) - - def __call__(self, *args): - """Call: if 'f' is a Symbol, then f(0) == Expr('f', 0).""" - if self.args: - raise ValueError('Can only do a call for a Symbol, not an Expr') - else: - return Expr(self.op, *args) - - # Equality and repr - def __eq__(self, other): - """'x == y' evaluates to True or False; does not build an Expr.""" - return isinstance(other, Expr) and self.op == other.op and self.args == other.args - - def __lt__(self, other): - return isinstance(other, Expr) and str(self) < str(other) - - def __hash__(self): - return hash(self.op) ^ hash(self.args) - - def __repr__(self): - op = self.op - args = [str(arg) for arg in self.args] - if op.isidentifier(): # f(x) or f(x, y) - return '{}({})'.format(op, ', '.join(args)) if args else op - elif len(args) == 1: # -x or -(x + 1) - return op + args[0] - else: # (x - y) - opp = (' ' + op + ' ') - return '(' + opp.join(args) + ')' - - -# An 'Expression' is either an Expr or a Number. -# Symbol is not an explicit type; it is any Expr with 0 args. - - -Number = (int, float, complex) -Expression = (Expr, Number) - - -def Symbol(name): - """A Symbol is just an Expr with no args.""" - return Expr(name) - - -def symbols(names): - """Return a tuple of Symbols; names is a comma/whitespace delimited str.""" - return tuple(Symbol(name) for name in names.replace(',', ' ').split()) - - -def subexpressions(x): - """Yield the subexpressions of an Expression (including x itself).""" - yield x - if isinstance(x, Expr): - for arg in x.args: - yield from subexpressions(arg) - - -def arity(expression): - """The number of sub-expressions in this expression.""" - if isinstance(expression, Expr): - return len(expression.args) - else: # expression is a number - return 0 - - -# For operators that are not defined in Python, we allow new InfixOps: - - -class PartialExpr: - """Given 'P |'==>'| Q, first form PartialExpr('==>', P), then combine with Q.""" - - def __init__(self, op, lhs): - self.op, self.lhs = op, lhs - - def __or__(self, rhs): - return Expr(self.op, self.lhs, rhs) - - def __repr__(self): - return "PartialExpr('{}', {})".format(self.op, self.lhs) - - -def expr(x): - """Shortcut to create an Expression. x is a str in which: - - identifiers are automatically defined as Symbols. - - ==> is treated as an infix |'==>'|, as are <== and <=>. - If x is already an Expression, it is returned unchanged. Example: - >>> expr('P & Q ==> Q') - ((P & Q) ==> Q) - """ - if isinstance(x, str): - return eval(expr_handle_infix_ops(x), defaultkeydict(Symbol)) - else: - return x - - -infix_ops = '==> <== <=>'.split() - - -def expr_handle_infix_ops(x): - """Given a str, return a new str with ==> replaced by |'==>'|, etc. - >>> expr_handle_infix_ops('P ==> Q') - "P |'==>'| Q" - """ - for op in infix_ops: - x = x.replace(op, '|' + repr(op) + '|') - return x - - -class defaultkeydict(collections.defaultdict): - """Like defaultdict, but the default_factory is a function of the key. - >>> d = defaultkeydict(len); d['four'] - 4 - """ - - def __missing__(self, key): - self[key] = result = self.default_factory(key) - return result - - -class hashabledict(dict): - """Allows hashing by representing a dictionary as tuple of key:value pairs. - May cause problems as the hash value may change during runtime.""" - - def __hash__(self): - return 1 - - -# ______________________________________________________________________________ -# Monte Carlo tree node and ucb function - - -class MCT_Node: - """Node in the Monte Carlo search tree, keeps track of the children states.""" - - def __init__(self, parent=None, state=None, U=0, N=0): - self.__dict__.update(parent=parent, state=state, U=U, N=N) - self.children = {} - self.actions = None - - -def ucb(n, C=1.4): - return np.inf if n.N == 0 else n.U / n.N + C * np.sqrt(np.log(n.parent.N) / n.N) - - -# ______________________________________________________________________________ -# Useful Shorthands - - -class Bool(int): - """Just like `bool`, except values display as 'T' and 'F' instead of 'True' and 'False'.""" - __str__ = __repr__ = lambda self: 'T' if self else 'F' - - -T = Bool(True) -F = Bool(False)