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..ba5858d47 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ __pycache__/ # Distribution / packaging .Python env/ +.venv/ +venv/ build/ develop-eggs/ dist/ diff --git a/.travis.yml b/.travis.yml index e465e8e4c..dc429c7c8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,12 @@ language: python +dist: jammy + python: - - 3.5 - - 3.6 - - 3.7 - - 3.8 + - 3.9 + - 3.10 + - 3.11 + - 3.12 before_install: - git submodule update --remote @@ -14,10 +16,9 @@ install: 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 . + - flake8 --max-line-length 100 --ignore=E121,E123,E126,E221,E222,E225,E226,E242,E701,E702,E704,E731,W503,W504,F405,F841 . notifications: email: false diff --git a/README.md b/README.md index 17f1d6085..3db26f689 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ -# `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) [![Binder](http://mybinder.org/badge.svg)](http://mybinder.org/repo/aimacode/aima-python) 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.). @@ -23,9 +23,9 @@ When complete, this project will have Python implementations for all the pseudoc - `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. -# 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). +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). 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. Features from Python 3.6 and 3.7 that we will be using for this version of the code: @@ -120,7 +120,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,15 +147,30 @@ 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` | [`mdp4e.py`](mdp4e.py) | Done | Included | +| 18.2 | Iterated-Dominance | `iterated_dominance` | [`game_theory4e.py`](game_theory4e.py) | Done | Included | +| 18.2 | Pure-Nash-Equilibria | `pure_nash_equilibria` | [`game_theory4e.py`](game_theory4e.py) | Done | Included | +| 18.2 | Zero-Sum-Game (LP) | `solve_zero_sum_game` | [`game_theory4e.py`](game_theory4e.py) | Done | Included | +| 18.3 | Shapley-Value | `shapley_value` | [`game_theory4e.py`](game_theory4e.py) | Done | Included | +| 18.3 | Core (cooperative game) | `is_in_core` | [`game_theory4e.py`](game_theory4e.py) | Done | Included | +| 18.4 | Voting (plurality/Borda/Condorcet)| `plurality_winner` etc. | [`game_theory4e.py`](game_theory4e.py) | Done | Included | +| 18.4 | Vickrey-Auction | `vickrey_auction` | [`game_theory4e.py`](game_theory4e.py) | Done | Included | +| 18.4.1 | Contract-Net-Protocol | `contract_net` | [`game_theory4e.py`](game_theory4e.py) | Done | Included | +| 18.4.4 | Alternating-Offers-Bargaining | `alternating_offers_bargaining` | [`game_theory4e.py`](game_theory4e.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 | +| 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`](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 | @@ -163,6 +178,7 @@ Here is a table of algorithms, the figure, name of the algorithm in the book and | 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 | +| 21.8 | SARSA-Agent | `SARSALearningAgent` | [`rl.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 | diff --git a/csp.py b/csp.py index 46ae07dd5..247171a3a 100644 --- a/csp.py +++ b/csp.py @@ -632,7 +632,7 @@ 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. @@ -818,7 +818,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 +829,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 +857,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 diff --git a/deep_learning4e.py b/deep_learning4e.py index 9f5b0a8f7..593916989 100644 --- a/deep_learning4e.py +++ b/deep_learning4e.py @@ -41,10 +41,10 @@ def forward(self, inputs): class Activation: def function(self, x): - return NotImplementedError + raise NotImplementedError def derivative(self, x): - return NotImplementedError + raise NotImplementedError def __call__(self, x): return self.function(x) @@ -575,7 +575,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/dynamic_decision_network.ipynb b/dynamic_decision_network.ipynb new file mode 100644 index 000000000..060e9424b --- /dev/null +++ b/dynamic_decision_network.ipynb @@ -0,0 +1,152 @@ +{ + "cells": [ + { + "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 [`mdp4e.py`](mdp4e.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 mdp4e 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/expectation_maximization.ipynb b/expectation_maximization.ipynb new file mode 100644 index 000000000..763ac72bb --- /dev/null +++ b/expectation_maximization.ipynb @@ -0,0 +1,198 @@ +{ + "cells": [ + { + "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 learning import gaussian_mixture_em, naive_bayes_em\n", + "from 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/game_theory4e.ipynb b/game_theory4e.ipynb new file mode 100644 index 000000000..266d4a808 --- /dev/null +++ b/game_theory4e.ipynb @@ -0,0 +1,265 @@ +{ + "cells": [ + { + "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_theory4e.py`](game_theory4e.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 game_theory4e 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/game_theory4e.py b/game_theory4e.py new file mode 100644 index 000000000..b25442f4f --- /dev/null +++ b/game_theory4e.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/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/ipyviews.py b/ipyviews.py index b304af7bb..6b7958b0f 100644 --- a/ipyviews.py +++ b/ipyviews.py @@ -49,7 +49,7 @@ def handle_add_obstacle(self, vertices): self.show() def handle_remove_obstacle(self): - return NotImplementedError + raise NotImplementedError def get_polygon_obstacles_coordinates(self): obstacle_coordiantes = [] @@ -89,7 +89,7 @@ def show(self): 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. """ @@ -108,7 +108,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,7 +127,7 @@ 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() diff --git a/kalman_filter.ipynb b/kalman_filter.ipynb new file mode 100644 index 000000000..3897fe39b --- /dev/null +++ b/kalman_filter.ipynb @@ -0,0 +1,148 @@ +{ + "cells": [ + { + "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 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/knowledge.py b/knowledge.py index 8c27c3eb8..0cfdb4ab8 100644 --- a/knowledge.py +++ b/knowledge.py @@ -325,11 +325,11 @@ def gain(self, l, examples): where: - pre_pos = number of possitive bindings of rule R (=current set of rules) + 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 """ diff --git a/knowledge_FOIL.ipynb b/knowledge_foil.ipynb similarity index 99% rename from knowledge_FOIL.ipynb rename to knowledge_foil.ipynb index 4cefd7f69..c34b0948d 100644 --- a/knowledge_FOIL.ipynb +++ b/knowledge_foil.ipynb @@ -444,7 +444,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", diff --git a/learning.ipynb b/learning.ipynb index 0cadd4e7b..ac45b36e0 100644 --- a/learning.ipynb +++ b/learning.ipynb @@ -796,7 +796,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", diff --git a/learning.py b/learning.py index 71b6b15e7..1207f99b2 100644 --- a/learning.py +++ b/learning.py @@ -1237,6 +1237,115 @@ 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/logic.py index 1624d55a5..65fcc42e2 100644 --- a/logic.py +++ b/logic.py @@ -677,7 +677,7 @@ 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|} Return the literal maximizing J @@ -1492,8 +1492,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() diff --git a/logic4e.py b/logic4e.py index 75608ad74..f9f97c635 100644 --- a/logic4e.py +++ b/logic4e.py @@ -1086,8 +1086,8 @@ def __eq__(self, other): class HybridWumpusAgent(Agent): """An agent for the wumpus world that does logical inference. [Figure 7.20]""" - 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() diff --git a/making_simple_decision4e.py b/making_simple_decisions4e.py similarity index 98% rename from making_simple_decision4e.py rename to making_simple_decisions4e.py index 4a35f94bd..7d1c9d074 100644 --- a/making_simple_decision4e.py +++ b/making_simple_decisions4e.py @@ -123,7 +123,7 @@ def sample(self): return kin_state def ray_cast(self, sensor_num, kin_state): - """Returns distace to nearest obstacle or map boundary in the direction of sensor""" + """Returns distance 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) diff --git a/mdp4e.py b/mdp4e.py index f8871bdc9..42c01d0ae 100644 --- a/mdp4e.py +++ b/mdp4e.py @@ -210,7 +210,9 @@ def q_value(mdp, s, a, U): return res -# TODO: DDN in figure 16.4 and 16.5 +# Dynamic decision networks (DDNs) are solved online by the belief-state +# look-ahead agent `pomdp_lookahead` defined below, with belief updates via +# `update_belief` (POMDP filtering). # ______________________________________________________________________________ # 16.2 Algorithms for MDPs @@ -480,6 +482,59 @@ 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.ipynb b/nlp.ipynb index 9656c1ea0..3a658ba26 100644 --- a/nlp.ipynb +++ b/nlp.ipynb @@ -586,7 +586,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 +607,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 +632,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/nlp.py b/nlp.py index 03aabf54b..c55bcab1a 100644 --- a/nlp.py +++ b/nlp.py @@ -380,7 +380,7 @@ def CYK_parse(words, grammar): # 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 +392,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 +458,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 +503,16 @@ 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()] + 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 +522,32 @@ def detect(self): return False -def getInLinks(page): +def get_in_links(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): 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): + 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 +562,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/nlp4e.py b/nlp4e.py index 095f54357..1f3bb64ac 100644 --- a/nlp4e.py +++ b/nlp4e.py @@ -512,7 +512,7 @@ def explore(frontier): }, lexicon={}) -g = Grammar("Ali loves Bob", # A example grammer of Ali loves Bob example +g = Grammar("Ali loves Bob", # A example grammar 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", diff --git a/notebook.py b/notebook.py index 7f0306335..d25ed9f85 100644 --- a/notebook.py +++ b/notebook.py @@ -924,7 +924,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 diff --git a/notebook4e.py b/notebook4e.py index 5b03081c6..d6542d593 100644 --- a/notebook4e.py +++ b/notebook4e.py @@ -960,7 +960,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 diff --git a/planning.ipynb b/planning.ipynb index 7b05b3c20..c9838859a 100644 --- a/planning.ipynb +++ b/planning.ipynb @@ -2898,23 +2898,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 +3008,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", diff --git a/planning.py b/planning.py index 1e4a19209..e0b6aaa24 100644 --- a/planning.py +++ b/planning.py @@ -1047,8 +1047,8 @@ def orderlevel(self, level, planning_problem): def execute(self): """Finds total-order solution for a planning graph""" - graphPlan_solution = GraphPlan(self.planning_problem).execute() - filtered_solution = self.filter(graphPlan_solution) + graph_plan_solution = GraphPlan(self.planning_problem).execute() + filtered_solution = self.filter(graph_plan_solution) ordered_solution = [] planning_problem = self.planning_problem for level in filtered_solution: @@ -1315,11 +1315,11 @@ 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): @@ -1380,37 +1380,37 @@ def execute(self, display=True): return self.constraints, self.causal_links -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]] -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() @@ -1611,11 +1611,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 diff --git a/planning_angelic_search.ipynb b/planning_angelic_search.ipynb index 71408e1d9..31666f6d4 100644 --- a/planning_angelic_search.ipynb +++ b/planning_angelic_search.ipynb @@ -7,11 +7,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" ] }, { @@ -34,7 +34,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 +141,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 +168,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",
@@ -405,7 +405,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", @@ -422,14 +422,14 @@ "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", "\n", - "initialPlan = [Angelic_Node(prob.init, None, [angelic_opt_description], [angelic_pes_description])] \n" + "initial_plan = [Angelic_Node(prob.init, 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 +449,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 = Problem.reach_opt(prob.init, initial_plan[0])\n", + "pes_reachable_set = Problem.reach_pes(prob.init, 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" ] @@ -510,7 +510,7 @@ } ], "source": [ - "plan= Problem.angelic_search(prob, library, initialPlan)\n", + "plan= Problem.angelic_search(prob, library, initial_plan)\n", "print (plan, '\\n')\n", "print ([x.__dict__ for x in plan])" ] @@ -552,7 +552,7 @@ } ], "source": [ - "plan_2 = Problem.angelic_search(prob, library_2, initialPlan)\n", + "plan_2 = Problem.angelic_search(prob, library_2, initial_plan)\n", "print(plan_2, '\\n')\n", "print([x.__dict__ for x in plan_2])" ] diff --git a/planning_graphPlan.ipynb b/planning_graph_plan.ipynb similarity index 100% rename from planning_graphPlan.ipynb rename to planning_graph_plan.ipynb diff --git a/planning_hierarchical_search.ipynb b/planning_hierarchical_search.ipynb index 18e57b23b..c926f2279 100644 --- a/planning_hierarchical_search.ipynb +++ b/planning_hierarchical_search.ipynb @@ -213,7 +213,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) " ] }, { diff --git a/probability.py b/probability.py index e1e77d224..1c2a579f7 100644 --- a/probability.py +++ b/probability.py @@ -727,6 +727,65 @@ 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()) + + # _________________________________________________________________________ @@ -799,6 +858,121 @@ def particle_filtering(e, N, HMM): return s +# _________________________________________________________________________ + + +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) + + # _________________________________________________________________________ # TODO: Implement continuous map for MonteCarlo similar to Fig25.10 from the book diff --git a/reinforcement_learning.py b/reinforcement_learning.py index 4cb91af0f..a6f978fc6 100644 --- a/reinforcement_learning.py +++ b/reinforcement_learning.py @@ -308,6 +308,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] = r1 + 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/reinforcement_learning4e.py b/reinforcement_learning4e.py index eaaba3e5a..6419555ff 100644 --- a/reinforcement_learning4e.py +++ b/reinforcement_learning4e.py @@ -324,6 +324,41 @@ def update_state(self, percept): return percept +class SARSALearningAgent(QLearningAgent): + """ + [Section 22.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] = r1 + 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/sarsa.ipynb b/sarsa.ipynb new file mode 100644 index 000000000..d881bbace --- /dev/null +++ b/sarsa.ipynb @@ -0,0 +1,97 @@ +{ + "cells": [ + { + "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 reinforcement_learning import SARSALearningAgent, QLearningAgent, run_single_trial\n", + "from 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/tests/test_agents.py b/tests/test_agents.py index d1a669486..85e2866c6 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -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 index 295a1ee47..ce2def02a 100644 --- a/tests/test_agents4e.py +++ b/tests/test_agents4e.py @@ -116,7 +116,7 @@ def test_TableDrivenAgent(): 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 + # 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_csp.py b/tests/test_csp.py index a070cd531..ccc271cd5 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 search import depth_first_tree_search import random random.seed("aima-python") @@ -516,6 +517,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 +676,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,13 +685,13 @@ 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} diff --git a/tests/test_game_theory4e.py b/tests/test_game_theory4e.py new file mode 100644 index 000000000..5b9f4d471 --- /dev/null +++ b/tests/test_game_theory4e.py @@ -0,0 +1,173 @@ +import numpy as np +import pytest + +from game_theory4e 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_learning.py b/tests/test_learning.py index 63a7fd9aa..879161dcc 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -153,5 +153,48 @@ 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) + + if __name__ == "__main__": pytest.main() diff --git a/tests/test_logic.py b/tests/test_logic.py index 2ead21746..01af60643 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -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) diff --git a/tests/test_mdp.py b/tests/test_mdp.py index 979b4ba85..273b6c0c6 100644 --- a/tests/test_mdp.py +++ b/tests/test_mdp.py @@ -23,31 +23,34 @@ 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 +58,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(): diff --git a/tests/test_mdp4e.py b/tests/test_mdp4e.py index e51bda5d6..38ed0e54b 100644 --- a/tests/test_mdp4e.py +++ b/tests/test_mdp4e.py @@ -172,5 +172,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_nlp.py b/tests/test_nlp.py index 85d246dfa..26693611d 100644 --- a/tests/test_nlp.py +++ b/tests/test_nlp.py @@ -3,9 +3,9 @@ import pytest 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 load_page_html, strip_raw_html, find_outlinks, only_wikipedia_urls +from nlp import expand_pages, relevant_pages, normalize, ConvergenceDetector, get_in_links +from nlp import get_out_links, Page, determine_inlinks, HITS from nlp import Rules, Lexicon, Grammar, ProbRules, ProbLexicon, ProbGrammar from nlp import Chart, CYK_parse # Clumsy imports because we want to access certain nlp.py globals explicitly, because @@ -126,15 +126,15 @@ def test_CYK_parse(): # ______________________________________________________________________________ # 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 +154,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 +202,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 +223,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_planning.py b/tests/test_planning.py index a39152adc..45f43aae9 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -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,19 +215,19 @@ 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 @@ -236,7 +236,7 @@ def test_graphPlan(): 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 @@ -278,7 +278,7 @@ def test_forwardPlan(): assert expr('Buy(Drill, 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 @@ -582,7 +582,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(): @@ -740,15 +740,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 +759,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_probability.py b/tests/test_probability.py index 8def79c68..4db6e0b81 100644 --- a/tests/test_probability.py +++ b/tests/test_probability.py @@ -329,6 +329,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') diff --git a/tests/test_reinforcement_learning.py b/tests/test_reinforcement_learning.py index d80ad3baf..7fb2d88f1 100644 --- a/tests/test_reinforcement_learning.py +++ b/tests/test_reinforcement_learning.py @@ -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 index 287ec397b..771d4ae03 100644 --- a/tests/test_reinforcement_learning4e.py +++ b/tests/test_reinforcement_learning4e.py @@ -65,5 +65,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_search.py b/tests/test_search.py index 9be3e4a47..c2ac60c0a 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -317,7 +317,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