{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {},
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import tensorflow as tf"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "   Age        BMI  Glucose  Insulin      HOMA   Leptin  Adiponectin  Resistin  \\\n",
      "0   48  23.500000       70    2.707  0.467409   8.8071     9.702400   7.99585   \n",
      "1   83  20.690495       92    3.115  0.706897   8.8438     5.429285   4.06405   \n",
      "2   82  23.124670       91    4.498  1.009651  17.9393    22.432040   9.27715   \n",
      "3   68  21.367521       77    3.226  0.612725   9.8827     7.169560  12.76600   \n",
      "4   86  21.111111       92    3.549  0.805386   6.6994     4.819240  10.57635   \n",
      "\n",
      "     MCP.1  Classification  \n",
      "0  417.114               1  \n",
      "1  468.786               1  \n",
      "2  554.697               1  \n",
      "3  928.220               1  \n",
      "4  773.920               1  \n",
      "              Age         BMI     Glucose     Insulin        HOMA      Leptin  \\\n",
      "count  116.000000  116.000000  116.000000  116.000000  116.000000  116.000000   \n",
      "mean    57.301724   27.582111   97.793103   10.012086    2.694988   26.615080   \n",
      "std     16.112766    5.020136   22.525162   10.067768    3.642043   19.183294   \n",
      "min     24.000000   18.370000   60.000000    2.432000    0.467409    4.311000   \n",
      "25%     45.000000   22.973205   85.750000    4.359250    0.917966   12.313675   \n",
      "50%     56.000000   27.662416   92.000000    5.924500    1.380939   20.271000   \n",
      "75%     71.000000   31.241442  102.000000   11.189250    2.857787   37.378300   \n",
      "max     89.000000   38.578759  201.000000   58.460000   25.050342   90.280000   \n",
      "\n",
      "       Adiponectin    Resistin        MCP.1  Classification  \n",
      "count   116.000000  116.000000   116.000000      116.000000  \n",
      "mean     10.180874   14.725966   534.647000        1.551724  \n",
      "std       6.843341   12.390646   345.912663        0.499475  \n",
      "min       1.656020    3.210000    45.843000        1.000000  \n",
      "25%       5.474283    6.881763   269.978250        1.000000  \n",
      "50%       8.352692   10.827740   471.322500        2.000000  \n",
      "75%      11.815970   17.755207   700.085000        2.000000  \n",
      "max      38.040000   82.100000  1698.440000        2.000000  \n",
      "<class 'pandas.core.frame.DataFrame'>\n",
      "RangeIndex: 116 entries, 0 to 115\n",
      "Data columns (total 10 columns):\n",
      " #   Column          Non-Null Count  Dtype  \n",
      "---  ------          --------------  -----  \n",
      " 0   Age             116 non-null    int64  \n",
      " 1   BMI             116 non-null    float64\n",
      " 2   Glucose         116 non-null    int64  \n",
      " 3   Insulin         116 non-null    float64\n",
      " 4   HOMA            116 non-null    float64\n",
      " 5   Leptin          116 non-null    float64\n",
      " 6   Adiponectin     116 non-null    float64\n",
      " 7   Resistin        116 non-null    float64\n",
      " 8   MCP.1           116 non-null    float64\n",
      " 9   Classification  116 non-null    int64  \n",
      "dtypes: float64(7), int64(3)\n",
      "memory usage: 9.2 KB\n",
      "None\n",
      "Age               0\n",
      "BMI               0\n",
      "Glucose           0\n",
      "Insulin           0\n",
      "HOMA              0\n",
      "Leptin            0\n",
      "Adiponectin       0\n",
      "Resistin          0\n",
      "MCP.1             0\n",
      "Classification    0\n",
      "dtype: int64\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "array([[<Axes: title={'center': 'Age'}>, <Axes: title={'center': 'BMI'}>,\n",
       "        <Axes: title={'center': 'Glucose'}>],\n",
       "       [<Axes: title={'center': 'Insulin'}>,\n",
       "        <Axes: title={'center': 'HOMA'}>,\n",
       "        <Axes: title={'center': 'Leptin'}>],\n",
       "       [<Axes: title={'center': 'Adiponectin'}>,\n",
       "        <Axes: title={'center': 'Resistin'}>,\n",
       "        <Axes: title={'center': 'MCP.1'}>],\n",
       "       [<Axes: title={'center': 'Classification'}>, <Axes: >, <Axes: >]],\n",
       "      dtype=object)"
      ]
     },
     "execution_count": 4,
     "metadata": {},
     "output_type": "execute_result"
    },
    {
     "data": {
      "image/png": "[encoded data, 56384 characters]",
      "text/plain": [
       "<Figure size 1000x1000 with 12 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "breast_cancer = pd.read_csv(\"data/breast_cancer.csv\")\n",
    "print(breast_cancer.head())\n",
    "\n",
    "print(breast_cancer.describe())\n",
    "\n",
    "print(breast_cancer.info())\n",
    "\n",
    "print(breast_cancer.isnull().sum())\n",
    "\n",
    "breast_cancer.hist(figsize=(10, 10))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Classification\n",
      "1    64\n",
      "0    52\n",
      "Name: count, dtype: int64\n"
     ]
    }
   ],
   "source": [
    "X = breast_cancer.drop(columns=[\"Classification\"])\n",
    "y = breast_cancer[\"Classification\"].replace({1: 0, 2: 1})\n",
    "\n",
    "print(y.value_counts())"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Number of samples: 116\n",
      "Number of features: 9\n",
      "Number of classes: 2\n"
     ]
    }
   ],
   "source": [
    "print(\"Number of samples:\", X.shape[0])\n",
    "print(\"Number of features:\", X.shape[1])\n",
    "print(\"Number of classes:\", len(np.unique(y)))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "metadata": {},
   "outputs": [],
   "source": [
    "def build_model():\n",
    "    model = tf.keras.models.Sequential(\n",
    "        [\n",
    "            tf.keras.layers.Dense(\n",
    "                16,\n",
    "                activation=\"relu\",\n",
    "                input_shape=(X.shape[1],),\n",
    "                kernel_regularizer=tf.keras.regularizers.l2(0.01),\n",
    "            ),\n",
    "            tf.keras.layers.Dense(\n",
    "                8, activation=\"relu\", kernel_regularizer=tf.keras.regularizers.l2(0.01)\n",
    "            ),\n",
    "            tf.keras.layers.Dense(1, activation=\"sigmoid\"),\n",
    "        ]\n",
    "    )\n",
    "    model.compile(optimizer=\"adam\", loss=\"binary_crossentropy\", metrics=[\"accuracy\"])\n",
    "    return model"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/Users/arthurdanjou/Workspace/studies/.venv/lib/python3.13/site-packages/keras/src/layers/core/dense.py:92: UserWarning: Do not pass an `input_shape`/`input_dim` argument to a layer. When using Sequential models, prefer using an `Input(shape)` object as the first layer in the model instead.\n",
      "  super().__init__(activity_regularizer=activity_regularizer, **kwargs)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\u001b[1m1/1\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 21ms/step\n",
      "Fold 1 - F1-score : 0.7742\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/Users/arthurdanjou/Workspace/studies/.venv/lib/python3.13/site-packages/keras/src/layers/core/dense.py:92: UserWarning: Do not pass an `input_shape`/`input_dim` argument to a layer. When using Sequential models, prefer using an `Input(shape)` object as the first layer in the model instead.\n",
      "  super().__init__(activity_regularizer=activity_regularizer, **kwargs)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\u001b[1m1/1\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 21ms/step\n",
      "Fold 2 - F1-score : 0.8000\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/Users/arthurdanjou/Workspace/studies/.venv/lib/python3.13/site-packages/keras/src/layers/core/dense.py:92: UserWarning: Do not pass an `input_shape`/`input_dim` argument to a layer. When using Sequential models, prefer using an `Input(shape)` object as the first layer in the model instead.\n",
      "  super().__init__(activity_regularizer=activity_regularizer, **kwargs)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\u001b[1m1/1\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 21ms/step\n",
      "Fold 3 - F1-score : 0.7222\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/Users/arthurdanjou/Workspace/studies/.venv/lib/python3.13/site-packages/keras/src/layers/core/dense.py:92: UserWarning: Do not pass an `input_shape`/`input_dim` argument to a layer. When using Sequential models, prefer using an `Input(shape)` object as the first layer in the model instead.\n",
      "  super().__init__(activity_regularizer=activity_regularizer, **kwargs)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\u001b[1m1/1\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 20ms/step\n",
      "Fold 4 - F1-score : 0.9286\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/Users/arthurdanjou/Workspace/studies/.venv/lib/python3.13/site-packages/keras/src/layers/core/dense.py:92: UserWarning: Do not pass an `input_shape`/`input_dim` argument to a layer. When using Sequential models, prefer using an `Input(shape)` object as the first layer in the model instead.\n",
      "  super().__init__(activity_regularizer=activity_regularizer, **kwargs)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "WARNING:tensorflow:5 out of the last 5 calls to <function TensorFlowTrainer.make_predict_function.<locals>.one_step_on_data_distributed at 0x129cb1080> triggered tf.function retracing. Tracing is expensive and the excessive number of tracings could be due to (1) creating @tf.function repeatedly in a loop, (2) passing tensors with different shapes, (3) passing Python objects instead of tensors. For (1), please define your @tf.function outside of the loop. For (2), @tf.function has reduce_retracing=True option that can avoid unnecessary retracing. For (3), please refer to https://www.tensorflow.org/guide/function#controlling_retracing and https://www.tensorflow.org/api_docs/python/tf/function for  more details.\n",
      "\u001b[1m1/1\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 22ms/step\n",
      "Fold 5 - F1-score : 0.8571\n",
      "\n",
      "F1-score moyen sur 5 folds : 0.8164\n"
     ]
    }
   ],
   "source": [
    "from keras.callbacks import EarlyStopping\n",
    "from sklearn.metrics import f1_score\n",
    "from sklearn.model_selection import StratifiedKFold\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "\n",
    "skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)\n",
    "f1_scores = []\n",
    "histories = []\n",
    "\n",
    "early_stopping = EarlyStopping(\n",
    "    monitor=\"val_loss\", patience=10, restore_best_weights=True, verbose=1\n",
    ")\n",
    "\n",
    "for fold, (train_idx, val_idx) in enumerate(skf.split(X, y), 1):\n",
    "    X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]\n",
    "    y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]\n",
    "\n",
    "    # Standardisation\n",
    "    scaler = StandardScaler()\n",
    "    X_train_scaled = scaler.fit_transform(X_train)\n",
    "    X_val_scaled = scaler.transform(X_val)\n",
    "\n",
    "    model = build_model()\n",
    "\n",
    "    model.compile(optimizer=\"adam\", loss=\"binary_crossentropy\", metrics=[\"f1_score\"])\n",
    "\n",
    "    # EarlyStopping\n",
    "    callback = tf.keras.callbacks.EarlyStopping(\n",
    "        monitor=\"val_loss\", patience=10, restore_best_weights=True\n",
    "    )\n",
    "\n",
    "    # Entraînement\n",
    "    history = model.fit(\n",
    "        X_train_scaled,\n",
    "        y_train,\n",
    "        epochs=50,\n",
    "        batch_size=8,\n",
    "        validation_data=(X_val_scaled, y_val),\n",
    "        callbacks=[callback],\n",
    "        verbose=0,\n",
    "        class_weight={0: 1.0, 1: 2.0},\n",
    "    )\n",
    "\n",
    "    histories.append(history.history)\n",
    "\n",
    "    # Prédiction & F1\n",
    "    y_pred_val = (model.predict(X_val_scaled) > 0.5).astype(int)\n",
    "    score = f1_score(y_val, y_pred_val)\n",
    "    f1_scores.append(score)\n",
    "    print(f\"Fold {fold} - F1-score : {score:.4f}\")\n",
    "\n",
    "print(f\"\\nF1-score moyen sur 5 folds : {np.mean(f1_scores):.4f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 150984 characters]",
      "text/plain": [
       "<Figure size 1200x1200 with 5 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "fig, axes = plt.subplots(3, 2, figsize=(12, 12), sharey=True)  # 3 rows, 2 columns\n",
    "axes = axes.flatten()  # Flatten to easily iterate\n",
    "\n",
    "for i, (hist, ax) in enumerate(zip(histories, axes, strict=False)):\n",
    "    ax.plot(hist[\"loss\"], label=\"Train loss\", alpha=0.6)\n",
    "    ax.plot(hist[\"val_loss\"], label=\"Val loss\", linestyle=\"--\", alpha=0.6)\n",
    "    ax.set_title(f\"Fold {i + 1}\")\n",
    "    ax.set_xlabel(\"Epochs\")\n",
    "    if i % 2 == 0:\n",
    "        ax.set_ylabel(\"Binary Crossentropy\")\n",
    "    ax.legend(fontsize=8)\n",
    "    ax.grid(True)\n",
    "\n",
    "# Hide any unused subplots if histories < 6\n",
    "for j in range(len(histories), len(axes)):\n",
    "    fig.delaxes(axes[j])\n",
    "\n",
    "plt.suptitle(\"Évolution de la loss sur chaque fold\")\n",
    "plt.tight_layout(rect=[0, 0, 1, 0.96])\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Dans le cadre de la classification binaire à partir du dataset Breast Cancer Coimbra, le respect de la distribution des classes lors de la séparation des données est une condition essentielle à la validité des résultats expérimentaux.\n",
    "\n",
    "1. Utilisation de stratify=y dans train_test_split\n",
    "Lors de la séparation du jeu de données en un ensemble d'entraînement et un ensemble de test, nous avons recours à la fonction train_test_split de la bibliothèque scikit-learn. Afin de garantir que les proportions des classes cibles soient conservées dans les deux sous-ensembles, l’argument stratify=y est utilisé.\n",
    "\n",
    "Cette précaution est particulièrement importante dans le cas de jeux de données déséquilibrés, comme c’est le cas ici, où les deux classes de la variable cible (\"Classification\") ne sont pas également représentées. Un échantillonnage aléatoire simple pourrait introduire un déséquilibre important entre les classes dans le jeu de test, rendant les métriques de performance peu fiables et favorisant potentiellement une classe au détriment de l’autre. L’option stratify=y assure donc une représentativité statistique des classes dans chacun des sous-échantillons.\n",
    "\n",
    "2. Recours à StratifiedKFold pour la validation croisée\n",
    "De manière analogue, lors de l'évaluation du modèle par validation croisée, nous avons choisi l’utilisation de la méthode StratifiedKFold. Contrairement à la validation croisée standard (KFold), cette méthode garantit que la proportion des classes est maintenue dans chacun des k folds.\n",
    "\n",
    "L’objectif est d’obtenir une estimation plus robuste et plus stable de la performance du modèle, en particulier en présence de déséquilibre entre les classes. Le maintien de la structure du dataset initial dans chaque fold limite le risque de surapprentissage (overfitting) ou de sous-apprentissage sur certains folds dominés par une seule classe.\n",
    "\n",
    "3. Justification statistique\n",
    "Le maintien de la distribution des classes dans les procédures d’échantillonnage est une exigence classique en statistique, relevant du principe de représentativité des échantillons. En classification supervisée, l'utilisation systématique de méthodes stratifiées permet d'améliorer la validité externe des résultats (capacité du modèle à généraliser) tout en réduisant la variance des estimations obtenues lors de la validation croisée."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/Users/arthurdanjou/Workspace/studies/.venv/lib/python3.13/site-packages/keras/src/layers/core/dense.py:92: UserWarning: Do not pass an `input_shape`/`input_dim` argument to a layer. When using Sequential models, prefer using an `Input(shape)` object as the first layer in the model instead.\n",
      "  super().__init__(activity_regularizer=activity_regularizer, **kwargs)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "WARNING:tensorflow:6 out of the last 6 calls to <function TensorFlowTrainer.make_predict_function.<locals>.one_step_on_data_distributed at 0x12a205120> triggered tf.function retracing. Tracing is expensive and the excessive number of tracings could be due to (1) creating @tf.function repeatedly in a loop, (2) passing tensors with different shapes, (3) passing Python objects instead of tensors. For (1), please define your @tf.function outside of the loop. For (2), @tf.function has reduce_retracing=True option that can avoid unnecessary retracing. For (3), please refer to https://www.tensorflow.org/guide/function#controlling_retracing and https://www.tensorflow.org/api_docs/python/tf/function for  more details.\n",
      "\u001b[1m1/1\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 24ms/step\n",
      "              precision    recall  f1-score   support\n",
      "\n",
      "        Sain       0.33      0.09      0.14        11\n",
      "      Malade       0.52      0.85      0.65        13\n",
      "\n",
      "    accuracy                           0.50        24\n",
      "   macro avg       0.43      0.47      0.39        24\n",
      "weighted avg       0.44      0.50      0.42        24\n",
      "\n",
      "0.6470588235294118\n"
     ]
    }
   ],
   "source": [
    "import numpy as np\n",
    "import tensorflow as tf\n",
    "from sklearn.metrics import classification_report, f1_score\n",
    "from sklearn.model_selection import train_test_split\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "\n",
    "X_train, X_test, y_train, y_test = train_test_split(\n",
    "    X, y, test_size=0.2, random_state=42, stratify=y\n",
    ")\n",
    "\n",
    "scaler = StandardScaler()\n",
    "X_train_scaled = scaler.fit_transform(X_train)\n",
    "X_test_scaled = scaler.transform(X_test)\n",
    "\n",
    "model = build_model()\n",
    "\n",
    "model.compile(optimizer=\"adam\", loss=\"binary_crossentropy\")\n",
    "\n",
    "callback = tf.keras.callbacks.EarlyStopping(\n",
    "    monitor=\"val_loss\", patience=10, restore_best_weights=True\n",
    ")\n",
    "\n",
    "history = model.fit(\n",
    "    X_train_scaled,\n",
    "    y_train,\n",
    "    epochs=50,\n",
    "    batch_size=8,\n",
    "    validation_split=0.2,\n",
    "    callbacks=[callback],\n",
    "    verbose=0,\n",
    "    class_weight={0: 1.0, 1: 2.0},\n",
    ")\n",
    "\n",
    "\n",
    "y_pred_test = (model.predict(X_test_scaled) > 0.5).astype(int)\n",
    "\n",
    "print(classification_report(y_test, y_pred_test, target_names=[\"Sain\", \"Malade\"]))\n",
    "print(f1_score(y_test, y_pred_test))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 46584 characters]",
      "text/plain": [
       "<Figure size 800x500 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "plt.figure(figsize=(8, 5))\n",
    "plt.plot(history.history[\"loss\"], label=\"Loss (train)\")\n",
    "plt.plot(history.history[\"val_loss\"], label=\"Loss (val)\", linestyle=\"--\")\n",
    "plt.xlabel(\"Epochs\")\n",
    "plt.ylabel(\"Binary Cross-Entropy Loss\")\n",
    "plt.title(\"Courbe d'apprentissage\")\n",
    "plt.legend()\n",
    "plt.grid(True)\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Lors de l'entraînement de mon réseau de neurones sur le dataset Breast Cancer Coimbra, j’ai obtenu un score F1 de 0.75, ce qui indique une bonne capacité du modèle à détecter les cas positifs (patients malades) tout en limitant les faux positifs.\n",
    "\n",
    "Un comportement particulier observé durant l’entraînement est que la val_loss est systématiquement inférieure à la train_loss. Ce phénomène s'explique principalement par l'utilisation de la régularisation L2, qui pénalise les poids uniquement pendant la phase d'entraînement, et non lors de l’évaluation sur les données de validation.\n",
    "De plus, la taille réduite du dataset, l'emploi de class_weights pour compenser le léger déséquilibre des classes, ainsi que l'utilisation du early stopping, peuvent accentuer cet écart.\n",
    "\n",
    "Ce comportement n’est pas problématique tant que les performances en validation restent stables et satisfaisantes, ce qui est le cas ici avec un score F1 élevé, métrique prioritaire dans un contexte médical où le rappel est crucial."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "studies",
   "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.13.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
