{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {
        "collapsed": true
      },
      "source": [
        "# Genetic Algorithms\n",
        "\n",
        "This notebook is part of [AI for Beginners Curriculum](http://github.com/microsoft/ai-for-beginners)."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 2,
      "metadata": {
        "trusted": true
      },
      "outputs": [],
      "source": [
        "import random\n",
        "import matplotlib.pyplot as plt\n",
        "import numpy as np\n",
        "import math\n",
        "import time"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Some Theory\n",
        "\n",
        "**Genetic Algorithms** (GA) are based on **evolutionary approach** to AI, in which methods of evolution of population is used to obtain an optimal solution for a given problem. They were proposed in 1975 by [John Henry Holland](https://en.wikipedia.org/wiki/John_Henry_Holland).\n",
        "\n",
        "Genetic Algorithms are based on the following ideas:\n",
        "* Valid solutions to the problem can be represented as **genes**\n",
        "* **Crossover** allows us to combine two solutions together to obtain new valid solution\n",
        "* **Selection** is used to select more optimal solutions using some **fitness function**\n",
        "* **Mutations** are introduced to destabilize optimization and get us out of the local minimum \n",
        "\n",
        "If you want to implement a Genetic Algorithm, you need the following:\n",
        "\n",
        " * To find a method of coding our problem solutions using **genes** $g\\in\\Gamma$\n",
        " * On the set of genes $\\Gamma$ we need to define **fitness function** $\\mathrm{fit}: \\Gamma\\to\\mathbb{R}$. Smaller function values would correspond to better solutions.\n",
        " * To define **crossover** mechanism to combine two genes together to get a new valid solution $\\mathrm{crossover}: \\Gamma^2\\to\\Gamma$.\n",
        " * To define **mutation** mechanism $\\mathrm{mutate}: \\Gamma\\to\\Gamma$.\n",
        "In many cases, crossover and mutation are quite simple algorithms to manipulate genes as numeric sequences or bit vectors.\n",
        "\n",
        "Specific implementation of a genetic algorithm can vary from case to case, but overall structure is the following:\n",
        "\n",
        "1. Select initial population $G\\subset\\Gamma$\n",
        "2. Randomly select one of the operations that will be performed at this step: crossover or mutation \n",
        "3. **Crossover**:\n",
        "  * Randomly select two genes $g_1, g_2 \\in G$\n",
        "  * Compute crossover $g=\\mathrm{crossover}(g_1,g_2)$\n",
        "  * If $\\mathrm{fit}(g)<\\mathrm{fit}(g_1)$ or $\\mathrm{fit}(g)<\\mathrm{fit}(g_2)$ - replace corresponding gene in the population by $g$.\n",
        "4. **Mutation** - select random gene $g\\in G$ and replace it by $\\mathrm{mutate}(g)$\n",
        "5. Repeat from step 2, until we get sufficiently small value of $\\mathrm{fit}$, or until the limit on the number of steps is reached.\n",
        "\n",
        "Tasks typically solved by GA:\n",
        "1. Schedule optimization\n",
        "1. Optimal packing\n",
        "1. Optimal cutting\n",
        "1. Speeding up exhaustive search\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Problem 1: Fair Treasure Split\n",
        "\n",
        "**Task**: \n",
        "Two people found a treasure that contains diamonds of different sizes (and, correspondingly, different price). They need to split the treasure in two parts in such a way that the difference in the price is 0 (or minimal).\n",
        "\n",
        "**Formal definition**: \n",
        "We have a set of numbers $S$. We need to split it into two subsets $S_1$ and $S_2$, such that $$\\left|\\sum_{i\\in S_1}i - \\sum_{j\\in S_2}j\\right|\\to\\min$$ and $S_1\\cup S_2=S$, $S_1\\cap S_2=\\emptyset$.\n",
        "\n",
        "First of all, let's define the set $S$:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 3,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "[8344 2197 9335 3131 5863 9429 3818 9791   15 5455 1396 9538 4872 6549\n",
            " 8587 5986 6021 9764 8102 5083 5739 7684 8498 3007 6599  820 7490 2372\n",
            " 9370 5235 3525 3154  859 1906 8159 3950 2173 2988 2050  349 8713 2284\n",
            " 4177 6033 1651 9176 5049 8201  171 5081 1216 3756 4711 2757 7738 1272\n",
            " 5650 6584 5395 9004 7797  969 8104 1283 1392 4001 5768  445  274  256\n",
            " 8239 8015 4381 9021 1189 8879 1411 3539 6526 8011  136 7230 2332  451\n",
            " 5702 2989 4320 2446 9578 8486 4027 2410 9588 8981 2177 1493 3232 9151\n",
            " 4835 5594 6859 8394  369 3200  126 4259 2283 7755 2014 2458 8327 8082\n",
            " 7413 7622 1206 5533 8751 3495 5868 8472 6850 3958 3149 4672 4810 6274\n",
            " 4700 6134 4627 4616 6656 9949  884 2256 7419 1926 7973 5319 5967 9158\n",
            " 3823 7697 9466 5675 5412 9784 5426 8209 3421 1136 6047 4429 8001 4417\n",
            " 1381  722 7350 6018 6235 7860 5853 7660 5937 6242    1 9552 3971 8302\n",
            " 2633 9227 7283  154 8599 4269 9392 8539 1630  368 2409 9351 3838 9814\n",
            " 6186 5743 5083 1325 1610  779 3643 3262 5768 8725  961 4611 6310 4788\n",
            " 1648 5951 8118 7779]\n"
          ]
        }
      ],
      "source": [
        "N = 200\n",
        "S = np.array([random.randint(1,10000) for _ in range(N)])\n",
        "print(S)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Let's encode each possible solution of the problem by a binary vector $B\\in\\{0,1\\}^N$, where the number on $i$-th position shows to which of the sets ($S_1$ or $S_2$) the $i$-th number in the original set $S$ belongs. `generate` function will generate those random binary vectors."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 5,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "[1 0 0 1 1 1 1 1 0 1 1 1 0 0 1 0 1 1 1 0 0 1 1 0 1 1 0 0 1 0 1 0 1 0 1 1 1\n",
            " 0 1 1 1 0 1 0 0 1 0 0 1 1 0 1 0 1 1 0 0 1 0 0 0 1 1 0 1 1 0 0 0 0 1 0 1 0\n",
            " 1 0 0 0 0 0 1 1 0 1 0 0 1 0 1 0 0 1 1 0 0 1 1 1 0 0 1 1 0 1 1 0 0 0 0 1 1\n",
            " 1 0 1 0 0 1 1 1 1 1 1 1 1 0 1 0 1 1 1 1 1 1 0 1 0 1 0 1 0 0 1 1 1 0 0 1 1\n",
            " 0 1 1 0 1 1 0 0 0 1 1 0 0 0 0 0 0 0 0 1 1 0 1 1 1 0 0 1 1 0 1 1 0 0 1 1 0\n",
            " 0 0 0 1 0 1 1 0 1 1 0 1 0 0 0]\n"
          ]
        }
      ],
      "source": [
        "def generate(S):\n",
        "    return np.array([random.randint(0,1) for _ in S])\n",
        "\n",
        "b = generate(S)\n",
        "print(b)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Let's now define `fit` function that calculates the \"cost\" of the solution. It will be the difference between sum or two sets, $S_1$ and $S_2$:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 6,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "data": {
            "text/plain": [
              "133784"
            ]
          },
          "execution_count": 6,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "def fit(B,S=S):\n",
        "    c1 = (B*S).sum()\n",
        "    c2 = ((1-B)*S).sum()\n",
        "    return abs(c1-c2)\n",
        "\n",
        "fit(b)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Now we need to define functions for mutation and crossover:\n",
        "* For mutation, we will select one random bit and negate it (change from 0 to 1 and vice versa)\n",
        "* For crossover, we will take some bits from one vector, and some bits from another one. We will use the same `generate` function to randomly select, which bits to take from which of the input masks."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 7,
      "metadata": {
        "trusted": true
      },
      "outputs": [],
      "source": [
        "def mutate(b):\n",
        "    x = b.copy()\n",
        "    i = random.randint(0,len(b)-1)\n",
        "    x[i] = 1-x[i]\n",
        "    return x\n",
        "\n",
        "def xover(b1,b2):\n",
        "    x = generate(b1)\n",
        "    return b1*x+b2*(1-x)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Let's create initial population of the solutions $P$ of the size `pop_size`:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 8,
      "metadata": {
        "trusted": true
      },
      "outputs": [],
      "source": [
        "pop_size = 30\n",
        "P = [generate(S) for _ in range(pop_size)]"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Now, the main function to perform the evolution. `n` is the number of steps of evolution to undergo. At each step:\n",
        "* With the probability of 30% we perform a mutation, and replace the element with the worst `fit` function by the mutated element\n",
        "* With the probability of 70% we perform crossover\n",
        "\n",
        "The function returns the best solution (gene corresponding to the best solution), and the history of minimal fit function in the population on each iteration."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 9,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "[0 0 0 1 1 0 0 0 0 1 1 1 0 1 0 0 0 1 0 1 0 1 0 1 0 1 1 0 0 0 0 0 1 0 1 1 0\n",
            " 0 0 0 1 1 0 0 1 0 0 0 0 0 1 0 0 1 1 1 1 1 1 1 0 1 1 0 1 1 1 1 1 0 1 0 0 0\n",
            " 0 1 1 1 0 1 0 1 1 1 1 1 0 0 0 1 1 0 1 0 0 1 0 0 1 1 1 1 1 1 1 1 0 1 0 1 1\n",
            " 0 1 1 0 0 0 0 1 1 1 1 0 1 0 0 1 0 1 1 1 0 1 0 0 0 0 0 0 1 1 0 0 0 1 1 0 0\n",
            " 1 0 1 1 1 1 1 0 1 0 1 0 1 1 1 0 0 0 1 1 0 0 0 0 0 0 1 1 1 0 1 0 0 0 1 0 1\n",
            " 0 1 0 1 0 0 1 1 1 0 1 1 0 0 1] 4\n"
          ]
        }
      ],
      "source": [
        "def evolve(P,S=S,n=2000):\n",
        "    res = []\n",
        "    for _ in range(n):\n",
        "        f = min([fit(b) for b in P])\n",
        "        res.append(f)\n",
        "        if f==0:\n",
        "            break\n",
        "        if random.randint(1,10)<3:\n",
        "            i = random.randint(0,len(P)-1)\n",
        "            b = mutate(P[i])\n",
        "            i = np.argmax([fit(z) for z in P])\n",
        "            P[i] = b\n",
        "        else:\n",
        "            i = random.randint(0,len(P)-1)\n",
        "            j = random.randint(0,len(P)-1)\n",
        "            b = xover(P[i],P[j])\n",
        "            if fit(b)<fit(P[i]):\n",
        "                P[i]=b\n",
        "            elif fit(b)<fit(P[j]):\n",
        "                P[j]=b\n",
        "            else:\n",
        "                pass\n",
        "    i = np.argmin([fit(b) for b in P])\n",
        "    return (P[i],res)\n",
        "\n",
        "(s,hist) = evolve(P)\n",
        "print(s,fit(s))"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "You can see that we have managed to minimize the `fit` function quite a bit! Here is the graph that shows how the `fit` function for the whole population behaves during the process."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 10,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "data": {
            "image/png": "[encoded data, 8408 characters]",
            "text/plain": [
              "<Figure size 432x288 with 1 Axes>"
            ]
          },
          "metadata": {
            "needs_background": "light"
          },
          "output_type": "display_data"
        }
      ],
      "source": [
        "plt.plot(hist)\n",
        "plt.show()"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Problem 2: N Queens Problem\n",
        "\n",
        "**Task**:\n",
        "You need to place $N$ queens on a chess board of the size $N\\times N$ in such a way that they do not attack each other.\n",
        "\n",
        "First of all, let's solve the problem without genetic algorithms, using full search. We can represent the state of the board by the list $L$, where $i$-th number in the list is the horizontal position of the queen in $i$-th row. It is quite obvious that each solution will have only one queen per row, and each row would have a queen.\n",
        "\n",
        "Our goal would be to find the first solution to the problem, after which we will stop searching. You can easily extend this function to generate all possible positions for queens."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 11,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "[1, 5, 8, 6, 3, 7, 2, 4]\n"
          ]
        },
        {
          "data": {
            "text/plain": [
              "True"
            ]
          },
          "execution_count": 11,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "N = 8\n",
        "\n",
        "def checkbeats(i_new,j_new,l):\n",
        "    for i,j in enumerate(l,start=1):\n",
        "        if j==j_new:\n",
        "            return False\n",
        "        else:\n",
        "            if abs(j-j_new) == i_new-i:\n",
        "                return False\n",
        "    return True\n",
        "\n",
        "def nqueens(l,N=8,disp=True):\n",
        "    if len(l)==N:\n",
        "        if disp: print(l)\n",
        "        return True\n",
        "    else:\n",
        "        for j in range(1,N+1):\n",
        "            if checkbeats(len(l)+1,j,l):\n",
        "                l.append(j)\n",
        "                if nqueens(l,N,disp): return True\n",
        "                else: l.pop()\n",
        "        return False\n",
        "            \n",
        "nqueens([],8)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Now let's measure how long does it take to get a solution for 20-queens problem:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 12,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "10.6 s ± 2.17 s per loop (mean ± std. dev. of 7 runs, 1 loop each)\n"
          ]
        }
      ],
      "source": [
        "%timeit nqueens([],20,False)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Now let's solve the same problem using genetic algorithm. This solution is inspired by [this blog post](https://kushalvyas.github.io/gen_8Q.html).\n",
        "\n",
        "We will represent each solution by the same list of length $N$, and as a `fit` function we will take the number of queens that attack each other:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 13,
      "metadata": {
        "trusted": true
      },
      "outputs": [],
      "source": [
        "def fit(L):\n",
        "    x=0\n",
        "    for i1,j1 in enumerate(L,1):\n",
        "        for i2,j2 in enumerate(L,1):\n",
        "            if i2>i1:\n",
        "                if j2==j1 or (abs(j2-j1)==i2-i1): x+=1\n",
        "    return x"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Since calculating fitness function is time consuming, let's store each solution in the population together with the value of fitness function. Let's generate the initial population:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 14,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "data": {
            "text/plain": [
              "[(array([2, 3, 8, 7, 5, 4, 1, 6]), 4),\n",
              " (array([3, 4, 5, 1, 2, 8, 6, 7]), 8),\n",
              " (array([1, 3, 7, 4, 5, 8, 6, 2]), 6),\n",
              " (array([1, 5, 4, 6, 8, 3, 7, 2]), 4),\n",
              " (array([3, 5, 7, 1, 8, 6, 4, 2]), 3)]"
            ]
          },
          "execution_count": 14,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "def generate_one(N):\n",
        "    x = np.arange(1,N+1)\n",
        "    np.random.shuffle(x)\n",
        "    return (x,fit(x))\n",
        "\n",
        "def generate(N,NP):\n",
        "    return [generate_one(N) for _ in range(NP)]\n",
        "\n",
        "generate(8,5)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Now we need to define mutation and crossover functions. Crossover would combine two genes together by breaking them at some random point and concatenating two parts from different genes together."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 15,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "data": {
            "text/plain": [
              "array([1, 2, 7, 8])"
            ]
          },
          "execution_count": 15,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "def mutate(G):\n",
        "    x=random.randint(0,len(G)-1)\n",
        "    G[x]=random.randint(1,len(G))\n",
        "    return G\n",
        "    \n",
        "def xover(G1,G2):\n",
        "    x=random.randint(0,len(G1))\n",
        "    return np.concatenate((G1[:x],G2[x:]))\n",
        "\n",
        "xover([1,2,3,4],[5,6,7,8])"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "We will enhance gene selection process by selecting more genes with better fitness function. The probability of selection of a gene would depend on the fitness function: "
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 17,
      "metadata": {
        "trusted": true
      },
      "outputs": [],
      "source": [
        "def choose_rand(P):\n",
        "    N=len(P[0][0])\n",
        "    mf = N*(N-1)//2 # max fitness fn\n",
        "    z = [mf-x[1] for x in P]\n",
        "    tf = sum(z) # total fitness\n",
        "    w = [x/tf for x in z]\n",
        "    p = np.random.choice(len(P),2,False,p=w)\n",
        "    return p[0],p[1]\n",
        "\n",
        "def choose(P):\n",
        "    def ch(w):\n",
        "        p=[]\n",
        "        while p==[]:\n",
        "            r = random.random()\n",
        "            p = [i for i,x in enumerate(P) if x[1]>=r]\n",
        "        return random.choice(p)\n",
        "    N=len(P[0][0])\n",
        "    mf = N*(N-1)//2 # max fitness fn\n",
        "    z = [mf-x[1] for x in P]\n",
        "    tf = sum(z) # total fitness\n",
        "    w = [x/tf for x in z]\n",
        "    p1=p2=0\n",
        "    while p1==p2:\n",
        "        p1 = ch(w)\n",
        "        p2 = ch(w)\n",
        "    return p1,p2"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Now let's define the main evolutionary loop. We will make the logic slightly different from previous example, to show that one can get creative. We will loop until we get the perfect solution (fitness function=0), and at each step we will take the current generation, and produce the new generation of the same size. This is done using `nxgeneration` function, using the following steps:\n",
        "\n",
        "1. Discard the most unfit solutions - there is `discard_unfit` function that does that\n",
        "1. Add some more random solutions to the generation\n",
        "1. Populate new generation of size `gen_size` using the following steps for each new gene:\n",
        "    - select two random genes, with probability proportional to fitness function\n",
        "    - calculate a crossover\n",
        "    - apply a mutation with the probability `mutation_prob`"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 20,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "data": {
            "text/plain": [
              "(array([4, 7, 5, 3, 1, 6, 8, 2]), 0)"
            ]
          },
          "execution_count": 20,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "mutation_prob = 0.1\n",
        "\n",
        "def discard_unfit(P):\n",
        "    P.sort(key=lambda x:x[1])\n",
        "    return P[:len(P)//3]\n",
        "\n",
        "def nxgeneration(P):\n",
        "    gen_size=len(P)\n",
        "    P = discard_unfit(P)\n",
        "    P.extend(generate(len(P[0][0]),3))\n",
        "    new_gen = []\n",
        "    for _ in range(gen_size):\n",
        "        p1,p2 = choose_rand(P)\n",
        "        n = xover(P[p1][0],P[p2][0])\n",
        "        if random.random()<mutation_prob:\n",
        "            n=mutate(n)\n",
        "        nf = fit(n)\n",
        "        new_gen.append((n,nf))\n",
        "        '''\n",
        "        if (nf<=P[p1][1]) or (nf<=P[p2][1]):\n",
        "            new_gen.append((n,nf))\n",
        "        elif (P[p1][1]<P[p2][1]):\n",
        "            new_gen.append(P[p1])\n",
        "        else:\n",
        "            new_gen.append(P[p2])\n",
        "        '''\n",
        "    return new_gen\n",
        "    \n",
        "def genetic(N,pop_size=100):\n",
        "    P = generate(N,pop_size)\n",
        "    mf = min([x[1] for x in P])\n",
        "    n=0\n",
        "    while mf>0:\n",
        "        #print(\"Generation {0}, fit={1}\".format(n,mf))\n",
        "        n+=1\n",
        "        mf = min([x[1] for x in P])\n",
        "        P = nxgeneration(P)\n",
        "    mi = np.argmin([x[1] for x in P])\n",
        "    return P[mi]\n",
        "\n",
        "genetic(8)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "It is interesting that in most of the times we are able to get a solution pretty quickly, but in some rare cases optimization reaches local minimum, and the process is stuck for a long time. It is important to take that into account when you are measuring average time: while in most of the cases genetic algorithm will be faster than full search, in some cases it can take longer. To overcome this problem, it often makes sense to limit the number of generations to consider, and if we are not able to find the solution - we can start from scratch.  "
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 19,
      "metadata": {
        "trusted": true
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "The slowest run took 18.71 times longer than the fastest. This could mean that an intermediate result is being cached.\n",
            "26.4 s ± 28.7 s per loop (mean ± std. dev. of 7 runs, 1 loop each)\n"
          ]
        }
      ],
      "source": [
        "%timeit genetic(10)"
      ]
    }
  ],
  "metadata": {
    "interpreter": {
      "hash": "86193a1ab0ba47eac1c69c1756090baa3b420b3eea7d4aafab8b85f8b312f0c5"
    },
    "kernelspec": {
      "display_name": "Python 3.6",
      "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.9.5"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 2
}
