{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "6a764d92",
   "metadata": {},
   "source": [
    "# Bayesian Optimization\n",
    "\n",
    "We can take advantage of the previous method by using it to optimize controls in an experiment. This procedure is called Bayesian Optimization: we collect data and calculate a metric indicating how good the current setup is. We then fit a function using a Gaussian Process to identify how this quality metric behaves as a function of the parameters we actually control. From this fit we may find the optimum of the quality function and shift our control parameters there.\n",
    "\n",
    "When collecting data points with several inputs in a system is very time-consuming, this methodology is particularly powerful, since it allows one to reach the optimum very fast.\n",
    "\n",
    "Here we will create an example function that we want to maximize, but we assume that collecting the value of the function in each point is very time-consuming or resource-intensive and therefore we would like to find the maximum of that function with very few attempts. The same logic would work for finding the minimum of a function.\n",
    "\n",
    "Let us start by importing some modules and creating an example function for us to maximize.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "8933f30f-4862-4b36-b19a-a3511726d77f",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Needed in Google Colab, since it does not have gpytorch by default\n",
    "#!pip install gpytorch torch botorch"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "3ee0feee-7453-4982-ac31-c661ce692449",
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/home/daniloefl/workspace/ml-lecture-oct-2023/env/lib/python3.9/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
      "  from .autonotebook import tqdm as notebook_tqdm\n"
     ]
    }
   ],
   "source": [
    "from typing import List, Tuple, Optional\n",
    "\n",
    "import numpy as np\n",
    "\n",
    "from numpy.random import MT19937\n",
    "from numpy.random import RandomState, SeedSequence\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "import torch\n",
    "import gpytorch\n",
    "\n",
    "from botorch.acquisition import UpperConfidenceBound\n",
    "from botorch.fit import fit_gpytorch_model\n",
    "from botorch.optim import optimize_acqf\n",
    "from botorch.acquisition.objective import ScalarizedPosteriorTransform\n",
    "from botorch.models import SingleTaskGP\n",
    "from torch import Tensor\n",
    "from gpytorch.kernels.scale_kernel import ScaleKernel\n",
    "from gpytorch.kernels.rbf_kernel import RBFKernel\n",
    "from gpytorch.mlls import ExactMarginalLogLikelihood"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "fec4cda5-6574-4f34-8b29-02061aab35c8",
   "metadata": {},
   "outputs": [],
   "source": [
    "def true_f(x: Tensor) -> Tensor:\n",
    "    \"\"\"Function that we would like to optimize. Usually we do not know this!\"\"\"\n",
    "    return ((torch.sin(2*np.pi*x) + torch.sin(2*np.pi*2*x))*torch.exp(-x**2/2.0/0.2)).sum(1, keepdims=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9bdd019f-5bae-401b-9c73-1e7a58fe2a24",
   "metadata": {},
   "source": [
    "We can now plot this function to see how it looks like. Usually, this is very hard to do, since we have many parameter dimensions. In this example, we have a single parameter dimension to makes things easy to visualize."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "c9a7d52c-588c-49dc-a05e-7f3bcc78e7a3",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "[<matplotlib.lines.Line2D at 0x7f49a4ebc8e0>]"
      ]
     },
     "execution_count": 4,
     "metadata": {},
     "output_type": "execute_result"
    },
    {
     "data": {
      "image/png": "[encoded data, 30752 characters]",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "x_axis = torch.from_numpy(np.linspace(0.0, 1.0, 200)).unsqueeze(1)\n",
    "y_axis = true_f(x_axis).detach()\n",
    "plt.plot(x_axis.numpy()[:,0], y_axis.numpy())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bee56813-6465-465a-9e93-232eada9ba46",
   "metadata": {},
   "source": [
    "We are now going to pretend we can only take a few points of this curve, because each time we \"measure\" the value of the function for a given x-axis value, it takes a lot of our resources.\n",
    "\n",
    "To this end, we start by taking 5 points at random from the curve to make an initial fit."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "442d8fc1-e45c-4162-b34c-f33ce56afd4a",
   "metadata": {},
   "outputs": [],
   "source": [
    "def initial_data(n: int=5) -> Tensor:\n",
    "    \"\"\"Randomly select some points to test f on.\"\"\"\n",
    "    rng = RandomState(MT19937(SeedSequence(123456789)))\n",
    "    return 0.2+torch.from_numpy(rng.rand(n, 1)*0.3).double()\n",
    "    \n",
    "x0 = initial_data(n=5)\n",
    "y0 = true_f(x0)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "af6e1101-80bf-4e36-a5bf-508d47f73614",
   "metadata": {},
   "source": [
    "Let's now plot it to visualize it, but let's make the plot a bit more detailed than before:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "d0825829-f78b-4443-84ca-922c0ef3c32a",
   "metadata": {},
   "outputs": [],
   "source": [
    "def plot_simple(x: Tensor, y: Tensor):\n",
    "    \"\"\"\n",
    "    Plot the observed x, y and the true function.\n",
    "    \"\"\"\n",
    "    # get a set of x-axis points from 0 to 1 for plotting\n",
    "    x_axis = torch.from_numpy(np.linspace(0.0, 1.0, 200)).double().unsqueeze(1)\n",
    "    \n",
    "    # get the true function value\n",
    "    true_y = true_f(x_axis)\n",
    "\n",
    "    f, ax = plt.subplots(1, 1, figsize=(12, 4))\n",
    "\n",
    "    # first the true function so we can compare things with it\n",
    "    ax.plot(x_axis, true_y, 'g--', label=r\"True $f$\", lw=2)\n",
    "\n",
    "    # plot the actual points we collected\n",
    "    ax.plot(x.cpu().numpy()[:,0], y.cpu().numpy(), 'k^', label=\"Observation\", markersize=8)\n",
    "\n",
    "    # make the plot look pretty\n",
    "    ax.set(xlabel=\"$x$\", ylabel=r\"$f(x)$\", title=\"\", xlim=(0.0, 1.0))\n",
    "    ax.legend(loc='upper left', frameon=False, bbox_to_anchor=(1.0,1), borderaxespad=0)\n",
    "\n",
    "    # prettify\n",
    "    plt.tight_layout()\n",
    "    plt.subplots_adjust(right=0.80,\n",
    "                        top=0.99)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "0876f631-e6a3-4104-839d-522244708982",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 43444 characters]",
      "text/plain": [
       "<Figure size 1200x400 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "plot_simple(x0, y0)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a0d87ed3-af4c-4379-8e81-0cf61610de0d",
   "metadata": {},
   "source": [
    "So, now we have some initial points. We are assuming the true function is not known, so how can we fit those points to make this curve?\n",
    "Let us create a fit model using a Gaussian Process. We create an object to do some preprocessing and hold the most important fit elements in the `FitModel` class.\n",
    "Check the `botorch` documentation for more information."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "5dd8089c-b3ed-4d67-b67e-9d34fc7ed77f",
   "metadata": {},
   "outputs": [],
   "source": [
    "class FitModel(SingleTaskGP):\n",
    "    \"\"\"Model to fit the function using the few points we know.\n",
    "\n",
    "    Args:\n",
    "      x_train: The parameters we control. Assumed of shape (B, K), where B is the number of points and K is the dimension of the data.\n",
    "      y_train: The function value. Assumed of shape (B, 1), containing a single value per point.\n",
    "    \"\"\"\n",
    "    def __init__(self, x_train: Tensor, y_train: Tensor):\n",
    "        assert len(x_train.shape) == 2\n",
    "        assert len(y_train.shape) == 2\n",
    "\n",
    "        # calculate the mean and root-mean-squared error to standardize the input data\n",
    "        self.x = x_train.detach()\n",
    "        self.y = y_train.detach()\n",
    "        self.mx = self.x.mean(dim=-2, keepdim=True)\n",
    "        self.sx = torch.clamp(self.x.std(dim=-2, keepdim=True), min=1e-6)\n",
    "        self.my = self.y.mean(dim=-2, keepdim=True)\n",
    "        self.sy = torch.clamp(self.y.std(dim=-2, keepdim=True), min=1e-6)\n",
    "\n",
    "        # the input data after some preprocessing\n",
    "        self.x_tr = self.transform_x(self.x)\n",
    "        self.y_tr = self.transform_y(self.y)\n",
    "\n",
    "        # representation of the covariance model\n",
    "        # this is the core of the model and it encodes a covariance function C(x1, x2) = S*exp(-|x2 - x1|^2/L^2)\n",
    "        # S encoded using the `ScaleKernel` object\n",
    "        # L is encoded using the `RBFKernel` object and it is called the lengthscale\n",
    "        covar_module = ScaleKernel(RBFKernel())\n",
    "\n",
    "        super(FitModel, self).__init__(self.x_tr, self.y_tr, covar_module=covar_module)\n",
    "        self.best_y_tr = torch.max(self.y_tr)\n",
    "\n",
    "    def transform_x(self, x: Tensor) -> Tensor:\n",
    "        \"\"\"Transform inputs.\"\"\"\n",
    "        x_tr = (x - self.mx)/self.sx\n",
    "        return x_tr\n",
    "\n",
    "    def transform_y(self, y: Tensor) -> Tensor:\n",
    "        \"\"\"Transform outputs.\"\"\"\n",
    "        y_tr = (y - self.my)/self.sy\n",
    "        return y_tr\n",
    "\n",
    "    def untransform_x(self, x: Tensor, no_constant: bool=False) -> Tensor:\n",
    "        \"\"\"Undo transform of inputs.\"\"\"\n",
    "        x_tr = x*self.sx\n",
    "        if not no_constant:\n",
    "            x_tr += self.mx\n",
    "        return x_tr\n",
    "\n",
    "    def untransform_y(self, y: Tensor, no_constant: bool=False) -> Tensor:\n",
    "        \"\"\"Undo transform of outputs.\"\"\"\n",
    "        y_tr = y*self.sy\n",
    "        if not no_constant:\n",
    "            y_tr += self.my\n",
    "        return y_tr\n",
    "\n",
    "    def predict(self, x: Tensor) -> Tuple[Tensor, Tensor]:\n",
    "        \"\"\"Predict the posterior mean and uncertainty.\n",
    "\n",
    "        Args:\n",
    "          x: Input parameters\n",
    "\n",
    "        Returns: The mean and uncertainty prediction.\n",
    "        \"\"\"\n",
    "        x_tr = self.transform_x(x)\n",
    "        pred = self.likelihood(self(x_tr))\n",
    "        m = self.untransform_y(pred.mean)\n",
    "        s = self.untransform_y(torch.sqrt(pred.variance), no_constant=True)\n",
    "        return m, s\n",
    "\n",
    "    def get_acquisition(self):\n",
    "        \"\"\"Get the acquisition function.\"\"\"\n",
    "        posterior_transform = ScalarizedPosteriorTransform(weights=self.sy.view(-1), offset=self.my.item())\n",
    "        UCB = UpperConfidenceBound(model=self, beta=0.1, posterior_transform=posterior_transform)\n",
    "        return UCB"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "310d19f5-187c-4887-b66c-dbf268709901",
   "metadata": {},
   "source": [
    "Let's also write down a function to make the fit using the initial data and from the fit, extract what is the next candidate to be probed:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "c53800fb-65f9-44d5-bff5-0308b4afc452",
   "metadata": {},
   "outputs": [],
   "source": [
    "def update_params(x: Tensor, y: Tensor) -> Tuple[Tensor, FitModel]:\n",
    "    \"\"\"\n",
    "    Given some input past parameters and the value of the function, get me a new value to probe.\n",
    "    \"\"\"\n",
    "    # create a fit object\n",
    "    model = FitModel(x, y)\n",
    "    # get the log-likelihood for it\n",
    "    mll = ExactMarginalLogLikelihood(model.likelihood, model)\n",
    "    # make the fit of the model to the observed data\n",
    "    opt_mll = fit_gpytorch_model(mll)\n",
    "\n",
    "    # get the acquisition function\n",
    "    acq = model.get_acquisition()\n",
    "\n",
    "    # only focus in the bounded region [0, 1]\n",
    "    d_min = torch.zeros((1, x.shape[-1]))\n",
    "    d_max = torch.ones((1, x.shape[-1]))\n",
    "    # transform it\n",
    "    d_min_tr = model.transform_x(d_min)\n",
    "    d_max_tr = model.transform_x(d_max)\n",
    "    bounds = torch.cat([d_min_tr, d_max_tr], dim=0)\n",
    "\n",
    "    # find the maximum of the acquisition function\n",
    "    next_parameter, _ = optimize_acqf(\n",
    "                    acq_function=acq,\n",
    "                    bounds=bounds,\n",
    "                    q=1,\n",
    "                    num_restarts=10,\n",
    "                    raw_samples=512,\n",
    "                    )\n",
    "    # transform the proposed result back to the original space\n",
    "    next_parameter = model.untransform_x(next_parameter)\n",
    "    # return the parameter and the fit result, in case we want to plot it\n",
    "    return next_parameter, model\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "edb4bacf-34d5-4c66-b66c-9910d63ef434",
   "metadata": {},
   "source": [
    "Finally, let's make an auxiliary plot function to show what is happening:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "5eee5e53-6382-49b1-9be2-09544abdefb7",
   "metadata": {},
   "outputs": [],
   "source": [
    "def plot(x: Tensor, y: Tensor, model: FitModel):\n",
    "    \"\"\"\n",
    "    Plot the observed x and y, and the fit result.\n",
    "    \"\"\"\n",
    "    # get a set of x-axis points from 0 to 1 for plotting\n",
    "    x_axis = torch.from_numpy(np.linspace(0.0, 1.0, 200)).double().unsqueeze(1)\n",
    "    \n",
    "    # get the true function value\n",
    "    true_y = true_f(x_axis)\n",
    "    \n",
    "    # get the prediction from the fit\n",
    "    m, s = model.predict(x_axis)\n",
    "    \n",
    "    # get the value of the acquisition function:\n",
    "    ei = model.get_acquisition()(model.transform_x(x_axis).unsqueeze(1))\n",
    "    \n",
    "    # transform the results into numpy objects and drop the extra dimension if needed\n",
    "    m = m.detach().cpu().numpy()[0,:]\n",
    "    s = s.detach().cpu().numpy()[0,:]\n",
    "    true_y = true_y.detach().cpu().numpy()\n",
    "    ei = ei.detach().cpu().numpy()\n",
    "    x_axis = x_axis.cpu().numpy()[:,0]\n",
    "    \n",
    "    # plot it\n",
    "    f, ax = plt.subplots(2, 1, figsize=(12, 8))\n",
    "\n",
    "    # first the true function so we can compare things with it\n",
    "    ax[0].plot(x_axis, true_y, 'g--', label=r\"True $f$\", lw=2)\n",
    "    # now the fit result\n",
    "    ax[0].plot(x_axis, m, 'b-', label=\"Mean\", lw=2)\n",
    "    # now the uncertainty of the fit\n",
    "    ax[0].fill_between(x_axis, (m - 1*s), (m + 1*s), alpha=0.3, label=\"68% CL\")\n",
    "\n",
    "    # plot the actual points we collected\n",
    "    ax[0].plot(x.cpu().numpy()[:,0], y.cpu().numpy(), 'k^', label=\"Observation\", markersize=8)\n",
    "\n",
    "    # highlight the last point we collected\n",
    "    ax[0].plot(x.cpu().numpy()[-1:, 0], y.cpu().numpy()[-1:], 'ro', label=\"Last observation\", markersize=10)\n",
    "\n",
    "    # make the plot look pretty\n",
    "    ax[0].set(xlabel=\"\", ylabel=r\"$f(x)$\", title=\"\", xlim=(0.0, 1.0))\n",
    "    ax[0].set_xticks([])\n",
    "    ax[0].legend(loc='upper left', frameon=False, bbox_to_anchor=(1.0,1), borderaxespad=0)\n",
    "\n",
    "    # now show the acquision function in the bottom plot\n",
    "    ax[1].plot(x_axis, ei, 'k-', lw=2, label=r\"$\\mathcal{A}$\")\n",
    "    ax[1].set(xlabel=r\"$x$\", ylabel=r\"Acquisition function\", title=\"\", xlim=(0.0, 1.0))\n",
    "\n",
    "    # find the maximum of the acquision function and show some dotted lines to indicate it in both plots\n",
    "    idx_next = np.argmax(ei)\n",
    "    x_next = x_axis[idx_next]\n",
    "    ax[1].axvline(x_next, 0, 1, c='r', ls=':', lw=2, label=r\"Next $\\vec{x}$\")\n",
    "    ax[0].axvline(x_next, 0, 1, c='r', ls=':', lw=2)\n",
    "    ax[1].legend(loc='upper left', frameon=False, bbox_to_anchor=(1.0,1), borderaxespad=0)\n",
    "\n",
    "    # prettify\n",
    "    plt.tight_layout()\n",
    "    plt.subplots_adjust(\n",
    "                        #left=0.05,\n",
    "                        #bottom=0.05,\n",
    "                        right=0.80,\n",
    "                        top=0.99,\n",
    "                        #wspace=0.4,\n",
    "                        hspace=0\n",
    "                        )"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c7db2a9b-2d9e-4182-b558-3c1d35be17d4",
   "metadata": {},
   "source": [
    "Now, let's use the initial x and y values to make the fit itself:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "8f7ebf8a-7503-4b2c-a544-691f1f13271f",
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/home/daniloefl/workspace/ml-lecture-oct-2023/env/lib/python3.9/site-packages/botorch/models/utils/assorted.py:173: InputDataWarning: Input data is not contained to the unit cube. Please consider min-max scaling the input data.\n",
      "  warnings.warn(msg, InputDataWarning)\n"
     ]
    }
   ],
   "source": [
    "next_parameter, model = update_params(x0, y0)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "edec7a8e-ec9f-48cb-a725-44a7d56f1456",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 103496 characters]",
      "text/plain": [
       "<Figure size 1200x800 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "plot(x0, y0, model)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "38515339-77f1-4981-8a7e-7c2bc11191ab",
   "metadata": {},
   "source": [
    "Notice that the model fit is quite close to the actual fit function. We didn't have to guess the actual function shape itself. The uncertainty band is also calculated and the maximum of the acquisition function, calculated using the fit result gives us the next point to probe.\n",
    "\n",
    "Let's probe the next point x in the `next_parameter` and add it to our table of values known. After that we can just repeat the process above."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "ae25ddb3-113f-4d7b-b640-9913260b542f",
   "metadata": {},
   "outputs": [],
   "source": [
    "next_y = true_f(next_parameter)\n",
    "\n",
    "x1 = torch.cat([x0, next_parameter], axis=0)\n",
    "y1 = torch.cat([y0, next_y], axis=0)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "3c6ac772-1f48-4685-8d06-033aa8d00858",
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/home/daniloefl/workspace/ml-lecture-oct-2023/env/lib/python3.9/site-packages/botorch/models/utils/assorted.py:173: InputDataWarning: Input data is not contained to the unit cube. Please consider min-max scaling the input data.\n",
      "  warnings.warn(msg, InputDataWarning)\n"
     ]
    }
   ],
   "source": [
    "next_parameter, model = update_params(x1, y1)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "634e111f-71b4-482a-923a-62a4d3e8f0d9",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 117408 characters]",
      "text/plain": [
       "<Figure size 1200x800 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "plot(x1, y1, model)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12bd6504-355a-4b13-b35f-246b9d555c1e",
   "metadata": {},
   "source": [
    "This can be repeated many times until we are very confident we are close enough to the maximum:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "id": "f1d29b5d-c0f6-43fc-8a19-57101ab8a783",
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/home/daniloefl/workspace/ml-lecture-oct-2023/env/lib/python3.9/site-packages/botorch/models/utils/assorted.py:173: InputDataWarning: Input data is not contained to the unit cube. Please consider min-max scaling the input data.\n",
      "  warnings.warn(msg, InputDataWarning)\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 115264 characters]",
      "text/plain": [
       "<Figure size 1200x800 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "next_y = true_f(next_parameter)\n",
    "\n",
    "x2 = torch.cat([x1, next_parameter], axis=0)\n",
    "y2 = torch.cat([y1, next_y], axis=0)\n",
    "\n",
    "next_parameter, model = update_params(x2, y2)\n",
    "\n",
    "plot(x2, y2, model)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f3a7a81b-7191-477a-bfc9-e8f19abd3908",
   "metadata": {},
   "outputs": [],
   "source": []
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2ffb46d2-3374-48b0-b176-9dce934e079d",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "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"
  },
  "widgets": {
   "application/vnd.jupyter.widget-state+json": {
    "state": {},
    "version_major": 2,
    "version_minor": 0
   }
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
