{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Module 2 - Uncertainty and Entropy II\n",
    "\n",
    "Author: Julio Correa, 2020; based on the original Matlab tutorials.<br/>\n",
    "Adaptations by: J. Lizier, 2023-\n",
    "\n",
    "The following block aims to import all the relevant libraries to analyse data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import math"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Preparing your environment\n",
    "\n",
    "In this and later notebooks, we want to use functions we have defined in our previous work in other notebooks.\n",
    "\n",
    "You have several choices on how to handle this:\n",
    "1. Add the `ipynb` library (`pip3 install ipynb`) and then you can use import statements such as `from ipynb.fs.full.Module_1_notebook import entropy`. This is quick, but a bit ugly (it runs the whole notebook and will output it under your import command).\n",
    "2. $\\star$ Edit the `simpleinfotheory.py` script to gather your functions as you write them, and import the required functions from this. Long term I think this is a better solution for you."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Option 1: your notebook from Module 1 is complete:\n",
    "# from ipynb.fs.full.Module_1_notebook import entropy\n",
    "# Option 2: you use the Module 1 note book solutions: (if so, ignore the out\n",
    "# from ipynb.fs.full.Module_1_notebook_solutions import entropy\n",
    "# Option 3: edit simpleinfotheory.py and past your functions into that as you write them\n",
    "from simpleinfotheory import entropy"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 5. Coding Shannon entropy for empirical data\n",
    "\n",
    "In this exercise we continue to alter the Python code in the next cell to measure the Shannon entropy. This time, let's code it not from a given distribution $p(x)$, but from empirical data of samples $x$ of the variable $X$.\n",
    "\n",
    "Your task is to edit the function <code>entropyempirical(xn)</code> in the next cell to return the Shannon entropy for the given samples $x_n$ of $X$ (n is the sample index). Note that the input <code>xn</code> is a vector, with each entry representing one sample.\n",
    "\n",
    "1. Examine the code template in the next cell. The first task the code performs is to work out the alphabet $A_X$ (contained in the variable <code>symbols</code>) that the samples are drawn from. Then the code template counts the number of occurrences of each symbol of the alphabet in the samples, normalises those counts into probabilities, and then computes the entropy from that. Fill out the code to perform these tasks where indicated with <code>???</code>."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "\"\"\" function entropyempirical(xn)\n",
    "Computes the Shannon entropy over all outcomes x of a random variable\n",
    "X from samples x_n.\n",
    "\n",
    "Inputs:\n",
    "- xn - samples of outcomes x as a numpy array or a list,\n",
    "   e.g. xn = [0,0,1,0,1,0,1,1,1,0] for a binary variable.\n",
    "\n",
    "Outputs:\n",
    "- result - Shannon entropy over all outcomes\n",
    "- symbols - numpy array of unique samples\n",
    "- probabilities - numpy array of probabilities for each sample\n",
    "\n",
    "Copyright (C) 2020-, Julio Correa, Joseph T. Lizier\n",
    "Distributed under GNU General Public License v3\n",
    "\"\"\"\n",
    "def entropyempirical(xn):\n",
    "\n",
    "    # First, error checking, and converting argument into standard form:    \n",
    "    if type(xn) == list:\n",
    "        xn = np.array(xn)\n",
    "    if xn.ndim == 1:\n",
    "        xn = np.reshape(xn,(len(xn), 1)) #reshaping our 1-dim vector to numpy format of a column vector\n",
    "    [xnSamples,xnDimensions] = xn.shape\n",
    "    \n",
    "    # We need to work out the alphabet here.\n",
    "    # The following returns a vector of the alphabet:    \n",
    "    symbols = np.unique(xn, axis=0)\n",
    "    # It would be faster to call:\n",
    "    #   [symbols, counts] = np.unique(xn, axis=0, return_counts=True)\n",
    "    # but we'll count the samples manually below for instructive purposes\n",
    "\n",
    "\t# Next we need to count the number of occurances of each symbol in \n",
    "\t# the alphabet:\n",
    "    counts = []\n",
    "    for symbol in symbols:\n",
    "        count = ???\n",
    "        counts.append(count)\n",
    "    counts = np.array(counts);\n",
    "    # Now normalise the counts into probabilities:\n",
    "    probabilities = ???\n",
    "    \n",
    "    # Once we have the probabilities we can simply call our existing function:\n",
    "    result = ???\n",
    "    \n",
    "    return result, symbols, probabilities"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "2. Test your code on some vectors of empirical data, e.g. <code>entropyempirical([0,0,1,1])</code> should return 1 bit. Design other test data sets where you know what the result should be, and test them."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Test the following case and add more cases:\n",
    "(result, symbols, probabilities) = entropyempirical([0,0,1,1])\n",
    "print( result )\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "3. What do you expect the average entropy of coin tosses to be? Toss a coin yourself 10 times, recording the results for each toss, and create a vector of boolean values to represent these samples. Call <code>entropyempirical</code> with this vector of samples -- did it return the result you expected? Try your experiment again and see if the result changed. Explain your results here."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Compute the empirical entropy from your coin tosses here:\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "4. Create boolean samples from random data, e.g. with <code>np.random.randint(0, 2, 10)</code>, and call <code>entropyempirical</code> with this vector of samples. Again -- does it return the result you expected? Try longer data sets, and also samples drawn from larger alphabets, and interpret the results."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Compute the entropy of your randomly generated samples here:\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 7. Coding Joint entropy\n",
    "\n",
    "In this exercise we continue to alter the code templates to measure the joint entropy for a distribution $p(x,y)$:\n",
    "\n",
    "$H\\left(X,Y\\right)=-\\sum_{x,y}p\\left(x,y\\right)\\log p\\left(x,y\\right)$\n",
    "\n",
    "Your task is to edit the function <code>jointentropy(p)</code> in the next cell to return the Shannon entropy for the given distribution $p(x,y)$ over joint outcomes $\\{x,y\\}$ of variables $X,Y$.\n",
    "\n",
    "Note the input argument to the function is a matrix <b>p</b>, representing the probability mass for each joint outcome of ${x,y}$. That is, <b>p</b> is a matrix with the $(i,j)$th entry in the matrix giving the probability for the joint outcome of the $i$th value that $x$ may take along with the $j$th value that $y$ may take. The sum of the items in the matrix <code>p</code> must be 1.\n",
    "\n",
    "For example, for a binary x and y we could have <code>p = np.array([[0.2, 0.3], [0.1, 0.4]])</code> where $p(x=0,y=0) = 0.2$, $p(x=0,y=1) = 0.3$, $p(x=1,y=0) = 0.1$, and $p(x=1,y=1) = 0.4$. If the variable $x$ can take more than two values for example, then we will have more than two rows in <code>p</code>.\n",
    "\n",
    "1. To get started, think about whether you can make simple changes to your code from <code>entropy(p)</code> to extend it to work here:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "\"\"\" function jointentropy(p)\n",
    "Computes the joint Shannon entropy over all outcome vectors x of a vector\n",
    "random variable X with probability matrix p(x) for each candidate outcome\n",
    "vector x.\n",
    "\n",
    "Inputs:\n",
    "- p - probability distribution function over all outcome vectors x.\n",
    "   p is a numpy matrix (or list of lists) over all combinations of the sub-variables of x,\n",
    "where p[0,2] gives the probability of the first symbol of sub-variable\n",
    "x1 co-occuring with the third symbol of sub-variable x2.\n",
    "   E.g. p = np.array([[0.2, 0.3], [0.1, 0.4]])\n",
    "   The sum over p must be 1.\n",
    "\n",
    "Outputs:\n",
    "- result - joint Shannon entropy of the probability distribution p\n",
    "\n",
    "Copyright (C) 2020-, Julio Correa, Joseph T. Lizier\n",
    "Distributed under GNU General Public License v3\n",
    "\"\"\"\n",
    "def jointentropy(p):\n",
    "    \n",
    "\t# Should we check any potential error conditions on the input?\n",
    "\n",
    "\t# We need to take the expectation value over the Shannon info content at\n",
    "\t#  p(x) for each outcome x in the joint PDF:\n",
    "\t# Hint: will your code for entropy(p) work, or can you alter it slightly\n",
    "\t#  to make it work?\n",
    "    \n",
    "    joint_entropy = ???\n",
    "    \n",
    "    return joint_entropy"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "2. Test that your code works, e.g. by running <code>jointentropy(np.array([[0.2, 0.3],[ 0.1, 0.4]]))</code> and validating that you get the result 1.85 bits. Come up with some other test cases to check, e.g. could you check similar boundary cases to what we used to test <code>entropy(p)</code> in the previous module?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Test the sample:\n",
    "print( jointentropy(np.array([[0.2, 0.3],[ 0.1, 0.4]])) )\n",
    "# Add other tests:\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "3. _Challenge_: try dropping the assumption that the input argument <code>p</code> is of 2 dimensions, but allow it to be a matrix of arbitrary dimensions. Can you do this with no or minimal changes to the code?<br/>\n",
    "Hint: the <code>numpy.sum()</code> and <code>numpy.nansum()</code> methods will sum all array elements (rather than along one dimension only) by default. (You can see that this is used in the solution code for <code>entropy(p)</code>)."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 8. _(Optional extension)_ Coding joint entropy for empirical data\n",
    "\n",
    "We continue with the Python code templates to measure the joint entropy from empirical data of samples $x$ of the variable $X$.\n",
    "\n",
    "This is already implemented in this Python function <code>jointentropyempirical(xn)</code>, to return the joint entropy for the given samples $x_n$ of $X$ ($n$ is the sample index). Note that the input <code>xn</code> is a matrix, where rows (the first array dimension) represent samples and columns (the second array dimension) represent variables; i.e. <code>xn=[[0,1], [1,1], [1,0]]</code> represents 3 samples of 2 variables. There will always be multiple rows (because we always should have many samples), but the number of columns will depend on how many variables we are jointly considering (it could be just one if we have only a single variable).\n",
    "\n",
    "1. Examine the code in the next cell. Most of the code actually pre-processes the input arguments, before it maps a distinct symbol for each unique row in <code>xn</code> and then asks <code>entropyempirical()</code> to calculate the entropy."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "\"\"\" function jointentropyempirical(xn, yn)\n",
    "Computes the Shannon entropy over all outcome vectors x of a vector random\n",
    "variable X from sample vectors x_n. User can call with two such arguments \n",
    "if they don't wish to join them outside of the call.\n",
    "\n",
    "Inputs:\n",
    "- xn - numpy matrix (or list of lists) of samples of outcomes x. May be a 1D vector of samples\n",
    "    (in which case yn is also supplied), or\n",
    "    a 2D matrix, where each row is a vector sample for a multivariate X\n",
    "    (in which case yn is not supplied).\n",
    "- yn - as per xn, except that yn is not required to be supplied (in which\n",
    " case the entropy is only calculated over the multivariate xn variable).\n",
    "\n",
    "Outputs:\n",
    "- result - joint Shannon entropy over all samples\n",
    "- symbols - numpy array of unique joint vector samples\n",
    "- probabilities - numpy array of probabilities for each joint symbol\n",
    "\n",
    "Copyright (C) 2020-, Julio Correa, Joseph T. Lizier\n",
    "Distributed under GNU General Public License v3\n",
    "\"\"\"\n",
    "def jointentropyempirical(xn, yn=[]):\n",
    "    \n",
    "    # First, error checking, and converting argument into standard form:    \n",
    "    xn = np.array(xn)\n",
    "    # Convert to column vectors if not already:\n",
    "    if xn.ndim == 1:\n",
    "        xn = np.reshape(xn,(len(xn),1))\n",
    "    yn = np.array(yn)\n",
    "    if (yn.size > 0):\n",
    "        # Convert to column vectors if not already:\n",
    "        if yn.ndim == 1:\n",
    "            yn = np.reshape(yn,(len(yn),1))\n",
    "        [rx,cx] = xn.shape\n",
    "        [ry,cy] = yn.shape\n",
    "        # Check that their number of rows are the same:\n",
    "        assert(rx == ry)\n",
    "        # Now joint them up so we only need work with xn\n",
    "        xn = np.concatenate((xn,yn), axis=1)\n",
    "        \n",
    "    # TRICK: Next combine the row vectors in each sample into a single \n",
    "    #  symbol (being the index from the symbols array,\n",
    "    # so that we can simply compute entropy on that combined symbol\n",
    "    [symbols, symbolIndexForEachSample] = np.unique(xn, axis=0, return_inverse=True)\n",
    "\n",
    "    # And compute the entropy using our existing function:\n",
    "    [result, symbols_of_indices, probabilities] = entropyempirical(symbolIndexForEachSample);\n",
    "\n",
    "    # The order of symbols is the same as their order for the probabilities\n",
    "\n",
    "    return result, symbols, probabilities"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "2. Test the code on some vectors of empirical data, e.g. <code>jointentropyempirical([[0,1],[0,0],[1,0],[1,1]])</code> should return 2 bits since we provided 4 distinct equiprobable samples. Design other test data sets where you know what the result should be, and test them."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Test the sample:\n",
    "(jentropyResult, symbols, probabilities) = jointentropyempirical([[0,1],[0,0],[1,0],[1,1]])\n",
    "print(jentropyResult)\n",
    "# Add other tests:\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 10. Coding Conditional entropy\n",
    "In this exercise we continue to alter the Python code templates to measure the conditional entropy for a distribution $p(x,y)$:\n",
    "\n",
    " $\\begin{aligned}\n",
    "H\\left(X\\mid Y\\right)=&-\\sum_{x,y}p\\left(x,y\\right)\\log p\\left(x\\mid y\\right)\\\\\n",
    "=&-\\sum_{x,y}p\\left(x,y\\right)\\left(\\log p\\left(x,y\\right)-\\log p\\left(y\\right)\\right)\\\\\n",
    "=&H\\left(X,Y\\right)-H\\left(Y\\right)\n",
    "\\end{aligned}$\n",
    "<br>\n",
    "\n",
    "Your task is to edit the Python function <code>conditionalentropy(p)</code> in the next cell to return the conditional entropy for the given distribution $p(x,y)$ over joint outcomes $\\{x,y\\}$ of variables $X,Y$.\n",
    "\n",
    "As above for the joint entropy, the input argument to the function is a matrix $p$, representing the probability mass for each joint outcome of $\\{x,y\\}$.\n",
    "\n",
    "1. To fill in the template, you will need to call your existing functions <code>entropy(p)</code> for $H(Y)$ and <code>jointentropy(p)</code> for $H(X,Y)$ to provide the calculations needed. Note that to compute $H(Y)$ you will need to extract $p(y)$ from the $p(x,y)$ matrix by summing over all $x$ rows.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "\"\"\"function conditionalentropy(p)\n",
    "\n",
    "Computes the conditional Shannon entropy over all outcomes x of a random\n",
    "variable X, given outcomes y of a random variable Y.\n",
    "Probability matrix p(x,y) is given for each candidate outcome\n",
    "(x,y).\n",
    "\n",
    "Inputs:\n",
    "- p - 2D probability distribution function over all outcomes (x,y).\n",
    "   p is a numpy matrix over all combinations of x and y,\n",
    "where p[0, 2] gives the probability of the first symbol of variable\n",
    "x co-occuring with the third symbol of variable y.\n",
    "   E.g. p = nump.array([[0.2, 0.3], [0.1, 0.4]]).\n",
    "   The sum over p must be 1.\n",
    "\n",
    "Outputs:\n",
    "- result - conditional Shannon entropy of X given Y\n",
    "\n",
    "Copyright (C) 2020-, Julio Correa, Joseph T. Lizier\n",
    "Distributed under GNU General Public License v3\n",
    "\"\"\"\n",
    "def conditionalentropy(p):\n",
    "    \n",
    "    # First make sure the array is now a numpy array\n",
    "    if type(p) != np.array:\n",
    "        p = np.array(p)\n",
    "\n",
    "    # Should we check any potential error conditions on the input?\n",
    "    # a. Should we check p is a matrix, not a vector?\n",
    "    # Actually we won't since a vector would be valid if one variable only ever took one value.\n",
    "    # b. Check that the probabilities normalise to 1:\n",
    "    if (abs(np.sum(p) - 1) > 0.00001):\n",
    "        raise Exception(\"Probability distribution must sum to 1: sum is %.4f\" % np.sum(p))\n",
    "\n",
    "    # We need to compute H(X,Y) - H(X):\n",
    "    # 1. joint entropy: Can we re-use existing code?\n",
    "    H_XY = ???;\n",
    "    # 2. marginal entropy of Y: Can we re-use existing code?\n",
    "    #  But how to get p_y???\n",
    "    p_y = ???;\n",
    "    H_Y = ???;\n",
    "\t\n",
    "    result = H_XY - H_Y;\n",
    "    return result"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "2. Test that your code works, e.g. by running:\n",
    "    1. `conditionalentropy([[0.2, 0.3], [0.1, 0.4]])` and validating that you get the result 0.965 bits. \n",
    "    2. `conditionalentropy([[0.5, 0], [0, 0.5]])` and validating that you get the result 0 bits. \n",
    "    3. `conditionalentropy([[0.25, 0.25], [0.25, 0.25]])` and validating that you get the result 1 bit. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Test the code here:\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "3. Coming back to the Guess Who? example using the [Kooky character sheet](https://web.archive.org/web/20170215034006/http://www.hasbro.com/upload/guesswho/GWc_Kooky-en_GB.pdf), compute the conditional entropy of whether the character has horns given that they have eyebrows, i.e. $H(horns | eyebrows)$? Construct first the table $p(horns, eyebrows)$ for all 4 combinations of these two binary variables, then pass this to your function.  Is $H(eyebrows | horns)$ the same?<br/>\n",
    "Can you identify two traits where the conditional entropy drops the entropy significantly compared to the unconditioned? What would such a reduction in uncertainty mean?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Construct the table p(horns,eyebrows)\n",
    "#                  [[h_0_e_0, h_0_e_1], [h_1_e_0, h_1_e_1]]\n",
    "\n",
    "# Compute H(horns | eyebrows)\n",
    "\n",
    "# Compute H(eyebrows | horns)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "4. _(Optional)_ Finally, let's code conditional entropy $H(X|Y)$ for empirical samples `xn` and `yn` in the cell below.<br/>\n",
    "_Hint_: You can call your existing code `jointentropyempirical` and `entropyempirical` to compute $H(X,Y)$ and $H(Y)$ respectively, by passing in `[xn,yn]` and `yn` as arguments to these functions respectively. Test that your code works by running, e.g.:\n",
    "    1. `conditionalentropyempirical([0,0,1,1],[0,1,0,1])` and validating that you get the result 1 bit.\n",
    "    2. `conditionalentropyempirical([0,0,1,1],[0,0,1,1])` and validating that you get the result 0 bits.\n",
    "    3. Can you explain the expected results for these boundary cases?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "\"\"\"function conditionalentropyempirical(xn, yn)\n",
    "Computes the conditional Shannon entropy over all samples xn of a random\n",
    "variable X, given samples yn of a random variable Y.\n",
    "\n",
    "Inputs:\n",
    "- xn - numpy matrix (or list of lists) of samples of outcomes x. May be a 1D vector of samples, or\n",
    "    a 2D matrix, where each row is a vector sample for a multivariate X.\n",
    "- yn - numpy matrix (or list of lists) of samples of outcomes x. May be a 1D vector of samples, or\n",
    "    a 2D matrix, where each row is a vector sample for a multivariate Y.\n",
    "    Must have the same number of rows as X.\n",
    "\n",
    "Outputs:\n",
    "- result - conditional Shannon entropy of X given Y\n",
    "\n",
    "Copyright (C) 2020-, Julio Correa, Joseph T. Lizier\n",
    "Distributed under GNU General Public License v3\n",
    "\"\"\"\n",
    "def conditionalentropyempirical(xn, yn):\n",
    "    \n",
    "    # First, error checking, and converting argument into standard form:    \n",
    "    xn = np.array(xn)\n",
    "    # Convert to column vectors if not already:\n",
    "    if xn.ndim == 1:\n",
    "        xn = np.reshape(xn,(len(xn),1))\n",
    "    yn = np.array(yn)\n",
    "    if yn.ndim == 1:\n",
    "        yn = np.reshape(yn,(len(yn),1))\n",
    "    [rx,cx] = xn.shape\n",
    "    [ry,cy] = yn.shape\n",
    "\n",
    "    # Should we check any potential error conditions on the input?\n",
    "    # Check that their number of rows are the same:\n",
    "    assert(rx == ry)\n",
    "    \n",
    "    # We need to compute H(X,Y) - H(X):\n",
    "    # 1. joint entropy: Can we re-use existing code?\n",
    "    (H_XY, xySymbols, xyProbs) = ???;\n",
    "    # 2. marginal entropy of Y: Can we re-use existing code?\n",
    "    (H_Y, ySymbols, yProbs) = ???;\n",
    "\t\n",
    "    result = H_XY - H_Y;\n",
    "    return result"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Run the boundary cases:\n",
    "print( conditionalentropyempirical([0,0,1,1],[0,1,0,1]) )\n",
    "print( conditionalentropyempirical([0,0,1,1],[0,0,1,1]) )"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "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.10.12"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
