{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"pygments_lexer":"ipython3","nbconvert_exporter":"python","version":"3.6.4","file_extension":".py","codemirror_mode":{"name":"ipython","version":3},"name":"python","mimetype":"text/x-python"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":3362,"databundleVersionId":31148,"sourceType":"competition"}],"dockerImageVersionId":30121,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Dogs vs Cats \n\nIn this competition, you'll write an algorithm to classify whether images contain either a dog or a cat.  This is easy for humans, dogs, and cats. Your computer will find it a bit more difficult.\n\n# Tensorflow Model with Transfer Learning using Augmentation\n## Transfer Learning phases\n* The layers containing the interesting features are taken from a previously trained model preferably from a large training set; The weights of the layers of the loaded model are kept constant (freezed layers), in order to avoid destroying the information containing the features during future learning rounds.\n* feature-extraction: New Dense layers are added for classification in place of those present in the pre-trained model. They will be tasked with learning how to transform old characteristics into predictions on a new dataset.\n* They train the new levels on your dataset.\n* fine-tuning, optional final part which consists in removing the constraint / freezed of some layers of the entire pre-trained model and re-training it again on the new data with a very low learning-rate. This allows for significant improvements by incrementally adapting characteristics to new data.*\n\n\n<div style=\"text-align: center;\">\n<img src=\"https://i.postimg.cc/2SYpC7CC/transfer-learning-dog-cats.png\"/>\n</div>","metadata":{}},{"cell_type":"code","source":"# This Python 3 environment comes with many helpful analytics libraries installed\n# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python\n# For example, here's several helpful packages to load\n\nimport numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\nimport tensorflow as tf\nimport matplotlib.pyplot as plt\n\n# Input data files are available in the read-only \"../input/\" directory\n# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory\n\nimport os\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        print(os.path.join(dirname, filename))\n\n# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using \"Save & Run All\" \n# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session","metadata":{"_kg_hide-input":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"### Seed All in order to reproduce results","metadata":{}},{"cell_type":"code","source":"import random\nSEED = 12\nrandom.seed(SEED)\nos.environ[\"PYTHONHASHSEED\"] = str(SEED)\nnp.random.seed(SEED)\ntf.random.set_seed(SEED)","metadata":{"execution":{"iopub.status.busy":"2022-06-25T10:35:04.199206Z","iopub.execute_input":"2022-06-25T10:35:04.199586Z","iopub.status.idle":"2022-06-25T10:35:04.204367Z","shell.execute_reply.started":"2022-06-25T10:35:04.199556Z","shell.execute_reply":"2022-06-25T10:35:04.203506Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"import os\nfrom zipfile import ZipFile\nWORKING_DIR = \"/kaggle/working\"\n\n# extracting zip files\nfor zip_file in ['/kaggle/input/dogs-vs-cats/train.zip', '/kaggle/input/dogs-vs-cats/test1.zip']:\n    ZipFile(zip_file, mode = \"r\").extractall()\n    \nos.listdir(WORKING_DIR)","metadata":{"execution":{"iopub.status.busy":"2022-06-25T10:35:08.05217Z","iopub.execute_input":"2022-06-25T10:35:08.052609Z","iopub.status.idle":"2022-06-25T10:35:18.496249Z","shell.execute_reply.started":"2022-06-25T10:35:08.05257Z","shell.execute_reply":"2022-06-25T10:35:18.494257Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"# EDA - Exploration data analysis \n\nWe explore the train and test folders showing first 5 images with their size (width x height)","metadata":{}},{"cell_type":"code","source":"import PIL\n\nPRINT_FILE_X_DIR = 5\n# exploring situation on extracted files : directories with images and their sizes\ndef explore_image_on_directory(data_dir):\n    directories = {}\n    directory_files = os.listdir(data_dir)\n    for d in directory_files:\n      class_dir = os.path.join(data_dir, d)\n      if os.path.isdir(class_dir):\n        directories[d] = []\n        files = os.listdir(class_dir)\n        for f in files:\n          file_path = os.path.join(class_dir,f)\n          if os.path.isfile(file_path) and os.path.getsize(file_path):\n            directories[d].append(os.path.join(class_dir,f))\n    # print analysis directories\n    tot = 0\n    for c in directories.keys():\n      print(c, len(directories[c]))\n      tot += len(directories[c])\n      for f in directories[c][:PRINT_FILE_X_DIR]:\n        image = PIL.Image.open(f)\n        print(c, f, image.size)\n    print(f\"TOT:{tot}\")\n    \nexplore_image_on_directory(\"./\")","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:03.488475Z","iopub.execute_input":"2021-08-06T08:23:03.4888Z","iopub.status.idle":"2021-08-06T08:23:03.883059Z","shell.execute_reply.started":"2021-08-06T08:23:03.488765Z","shell.execute_reply":"2021-08-06T08:23:03.88219Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"TRAIN_PATH = os.path.join(WORKING_DIR, 'train')\nTEST_PATH = os.path.join(WORKING_DIR, 'test1')","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:03.884895Z","iopub.execute_input":"2021-08-06T08:23:03.885304Z","iopub.status.idle":"2021-08-06T08:23:03.890935Z","shell.execute_reply.started":"2021-08-06T08:23:03.885263Z","shell.execute_reply":"2021-08-06T08:23:03.88981Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"### We create a Dataframe starting from directory structure with image name and its related category class","metadata":{}},{"cell_type":"code","source":"# creating Dataframe for training: category is on filename\ntrain_filenames = os.listdir(TRAIN_PATH)\ntrain_categories = [ f.split('.')[0] for f in train_filenames ]\n\ntrain_df = pd.DataFrame({\n    'image': train_filenames,\n    'class': train_categories})\n\ntrain_df.head()","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:03.892547Z","iopub.execute_input":"2021-08-06T08:23:03.892934Z","iopub.status.idle":"2021-08-06T08:23:03.944584Z","shell.execute_reply.started":"2021-08-06T08:23:03.892895Z","shell.execute_reply":"2021-08-06T08:23:03.943606Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"### We show 30 sample images for each class 'cat' and 'dog'","metadata":{}},{"cell_type":"code","source":"# show the first images of cats and dogs\ndef show_images(df, images_path):\n    fig, axs = plt.subplots(2, 15, figsize=(30, 4))\n    for i in range(30):\n        ax = axs[i // 15, i % 15]\n        ax.set_axis_off()\n        if i<len(df['image']):            \n            image = PIL.Image.open(os.path.join(images_path, df['image'].iloc[i]))\n            ax.set_title(df['class'].iloc[i])\n            ax.imshow(image)\n        \ncats_df = train_df.loc[train_df['class'] == 'cat']\ndogs_df = train_df.loc[train_df['class'] == 'dog']\nshow_images(cats_df, TRAIN_PATH)\nshow_images(dogs_df, TRAIN_PATH)\n","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:03.94601Z","iopub.execute_input":"2021-08-06T08:23:03.946385Z","iopub.status.idle":"2021-08-06T08:23:08.695628Z","shell.execute_reply.started":"2021-08-06T08:23:03.94635Z","shell.execute_reply":"2021-08-06T08:23:08.691292Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"BATCH_SIZE = 150\nIMG_WIDTH = 160\nIMG_HEIGHT = 160","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:08.697328Z","iopub.execute_input":"2021-08-06T08:23:08.697945Z","iopub.status.idle":"2021-08-06T08:23:08.702259Z","shell.execute_reply.started":"2021-08-06T08:23:08.697903Z","shell.execute_reply":"2021-08-06T08:23:08.701101Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"# Data Pre-processing with Augmentation\n## What is the Augmentation technique?\n\nWhen you do not have too much data or you want to avoid overfitting, augmentation techniques in image preprocessing are used by resizing, flipping, zooming, size shifting, rotating etc.","metadata":{}},{"cell_type":"code","source":"rescale = tf.keras.layers.experimental.preprocessing.Rescaling(1./127.5, offset= -1)\n# rescaling on MobileV2 with preprocess_input\n# Add our data-augmentation parameters to ImageDataGenerator\nfrom tensorflow.keras.preprocessing.image import ImageDataGenerator\ntrain_datagen = ImageDataGenerator(validation_split=0.2,\n                                   rotation_range = 40,\n                                   width_shift_range = 0.2,\n                                   height_shift_range = 0.2,\n                                   shear_range = 0.2,\n                                   zoom_range = 0.2,\n                                   horizontal_flip = True\n                                   )\n\n# rescaling on MobileV2 with preprocess_input\n# Note that the validation data should not be augmented!\ntest_datagen = ImageDataGenerator(validation_split=0.2)\n\n# Flow training images in batches of BATCH_SIZE using train_datagen generator\ntrain_generator = train_datagen.flow_from_dataframe(train_df,\n                                                    directory=TRAIN_PATH,\n                                                    x_col=\"image\",\n                                                    y_col=\"class\",\n                                                    subset=\"training\",\n                                                    batch_size = BATCH_SIZE,\n                                                    class_mode = 'binary', \n                                                    target_size = (IMG_WIDTH, IMG_HEIGHT)\n                                                   )\n\n# Flow validation images in batches of BATCH_SIZE using test_datagen generator\nvalidation_generator =  test_datagen.flow_from_dataframe(train_df,\n                                                         directory=TRAIN_PATH,\n                                                         x_col=\"image\",\n                                                         y_col=\"class\",\n                                                         subset=\"validation\",\n                                                         batch_size = BATCH_SIZE,\n                                                         class_mode = 'binary', \n                                                         target_size = (IMG_WIDTH, IMG_HEIGHT)\n                                                        )","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:08.705848Z","iopub.execute_input":"2021-08-06T08:23:08.706576Z","iopub.status.idle":"2021-08-06T08:23:09.19375Z","shell.execute_reply.started":"2021-08-06T08:23:08.706531Z","shell.execute_reply":"2021-08-06T08:23:09.192909Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"# Transfer learning from MobileNet model\nWe will use Transfer learning starting from MobileNet model trained from ImageNet dataset","metadata":{}},{"cell_type":"code","source":"# Create the base model from the pre-trained model MobileNet V2 (feature extraction without TOP layers)\nbase_model = tf.keras.applications.MobileNetV2(input_shape=(IMG_WIDTH, IMG_HEIGHT, 3),\n                                               include_top=False,\n                                               weights='imagenet')\n\n# first pass of transfer learning with no trainable layers\nbase_model.trainable = False\nbase_model.summary()","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:09.195668Z","iopub.execute_input":"2021-08-06T08:23:09.196006Z","iopub.status.idle":"2021-08-06T08:23:12.669508Z","shell.execute_reply.started":"2021-08-06T08:23:09.195969Z","shell.execute_reply":"2021-08-06T08:23:12.660387Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"# Our Model\nWe add a Dropout and Binary dense final layer to the previous MobileNet based model ","metadata":{}},{"cell_type":"code","source":"# Our model definition with our Dropout and Dense TOP layers\ninputs = tf.keras.Input(shape=(IMG_WIDTH, IMG_HEIGHT, 3))\nx = tf.keras.applications.mobilenet_v2.preprocess_input(inputs)\nx = base_model(x, training=False)\nx = tf.keras.layers.GlobalAveragePooling2D()(x)\nx = tf.keras.layers.Dropout(0.2)(x)\noutputs = tf.keras.layers.Dense(1)(x)\nmodel = tf.keras.Model(inputs, outputs)","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:12.671096Z","iopub.execute_input":"2021-08-06T08:23:12.671586Z","iopub.status.idle":"2021-08-06T08:23:13.032513Z","shell.execute_reply.started":"2021-08-06T08:23:12.671534Z","shell.execute_reply":"2021-08-06T08:23:13.031552Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"### Note:\n* we use from_logits=True when end layer is not a probabilistic layer like sigmoid (for binary out) or softmax (for multiclass out)","metadata":{}},{"cell_type":"code","source":"base_learning_rate = 0.0001\nmodel.compile(optimizer=tf.keras.optimizers.RMSprop(lr=base_learning_rate),\n              loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),\n              metrics=['accuracy'])","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:13.033746Z","iopub.execute_input":"2021-08-06T08:23:13.034081Z","iopub.status.idle":"2021-08-06T08:23:13.055396Z","shell.execute_reply.started":"2021-08-06T08:23:13.03405Z","shell.execute_reply":"2021-08-06T08:23:13.054337Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"# Model summary","metadata":{}},{"cell_type":"code","source":"model.summary()\nlen(model.trainable_variables)","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:13.056754Z","iopub.execute_input":"2021-08-06T08:23:13.057141Z","iopub.status.idle":"2021-08-06T08:23:13.078538Z","shell.execute_reply.started":"2021-08-06T08:23:13.057101Z","shell.execute_reply":"2021-08-06T08:23:13.077634Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# only for test tf version\ntf.test.gpu_device_name()\ntf.__version__","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:23:13.079972Z","iopub.execute_input":"2021-08-06T08:23:13.080318Z","iopub.status.idle":"2021-08-06T08:23:13.09607Z","shell.execute_reply.started":"2021-08-06T08:23:13.080284Z","shell.execute_reply":"2021-08-06T08:23:13.095249Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"# Training the final part of the Model freezing the pre-trained layers\n##### In this first phase we are going to train only our last layers keeping fixed the base pre-trained model with **training=False**  ","metadata":{}},{"cell_type":"code","source":"# training\ninitial_epochs = 10\nhistory = model.fit(train_generator,\n                    epochs=initial_epochs,\n                    validation_data=validation_generator,\n                    batch_size=BATCH_SIZE)","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:40:00.182985Z","iopub.execute_input":"2021-08-06T08:40:00.183391Z","iopub.status.idle":"2021-08-06T08:43:04.568457Z","shell.execute_reply.started":"2021-08-06T08:40:00.183356Z","shell.execute_reply":"2021-08-06T08:43:04.566598Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"### Showing performances between Train and Validation data (Learning Curve)\n### Note:\n* It allows you to compare the metric trend (loss or accuracy) between validation set and training set according to the epochs in order to identify when you start generating overfitting","metadata":{}},{"cell_type":"code","source":"# show loss and accuracy\ndef show_loss_accuracy(history):\n  acc = history.history['accuracy']\n  val_acc = history.history['val_accuracy']\n  loss = history.history['loss']\n  val_loss = history.history['val_loss']\n\n  epochs_range = range(len(acc))\n\n  plt.figure(figsize=(20, 5))\n  plt.subplot(1, 2, 1)\n  plt.plot(epochs_range, acc, label='Training Accuracy')\n  plt.plot(epochs_range, val_acc, label='Validation Accuracy')\n  plt.legend(loc='lower right')\n  plt.title('Training and Validation Accuracy')\n  plt.subplot(1, 2, 2)\n  plt.plot(epochs_range, loss, label='Training Loss')\n  plt.plot(epochs_range, val_loss, label='Validation Loss')\n  plt.legend(loc='upper right')\n  plt.title('Training and Validation Loss')\n  plt.show()\n\nshow_loss_accuracy(history)","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:36:56.662855Z","iopub.execute_input":"2021-08-06T08:36:56.663239Z","iopub.status.idle":"2021-08-06T08:36:56.694749Z","shell.execute_reply.started":"2021-08-06T08:36:56.663192Z","shell.execute_reply":"2021-08-06T08:36:56.692782Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"# Fine-tuning phase\n##### In this final phase we are going to remove the constraint of freezed pre-trained layers (trainable=True) and re-training all again with a very low learning-rate.  \n\nThis allows for significant improvements by incrementally adapting characteristics to new data.*","metadata":{}},{"cell_type":"code","source":"# fine tuning\nbase_model.trainable = True\n# Let's take a look to see how many layers are in the base model\nprint(\"Number of layers in the base model: \", len(base_model.layers))\n\n# Fine-tune from this layer onwards\nfine_tune_at = 100\n\n# Freeze all the layers before the `fine_tune_at` layer\nfor layer in base_model.layers[:fine_tune_at]:\n  layer.trainable =  False\n","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:37:11.864264Z","iopub.execute_input":"2021-08-06T08:37:11.864587Z","iopub.status.idle":"2021-08-06T08:37:11.88062Z","shell.execute_reply.started":"2021-08-06T08:37:11.864559Z","shell.execute_reply":"2021-08-06T08:37:11.878213Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# fine tuning with low learning_rate 1/10 from base\nmodel.compile(loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),\n              optimizer = tf.keras.optimizers.RMSprop(learning_rate=base_learning_rate/10),\n              metrics=['accuracy'])\nlen(model.trainable_variables)","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:36:52.853417Z","iopub.status.idle":"2021-08-06T08:36:52.854171Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# running fine tuning\nFINE_TUNING = True\nif FINE_TUNING:\n    fine_tune_epochs = 10\n    total_epochs =  initial_epochs + fine_tune_epochs\n\n    # fine tuning training \n    history_fine = model.fit(train_generator,\n                             batch_size=BATCH_SIZE,\n                             epochs=total_epochs,\n                             initial_epoch=history.epoch[-1],\n                             validation_data=validation_generator)","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:36:52.855452Z","iopub.status.idle":"2021-08-06T08:36:52.856137Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"# Model Validation\n### Learning Curve of the Fine-Tuned Model (validation-accuracy from 0.97 to 0.98+)","metadata":{}},{"cell_type":"code","source":"if FINE_TUNING:\n    # show complete accuracy and loss\n    history.history['accuracy'] += history_fine.history['accuracy']\n    history.history['val_accuracy'] += history_fine.history['val_accuracy']\n    history.history['loss'] += history_fine.history['loss']\n    history.history['val_loss'] += history_fine.history['val_loss']\n\n    show_loss_accuracy(history)","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:36:52.857281Z","iopub.status.idle":"2021-08-06T08:36:52.85796Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"# Model Prediction\n### Predictions for Test set and Submission","metadata":{}},{"cell_type":"code","source":"# building test dataset\ntest_files = os.listdir(TEST_PATH)\ntest_df = pd.DataFrame({'image':test_files})\n\ntest_datagen = ImageDataGenerator()\n# NB. shuffle disabled on test set in order to join predictions with test_df\ntest_dataset = test_datagen.flow_from_dataframe(\n    test_df, \n    directory=TEST_PATH, \n    x_col='image',\n    y_col=None,\n    class_mode=None,\n    batch_size = BATCH_SIZE,\n    target_size = (IMG_WIDTH, IMG_HEIGHT),\n    shuffle=False\n)\n\ntest_df.head()","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:46:56.23512Z","iopub.execute_input":"2021-08-06T08:46:56.23548Z","iopub.status.idle":"2021-08-06T08:46:56.36126Z","shell.execute_reply.started":"2021-08-06T08:46:56.235446Z","shell.execute_reply":"2021-08-06T08:46:56.360186Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# class map => { logit : class name }\nclass_map = { v: k for k, v in train_generator.class_indices.items() }\n# predict test images set\npredictions = model.predict(test_dataset)\n# apply a sigmoid because our model returns logits\npredictions = tf.nn.sigmoid(predictions)\npredictions = tf.where(predictions < 0.5, 0, 1)\ntest_df['label'] = predictions.numpy()\ntest_df['class'] = test_df['label'].map(lambda x: class_map[x])","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:54:32.695761Z","iopub.execute_input":"2021-08-06T08:54:32.696209Z","iopub.status.idle":"2021-08-06T08:55:11.322289Z","shell.execute_reply.started":"2021-08-06T08:54:32.696158Z","shell.execute_reply":"2021-08-06T08:55:11.321429Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"# Results and Submission","metadata":{}},{"cell_type":"code","source":"# show random test images predictions\nshow_images(test_df.sample(frac=1).head(30), TEST_PATH)","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:56:06.664589Z","iopub.execute_input":"2021-08-06T08:56:06.664916Z","iopub.status.idle":"2021-08-06T08:56:08.917424Z","shell.execute_reply.started":"2021-08-06T08:56:06.664886Z","shell.execute_reply":"2021-08-06T08:56:08.916225Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# submission csv creation\nsubmission_df = test_df.copy()\nsubmission_df['id'] = submission_df['image'].str.split('.').str[0]\nsubmission_df['label'] = submission_df['class']\nsubmission_df.drop(['image', 'class'], axis=1, inplace=True)\nsubmission_df.to_csv('submission.csv', index=False)","metadata":{"execution":{"iopub.status.busy":"2021-08-06T08:36:52.864866Z","iopub.status.idle":"2021-08-06T08:36:52.865569Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"-------------------------------------------------------------------------\n<div style=\"text-align: center;\">\n    <h3>Thanks for watching till the end ;)  </h3>\n    <h2>If you liked this notebook, upvote it ! </h2>\n<img src=\"https://i.postimg.cc/SsChkSJv/upvote.png\" width=\"70\"/>\n</div>","metadata":{}}]}