{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "8e837f8b-7b49-42af-a0f5-e0ff21cab09d",
   "metadata": {},
   "source": [
    "# Introduction à PyTorch\n",
    "**Marc BUFFAT** , dpt mécanique, Université Lyon 1\n",
    "![pytorch](images/python-pytorch.png)\n",
    "\n",
    "*Certains contenus ont été coconstruits avec l'appui d'un outil d'IA générative puis relus et validés par l'enseignant.* "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "e339cd92-1d1c-4831-bd6e-d5e7452edfd8",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Max threads : 4 / used threads 4\n",
      "Attention: no GPU available! using CPU\n",
      "cpu\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/home/buffat/venvs/jupyter/lib/python3.10/site-packages/torch/cuda/__init__.py:107: UserWarning: CUDA initialization: The NVIDIA driver on your system is too old (found version 9010). Please update your GPU driver by downloading and installing a new version from the URL: http://www.nvidia.com/Download/index.aspx Alternatively, go to: https://pytorch.org to install a PyTorch version that has been compiled with your version of the CUDA driver. (Triggered internally at ../c10/cuda/CUDAFunctions.cpp:109.)\n",
      "  return torch._C._cuda_getDeviceCount() > 0\n"
     ]
    }
   ],
   "source": [
    "from validation.libIA_GPU import Init_torchGPU\n",
    "\n",
    "try: cuda_dev\n",
    "except NameError: cuda_dev = Init_torchGPU(4, 0)\n",
    "if cuda_dev is None: cuda_dev = \"cpu\"\n",
    "print(cuda_dev)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1b14747d-29d9-40c9-bd28-4838d065051d",
   "metadata": {},
   "source": [
    "## Introduction\n",
    "PyTorch est une bibliothèque logicielle Python Open Source  d'apprentissage automatique [https://pytorch.org](https://pytorch.org) qui s'appuie sur Torch développée par Meta (Facebook).\n",
    "\n",
    "Tout comme Numpy, PyTorch est une bibliothèque python qui permet de faire du calcul numérique optimisé avec des tableaux multidimensionnels (`tensor` sous Pytporch,  `ndarray` sous Numpy).\n",
    "\n",
    "NumPy est la base du calcul numérique en Python, tandis que PyTorch s'appuie sur des concepts similaires (les tenseurs) mais ajoute des fonctionnalités essentielles pour l'intelligence artificielle, comme l'accélération GPU et le calcul automatique des gradients.\n",
    "\n",
    "La syntaxe de PyTorch est similaire à celle de Numpy.\n",
    "\n",
    "**ATTENTION** par defaut pytorch utilise des réels en simple précision (`float32`) pour des questions de mémoire, alors que numpy utilise des réels en double pression (`float64`).\n",
    "\n",
    "Il faut donc faire attention lors des conversions `numpy`vers `pytorch` "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "70716f4c-a536-420c-8441-9d8b5a0cbf4a",
   "metadata": {},
   "source": [
    "## Vérification de l'installation\n",
    "\n",
    "La principale bibliothèque python s'appelle `torch` (et pour des applications en imagerie `torchvision`). Pour l'utilisation sur GPU, elle nécessite l'installation de la bibliothèque cuda (utilisation de GPU nvidia pour faire du calcul scientifique HPC).\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "700b6762-77b6-4292-baec-3fa5f2aa0df9",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "False\n",
      "CUDA device:  cpu\n",
      "Torch CUDA device:  cpu  threads: 4 4\n"
     ]
    }
   ],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "\n",
    "print(torch.cuda.is_available())\n",
    "device = torch.device(cuda_dev)\n",
    "print(\"CUDA device: \",cuda_dev)\n",
    "print(\"Torch CUDA device: \",device,\" threads:\",torch.get_num_threads(),torch.get_num_interop_threads())\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8ef8947d-5892-49d8-b4ec-6b787b416998",
   "metadata": {},
   "source": [
    "## Objets de base: les tenseurs\n",
    "\n",
    "Les tenseurs sont les objets fondamentaux de PyTorch.\n",
    "\n",
    "Création d'un tenseur (tensor):\n",
    "\n",
    "- Vecteur\n",
    "\n",
    "```python\n",
    "import torch\n",
    "x = torch.tensor([1.,2.,3.])\n",
    "print(x)\n",
    "```\n",
    "\n",
    "- Matrice :\n",
    "\n",
    "```python\n",
    "A = torch.tensor([[1.,2.],\n",
    "                  [3.,4.]])\n",
    "```\n",
    "\n",
    "- Vecteur aléatoire :\n",
    "\n",
    "```python\n",
    "x = torch.randn(5)\n",
    "```\n",
    "\n",
    "- Matrice aléatoire :\n",
    "\n",
    "```python\n",
    "A = torch.randn(3,4)\n",
    "```"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "34526f6d-5bcf-4e56-a8d8-899567c2c742",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "tensor([1., 2., 3.])\n",
      "tensor([[1., 2.],\n",
      "        [3., 4.]])\n"
     ]
    }
   ],
   "source": [
    "import torch\n",
    "\n",
    "x = torch.tensor([1.,2.,3.])\n",
    "print(x)\n",
    "A = torch.tensor([[1.,2.],\n",
    "                 [3.,4.]])\n",
    "print(A)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f31f6736-e7c6-4fd3-826e-56c79f4bb365",
   "metadata": {},
   "source": [
    "## Opérations matricielles\n",
    "\n",
    "Utilisation de notations vectorielles similaires à Numpy\n",
    "\n",
    "```python\n",
    "x = torch.tensor([1.,2.,3.])\n",
    "y = 2*x + 1\n",
    "print(y)\n",
    "```\n",
    "\n",
    "Produit tensoriel (matriciel) @ :\n",
    "\n",
    " - pytorch.dot\n",
    " - pytorch.tensordot\n",
    "\n",
    "```python\n",
    "A = torch.randn(3,3)\n",
    "B = torch.randn(3,3)\n",
    "C = A @ B\n",
    "```"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "34b59c02-734c-4ace-901e-368912b300b5",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "tensor([3., 5., 7.])\n",
      "tensor(34.)\n"
     ]
    }
   ],
   "source": [
    "x = torch.tensor([1.,2.,3.])\n",
    "y = 2*x + 1\n",
    "print(y)\n",
    "C = x@y\n",
    "print(C)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e5cfe5ba-b970-48b0-bef5-f8eb69c0a86b",
   "metadata": {},
   "source": [
    "## Calcul automatique des dérivées\n",
    "\n",
    "C'est la fonctionnalité indispensable pour les Réseaux de Neurones. Elle permet de calculer numériquement la valeur de la dérivée grâce à une analyse de graphe avec retour arrière (backtracking)\n",
    "\n",
    "On active le calcul des gradients par rapport à une variable lors de la création du tenseur associé.\n",
    "\n",
    "```python\n",
    "x = torch.tensor([2.0], requires_grad=True)\n",
    "y = x**2\n",
    "y.backward()\n",
    "print(x.grad)\n",
    "```"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "76fb8d4c-de69-410f-aad7-21003eb821ce",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "tensor([4.])\n"
     ]
    }
   ],
   "source": [
    "x = torch.tensor([2.0], requires_grad=True)\n",
    "y = x**2\n",
    "y.backward()\n",
    "print(x.grad)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e31c2705-8042-442e-8353-e185362549a2",
   "metadata": {},
   "source": [
    "## Exemple plus complexe\n",
    "\n",
    "```python\n",
    "x = torch.tensor([3.0], requires_grad=True)\n",
    "f = x**3 + 2*x\n",
    "f.backward()\n",
    "print(x.grad)\n",
    "```\n",
    "\n",
    "PyTorch calcule la valeur numérique du gradient pour $x=3$\n",
    "\n",
    "$$\n",
    "3x^2+2=29.\n",
    "$$"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "7fb6d027-2d93-48a8-9c6b-622ad0307769",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "tensor([29.])\n"
     ]
    }
   ],
   "source": [
    "x = torch.tensor([3.0], requires_grad=True)\n",
    "f = x**3 + 2*x\n",
    "f.backward()\n",
    "print(x.grad)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c02e2482-bc4b-4643-941b-315d2368b38e",
   "metadata": {},
   "source": [
    "## Les réseaux de neurones\n",
    "\n",
    "Un réseau de neurones est une classe héritée de `nn.Module`. On doit définir deux méthodes:\n",
    "\n",
    "- `__init__(self)` : pour définir la structure du réseau, par exemple un réseau multi-couche linéaire avec des fonctions d'activation en tangente hyperbolique (' tanh`).\n",
    "- `forward(self,x)`: pour définir la façon de calculer la valeur du réseau de neurones pour une entrée `x`. Dans le cas d'un réseau séquentiel, on appelle successivement chaque couche, ce qui si se simplifie si on utilise la fonction `Sequential` et la méthode associée dans `forward`\n",
    "\n",
    "Un réseau de neurones dépend d'un certain nombre de paramètres, que l'on va déterminer en minimisant une fonction coût par une méthode d'optimisation par gradient.\n",
    "\n",
    "### Création\n",
    "\n",
    "**définition du réseau:**\n",
    "\n",
    "```python\n",
    "import torch.nn as nn\n",
    "\n",
    "class NeuralNet(nn.Module):\n",
    "\n",
    "    def __init__(self):\n",
    "        super().__init__()\n",
    "        self.layers = nn.Sequential(\n",
    "            nn.Linear(1,20),\n",
    "            nn.Tanh(),\n",
    "            nn.Linear(20,20),\n",
    "            nn.Tanh(),\n",
    "            nn.Linear(20,1)\n",
    "        )\n",
    "    def forward(self,x):\n",
    "        return self.layers(x)\n",
    "```\n",
    "qui équivaut à\n",
    "\n",
    "```python\n",
    "\n",
    "class NeuralNet(nn.Module):\n",
    "\n",
    "    def __init__(self):\n",
    "        super().__init__()\n",
    "        self.fc1 = nn.Linear(1,20),\n",
    "        self.ac1 = nn.Tanh(),\n",
    "        self.fc2 = nn.Linear(20,20),\n",
    "        self.ac2 = nn.Tanh(),\n",
    "        self.fc3 = nn.Linear(20,1)\n",
    "        \n",
    "\n",
    "    def forward(self,x):\n",
    "        x = self.fc1(x)\n",
    "        x = self.ac1(x)\n",
    "        x = self.fc2(x)\n",
    "        x = self.ac2(x)\n",
    "        x = self.fc3(x)\n",
    "        return x\n",
    "```\n",
    "\n",
    "\n",
    "**création/utilisation :**\n",
    "\n",
    "```python\n",
    "model = NeuralNet()\n",
    "```"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "bf76dad9-c503-4e00-8d39-7b290719c826",
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch.nn as nn\n",
    "\n",
    "class NeuralNet(nn.Module):\n",
    "\n",
    "    def __init__(self):\n",
    "        super().__init__()\n",
    "        self.layers = nn.Sequential(\n",
    "            nn.Linear(1,20),\n",
    "            nn.Tanh(),\n",
    "            nn.Linear(20,20),\n",
    "            nn.Tanh(),\n",
    "            nn.Linear(20,1)\n",
    "        )\n",
    "    def forward(self,x):\n",
    "        return self.layers(x)\n",
    "# création\n",
    "model = NeuralNet()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "40dd3dba-b4c9-419f-9312-4ff4c8861b75",
   "metadata": {},
   "source": [
    "### Prédiction\n",
    "\n",
    "Pour calculer la valeur du réseau de Neurones, on utilise simplement la valeur de l'instance de la classe en fournissant la valeur du tenseur d'entrée\n",
    "\n",
    "```python\n",
    "x = torch.tensor([[0.5]])\n",
    "y = model(x)\n",
    "print(y)\n",
    "```\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "cb3bea87-2051-42cd-ae54-17db57fc93b8",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "tensor([[-0.0164]], grad_fn=<AddmmBackward0>)\n"
     ]
    }
   ],
   "source": [
    "x = torch.tensor([[0.5]])\n",
    "y = model(x)\n",
    "print(y)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "432d4206-37cb-4590-9854-4994d4ce2851",
   "metadata": {},
   "source": [
    "### Fonction coût (ou fonction perte)\n",
    "\n",
    "C'est l'écart entre la valeur attendue et la valeur du réseau de neurones: i.e. entre les données et la prédiction. On calcule en générale un écart quadratique fournit par la fonction `MSELoss()`\n",
    "\n",
    "```python\n",
    "prediction = model(x)\n",
    "target = torch.tensor([[1.5]])\n",
    "loss = ((prediction-target)**2).mean()\n",
    "```\n",
    "\n",
    "ou\n",
    "\n",
    "```python\n",
    "criterion = nn.MSELoss()\n",
    "loss = criterion(prediction,target)\n",
    "```\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "786fa9c5-b0e6-4363-9fa0-135fc62e4d55",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "tensor(2.2996, grad_fn=<MseLossBackward0>)\n"
     ]
    }
   ],
   "source": [
    "prediction = model(x)\n",
    "target = torch.tensor([[1.5]])\n",
    "criterion = nn.MSELoss()\n",
    "loss = criterion(prediction,target)\n",
    "print(loss)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2240c992-9637-41d8-8c3e-6a22ccdbd253",
   "metadata": {},
   "source": [
    "### Méthode d'optimisation\n",
    "\n",
    "Choix de la méthode d'optimisation.  Un algorithme très utilisé est l'algorithme d'Adam. Adam est un algorithme d'optimisation du premier ordre fondé sur le gradient, destiné à l'optimisation de fonctions objectif stochastiques. Il repose sur des estimations adaptatives des moments d'ordre 1 et 2 à partir d'une évaluation stochastique du gradient. C'est l'itération élémentaire d'un algorithme de descente par gradient stochastique (SGD).\n",
    "\n",
    "**Création de l'optimiseur :**\n",
    "\n",
    "```python\n",
    "optimizer = torch.optim.Adam(\n",
    "    model.parameters(),\n",
    "    lr=1e-3\n",
    ")\n",
    "```"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "74bb0780-d0b1-4f68-a279-ff1c0fd2002f",
   "metadata": {},
   "outputs": [],
   "source": [
    "optimizer = torch.optim.Adam(\n",
    "    model.parameters(),\n",
    "    lr=1e-3\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c21db6c4-f7c6-4068-bd29-764fd13947e1",
   "metadata": {},
   "source": [
    "### Boucle d'entraînement\n",
    "C'est la boucle  itérative de l'algorithme de descente par gradient stochastique. La variable d'itération est communément appelée `epoch`  pour indiquer que le calcul du gradient se fait en utilisant l'ensemble des données. \n",
    "Pour certains très gros problèmes, on peut inclure à l'intérieur de cette boucle, une boucle interne de sous-itérations pour ne calculer les gradients que sur un sous-ensemble des données (mini-batch).  \n",
    "\n",
    "```python\n",
    "for epoch in range(1000):\n",
    "    optimizer.zero_grad()\n",
    "    prediction = model(x)\n",
    "    loss = criterion(prediction,target)\n",
    "    loss.backward()\n",
    "    optimizer.step()\n",
    "```\n",
    "\n",
    "Les trois étapes importantes sont :\n",
    "\n",
    "1. calcul de la fonction perte (`loss`) ;\n",
    "2. calcul des gradients (`backward()`) ;\n",
    "3. mise à jour des paramètres ou poids (`step()`).\n",
    "\n",
    "---"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "2a4dceb7-aa6e-4665-af0a-1f6af7114ad9",
   "metadata": {},
   "outputs": [],
   "source": [
    "for epoch in range(1000):\n",
    "    optimizer.zero_grad()\n",
    "    prediction = model(x)\n",
    "    loss = criterion(prediction,target)\n",
    "    loss.backward()\n",
    "    optimizer.step()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0e21c130-54d1-4460-832b-b2ee01d4f17d",
   "metadata": {},
   "source": [
    "## Calcul du gradient\n",
    "\n",
    "### Dérivée première par rapport aux entrées\n",
    "\n",
    "Pour les PINN, on ne dérive pas par rapport aux poids mais par rapport aux entrées: i.e. temps ou à l'espace.\n",
    "\n",
    "Le calcul du gradient :\n",
    "\n",
    "```python\n",
    "t = torch.linspace(0,1,100).reshape(-1,1)\n",
    "t.requires_grad = True\n",
    "y = model(t)\n",
    "dy = torch.autograd.grad(\n",
    "    y,\n",
    "    t,\n",
    "    grad_outputs=torch.ones_like(y),\n",
    "    create_graph=True\n",
    ")[0]\n",
    "```\n",
    "\n",
    "Cette instruction calcule la valeur numérique\n",
    "$\\frac{dy}{dt}.$ pour les valeurs des entrées t.\n",
    "\n",
    "---\n",
    "\n",
    "### Dérivée seconde\n",
    "\n",
    "```python\n",
    "d2y = torch.autograd.grad(\n",
    "    dy,\n",
    "    t,\n",
    "    grad_outputs=torch.ones_like(dy),\n",
    "    create_graph=True\n",
    ")[0]\n",
    "```\n",
    "\n",
    "On obtient\n",
    "$\\frac{d^2y}{dt^2}.$\n",
    "\n",
    "C'est cette capacité à calculer des dérivées d'ordre élevé qui rend PyTorch particulièrement adapté aux PINN.\n",
    "\n",
    "---"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "133fecf1-dd9d-4cbd-b534-7f614593fe7f",
   "metadata": {},
   "source": [
    "## Sauvegarder/ charger un modèle\n",
    "\n",
    "Python permet de sauvegarder le modèle RNN dans un fichier d'extension `.pt` du type zip-file.\n",
    "\n",
    "```python\n",
    "torch.save(model.state_dict(),\"model.pt\")\n",
    "```\n",
    "\n",
    "Chargement :\n",
    "\n",
    "```python\n",
    "model = NeuralNet()\n",
    "model.load_state_dict(torch.load(\"model.pt\"))\n",
    "model.eval()\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0ab662a4-a574-43ae-a806-bad9cb63bc3c",
   "metadata": {},
   "source": [
    "## Utilisation du GPU\n",
    "L'un des points forts de PyTorch est la possibilité d'utiliser un GPU nvidia pour faire les calculs tensoriels de façon optimisé.  Pour cela, on sélectionne le périphérique (device) à utiliser our faire les calculs ('cuda' pour un GPU nvidia ou 'CPU' pour un calcul sans GPU).\n",
    "\n",
    "Attention si les tenseurs sont créés sur le GPU, il faut explicitement les transférer dans la mémoire du CPU  pour utiliser Numpy et les bibliothèques d'analyse et de tracé. Attention le transfert de données entre GPU et CPU n'est pas instantané et peut devenir un point d'étranglement des algorithmes de machine learning si on ne les minimise pas. \n",
    "\n",
    "```python\n",
    "device = torch.device(\n",
    "    \"cuda\" if torch.cuda.is_available()\n",
    "    else \"cpu\"\n",
    ")\n",
    "\n",
    "model = model.to(device)\n",
    "x = x.to(device)\n",
    "```\n",
    "\n",
    "Sinon, on peut dans la fonction Pytorch de création d'un tenseur ajouter le paramètre `device` pour spécifier le device (cpu ou gpu)\n",
    "\n",
    "\n",
    "```python\n",
    "x = torch.zeros([5,10],device=torch.device(\"cuda:0\")\n",
    "```\n",
    "\n",
    "Pour transférer des données du GPU au CPU on utilise la méthode `cpu()`\n",
    "\n",
    "```python\n",
    "xc = x.cpu()\n",
    "```\n",
    "\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8509c4cc-172b-4f97-93c3-fcdca8a927ec",
   "metadata": {},
   "source": [
    "## Schéma général d'un projet PyTorch pour du machine learning/PINN\n",
    "\n",
    "```text\n",
    "Création des données\n",
    "        │\n",
    "        ▼\n",
    "Création du réseau\n",
    "        │\n",
    "        ▼\n",
    "Choix de la fonction de coût\n",
    "        │\n",
    "        ▼\n",
    "Choix de l'optimiseur\n",
    "        │\n",
    "        ▼\n",
    "Boucle d'entraînement\n",
    "    ├── calcul de la valeur: forward()\n",
    "    ├── calcul de la fonction coût/perte: loss\n",
    "    ├── calcul du gradient: backward()\n",
    "    └── itération suivante\n",
    "        │\n",
    "        ▼\n",
    "Évaluation de l'approximation RNN ou PINN\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "77c0fa86-45ee-46bd-b4d3-4e980e12bcfe",
   "metadata": {},
   "source": [
    "### Approche recommandée\n",
    "\n",
    "Pour apprendre à utiliser efficacement PyTorch en machine learning / PINN, vous pouvez suivre cette démarche :\n",
    "\n",
    "1. Apprendre à manipuler les tenseurs et les opérations de base.\n",
    "2. Comprendre le calcul automatique des gradients (`autograd`).\n",
    "3. Construire des réseaux de neurones simples avec `nn.Module`.\n",
    "4. Maîtriser la boucle d'entraînement (perte, rétropropagation, optimiseur).\n",
    "5. Calculer des dérivées par rapport aux entrées avec `torch.autograd.grad`.\n",
    "6. Implémenter un premier PINN pour une ODE du premier ordre, puis étendre à des ODE du second ordre et enfin à des PDE.\n",
    "\n",
    "Une fois ces bases acquises, la transition vers les PINN est naturelle, car ils utilisent les mêmes briques de PyTorch : réseau de neurones, différentiation automatique et optimisation. La principale différence réside dans la fonction de perte, qui intègre le résidu des équations différentielles et les conditions initiales ou aux limites."
   ]
  }
 ],
 "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": 5
}
