diff --git a/TD/TD7/ML-TD7.ipynb b/TD/TD7/ML-TD7.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..b946c8f85331b20d7613342c9dbad4038a55934f --- /dev/null +++ b/TD/TD7/ML-TD7.ipynb @@ -0,0 +1,735 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7edf7168", + "metadata": {}, + "source": [ + "### **_Introduction au Machine Learning - Enise - Centrale Lyon_**\n", + "\n", + "2024-2025\n", + "\n", + "Emmanuel Dellandréa\t \n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "4c69d182", + "metadata": {}, + "source": [ + "# TD7 – Convolutional Neural Networks" + ] + }, + { + "cell_type": "markdown", + "id": "fa71eda4", + "metadata": {}, + "source": [ + "The objective of this tutorial is to use the PyTorch library for building, training, and evaluating CNN models." + ] + }, + { + "cell_type": "markdown", + "id": "23f266da", + "metadata": {}, + "source": [ + "## Sequence 1: Training a CNN to classify CIFAR10 images\n", + "\n", + "The goal is to apply a Convolutional Neural Net (CNN) model on the CIFAR10 image dataset and test the accuracy of the model on the basis of image classification. \n", + "\n", + "Be sure to check the PyTorch tutorials and documentation when needed:\n", + "\n", + "https://pytorch.org/tutorials/\n", + "\n", + "https://pytorch.org/docs/stable/index.html\n" + ] + }, + { + "cell_type": "markdown", + "id": "4ba1c82d", + "metadata": {}, + "source": [ + "You can test if GPU is available on your machine and thus train on it to speed up the process" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e18f2fd", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "\n", + "# check if CUDA is available\n", + "train_on_gpu = torch.cuda.is_available()\n", + "\n", + "if not train_on_gpu:\n", + " print(\"CUDA is not available. Training on CPU ...\")\n", + "else:\n", + " print(\"CUDA is available! Training on GPU ...\")" + ] + }, + { + "cell_type": "markdown", + "id": "5cf214eb", + "metadata": {}, + "source": [ + "Next we load the CIFAR10 dataset" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "462666a2", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from torchvision import datasets, transforms\n", + "from torch.utils.data.sampler import SubsetRandomSampler\n", + "\n", + "# number of subprocesses to use for data loading\n", + "num_workers = 0\n", + "# how many samples per batch to load\n", + "batch_size = 20\n", + "# percentage of training set to use as validation\n", + "valid_size = 0.2\n", + "\n", + "# convert data to a normalized torch.FloatTensor\n", + "transform = transforms.Compose(\n", + " [transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]\n", + ")\n", + "\n", + "# choose the training and test datasets\n", + "train_data = datasets.CIFAR10(\"data\", train=True, download=True, transform=transform)\n", + "test_data = datasets.CIFAR10(\"data\", train=False, download=True, transform=transform)\n", + "\n", + "# obtain training indices that will be used for validation\n", + "num_train = len(train_data)\n", + "indices = list(range(num_train))\n", + "np.random.shuffle(indices)\n", + "split = int(np.floor(valid_size * num_train))\n", + "train_idx, valid_idx = indices[split:], indices[:split]\n", + "\n", + "# define samplers for obtaining training and validation batches\n", + "train_sampler = SubsetRandomSampler(train_idx)\n", + "valid_sampler = SubsetRandomSampler(valid_idx)\n", + "\n", + "# prepare data loaders (combine dataset and sampler)\n", + "train_loader = torch.utils.data.DataLoader(\n", + " train_data, batch_size=batch_size, sampler=train_sampler, num_workers=num_workers\n", + ")\n", + "valid_loader = torch.utils.data.DataLoader(\n", + " train_data, batch_size=batch_size, sampler=valid_sampler, num_workers=num_workers\n", + ")\n", + "test_loader = torch.utils.data.DataLoader(\n", + " test_data, batch_size=batch_size, num_workers=num_workers\n", + ")\n", + "\n", + "# specify the image classes\n", + "classes = [\n", + " \"airplane\",\n", + " \"automobile\",\n", + " \"bird\",\n", + " \"cat\",\n", + " \"deer\",\n", + " \"dog\",\n", + " \"frog\",\n", + " \"horse\",\n", + " \"ship\",\n", + " \"truck\",\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "58ec3903", + "metadata": {}, + "source": [ + "CNN definition (this one is an example)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "317bf070", + "metadata": {}, + "outputs": [], + "source": [ + "import torch.nn as nn\n", + "import torch.nn.functional as F\n", + "\n", + "# define the CNN architecture\n", + "\n", + "\n", + "class Net(nn.Module):\n", + " def __init__(self):\n", + " super(Net, self).__init__()\n", + " self.conv1 = nn.Conv2d(3, 6, 5)\n", + " self.pool = nn.MaxPool2d(2, 2)\n", + " self.conv2 = nn.Conv2d(6, 16, 5)\n", + " self.fc1 = nn.Linear(16 * 5 * 5, 120)\n", + " self.fc2 = nn.Linear(120, 84)\n", + " self.fc3 = nn.Linear(84, 10)\n", + "\n", + " def forward(self, x):\n", + " x = self.pool(F.relu(self.conv1(x)))\n", + " x = self.pool(F.relu(self.conv2(x)))\n", + " x = x.view(-1, 16 * 5 * 5)\n", + " x = F.relu(self.fc1(x))\n", + " x = F.relu(self.fc2(x))\n", + " x = self.fc3(x)\n", + " return x\n", + "\n", + "\n", + "# create a complete CNN\n", + "model = Net()\n", + "print(model)\n", + "# move tensors to GPU if CUDA is available\n", + "if train_on_gpu:\n", + " model.cuda()" + ] + }, + { + "cell_type": "markdown", + "id": "a2dc4974", + "metadata": {}, + "source": [ + "Loss function and training using SGD (Stochastic Gradient Descent) optimizer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4b53f229", + "metadata": {}, + "outputs": [], + "source": [ + "import torch.optim as optim\n", + "\n", + "criterion = nn.CrossEntropyLoss() # specify loss function\n", + "optimizer = optim.SGD(model.parameters(), lr=0.01) # specify optimizer\n", + "\n", + "n_epochs = 30 # number of epochs to train the model\n", + "train_loss_list = [] # list to store loss to visualize\n", + "valid_loss_min = np.Inf # track change in validation loss\n", + "\n", + "for epoch in range(n_epochs):\n", + " # Keep track of training and validation loss\n", + " train_loss = 0.0\n", + " valid_loss = 0.0\n", + "\n", + " # Train the model\n", + " model.train()\n", + " for data, target in train_loader:\n", + " # Move tensors to GPU if CUDA is available\n", + " if train_on_gpu:\n", + " data, target = data.cuda(), target.cuda()\n", + " # Clear the gradients of all optimized variables\n", + " optimizer.zero_grad()\n", + " # Forward pass: compute predicted outputs by passing inputs to the model\n", + " output = model(data)\n", + " # Calculate the batch loss\n", + " loss = criterion(output, target)\n", + " # Backward pass: compute gradient of the loss with respect to model parameters\n", + " loss.backward()\n", + " # Perform a single optimization step (parameter update)\n", + " optimizer.step()\n", + " # Update training loss\n", + " train_loss += loss.item() * data.size(0)\n", + "\n", + " # Validate the model\n", + " model.eval()\n", + " for data, target in valid_loader:\n", + " # Move tensors to GPU if CUDA is available\n", + " if train_on_gpu:\n", + " data, target = data.cuda(), target.cuda()\n", + " # Forward pass: compute predicted outputs by passing inputs to the model\n", + " output = model(data)\n", + " # Calculate the batch loss\n", + " loss = criterion(output, target)\n", + " # Update average validation loss\n", + " valid_loss += loss.item() * data.size(0)\n", + "\n", + " # Calculate average losses\n", + " train_loss = train_loss / len(train_loader)\n", + " valid_loss = valid_loss / len(valid_loader)\n", + " train_loss_list.append(train_loss)\n", + "\n", + " # Print training/validation statistics\n", + " print(\n", + " \"Epoch: {} \\tTraining Loss: {:.6f} \\tValidation Loss: {:.6f}\".format(\n", + " epoch, train_loss, valid_loss\n", + " )\n", + " )\n", + "\n", + " # Save model if validation loss has decreased\n", + " if valid_loss <= valid_loss_min:\n", + " print(\n", + " \"Validation loss decreased ({:.6f} --> {:.6f}). Saving model ...\".format(\n", + " valid_loss_min, valid_loss\n", + " )\n", + " )\n", + " torch.save(model.state_dict(), \"model_cifar.pt\")\n", + " valid_loss_min = valid_loss" + ] + }, + { + "cell_type": "markdown", + "id": "13e1df74", + "metadata": {}, + "source": [ + "Does overfit occur? If so, do an early stopping." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d39df818", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "plt.plot(range((len(train_loss_list))), train_loss_list)\n", + "plt.xlabel(\"Epoch\")\n", + "plt.ylabel(\"Loss\")\n", + "plt.title(\"Performance of Model 1\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "11df8fd4", + "metadata": {}, + "source": [ + "Now loading the model with the lowest validation loss value\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e93efdfc", + "metadata": {}, + "outputs": [], + "source": [ + "model.load_state_dict(torch.load(\"./model_cifar.pt\"))\n", + "\n", + "# track test loss\n", + "test_loss = 0.0\n", + "class_correct = list(0.0 for i in range(10))\n", + "class_total = list(0.0 for i in range(10))\n", + "\n", + "model.eval()\n", + "# iterate over test data\n", + "for data, target in test_loader:\n", + " # move tensors to GPU if CUDA is available\n", + " if train_on_gpu:\n", + " data, target = data.cuda(), target.cuda()\n", + " # forward pass: compute predicted outputs by passing inputs to the model\n", + " output = model(data)\n", + " # calculate the batch loss\n", + " loss = criterion(output, target)\n", + " # update test loss\n", + " test_loss += loss.item() * data.size(0)\n", + " # convert output probabilities to predicted class\n", + " _, pred = torch.max(output, 1)\n", + " # compare predictions to true label\n", + " correct_tensor = pred.eq(target.data.view_as(pred))\n", + " correct = (\n", + " np.squeeze(correct_tensor.numpy())\n", + " if not train_on_gpu\n", + " else np.squeeze(correct_tensor.cpu().numpy())\n", + " )\n", + " # calculate test accuracy for each object class\n", + " for i in range(batch_size):\n", + " label = target.data[i]\n", + " class_correct[label] += correct[i].item()\n", + " class_total[label] += 1\n", + "\n", + "# average test loss\n", + "test_loss = test_loss / len(test_loader)\n", + "print(\"Test Loss: {:.6f}\\n\".format(test_loss))\n", + "\n", + "for i in range(10):\n", + " if class_total[i] > 0:\n", + " print(\n", + " \"Test Accuracy of %5s: %2d%% (%2d/%2d)\"\n", + " % (\n", + " classes[i],\n", + " 100 * class_correct[i] / class_total[i],\n", + " np.sum(class_correct[i]),\n", + " np.sum(class_total[i]),\n", + " )\n", + " )\n", + " else:\n", + " print(\"Test Accuracy of %5s: N/A (no training examples)\" % (classes[i]))\n", + "\n", + "print(\n", + " \"\\nTest Accuracy (Overall): %2d%% (%2d/%2d)\"\n", + " % (\n", + " 100.0 * np.sum(class_correct) / np.sum(class_total),\n", + " np.sum(class_correct),\n", + " np.sum(class_total),\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "944991a2", + "metadata": {}, + "source": [ + "### Experiments:\n", + "\n", + "Build a new network with the following structure.\n", + "\n", + "- It has 3 convolutional layers of kernel size 3 and padding of 1.\n", + "- The first convolutional layer must output 16 channels, the second 32 and the third 64.\n", + "- At each convolutional layer output, we apply a ReLU activation then a MaxPool with kernel size of 2.\n", + "- Then, three fully connected layers, the first two being followed by a ReLU activation.\n", + "- The first fully connected layer will have an output size of 512.\n", + "- The second fully connected layer will have an output size of 64.\n", + "\n", + "Compare the results obtained with this new network to those obtained previously." + ] + }, + { + "cell_type": "markdown", + "id": "201470f9", + "metadata": {}, + "source": [ + "## Sequence 2: Working with pre-trained models.\n", + "\n", + "PyTorch offers several pre-trained models https://pytorch.org/vision/0.8/models.html \n", + "We will use ResNet50 trained on ImageNet dataset (https://www.image-net.org/index.php). Use the following code with the files `imagenet-simple-labels.json` that contains the imagenet labels and the image dog.png that we will use as test.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b4d13080", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from PIL import Image\n", + "from torchvision import models\n", + "\n", + "# Choose an image to pass through the model\n", + "test_image = \"dog.png\"\n", + "\n", + "# Configure matplotlib for pretty inline plots\n", + "#%matplotlib inline\n", + "#%config InlineBackend.figure_format = 'retina'\n", + "\n", + "# Prepare the labels\n", + "with open(\"imagenet-simple-labels.json\") as f:\n", + " labels = json.load(f)\n", + "\n", + "# First prepare the transformations: resize the image to what the model was trained on and convert it to a tensor\n", + "data_transform = transforms.Compose(\n", + " [\n", + " transforms.Resize((224, 224)),\n", + " transforms.ToTensor(),\n", + " transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),\n", + " ]\n", + ")\n", + "# Load the image\n", + "\n", + "image = Image.open(test_image)\n", + "plt.imshow(image), plt.xticks([]), plt.yticks([])\n", + "\n", + "# Now apply the transformation, expand the batch dimension, and send the image to the GPU\n", + "# image = data_transform(image).unsqueeze(0).cuda()\n", + "image = data_transform(image).unsqueeze(0)\n", + "\n", + "# Download the model if it's not there already. It will take a bit on the first run, after that it's fast\n", + "model = models.resnet50(pretrained=True)\n", + "# Send the model to the GPU\n", + "# model.cuda()\n", + "# Set layers such as dropout and batchnorm in evaluation mode\n", + "model.eval()\n", + "\n", + "# Get the 1000-dimensional model output\n", + "out = model(image)\n", + "# Find the predicted class\n", + "print(\"Predicted class is: {}\".format(labels[out.argmax()]))" + ] + }, + { + "cell_type": "markdown", + "id": "184cfceb", + "metadata": {}, + "source": [ + "### Experiments:\n", + "\n", + "Study the code and the results obtained. Possibly add other images downloaded from the internet.\n", + "\n", + "Experiment with other pre-trained CNN models.\n", + "\n", + " \n" + ] + }, + { + "cell_type": "markdown", + "id": "5d57da4b", + "metadata": {}, + "source": [ + "## Sequence 3: Transfer Learning\n", + " \n", + " \n", + "For this work, we will use a pre-trained model (ResNet18) as a descriptor extractor and will refine the classification by training only the last fully connected layer of the network. Thus, the output layer of the pre-trained network will be replaced by a layer adapted to the new classes to be recognized which will be in our case ants and bees.\n", + "Download and unzip in your working directory the dataset available at the address :\n", + " \n", + "https://download.pytorch.org/tutorial/hymenoptera_data.zip\n", + " \n", + "Execute the following code in order to display some images of the dataset." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be2d31f5", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import torch\n", + "import torchvision\n", + "from torchvision import datasets, transforms\n", + "import torch.nn as nn\n", + "import torch.optim as optim\n", + "from torch.optim import lr_scheduler\n", + "\n", + "# Data augmentation and normalization for training\n", + "# Just normalization for validation\n", + "data_transforms = {\n", + " \"train\": transforms.Compose(\n", + " [\n", + " transforms.RandomResizedCrop(\n", + " 224\n", + " ), # ImageNet models were trained on 224x224 images\n", + " transforms.RandomHorizontalFlip(), # flip horizontally 50% of the time - increases train set variability\n", + " transforms.ToTensor(), # convert it to a PyTorch tensor\n", + " transforms.Normalize(\n", + " [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]\n", + " ), # ImageNet models expect this norm\n", + " ]\n", + " ),\n", + " \"val\": transforms.Compose(\n", + " [\n", + " transforms.Resize(256),\n", + " transforms.CenterCrop(224),\n", + " transforms.ToTensor(),\n", + " transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),\n", + " ]\n", + " ),\n", + "}\n", + "\n", + "data_dir = \"hymenoptera_data\"\n", + "# Create train and validation datasets and loaders\n", + "image_datasets = {\n", + " x: datasets.ImageFolder(os.path.join(data_dir, x), data_transforms[x])\n", + " for x in [\"train\", \"val\"]\n", + "}\n", + "dataloaders = {\n", + " x: torch.utils.data.DataLoader(\n", + " image_datasets[x], batch_size=4, shuffle=True, num_workers=0\n", + " )\n", + " for x in [\"train\", \"val\"]\n", + "}\n", + "dataset_sizes = {x: len(image_datasets[x]) for x in [\"train\", \"val\"]}\n", + "class_names = image_datasets[\"train\"].classes\n", + "device = torch.device(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")\n", + "\n", + "# Helper function for displaying images\n", + "def imshow(inp, title=None):\n", + " \"\"\"Imshow for Tensor.\"\"\"\n", + " inp = inp.numpy().transpose((1, 2, 0))\n", + " mean = np.array([0.485, 0.456, 0.406])\n", + " std = np.array([0.229, 0.224, 0.225])\n", + "\n", + " # Un-normalize the images\n", + " inp = std * inp + mean\n", + " # Clip just in case\n", + " inp = np.clip(inp, 0, 1)\n", + " plt.imshow(inp)\n", + " if title is not None:\n", + " plt.title(title)\n", + " plt.pause(0.001) # pause a bit so that plots are updated\n", + " plt.show()\n", + "\n", + "\n", + "# Get a batch of training data\n", + "inputs, classes = next(iter(dataloaders[\"train\"]))\n", + "\n", + "# Make a grid from batch\n", + "out = torchvision.utils.make_grid(inputs)\n", + "\n", + "imshow(out, title=[class_names[x] for x in classes])\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "bbd48800", + "metadata": {}, + "source": [ + "Now, execute the following code which uses a pre-trained model ResNet18 having replaced the output layer for the ants/bees classification and performs the model training by only changing the weights of this output layer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "572d824c", + "metadata": {}, + "outputs": [], + "source": [ + "import copy\n", + "import time\n", + "\n", + "\n", + "def train_model(model, criterion, optimizer, scheduler, num_epochs=25):\n", + " since = time.time()\n", + "\n", + " best_model_wts = copy.deepcopy(model.state_dict())\n", + " best_acc = 0.0\n", + "\n", + " epoch_time = [] # we'll keep track of the time needed for each epoch\n", + "\n", + " for epoch in range(num_epochs):\n", + " epoch_start = time.time()\n", + " print(\"Epoch {}/{}\".format(epoch + 1, num_epochs))\n", + " print(\"-\" * 10)\n", + "\n", + " # Each epoch has a training and validation phase\n", + " for phase in [\"train\", \"val\"]:\n", + " if phase == \"train\":\n", + " scheduler.step()\n", + " model.train() # Set model to training mode\n", + " else:\n", + " model.eval() # Set model to evaluate mode\n", + "\n", + " running_loss = 0.0\n", + " running_corrects = 0\n", + "\n", + " # Iterate over data.\n", + " for inputs, labels in dataloaders[phase]:\n", + " inputs = inputs.to(device)\n", + " labels = labels.to(device)\n", + "\n", + " # zero the parameter gradients\n", + " optimizer.zero_grad()\n", + "\n", + " # Forward\n", + " # Track history if only in training phase\n", + " with torch.set_grad_enabled(phase == \"train\"):\n", + " outputs = model(inputs)\n", + " _, preds = torch.max(outputs, 1)\n", + " loss = criterion(outputs, labels)\n", + "\n", + " # backward + optimize only if in training phase\n", + " if phase == \"train\":\n", + " loss.backward()\n", + " optimizer.step()\n", + "\n", + " # Statistics\n", + " running_loss += loss.item() * inputs.size(0)\n", + " running_corrects += torch.sum(preds == labels.data)\n", + "\n", + " epoch_loss = running_loss / dataset_sizes[phase]\n", + " epoch_acc = running_corrects.double() / dataset_sizes[phase]\n", + "\n", + " print(\"{} Loss: {:.4f} Acc: {:.4f}\".format(phase, epoch_loss, epoch_acc))\n", + "\n", + " # Deep copy the model\n", + " if phase == \"val\" and epoch_acc > best_acc:\n", + " best_acc = epoch_acc\n", + " best_model_wts = copy.deepcopy(model.state_dict())\n", + "\n", + " # Add the epoch time\n", + " t_epoch = time.time() - epoch_start\n", + " epoch_time.append(t_epoch)\n", + " print()\n", + "\n", + " time_elapsed = time.time() - since\n", + " print(\n", + " \"Training complete in {:.0f}m {:.0f}s\".format(\n", + " time_elapsed // 60, time_elapsed % 60\n", + " )\n", + " )\n", + " print(\"Best val Acc: {:4f}\".format(best_acc))\n", + "\n", + " # Load best model weights\n", + " model.load_state_dict(best_model_wts)\n", + " return model, epoch_time\n", + "\n", + "\n", + "# Download a pre-trained ResNet18 model and freeze its weights\n", + "model = torchvision.models.resnet18(pretrained=True)\n", + "for param in model.parameters():\n", + " param.requires_grad = False\n", + "\n", + "# Replace the final fully connected layer\n", + "# Parameters of newly constructed modules have requires_grad=True by default\n", + "num_ftrs = model.fc.in_features\n", + "model.fc = nn.Linear(num_ftrs, 2)\n", + "# Send the model to the GPU\n", + "model = model.to(device)\n", + "# Set the loss function\n", + "criterion = nn.CrossEntropyLoss()\n", + "\n", + "# Observe that only the parameters of the final layer are being optimized\n", + "optimizer_conv = optim.SGD(model.fc.parameters(), lr=0.001, momentum=0.9)\n", + "exp_lr_scheduler = lr_scheduler.StepLR(optimizer_conv, step_size=7, gamma=0.1)\n", + "model, epoch_time = train_model(\n", + " model, criterion, optimizer_conv, exp_lr_scheduler, num_epochs=10\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "bbd48800", + "metadata": {}, + "source": [ + "### Experiments:\n", + "\n", + "Study the code and the results obtained.\n", + "\n", + "Modify the code and add an \"eval_model\" function to allow\n", + "the evaluation of the model on a test set (different from the learning and validation sets used during the learning phase). Study the results obtained.\n", + "\n", + "Now modify the code to replace the current classification layer with a set of two layers using a \"relu\" activation function for the middle layer. Renew the experiments and study the results obtained.\n", + "\n", + "Experiment with other models and datasets." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "base", + "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.9.20" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}