{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Wprowadzenie\n",
    "\n",
    "W tym notatniku przyjrzymy się jednej z najprostszych, klasycznych warstw splotu grafowego - GNN (ang. *Graph Neural Network*).\n",
    "\n",
    "Oryginalne publikacje:\n",
    "\n",
    "> Merkwirth, C., & Lengauer, T. (2005). Automatic generation of complementary descriptors with molecular graph networks. Journal of Chemical Information and Modeling, 45(5). https://doi.org/10.1021/ci049613b\n",
    "\n",
    "> Scarselli, F., Gori, M., Tsoi, A. C., Hagenbuchner, M., & Monfardini, G. (2009). Computational capabilities of graph neural networks. IEEE Transactions on Neural Networks, 20(1), 81–102. https://doi.org/10.1109/TNN.2008.2005141"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Będziemy pracować na danych ze zbioru `Cora` poznanych w poprzedniej sekcji."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Import bibliotek"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "import torch as th\n",
    "import torch.nn as nn\n",
    "import torch_geometric as pyg\n",
    "import torch_geometric.nn as pyg_nn\n",
    "import torch_geometric.utils as pyg_utils\n",
    "import torch_geometric.transforms as pyg_t\n",
    "import torch_geometric.datasets as pyg_datasets\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "from pathlib import Path\n",
    "from typing import Tuple, Union\n",
    "from tqdm.auto import tqdm\n",
    "from sklearn.metrics import classification_report\n",
    "\n",
    "\n",
    "import sys\n",
    "sys.path.append('..')\n",
    "import utils.vis_utils as vis_utils"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [],
   "source": [
    "import utilities as utils\n",
    "from base_models import NodesClassifierModel, NaiveGraphConv"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {},
   "outputs": [],
   "source": [
    "import warnings\n",
    "\n",
    "warnings.filterwarnings(\"ignore\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Przygotowanie danych"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "Downloading https://github.com/kimiyoung/planetoid/raw/master/data/ind.cora.x\n",
      "Downloading https://github.com/kimiyoung/planetoid/raw/master/data/ind.cora.tx\n",
      "Downloading https://github.com/kimiyoung/planetoid/raw/master/data/ind.cora.allx\n",
      "Downloading https://github.com/kimiyoung/planetoid/raw/master/data/ind.cora.y\n",
      "Downloading https://github.com/kimiyoung/planetoid/raw/master/data/ind.cora.ty\n",
      "Downloading https://github.com/kimiyoung/planetoid/raw/master/data/ind.cora.ally\n",
      "Downloading https://github.com/kimiyoung/planetoid/raw/master/data/ind.cora.graph\n",
      "Downloading https://github.com/kimiyoung/planetoid/raw/master/data/ind.cora.test.index\n",
      "Processing...\n",
      "Done!\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "Data(x=[2708, 1433], edge_index=[2, 10556], y=[2708], train_mask=[2708], val_mask=[2708], test_mask=[2708])"
      ]
     },
     "execution_count": 4,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "dataset = pyg_datasets.Planetoid(root=Path(os.pardir, \"../data\", \"Planetoid\"), name='Cora', transform=pyg_t.NormalizeFeatures())\n",
    "cora = dataset[0]\n",
    "cora"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# GNN\n",
    "\n",
    "Uproszczona formalizacja wg. \n",
    "\n",
    "> Hamilton, W. L. (2020). Graph Representation Learning Hamilton. Synthesis Lectures on Artificial Intelligence and Machine Learning, 14(3), 1–159. https://doi.org/10.2200/S01045ED1V01Y202009AIM046\n",
    "\n",
    "$$\n",
    "\\begin{align*}\n",
    "\\mathbf{h}_v^{(0)} &= \\sigma \\left(\\mathbf{W}_{\\text{self}}^{(k)} \\mathbf{h}_v^{(k-1)} + \\mathbf{W}_{\\text{neigh}}^{(k)} \\sum_{u \\in \\mathcal{N}(v)} \\mathbf{h}_{v}^{(k-1)} + \\mathbf{b}^{(k)} \\right)\n",
    "\\end{align*}\n",
    "$$\n",
    "\n",
    "gdzie:\n",
    "\n",
    "- $\\mathbf{h}_v^{(k)}$ to reprezentacja wierzchołka $v$ w warstwie $k$,\n",
    "- $\\mathbf{W}_{\\text{self}}^{(k)}$ to macierz wag warstwy $k$ dla wierzchołka $v$,\n",
    "- $\\mathbf{W}_{\\text{neigh}}^{(k)}$ to macierz wag warstwy $k$ dla sąsiadów wierzchołka $v$,\n",
    "- $\\mathbf{b}^{(k)}$ to bias warstwy $k$,\n",
    "- $\\sigma$ funkcja aktywacji - zwykle nieliniowa (jak np. ReLU). Można ją opuścić, jeśli sieć GNN jest częścią większej architektury.\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Naiwna implementacja\n",
    "\n",
    "Zaczniemy od naiwnej, prostej implementacji, bez korzystania z PyG-a. Będziemy jednak przestrzegać koncepcyjnej architektury MPNN."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [],
   "source": [
    "class NaiveGNN(NaiveGraphConv):\n",
    "\n",
    "    def __init__(self, dim_in: int, dim_out: int, agg_fn=th.sum, sigma=nn.Identity()):\n",
    "        super().__init__(dim_in, dim_out, agg_fn, sigma)\n",
    "        self.Wself = nn.Linear(dim_in, dim_out, bias=False)\n",
    "        self.Wneigh = nn.Linear(dim_in, dim_out)\n",
    "\n",
    "    def reset_parameters(self):\n",
    "        super().reset_parameters()\n",
    "        self.Wself.reset_parameters()\n",
    "        self.Wneigh.reset_parameters()\n",
    "\n",
    "    def message(self, v_neighbors: th.Tensor) -> th.Tensor:\n",
    "        \"\"\"Funkcja budująca wiadomości od sąsiadów, implementacja abstrakcyjnej\n",
    "        funkcji `message` z architektury MPNN. Przyjmuje jako wejście\n",
    "        macierz cech sąsiadów wierzchołka v:  `u in N(v)` i buduje z niej wiadomości.\n",
    "        W tym przypadku - bez wprowadzania żadnych zmian.\n",
    "\n",
    "        Parameters\n",
    "        ----------\n",
    "        v_neighbors : th.Tensor\n",
    "            Macierz cech sąsiadów wierzchołka v: `N(v)`.\n",
    "            Wymiar: `|N(v)| x dim_in`.\n",
    "\n",
    "        Returns\n",
    "        -------\n",
    "        th.Tensor\n",
    "            Macierz wiadomości od sąsiadów.\n",
    "            Wymiar: `|N(v)| x dim_out`.\n",
    "        \"\"\"\n",
    "        return v_neighbors\n",
    "\n",
    "    def aggregate(self, m_nv: th.Tensor) -> th.Tensor:\n",
    "        \"\"\"Funkcja agregująca wiadomości od sąsiadów, implementacja abstrakcyjnej\n",
    "        funkcji `aggregate` z architektury MPNN. Przyjmuje jako wejście\n",
    "        macierz wiadomości od sąsiadów wierzchołka v i agreguje je.\n",
    "\n",
    "        Parameters\n",
    "        ----------\n",
    "        m_nv : th.Tensor\n",
    "            Macierz wiadomości od sąsiadów.\n",
    "            Wymiar: `|N(v)| x dim_out`.\n",
    "\n",
    "        Returns\n",
    "        -------\n",
    "        th.Tensor\n",
    "            Zagregowane wiadomości. Wymiar: `1 x dim_out`.\n",
    "        \"\"\"\n",
    "        # Po agregacji upewniamy się, że wynikowy wektor zachowa wymiar (1 x dim out).\n",
    "        return self.aggr(m_nv, dim=0, keepdim=True)\n",
    "\n",
    "    def update(self, v: th.Tensor, agg: th.Tensor) -> th.Tensor:\n",
    "        \"\"\"Implementacja funkcji `update` z architektury MPNN. Aktualizuje\n",
    "        reprezentacje wierzchołka v na podstawie jego cech i zagregowanych wiadomości\n",
    "        od sąsiadów.\n",
    "\n",
    "        Parameters\n",
    "        ----------\n",
    "        v : th.Tensor\n",
    "            Aktualna reprezentacja wierzchołka v. Wymiar: `1 x dim_in`.\n",
    "        agg : th.Tensor\n",
    "            Zagregowane wiadomości sąsiadów. Wymiar: `1 x dim_out`.\n",
    "\n",
    "        Returns\n",
    "        -------\n",
    "        th.Tensor\n",
    "            Zaktualizowana reprezentacja wierzchołka v. Wymiar: `1 x dim_out`.\n",
    "        \"\"\"\n",
    "\n",
    "        # mamy tylko 1 wierzchołek, upewniamy się, że Torch zachowa wymiar (1 x dim out)\n",
    "        v_transformed = th.atleast_2d(self.Wself(v))\n",
    "        agg_transformed = self.Wneigh(agg)\n",
    "        v_new = v_transformed + agg_transformed\n",
    "        return self.sigma(v_new)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Sprawdzenie, czy implementacja działa"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [],
   "source": [
    "with th.no_grad():\n",
    "    naive_model = NaiveGNN(dim_in=cora.num_features, dim_out=16)\n",
    "    x = cora.x\n",
    "    edge_index = cora.edge_index\n",
    "    h = naive_model(x, edge_index)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Model klasyfikacji z naiwną implementacją GNN"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<div class=\"alert alert-warning\">\n",
    "⚠️ Uwaga, ze względu na fakt, iż implementacja naiwna nie jest zoptymalizowana, może być konieczne dłuższe oczekiwanie na wyniki. W zależności od posiadanego sprzętu i procesora, całość może zająć chwilę. Jeśli czas oczekiwani jest za długi - warto zmniejszyć ilość epok.\n",
    "</div>"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pyg.seed_everything(123)\n",
    "naive_gnn1 = NaiveGNN(dim_in=cora.num_features, dim_out=64)\n",
    "naive_gnn2 = NaiveGNN(dim_in=64, dim_out=32)\n",
    "\n",
    "naive_clf = NodesClassifierModel(gnn_convs=(naive_gnn1, naive_gnn2), lin_sizes=(32, 16, 7))\n",
    "optim = th.optim.Adam(naive_clf.parameters(), lr=0.01)\n",
    "loss_fn = nn.CrossEntropyLoss()\n",
    "\n",
    "naive_clf.eval()\n",
    "yhat_naive_before_training = naive_clf(cora.x, cora.edge_index)\n",
    "\n",
    "\n",
    "epoch_losses_naive = []\n",
    "val_losses_naive = []\n",
    "\n",
    "for epoch in tqdm(range(15)):\n",
    "    naive_clf.train()\n",
    "    optim.zero_grad()\n",
    "    out = naive_clf(cora.x, cora.edge_index)\n",
    "    loss = loss_fn(out[cora.train_mask], cora.y[cora.train_mask])\n",
    "    loss.backward()\n",
    "    optim.step()\n",
    "    epoch_losses_naive.append(loss.item())\n",
    "\n",
    "    naive_clf.eval()\n",
    "    with th.no_grad():\n",
    "        val_loss = loss_fn(out[cora.val_mask], cora.y[cora.val_mask])\n",
    "        val_losses_naive.append(val_loss.item())\n",
    "\n",
    "naive_clf.eval()\n",
    "yhat_naive_after_training = naive_clf(cora.x, cora.edge_index)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "+-----------------------------+--------------------------+----------------+----------+\n",
      "| Layer                       | Input Shape              | Output Shape   | #Param   |\n",
      "|-----------------------------+--------------------------+----------------+----------|\n",
      "| NodesClassifierModel        | [2708, 1433], [2, 10556] | [2708, 7]      | 188,263  |\n",
      "| ├─(gnn_convs)ModuleList     | --                       | --             | 187,616  |\n",
      "| │    └─(0)NaiveGNN          | [2708, 1433], [2, 10556] | [2708, 64]     | 183,488  |\n",
      "| │    │    └─(sigma)Identity | [1, 64]                  | [1, 64]        | --       |\n",
      "| │    │    └─(Wself)Linear   | [1433]                   | [64]           | 91,712   |\n",
      "| │    │    └─(Wneigh)Linear  | [1, 1433]                | [1, 64]        | 91,776   |\n",
      "| │    └─(1)NaiveGNN          | [2708, 64], [2, 10556]   | [2708, 32]     | 4,128    |\n",
      "| │    │    └─(sigma)Identity | [1, 64]                  | [1, 64]        | --       |\n",
      "| │    │    └─(Wself)Linear   | [64]                     | [32]           | 2,048    |\n",
      "| │    │    └─(Wneigh)Linear  | [1, 64]                  | [1, 32]        | 2,080    |\n",
      "| ├─(lin_layers)Sequential    | [2708, 32]               | [2708, 7]      | 647      |\n",
      "| │    └─(0)Linear            | [2708, 32]               | [2708, 16]     | 528      |\n",
      "| │    └─(1)ReLU              | [2708, 16]               | [2708, 16]     | --       |\n",
      "| │    └─(2)Linear            | [2708, 16]               | [2708, 7]      | 119      |\n",
      "| │    └─(3)ReLU              | [2708, 7]                | [2708, 7]      | --       |\n",
      "+-----------------------------+--------------------------+----------------+----------+\n"
     ]
    }
   ],
   "source": [
    "print(pyg_nn.summary(naive_clf, cora.x, cora.edge_index))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Wynik klasyfikacji modelu Naiwny GNN przed trenowaniem:\n",
      "              precision    recall  f1-score   support\n",
      "\n",
      "           0       0.00      0.00      0.00        61\n",
      "           1       0.00      0.00      0.00        36\n",
      "           2       0.12      0.77      0.22        78\n",
      "           3       0.00      0.00      0.00       158\n",
      "           4       0.00      0.00      0.00        81\n",
      "           5       0.00      0.00      0.00        57\n",
      "           6       0.00      0.00      0.00        29\n",
      "\n",
      "    accuracy                           0.12       500\n",
      "   macro avg       0.02      0.11      0.03       500\n",
      "weighted avg       0.02      0.12      0.03       500\n",
      "\n",
      "\n",
      "\n",
      "\n",
      "\n",
      "Wynik klasyfikacji modelu Naiwny GNN po trenowaniu:\n",
      "              precision    recall  f1-score   support\n",
      "\n",
      "           0       0.53      0.75      0.63        61\n",
      "           1       0.46      0.86      0.60        36\n",
      "           2       0.91      0.76      0.83        78\n",
      "           3       0.94      0.56      0.70       158\n",
      "           4       0.52      0.80      0.63        81\n",
      "           5       0.00      0.00      0.00        57\n",
      "           6       0.42      0.90      0.57        29\n",
      "\n",
      "    accuracy                           0.63       500\n",
      "   macro avg       0.54      0.66      0.57       500\n",
      "weighted avg       0.64      0.63      0.61       500\n",
      "\n"
     ]
    }
   ],
   "source": [
    "utils.val_scores_before_after_train(\n",
    "    cora.y[cora.val_mask],\n",
    "    yhat_naive_before_training[cora.val_mask],\n",
    "    yhat_naive_after_training[cora.val_mask],\n",
    "    model_name=\"Naiwny GNN\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 44204 characters]",
      "text/plain": [
       "<Figure size 472.441x472.441 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "utils.plot_results(epoch_losses_naive, val_losses_naive, plot_name=\"naive_gnn.tiff\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Model klasyfikacji z warstwą GraphConv z PyG"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "d663befe6738430db101facd59102ff2",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "  0%|          | 0/15 [00:00<?, ?it/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "pyg.seed_everything(123)\n",
    "pyg_gnn1 = pyg_nn.GraphConv(in_channels=cora.num_features, out_channels=64)\n",
    "pyg_gnn2 = pyg_nn.GraphConv(in_channels=64, out_channels=32)\n",
    "\n",
    "pyg_clf = NodesClassifierModel(gnn_convs=(pyg_gnn1, pyg_gnn2), lin_sizes=(32, 16, 7))\n",
    "optim = th.optim.Adam(pyg_clf.parameters(), lr=0.01)\n",
    "loss_fn = nn.CrossEntropyLoss()\n",
    "\n",
    "pyg_clf.eval()\n",
    "yhat_pyg_before_training = pyg_clf(cora.x, cora.edge_index)\n",
    "\n",
    "\n",
    "epoch_losses_pyg = []\n",
    "val_losses_pyg = []\n",
    "\n",
    "for epoch in tqdm(range(15)):\n",
    "    pyg_clf.train()\n",
    "    optim.zero_grad()\n",
    "    out = pyg_clf(cora.x, cora.edge_index)\n",
    "    loss = loss_fn(out[cora.train_mask], cora.y[cora.train_mask])\n",
    "    loss.backward()\n",
    "    optim.step()\n",
    "    epoch_losses_pyg.append(loss.item())\n",
    "\n",
    "    pyg_clf.eval()\n",
    "    with th.no_grad():\n",
    "        val_loss = loss_fn(out[cora.val_mask], cora.y[cora.val_mask])\n",
    "        val_losses_pyg.append(val_loss.item())\n",
    "pyg_clf.eval()\n",
    "yhat_pyg_after_training = pyg_clf(cora.x, cora.edge_index)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "+--------------------------+--------------------------+----------------+----------+\n",
      "| Layer                    | Input Shape              | Output Shape   | #Param   |\n",
      "|--------------------------+--------------------------+----------------+----------|\n",
      "| NodesClassifierModel     | [2708, 1433], [2, 10556] | [2708, 7]      | 188,263  |\n",
      "| ├─(gnn_convs)ModuleList  | --                       | --             | 187,616  |\n",
      "| │    └─(0)GraphConv      | [2708, 1433], [2, 10556] | [2708, 64]     | 183,488  |\n",
      "| │    └─(1)GraphConv      | [2708, 64], [2, 10556]   | [2708, 32]     | 4,128    |\n",
      "| ├─(lin_layers)Sequential | [2708, 32]               | [2708, 7]      | 647      |\n",
      "| │    └─(0)Linear         | [2708, 32]               | [2708, 16]     | 528      |\n",
      "| │    └─(1)ReLU           | [2708, 16]               | [2708, 16]     | --       |\n",
      "| │    └─(2)Linear         | [2708, 16]               | [2708, 7]      | 119      |\n",
      "| │    └─(3)ReLU           | [2708, 7]                | [2708, 7]      | --       |\n",
      "+--------------------------+--------------------------+----------------+----------+\n"
     ]
    }
   ],
   "source": [
    "print(pyg_nn.summary(pyg_clf, cora.x, cora.edge_index))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Wynik klasyfikacji modelu PyG GNN przed trenowaniem:\n",
      "              precision    recall  f1-score   support\n",
      "\n",
      "           0       0.12      1.00      0.22        61\n",
      "           1       0.00      0.00      0.00        36\n",
      "           2       0.00      0.00      0.00        78\n",
      "           3       0.00      0.00      0.00       158\n",
      "           4       0.00      0.00      0.00        81\n",
      "           5       0.00      0.00      0.00        57\n",
      "           6       0.00      0.00      0.00        29\n",
      "\n",
      "    accuracy                           0.12       500\n",
      "   macro avg       0.02      0.14      0.03       500\n",
      "weighted avg       0.01      0.12      0.03       500\n",
      "\n",
      "\n",
      "\n",
      "\n",
      "\n",
      "Wynik klasyfikacji modelu PyG GNN po trenowaniu:\n",
      "              precision    recall  f1-score   support\n",
      "\n",
      "           0       0.67      0.67      0.67        61\n",
      "           1       0.70      0.78      0.74        36\n",
      "           2       0.85      0.88      0.87        78\n",
      "           3       0.67      0.63      0.65       158\n",
      "           4       0.62      0.56      0.59        81\n",
      "           5       0.61      0.67      0.64        57\n",
      "           6       0.59      0.76      0.67        29\n",
      "\n",
      "    accuracy                           0.68       500\n",
      "   macro avg       0.68      0.71      0.69       500\n",
      "weighted avg       0.68      0.68      0.68       500\n",
      "\n"
     ]
    }
   ],
   "source": [
    "utils.val_scores_before_after_train(\n",
    "    cora.y[cora.val_mask],\n",
    "    yhat_pyg_before_training[cora.val_mask],\n",
    "    yhat_pyg_after_training[cora.val_mask],\n",
    "    model_name=\"PyG GNN\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 44292 characters]",
      "text/plain": [
       "<Figure size 472.441x472.441 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "from importlib import reload\n",
    "utils = reload(utils)\n",
    "utils.plot_results(epoch_losses_pyg, val_losses_pyg, plot_name=\"rys_4.1.tiff\")"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": ".venv",
   "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.12.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
