{ "cells": [ { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "# Implementing an Animal Expert System\n", "\n", "An example from [AI for Beginners Curriculum](http://github.com/microsoft/ai-for-beginners).\n", "\n", "In this sample, we will implement a simple knowledge-based system to determine an animal based on some physical characteristics. The system can be represented by the following AND-OR tree (this is a part of the whole tree, we can easily add some more rules):\n", "\n", "![](images/AND-OR-Tree.png)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Our own expert systems shell with backward inference\n", "\n", "Let's try to define a simple language for knowledge representation based on production rules. We will use Python classes as keywords to define rules. There would be essentially 3 types of classes:\n", "* `Ask` represents a question that needs to be asked to the user. It contains the set of possible answers.\n", "* `If` represents a rule, and it is just a syntactic sugar to store the content of the rule\n", "* `AND`/`OR` are classes to represent AND/OR branches of the tree. They just store the list of arguments inside. To simplify code, all functionality is defined in the parent class `Content`" ] }, { "cell_type": "code", "execution_count": 1, "metadata": { "trusted": true }, "outputs": [], "source": [ "class Ask():\n", " def __init__(self,choices=['y','n']):\n", " self.choices = choices\n", " def ask(self):\n", " if max([len(x) for x in self.choices])>1:\n", " for i,x in enumerate(self.choices):\n", " print(\"{0}. {1}\".format(i,x),flush=True)\n", " x = int(input())\n", " return self.choices[x]\n", " else:\n", " print(\"/\".join(self.choices),flush=True)\n", " return input()\n", "\n", "class Content():\n", " def __init__(self,x):\n", " self.x=x\n", " \n", "class If(Content):\n", " pass\n", "\n", "class AND(Content):\n", " pass\n", "\n", "class OR(Content):\n", " pass" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "In our system, working memory would contain the list of **facts** as **attribute-value pairs**. The knowledgebase can be defined as one big dictionary that maps actions (new facts that should be inserted into working memory) to conditions, expressed as AND-OR expressions. Also, some facts can be `Ask`-ed." ] }, { "cell_type": "code", "execution_count": 2, "metadata": { "trusted": true }, "outputs": [], "source": [ "rules = {\n", " 'default': Ask(['y','n']),\n", " 'color' : Ask(['red-brown','black and white','other']),\n", " 'pattern' : Ask(['dark stripes','dark spots']),\n", " 'mammal': If(OR(['hair','gives milk'])),\n", " 'carnivor': If(OR([AND(['sharp teeth','claws','forward-looking eyes']),'eats meat'])),\n", " 'ungulate': If(['mammal',OR(['has hooves','chews cud'])]),\n", " 'bird': If(OR(['feathers',AND(['flies','lies eggs'])])),\n", " 'animal:monkey' : If(['mammal','carnivor','color:red-brown','pattern:dark spots']),\n", " 'animal:tiger' : If(['mammal','carnivor','color:red-brown','pattern:dark stripes']),\n", " 'animal:giraffe' : If(['ungulate','long neck','long legs','pattern:dark spots']),\n", " 'animal:zebra' : If(['ungulate','pattern:dark stripes']),\n", " 'animal:ostrich' : If(['bird','long nech','color:black and white','cannot fly']),\n", " 'animal:pinguin' : If(['bird','swims','color:black and white','cannot fly']),\n", " 'animal:albatross' : If(['bird','flies well'])\n", "}" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To perform the backward inference, we will define `Knowledgebase` class. It will contain:\n", "* Working `memory` - a dictionary that maps attributes to values\n", "* Knowledgebase `rules` in the format as defined above\n", "\n", "Two main methods are:\n", "* `get` to obtain the value of an attribute, performing inference if necessary. For example, `get('color')` would get the value of a color slot (it will ask if necessary, and store the value for later usage in the working memory). If we ask `get('color:blue')`, it will ask for a color, and then return `y`/`n` value depending on the color.\n", "* `eval` performs the actual inference, i.e. traverses AND/OR tree, evaluates sub-goals, etc." ] }, { "cell_type": "code", "execution_count": 3, "metadata": { "trusted": true }, "outputs": [], "source": [ "class KnowledgeBase():\n", " def __init__(self,rules):\n", " self.rules = rules\n", " self.memory = {}\n", " \n", " def get(self,name):\n", " if ':' in name:\n", " k,v = name.split(':')\n", " vv = self.get(k)\n", " return 'y' if v==vv else 'n'\n", " if name in self.memory.keys():\n", " return self.memory[name]\n", " for fld in self.rules.keys():\n", " if fld==name or fld.startswith(name+\":\"):\n", " # print(\" + proving {}\".format(fld))\n", " value = 'y' if fld==name else fld.split(':')[1]\n", " res = self.eval(self.rules[fld],field=name)\n", " if res!='y' and res!='n' and value=='y':\n", " self.memory[name] = res\n", " return res\n", " if res=='y':\n", " self.memory[name] = value\n", " return value\n", " # field is not found, using default\n", " res = self.eval(self.rules['default'],field=name)\n", " self.memory[name]=res\n", " return res\n", " \n", " def eval(self,expr,field=None):\n", " # print(\" + eval {}\".format(expr))\n", " if isinstance(expr,Ask):\n", " print(field)\n", " return expr.ask()\n", " elif isinstance(expr,If):\n", " return self.eval(expr.x)\n", " elif isinstance(expr,AND) or isinstance(expr,list):\n", " expr = expr.x if isinstance(expr,AND) else expr\n", " for x in expr:\n", " if self.eval(x)=='n':\n", " return 'n'\n", " return 'y'\n", " elif isinstance(expr,OR):\n", " for x in expr.x:\n", " if self.eval(x)=='y':\n", " return 'y'\n", " return 'n'\n", " elif isinstance(expr,str):\n", " return self.get(expr)\n", " else:\n", " print(\"Unknown expr: {}\".format(expr))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now let's define our animal knowledgebase and perform the consultation. Note that this call will ask you questions. You can answer by typing `y`/`n` for yes-no questions, or by specifying number (0..N) for questions with longer multiple-choice answers." ] }, { "cell_type": "code", "execution_count": 4, "metadata": { "trusted": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "hair\n", "y/n\n", "sharp teeth\n", "y/n\n", "claws\n", "y/n\n", "forward-looking eyes\n", "y/n\n", "color\n", "0. red-brown\n", "1. black and white\n", "2. other\n", "has hooves\n", "y/n\n", "long neck\n", "y/n\n", "long legs\n", "y/n\n", "pattern\n", "0. dark stripes\n", "1. dark spots\n" ] }, { "data": { "text/plain": [ "'giraffe'" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "kb = KnowledgeBase(rules)\n", "kb.get('animal')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Using Experta for Forward Inference\n", "\n", "In the next example, we will try to implement forward inference using one of the libraries for knowledge representation, [Experta](https://github.com/nilp0inter/experta). **Experta** is a library for creating forward inference systems in Python, which is designed to be similar to classical old system [CLIPS](http://www.clipsrules.net/index.html). \n", "\n", "We could have also implemented forward chaining ourselves without many problems, but naive implementations are usually not very efficient. For more effective rule matching a special algorithm [Rete](https://en.wikipedia.org/wiki/Rete_algorithm) is used." ] }, { "cell_type": "code", "execution_count": 5, "metadata": { "trusted": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Collecting git+https://github.com/nilp0inter/experta\n", " Cloning https://github.com/nilp0inter/experta to /tmp/pip-req-build-7qurtwk3\n", " Running command git clone --filter=blob:none --quiet https://github.com/nilp0inter/experta /tmp/pip-req-build-7qurtwk3\n", " Resolved https://github.com/nilp0inter/experta to commit c6d5834b123861f5ae09e7d07027dc98bec58741\n", " Installing build dependencies ... \u001b[?25ldone\n", "\u001b[?25h Getting requirements to build wheel ... \u001b[?25ldone\n", "\u001b[?25h Preparing metadata (pyproject.toml) ... \u001b[?25ldone\n", "\u001b[?25hRequirement already satisfied: frozendict~=2.4.6 in /opt/conda/envs/ai4beg/lib/python3.12/site-packages (from experta==1.9.5.dev1) (2.4.7)\n", "Collecting schema~=0.6.7 (from experta==1.9.5.dev1)\n", " Downloading schema-0.6.8-py2.py3-none-any.whl.metadata (14 kB)\n", "Downloading schema-0.6.8-py2.py3-none-any.whl (14 kB)\n", "Building wheels for collected packages: experta\n", " Building wheel for experta (pyproject.toml) ... \u001b[?25ldone\n", "\u001b[?25h Created wheel for experta: filename=experta-1.9.5.dev1-py3-none-any.whl size=34804 sha256=888c459512a5e713f4b674caa9a0f96cfdf07ec0d6eb56cc318ce0653d218014\n", " Stored in directory: /tmp/pip-ephem-wheel-cache-1eeii9zy/wheels/3d/e8/bb/22d7956359603fa8dd679aa09f5b8efb3f29991c3986fdc787\n", "Successfully built experta\n", "Installing collected packages: schema, experta\n", "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2/2\u001b[0m [experta]\n", "\u001b[1A\u001b[2KSuccessfully installed experta-1.9.5.dev1 schema-0.6.8\n" ] } ], "source": [ "import sys\n", "!{sys.executable} -m pip install git+https://github.com/nilp0inter/experta" ] }, { "cell_type": "code", "execution_count": 13, "metadata": { "trusted": true }, "outputs": [], "source": [ "from experta import *\n", "#import experta" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We will define our system as a class that subclasses `KnowledgeEngine`. Each rule is defined by a separate function with `@Rule` annotation, which specifies when the rule should fire. Inside the rule, we can add new facts using `declare` function, and adding those facts will result in some more rules being called by forward inference engine. " ] }, { "cell_type": "code", "execution_count": 14, "metadata": { "trusted": true }, "outputs": [], "source": [ "class Animals(KnowledgeEngine):\n", " @Rule(OR(\n", " AND(Fact('sharp teeth'),Fact('claws'),Fact('forward looking eyes')),\n", " Fact('eats meat')))\n", " def cornivor(self):\n", " self.declare(Fact('carnivor'))\n", " \n", " @Rule(OR(Fact('hair'),Fact('gives milk')))\n", " def mammal(self):\n", " self.declare(Fact('mammal'))\n", "\n", " @Rule(Fact('mammal'),\n", " OR(Fact('has hooves'),Fact('chews cud')))\n", " def hooves(self):\n", " self.declare('ungulate')\n", " \n", " @Rule(OR(Fact('feathers'),AND(Fact('flies'),Fact('lays eggs'))))\n", " def bird(self):\n", " self.declare('bird')\n", " \n", " @Rule(Fact('mammal'),Fact('carnivor'),\n", " Fact(color='red-brown'),\n", " Fact(pattern='dark spots'))\n", " def monkey(self):\n", " self.declare(Fact(animal='monkey'))\n", "\n", " @Rule(Fact('mammal'),Fact('carnivor'),\n", " Fact(color='red-brown'),\n", " Fact(pattern='dark stripes'))\n", " def tiger(self):\n", " self.declare(Fact(animal='tiger'))\n", "\n", " @Rule(Fact('ungulate'),\n", " Fact('long neck'),\n", " Fact('long legs'),\n", " Fact(pattern='dark spots'))\n", " def giraffe(self):\n", " self.declare(Fact(animal='giraffe'))\n", "\n", " @Rule(Fact('ungulate'),\n", " Fact(pattern='dark stripes'))\n", " def zebra(self):\n", " self.declare(Fact(animal='zebra'))\n", "\n", " @Rule(Fact('bird'),\n", " Fact('long neck'),\n", " Fact('cannot fly'),\n", " Fact(color='black and white'))\n", " def straus(self):\n", " self.declare(Fact(animal='ostrich'))\n", "\n", " @Rule(Fact('bird'),\n", " Fact('swims'),\n", " Fact('cannot fly'),\n", " Fact(color='black and white'))\n", " def pinguin(self):\n", " self.declare(Fact(animal='pinguin'))\n", "\n", " @Rule(Fact('bird'),\n", " Fact('flies well'))\n", " def albatros(self):\n", " self.declare(Fact(animal='albatross'))\n", " \n", " @Rule(Fact(animal=MATCH.a))\n", " def print_result(self,a):\n", " print('Animal is {}'.format(a))\n", " \n", " def factz(self,l):\n", " for x in l:\n", " self.declare(x)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Once we have defined a knowledgebase, we populate our working memory with some initial facts, and then call `run()` method to perform the inference. You can see as a result that new inferred facts are added to the working memory, including the final fact about the animal (if we set up all the initial facts correctly)." ] }, { "cell_type": "code", "execution_count": 15, "metadata": { "trusted": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Animal is tiger\n" ] }, { "data": { "text/plain": [ "FactList([(0, InitialFact()),\n", " (1, Fact(color='red-brown')),\n", " (2, Fact(pattern='dark stripes')),\n", " (3, Fact('sharp teeth')),\n", " (4, Fact('claws')),\n", " (5, Fact('forward looking eyes')),\n", " (6, Fact('gives milk')),\n", " (7, Fact('mammal')),\n", " (8, Fact('carnivor')),\n", " (9, Fact(animal='tiger'))])" ] }, "execution_count": 15, "metadata": {}, "output_type": "execute_result" } ], "source": [ "ex1 = Animals()\n", "ex1.reset()\n", "ex1.factz([\n", " Fact(color='red-brown'),\n", " Fact(pattern='dark stripes'),\n", " Fact('sharp teeth'),\n", " Fact('claws'),\n", " Fact('forward looking eyes'),\n", " Fact('gives milk')])\n", "ex1.run()\n", "ex1.facts" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3.7.4 64-bit (conda)", "metadata": { "interpreter": { "hash": "86193a1ab0ba47eac1c69c1756090baa3b420b3eea7d4aafab8b85f8b312f0c5" } }, "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.11.2" } }, "nbformat": 4, "nbformat_minor": 2 }