{
  "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": 11,
      "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": 12,
      "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": 33,
      "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": 34,
      "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",
            "eats meat\n",
            "y/n\n",
            "color\n",
            "0. red-brown\n",
            "1. black and white\n",
            "2. other\n",
            "pattern\n",
            "0. dark stripes\n",
            "1. dark spots\n"
          ]
        },
        {
          "data": {
            "text/plain": [
              "'monkey'"
            ]
          },
          "execution_count": 34,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "kb = KnowledgeBase(rules)\n",
        "kb.get('animal')"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Using PyKnow for Forward Inference\n",
        "\n",
        "In the next example, we will try to implement forward inference using one of the libraries for knowledge representation, [PyKnow](https://github.com/buguroo/pyknow/). **PyKnow** 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": 36,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Collecting git+https://github.com/buguroo/pyknow/\n",
            "  Cloning https://github.com/buguroo/pyknow/ to c:\\users\\dmitryso\\appdata\\local\\temp\\pip-req-build-3iv4twpl\n",
            "Collecting frozendict==1.2\n",
            "  Using cached frozendict-1.2.tar.gz (2.6 kB)\n",
            "Collecting schema==0.6.7\n",
            "  Using cached schema-0.6.7-py2.py3-none-any.whl (14 kB)\n",
            "Building wheels for collected packages: pyknow, frozendict\n",
            "  Building wheel for pyknow (setup.py): started\n",
            "  Building wheel for pyknow (setup.py): finished with status 'done'\n",
            "  Created wheel for pyknow: filename=pyknow-1.7.0-py3-none-any.whl size=34580 sha256=334cc7a6eb47459f488db594e8537d7d33d2865c2dbcdd44854146c5c27608e3\n",
            "  Stored in directory: C:\\Users\\dmitryso\\AppData\\Local\\Temp\\pip-ephem-wheel-cache-l_g7bnq7\\wheels\\96\\36\\bd\\ee1de50bbcf2c7a323dead05584cf90db8898524cf7f57f488\n",
            "  Building wheel for frozendict (setup.py): started\n",
            "  Building wheel for frozendict (setup.py): finished with status 'done'\n",
            "  Created wheel for frozendict: filename=frozendict-1.2-py3-none-any.whl size=3146 sha256=71e32ca6c8ad7e0413bdc9a38f5882a36ba0509e562564a69904fcc9c8b66a9b\n",
            "  Stored in directory: c:\\users\\dmitryso\\appdata\\local\\pip\\cache\\wheels\\5b\\fa\\ab\\0a80360debb57b95f092356ee3a075bbbffc631b9813136599\n",
            "Successfully built pyknow frozendict\n",
            "Installing collected packages: schema, frozendict, pyknow\n",
            "Successfully installed frozendict-1.2 pyknow-1.7.0 schema-0.6.7\n"
          ]
        },
        {
          "name": "stderr",
          "output_type": "stream",
          "text": [
            "  Running command git clone -q https://github.com/buguroo/pyknow/ 'C:\\Users\\dmitryso\\AppData\\Local\\Temp\\pip-req-build-3iv4twpl'\n"
          ]
        }
      ],
      "source": [
        "import sys\n",
        "!{sys.executable} -m pip install git+https://github.com/buguroo/pyknow/"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 37,
      "metadata": {
        "trusted": true
      },
      "outputs": [],
      "source": [
        "from pyknow import *"
      ]
    },
    {
      "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": 39,
      "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": 43,
      "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": 43,
          "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.9.5"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 2
}
