{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 26,
   "metadata": {},
   "outputs": [],
   "source": [
    "from poplar.distributions import VariableLimitsPowerLaw, FixedLimitsPowerLaw, UniformDistribution\n",
    "from poplar.nn.networks import LinearModel\n",
    "from poplar.nn.training import train, train_test_split\n",
    "from poplar.nn.rescaling import ZScoreRescaler\n",
    "from poplar.nn.plot import loss_plot\n",
    "from poplar.selection import selection_function_from_optimal_snr, matched_filter_snr_from_optimal_snr\n",
    "import matplotlib.pyplot as plt\n",
    "import numpy as np\n",
    "import torch\n",
    "import tqdm\n",
    "\n",
    "device = \"cuda:1\"\n",
    "plt.style.use('seaborn')\n",
    "plt.rcParams['figure.figsize'] = [12,7]"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# End-to-end usage example"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In this example, we're going to learn an analytic SNR function and use it to model selection effects for a population of gravitational \n",
    "wave signals from compact binary coalescences. \n",
    "\n",
    "In reality, the SNR function is not analytic but our approach would be valid nonetheless."
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Learning signal detectability\n",
    "\n",
    "Firstly, let's define an SNR function. We're going to focus on a few parameters: the masses $(m_1, m_2)$ of the compact objects, the luminosity distance to the binary $d_L$ (in some arbitrary units) and a trigonometric function of two \"sky position angles\" $(\\alpha, \\delta)$. \n",
    "We define the optimal SNR $\\rho$ as\n",
    "$$ \\rho = \\frac{1}{d_L} \\mathcal{M_c}^{2.2} f(\\alpha, \\delta)$$\n",
    "where $\\mathcal{M_c}$ is the chirp mass of the binary, defined as \n",
    "$$ \\mathcal{M_c} = \\frac{(m_1m_2)^{3/5}}{(m_1 + m_2)^{1/5}} $$\n",
    "and\n",
    "$$ f(\\alpha, \\delta) = -(1 + \\cos^2(2\\alpha/3) - \\sin^2(5\\delta/3))\\cos(5\\alpha/6)\\sin(4\\delta/3)$$\n",
    "\n",
    "Let's define these functions and visualise the form of $f$:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 29,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 19360 characters]",
      "text/plain": [
       "<Figure size 864x504 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "def dummy_antenna_pattern(a, d):\n",
    "    return 1 + (np.cos(2*a/3)**2 - np.sin(5*d/3)**2)*np.cos(5*a/6)*np.cos(4*d/3)\n",
    "\n",
    "def mchirp(m1, m2):\n",
    "    return (m1 * m2)**(3/5) / (m1 + m2)**(1/5)\n",
    "\n",
    "def snr(m1, m2, dL, alpha, delta):\n",
    "    return 1/dL * mchirp(m1, m2)**2.2 * dummy_antenna_pattern(alpha, delta)\n",
    "\n",
    "av, dv = np.meshgrid(np.linspace(0,2*np.pi), np.linspace(0, np.pi))\n",
    "Z = dummy_antenna_pattern(av, dv)\n",
    "plt.pcolormesh(av, dv, Z)\n",
    "plt.colorbar()\n",
    "plt.show()"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We're going to consider masses in the range [1, 100] $M_\\odot$, angles in the range $[0, 2 \\pi]$ and $[0, \\pi]$ for $\\alpha$ and $\\delta$ respectively and $d_L$ in the range $[0.01, 1]$ (in some arbitrary units). \n",
    "\n",
    "Let's generate a dataset of SNRs in this prior range, and our neural network to be trained on these SNRs. As SNR scales inversely with luminosity distance (which is obviously true for our analytic example, but is also true in general), we can factorise this out and train on the remaining parameters. To ensure that the network output is positive definite, we train on $\\log(\\rho)$."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 62,
   "metadata": {},
   "outputs": [],
   "source": [
    "n_train = int(2e5)  # number of training examples\n",
    "\n",
    "# sample from our priors\n",
    "m1 = np.random.uniform(1, 100, n_train)\n",
    "m2 = np.random.uniform(1, 100, n_train)\n",
    "dL = np.random.uniform(0.01, 1, n_train)\n",
    "alpha = np.random.uniform(0, 2*np.pi, n_train)\n",
    "delta = np.random.uniform(0, np.pi, n_train)\n",
    "\n",
    "true_snrs = snr(m1, m2, dL , alpha, delta)\n",
    "xdata = torch.as_tensor(np.vstack((m1, m2, alpha, delta)).T, device=device).float()\n",
    "ydata = torch.as_tensor(true_snrs * dL, device=device).float()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 63,
   "metadata": {},
   "outputs": [],
   "source": [
    "# define a rescaler, which handles the scaling of input data to facilitate training\n",
    "rescaler = ZScoreRescaler(xdata, ydata, yfunctions=[torch.log, torch.exp])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 64,
   "metadata": {},
   "outputs": [],
   "source": [
    "# define the neural network\n",
    "model = LinearModel(\n",
    "    in_features=4,\n",
    "    out_features=1,\n",
    "    neurons=[128, 128, 128, 128],\n",
    "    activation=torch.nn.SiLU,\n",
    "    rescaler=rescaler\n",
    ")   \n",
    "\n",
    "model.set_device(device)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 65,
   "metadata": {},
   "outputs": [],
   "source": [
    "train_up_to = int(0.9 * n_train)\n",
    "xtrain, xtest, ytrain, ytest = train_test_split([xdata, ydata], 0.9)\n",
    "optimiser = torch.optim.Adam(model.parameters(), lr=1e-4)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 66,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Epoch: 9999 | Train loss: 1.778e-02 | Test loss: 1.853e-02 (Lowest: 1.814e-02)\n",
      "Training complete - saving.\n"
     ]
    }
   ],
   "source": [
    "train(\n",
    "    model, \n",
    "    data=[xtrain, ytrain, xtest, ytest], \n",
    "    n_epochs=10000, \n",
    "    n_batches=1, \n",
    "    loss_function=torch.nn.L1Loss(),\n",
    "    optimiser=optimiser,\n",
    "    update_every=1000,\n",
    "    verbose=True,\n",
    ")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 67,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 26656 characters]",
      "text/plain": [
       "<Figure size 864x504 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "_ = loss_plot(model.loss_curves[0], model.loss_curves[1])"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The model has trained well (but could be improved with some iterative adjustment of training settings). \n",
    "\n",
    "We can plot a histogram of the performance of the network on the validation set to see how well it has trained."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 68,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 9904 characters]",
      "text/plain": [
       "<Figure size 864x504 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "ypred = model.run_on_dataset(xtest)\n",
    "\n",
    "plt.hist(np.log10(abs((1 - ypred/ytest).cpu().numpy())), bins='auto', density=True)\n",
    "plt.xlabel(r'$\\log_{10}(\\mathrm{Percent Error})$')\n",
    "plt.show()"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Estimating the Selection Function\n",
    "\n",
    "We're now ready to use this model to predict the detectability of a distribution of these events. We'll consider a distribution where we parameterise the mass distributions as powerlaws with slope $\\lambda = -2$ and limits $[1, 100]M_\\odot$. Events will be distributed uniformly on the sky, and luminosity distance will scale with a power law of slope $\\lambda = -3$ (as one would expect for a volumetric distribution).\n",
    "\n",
    "Detectability is evaluated by comparing the optimal SNR of each event with a pre-defined threshold $\\rho_t$. The fraction of the population that is detectable (known as the selection function) is $$ \\alpha = 1 - \\Phi_{\\chi^2}(\\rho| k=N_\\mathrm{det}, n=\\rho_t)$$ where $\\Phi_{\\chi^2}$ is the CDF of the non-central chi-square distribution with degrees of freedom $k$ and non-centrality parameter $n$. $N_\\mathrm{det}$ is the number of detectors in the observing network.\n",
    "\n",
    "In this somewhat arbitrary case, we'll use a threshold of $20$ and assume a network of two detectors was used to observe all of the events."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 69,
   "metadata": {},
   "outputs": [],
   "source": [
    "# set up distributions\n",
    "distributions = {\n",
    "    \"m1\": VariableLimitsPowerLaw([1,100], device=device),\n",
    "    \"m2\": FixedLimitsPowerLaw([1,100], device=device),\n",
    "    \"dL\": FixedLimitsPowerLaw([0.01,1], device=device),\n",
    "    \"alpha\": UniformDistribution([0, 2*np.pi], device=device),\n",
    "    \"delta\": UniformDistribution([0, np.pi], device=device)\n",
    "}\n",
    "\n",
    "#Population distribution class for ease of evaluating PDF and drawing points\n",
    "class PopulationDistribution:\n",
    "    def __init__(self, distributions, data) -> None:\n",
    "        self.distributions = distributions\n",
    "        self.data = data\n",
    "\n",
    "    def pdf(self, x):\n",
    "        m1params = x['m1']\n",
    "        return self.distributions['m1'].pdf(self.data['m1'], **m1params)\n",
    "\n",
    "    def draw_samples(self, x, size):\n",
    "        out = {}\n",
    "        for key in self.distributions.keys():\n",
    "            out[key] = self.distributions[key].draw_samples(**x[key], size=size)\n",
    "        return out\n",
    "\n",
    "popdist = PopulationDistribution(distributions=distributions, data=None)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 70,
   "metadata": {},
   "outputs": [],
   "source": [
    "true_x = {\n",
    "    \"m1\": {\"lam\": -2, \"xlow\": 1, \"xhigh\": 100},\n",
    "    \"m2\": {\"lam\": -2},\n",
    "    \"dL\": {\"lam\": 3},\n",
    "    \"alpha\": {},\n",
    "    \"delta\": {},\n",
    "}\n",
    "\n",
    "catalogue = popdist.draw_samples(true_x, size=int(1e5))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 71,
   "metadata": {},
   "outputs": [],
   "source": [
    "catalogue_snrs = model.run_on_dataset(torch.vstack([catalogue['m1'],catalogue['m2'],catalogue['alpha'],catalogue['delta']]).T, luminosity_distances=catalogue['dL'])\n",
    "numpy_catalogue = {cat_label: cat_samples.cpu().numpy() for cat_label, cat_samples in catalogue.items()}\n",
    "true_snrs = snr(**numpy_catalogue)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 72,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 12032 characters]",
      "text/plain": [
       "<Figure size 864x504 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "plt.hist(np.log10(abs((1 - catalogue_snrs.cpu().numpy()/true_snrs))), bins='auto', density=True, histtype='step', lw=4)\n",
    "plt.hist(np.log10(abs((1 - ypred/ytest).cpu().numpy())), bins='auto', density=True, histtype='step', lw=4)\n",
    "plt.xlabel(r'$\\log_{10}(\\mathrm{Percent Error})$')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 73,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "0.20733422802864038 0.2004947033716055\n"
     ]
    }
   ],
   "source": [
    "predicted_alpha = selection_function_from_optimal_snr(catalogue_snrs, 20, number_of_detectors=2)\n",
    "true_alpha = selection_function_from_optimal_snr(torch.as_tensor(true_snrs, device=device), 20, number_of_detectors=2)\n",
    "\n",
    "print(predicted_alpha.item(), true_alpha.item())"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We are capable of estimating the selection function using our SNR network in this way, but we can make some further improvements by introducing a second network into the mix: one that is trained on _selection function estimates_. The training dataset for this network would be expensive to produce for a realistic scenario, but our SNR network allows us to bypass this cost and produce the dataset quickly.\n",
    "\n",
    "Let's illustrate this with a one-dimensional example, where we investigate how varying $\\lambda_{m_1}$ affects the population detectability."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 74,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "100%|██████████| 1000/1000 [02:25<00:00,  6.86it/s]\n"
     ]
    }
   ],
   "source": [
    "lambda_m1s = np.random.uniform(-3, 2, int(1e3))\n",
    "selection_functions = []\n",
    "true_selection_functions = []\n",
    "\n",
    "for lam_m1 in tqdm.tqdm(lambda_m1s):\n",
    "    true_x['m1']['lam'] = torch.as_tensor(lam_m1).float()\n",
    "    catalogue = popdist.draw_samples(true_x, size=int(1e5))\n",
    "    numpy_catalogue = {cat_label: cat_samples.cpu().numpy() for cat_label, cat_samples in catalogue.items()}\n",
    "    true_snrs = snr(**numpy_catalogue)\n",
    "    catalogue_snrs = model.run_on_dataset(torch.vstack([catalogue['m1'],catalogue['m2'],catalogue['alpha'],catalogue['delta']]).T, luminosity_distances=catalogue['dL'])\n",
    "    selection_functions.append(selection_function_from_optimal_snr(catalogue_snrs, 20, number_of_detectors=2))\n",
    "    true_selection_functions.append(selection_function_from_optimal_snr(true_snrs, 20, number_of_detectors=2))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 75,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Epoch: 4999 | Train loss: 2.932e-03 | Test loss: 2.928e-03 (Lowest: 2.617e-03)\n",
      "Training complete - saving.\n"
     ]
    }
   ],
   "source": [
    "sel_xdata = torch.as_tensor(lambda_m1s, device=device)[:,None].float()\n",
    "sel_ydata = torch.as_tensor(selection_functions, device=device).float()\n",
    "\n",
    "rescaler2 = ZScoreRescaler(sel_xdata, sel_ydata)\n",
    "\n",
    "selection_function_model = LinearModel(\n",
    "    1,\n",
    "    1,\n",
    "    [128, 128, 128],\n",
    "    activation=torch.nn.SiLU,\n",
    "    rescaler=rescaler2\n",
    ")\n",
    "\n",
    "selection_function_model.set_device(device)\n",
    "\n",
    "sel_xtrain, sel_xtest, sel_ytrain, sel_ytest = train_test_split([sel_xdata, sel_ydata], 0.9)\n",
    "\n",
    "train(\n",
    "    selection_function_model, \n",
    "    [sel_xtrain, sel_ytrain, sel_xtest, sel_ytest], \n",
    "    n_epochs=5000, \n",
    "    n_batches=1,\n",
    "    optimiser = torch.optim.Adam(selection_function_model.parameters(), lr=1e-4),\n",
    "    loss_function=torch.nn.L1Loss(),\n",
    "    update_every=1000,\n",
    "    verbose=True)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 76,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 32200 characters]",
      "text/plain": [
       "<Figure size 864x504 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "_ = loss_plot(selection_function_model.loss_curves[0], selection_function_model.loss_curves[1])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 77,
   "metadata": {},
   "outputs": [],
   "source": [
    "lambda_m1_testvec = torch.linspace(-3, 2, int(1e3), device=device)[:,None]\n",
    "sel_pred = selection_function_model.run_on_dataset(lambda_m1_testvec)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 78,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 36696 characters]",
      "text/plain": [
       "<Figure size 864x504 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "sort_inds = np.argsort(lambda_m1s)\n",
    "plt.plot(lambda_m1s[sort_inds], torch.as_tensor(selection_functions).numpy()[sort_inds], label='SNR network pred.')\n",
    "plt.plot(lambda_m1s[sort_inds], np.array(true_selection_functions)[sort_inds], label='Analytic SNR SF')\n",
    "plt.plot(lambda_m1_testvec.cpu().numpy(), sel_pred.cpu().numpy(), label='Learned SF')\n",
    "plt.legend()\n",
    "plt.ylabel(r'Selection function, $\\alpha$')\n",
    "plt.xlabel(r'$\\lambda_{m_1}$')\n",
    "plt.legend()\n",
    "plt.show()"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "This trained selection function network is now ready to be used in population inference to deliver rapid estimates of the selection function at low additional computational cost.\n",
    "\n",
    "We can demonstrate the effectiveness of our interpolator with a population inference example."
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "First, we generate a catalogue based on a set of true parameters and threshold it for detectability."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 79,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "'Number of detected events: 335, NN selection function predicts ~347'"
      ]
     },
     "execution_count": 79,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "n_events = 1000  # total number of events\n",
    "true_x = {\n",
    "    \"m1\": {\"lam\": -1.5, \"xlow\": 1, \"xhigh\": 100},\n",
    "    \"m2\": {\"lam\": -2},\n",
    "    \"dL\": {\"lam\": 3},\n",
    "    \"alpha\": {},\n",
    "    \"delta\": {},\n",
    "}\n",
    "catalogue = popdist.draw_samples(true_x, size=n_events)\n",
    "catalogue = {n: d.cpu().numpy() for n, d in catalogue.items()}\n",
    "optimal_snrs = snr(**catalogue)\n",
    "noise_realised_snrs = matched_filter_snr_from_optimal_snr(optimal_snrs, number_of_detectors=2)\n",
    "kept_catalogue_indices = np.where(noise_realised_snrs > 20)[0]\n",
    "kept_catalogue = {n: d[kept_catalogue_indices] for n, d in catalogue.items()}\n",
    "popdist.data = kept_catalogue  # update the catalogue with our detections\n",
    "Ndet = len(kept_catalogue_indices)\n",
    "\n",
    "f\"Number of detected events: {Ndet}, NN selection function predicts ~{int(n_events*selection_function_model.run_on_dataset(true_x['m1']['lam']))}\""
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now, we define our hierarchical likelihood. We ignore measurement uncertainty here and assume wide, flat priors."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 80,
   "metadata": {},
   "outputs": [],
   "source": [
    "def hierarchical_likelihood(hyperparameters, Ndet, selection=False):\n",
    "    numerator = np.sum(np.log(popdist.pdf(hyperparameters).cpu().numpy()), axis=-1)  # sum the log-probabilities\n",
    "    denominator = Ndet*np.log(selection_function_model.run_on_dataset(hyperparameters['m1']['lam']).cpu().numpy()) if selection else 0\n",
    "    return numerator - denominator"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 81,
   "metadata": {},
   "outputs": [],
   "source": [
    "lambda_m1_grid = torch.linspace(-2, -0.3, int(1e4), device=device)[:,None]\n",
    "ones = torch.ones_like(lambda_m1_grid, device=device)\n",
    "trial_x = {\n",
    "    \"m1\": {\"lam\": lambda_m1_grid, \"xlow\": ones, \"xhigh\": 100*ones},\n",
    "    \"m2\": {\"lam\": -2*ones},\n",
    "    \"dL\": {\"lam\": 3*ones},\n",
    "    \"alpha\": {},\n",
    "    \"delta\": {},\n",
    "}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 82,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 36912 characters]",
      "text/plain": [
       "<Figure size 864x504 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "no_sf_logposterior = hierarchical_likelihood(trial_x, Ndet, selection=False)\n",
    "sf_logposterior = hierarchical_likelihood(trial_x, Ndet, selection=True)\n",
    "\n",
    "plt.plot(lambda_m1_grid.cpu().numpy(), np.exp(no_sf_logposterior - no_sf_logposterior.max()), label='Selection function ignored')\n",
    "plt.plot(lambda_m1_grid.cpu().numpy(), np.exp(sf_logposterior - sf_logposterior.max()), label='Selection function included')\n",
    "plt.axvline(true_x['m1']['lam'], c='k', ls='--')\n",
    "plt.ylabel(r'$\\propto p(\\lambda_{m_1} | \\theta)$')\n",
    "plt.xlabel(r'$\\lambda_{m_1}$')\n",
    "plt.legend()\n",
    "plt.show()"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "This demonstrates the importance of including the selection function in population inference, and that we can model the selection function from start to finish with neural networks."
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "test_env",
   "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.5"
  },
  "orig_nbformat": 4,
  "vscode": {
   "interpreter": {
    "hash": "0db5928c791268e2e643772b1a55828ebb95dcc151f26e4b82932884ba528d53"
   }
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
