{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "# Text classification task\n",
        "\n",
        "In this module, we will start with a simple text classification task based on the **[AG_NEWS](http://www.di.unipi.it/~gulli/AG_corpus_of_news_articles.html)** dataset: we'll classify news headlines into one of 4 categories: World, Sports, Business and Sci/Tech. \n",
        "\n",
        "## The Dataset\n",
        "\n",
        "To load the dataset, we will use the **[TensorFlow Datasets](https://www.tensorflow.org/datasets)** API."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 1,
      "metadata": {},
      "outputs": [],
      "source": [
        "import tensorflow as tf\n",
        "from tensorflow import keras\n",
        "import tensorflow_datasets as tfds\n",
        "\n",
        "# In this tutorial, we will be training a lot of models. In order to use GPU memory cautiously,\n",
        "# we will set tensorflow option to grow GPU memory allocation when required.\n",
        "physical_devices = tf.config.list_physical_devices('GPU') \n",
        "if len(physical_devices)>0:\n",
        "    tf.config.experimental.set_memory_growth(physical_devices[0], True)\n",
        "\n",
        "dataset = tfds.load('ag_news_subset')"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "We can now access the training and test portions of the dataset by using `dataset['train']` and `dataset['test']` respectively:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 3,
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Length of train dataset = 120000\n",
            "Length of test dataset = 7600\n"
          ]
        }
      ],
      "source": [
        "ds_train = dataset['train']\n",
        "ds_test = dataset['test']\n",
        "\n",
        "print(f\"Length of train dataset = {len(ds_train)}\")\n",
        "print(f\"Length of test dataset = {len(ds_test)}\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Let's print out the first 10 new headlines from our dataset: "
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 4,
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "3 (Sci/Tech) -> b'AMD Debuts Dual-Core Opteron Processor' b'AMD #39;s new dual-core Opteron chip is designed mainly for corporate computing applications, including databases, Web services, and financial transactions.'\n",
            "1 (Sports) -> b\"Wood's Suspension Upheld (Reuters)\" b'Reuters - Major League Baseball\\\\Monday announced a decision on the appeal filed by Chicago Cubs\\\\pitcher Kerry Wood regarding a suspension stemming from an\\\\incident earlier this season.'\n",
            "2 (Business) -> b'Bush reform may have blue states seeing red' b'President Bush #39;s  quot;revenue-neutral quot; tax reform needs losers to balance its winners, and people claiming the federal deduction for state and local taxes may be in administration planners #39; sights, news reports say.'\n",
            "3 (Sci/Tech) -> b\"'Halt science decline in schools'\" b'Britain will run out of leading scientists unless science education is improved, says Professor Colin Pillinger.'\n",
            "1 (Sports) -> b'Gerrard leaves practice' b'London, England (Sports Network) - England midfielder Steven Gerrard injured his groin late in Thursday #39;s training session, but is hopeful he will be ready for Saturday #39;s World Cup qualifier against Austria.'\n"
          ]
        }
      ],
      "source": [
        "classes = ['World', 'Sports', 'Business', 'Sci/Tech']\n",
        "\n",
        "for i,x in zip(range(5),ds_train):\n",
        "    print(f\"{x['label']} ({classes[x['label']]}) -> {x['title']} {x['description']}\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Text vectorization\n",
        "\n",
        "Now we need to convert text into **numbers** that can be represented as tensors. If we want word-level representation, we need to do two things:\n",
        "\n",
        "* Use a **tokenizer** to split text into **tokens**.\n",
        "* Build a **vocabulary** of those tokens.\n",
        "\n",
        "### Limiting vocabulary size\n",
        "\n",
        "In the AG News dataset example, the vocabulary size is rather big, more than 100k words. Generally speaking, we don't need words that are rarely present in the text &mdash; only a few sentences will have them, and the model will not learn from them. Thus, it makes sense to limit the vocabulary size to a smaller number by passing an argument to the vectorizer constructor:\n",
        "\n",
        "Both of those steps can be handled using the **TextVectorization** layer. Let's instantiate the vectorizer object, and then call the `adapt` method to go through all text and build a vocabulary:\n",
        "\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 5,
      "metadata": {},
      "outputs": [],
      "source": [
        "vocab_size = 50000\n",
        "vectorizer = keras.layers.experimental.preprocessing.TextVectorization(max_tokens=vocab_size)\n",
        "vectorizer.adapt(ds_train.take(500).map(lambda x: x['title']+' '+x['description']))"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "> **Note** that we are using only subset of the whole dataset to build a vocabulary. We do it to speed up the execution time and not keep you waiting. However, we are taking the risk that some of the words from the whole dateset would not be included into the vocabulary, and will be ignored during training. Thus, using the whole vocabulary size and running through all dataset during `adapt` should increase the final accuracy, but not significantly.\n",
        "\n",
        "Now we can access the actual vocabulary:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 6,
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "['', '[UNK]', 'the', 'to', 'a', 'in', 'of', 'and', 'on', 'for']\n",
            "Length of vocabulary: 5335\n"
          ]
        }
      ],
      "source": [
        "vocab = vectorizer.get_vocabulary()\n",
        "vocab_size = len(vocab)\n",
        "print(vocab[:10])\n",
        "print(f\"Length of vocabulary: {vocab_size}\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Using the vectorizer, we can easily encode any text into a set of numbers:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 7,
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/plain": [
              "<tf.Tensor: shape=(7,), dtype=int64, numpy=array([ 112, 3695,    3,  304,   11, 1041,    1], dtype=int64)>"
            ]
          },
          "execution_count": 7,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "vectorizer('I love to play with my words')"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Bag-of-words text representation\n",
        "\n",
        "Because words represent meaning, sometimes we can figure out the meaning of a piece of text by just looking at the individual words, regardless of their order in the sentence. For example, when classifying news, words like *weather* and *snow* are likely to indicate *weather forecast*, while words like *stocks* and *dollar* would count towards *financial news*.\n",
        "\n",
        "**Bag-of-words** (BoW) vector representation is the most simple to understand traditional vector representation. Each word is linked to a vector index, and a vector element contains the number of occurrences of each word in a given document.\n",
        "\n",
        "![Image showing how a bag of words vector representation is represented in memory.](images/bag-of-words-example.png) \n",
        "\n",
        "> **Note**: You can also think of BoW as a sum of all one-hot-encoded vectors for individual words in the text.\n",
        "\n",
        "Below is an example of how to generate a bag-of-words representation using the Scikit Learn python library:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 8,
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/plain": [
              "array([[1, 1, 0, 2, 0, 0, 0, 0, 0]], dtype=int64)"
            ]
          },
          "execution_count": 8,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "from sklearn.feature_extraction.text import CountVectorizer\n",
        "sc_vectorizer = CountVectorizer()\n",
        "corpus = [\n",
        "        'I like hot dogs.',\n",
        "        'The dog ran fast.',\n",
        "        'Its hot outside.',\n",
        "    ]\n",
        "sc_vectorizer.fit_transform(corpus)\n",
        "sc_vectorizer.transform(['My dog likes hot dogs on a hot day.']).toarray()"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "We can also use the Keras vectorizer that we defined above, converting each word number into a one-hot encoding and adding all those vectors up:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 9,
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/plain": [
              "array([0., 5., 0., ..., 0., 0., 0.], dtype=float32)"
            ]
          },
          "execution_count": 9,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "def to_bow(text):\n",
        "    return tf.reduce_sum(tf.one_hot(vectorizer(text),vocab_size),axis=0)\n",
        "\n",
        "to_bow('My dog likes hot dogs on a hot day.').numpy()"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "> **Note**: You may be surprised that the result differs from the previous example. The reason is that in the Keras example the length of the vector corresponds to the vocabulary size, which was built from the whole AG News dataset, while in the Scikit Learn example we built the vocabulary from the sample text on the fly. \n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Training the BoW classifier\n",
        "\n",
        "Now that we have learned how to build the bag-of-words representation of our text, let's train a classifier that uses it. First, we need to convert our dataset to a bag-of-words representation. This can be achieved by using `map` function in the following way:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 11,
      "metadata": {},
      "outputs": [],
      "source": [
        "batch_size = 128\n",
        "\n",
        "ds_train_bow = ds_train.map(lambda x: (to_bow(x['title']+x['description']),x['label'])).batch(batch_size)\n",
        "ds_test_bow = ds_test.map(lambda x: (to_bow(x['title']+x['description']),x['label'])).batch(batch_size)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Now let's define a simple classifier neural network that contains one linear layer. The input size is `vocab_size`, and the output size corresponds to the number of classes (4). Because we're solving a classification task, the final activation function is **softmax**:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 12,
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "938/938 [==============================] - 66s 70ms/step - loss: 0.6144 - acc: 0.8427 - val_loss: 0.4416 - val_acc: 0.8697\n"
          ]
        },
        {
          "data": {
            "text/plain": [
              "<keras.callbacks.History at 0x20c70a947f0>"
            ]
          },
          "execution_count": 12,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "model = keras.models.Sequential([\n",
        "    keras.layers.Dense(4,activation='softmax',input_shape=(vocab_size,))\n",
        "])\n",
        "model.compile(loss='sparse_categorical_crossentropy',optimizer='adam',metrics=['acc'])\n",
        "model.fit(ds_train_bow,validation_data=ds_test_bow)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Since we have 4 classes, an accuracy of above 80% is a good result.\n",
        "\n",
        "## Training a classifier as one network\n",
        "\n",
        "Because the vectorizer is also a Keras layer, we can define a network that includes it, and train it end-to-end. This way we don't need to vectorize the dataset using `map`, we can just pass the original dataset to the input of the network.\n",
        "\n",
        "> **Note**: We would still have to apply maps to our dataset to convert fields from dictionaries (such as `title`, `description` and `label`) to tuples. However, when loading data from disk, we can build a dataset with the required structure in the first place."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 13,
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Model: \"model\"\n",
            "_________________________________________________________________\n",
            " Layer (type)                Output Shape              Param #   \n",
            "=================================================================\n",
            " input_1 (InputLayer)        [(None, 1)]               0         \n",
            "                                                                 \n",
            " text_vectorization (TextVec  (None, None)             0         \n",
            " torization)                                                     \n",
            "                                                                 \n",
            " tf.one_hot (TFOpLambda)     (None, None, 5335)        0         \n",
            "                                                                 \n",
            " tf.math.reduce_sum (TFOpLam  (None, 5335)             0         \n",
            " bda)                                                            \n",
            "                                                                 \n",
            " dense_2 (Dense)             (None, 4)                 21344     \n",
            "                                                                 \n",
            "=================================================================\n",
            "Total params: 21,344\n",
            "Trainable params: 21,344\n",
            "Non-trainable params: 0\n",
            "_________________________________________________________________\n",
            "938/938 [==============================] - 73s 77ms/step - loss: 0.6057 - acc: 0.8414 - val_loss: 0.4202 - val_acc: 0.8736\n"
          ]
        },
        {
          "data": {
            "text/plain": [
              "<keras.callbacks.History at 0x20c721521f0>"
            ]
          },
          "execution_count": 13,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "def extract_text(x):\n",
        "    return x['title']+' '+x['description']\n",
        "\n",
        "def tupelize(x):\n",
        "    return (extract_text(x),x['label'])\n",
        "\n",
        "inp = keras.Input(shape=(1,),dtype=tf.string)\n",
        "x = vectorizer(inp)\n",
        "x = tf.reduce_sum(tf.one_hot(x,vocab_size),axis=1)\n",
        "out = keras.layers.Dense(4,activation='softmax')(x)\n",
        "model = keras.models.Model(inp,out)\n",
        "model.summary()\n",
        "\n",
        "model.compile(loss='sparse_categorical_crossentropy',optimizer='adam',metrics=['acc'])\n",
        "model.fit(ds_train.map(tupelize).batch(batch_size),validation_data=ds_test.map(tupelize).batch(batch_size))\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Bigrams, trigrams and n-grams\n",
        "\n",
        "One limitation of the bag-of-words approach is that some words are part of multi-word expressions, for example, the word 'hot dog' has a completely different meaning from the words 'hot' and 'dog' in other contexts. If we represent the words 'hot' and 'dog' always using the same vectors, it can confuse our model.\n",
        "\n",
        "To address this, **n-gram representations** are often used in methods of document classification, where the frequency of each word, bi-word or tri-word is a useful feature for training classifiers. In bigram representations, for example, we will add all word pairs to the vocabulary, in addition to original words.\n",
        "\n",
        "Below is an example of how to generate a bigram bag of word representation using Scikit Learn:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 14,
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Vocabulary:\n",
            " {'i': 7, 'like': 11, 'hot': 4, 'dogs': 2, 'i like': 8, 'like hot': 12, 'hot dogs': 5, 'the': 16, 'dog': 0, 'ran': 14, 'fast': 3, 'the dog': 17, 'dog ran': 1, 'ran fast': 15, 'its': 9, 'outside': 13, 'its hot': 10, 'hot outside': 6}\n"
          ]
        },
        {
          "data": {
            "text/plain": [
              "array([[1, 0, 1, 0, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],\n",
              "      dtype=int64)"
            ]
          },
          "execution_count": 14,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "bigram_vectorizer = CountVectorizer(ngram_range=(1, 2), token_pattern=r'\\b\\w+\\b', min_df=1)\n",
        "corpus = [\n",
        "        'I like hot dogs.',\n",
        "        'The dog ran fast.',\n",
        "        'Its hot outside.',\n",
        "    ]\n",
        "bigram_vectorizer.fit_transform(corpus)\n",
        "print(\"Vocabulary:\\n\",bigram_vectorizer.vocabulary_)\n",
        "bigram_vectorizer.transform(['My dog likes hot dogs on a hot day.']).toarray()\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "The main drawback of the n-gram approach is that the vocabulary size starts to grow extremely fast. In practice, we need to combine the n-gram representation with a dimensionality reduction technique, such as *embeddings*, which we will discuss in the next unit.\n",
        "\n",
        "To use an n-gram representation in our **AG News** dataset, we need to pass the `ngrams` parameter to our `TextVectorization` constructor. The length of a bigram vocaculary is **significantly larger**, in our case it is more than 1.3 million tokens! Thus it makes sense to limit bigram tokens as well by some reasonable number.\n",
        "\n",
        "We could use the same code as above to train the classifier, however, it would be very memory-inefficient. In the next unit, we will train the bigram classifier using embeddings. In the meantime, you can experiment with bigram classifier training in this notebook and see if you can get higher accuracy."
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Automatically calculating BoW Vectors\n",
        "\n",
        "In the example above we calculated BoW vectors by hand by summing the one-hot encodings of individual words. However, the latest version of TensorFlow allows us to calculate BoW vectors automatically by passing the `output_mode='count` parameter to the vectorizer constructor. This makes defining and training our model significanly easier:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 15,
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Training vectorizer\n",
            "938/938 [==============================] - 7s 7ms/step - loss: 0.5929 - acc: 0.8486 - val_loss: 0.4168 - val_acc: 0.8772\n"
          ]
        },
        {
          "data": {
            "text/plain": [
              "<keras.callbacks.History at 0x20c725217c0>"
            ]
          },
          "execution_count": 15,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "model = keras.models.Sequential([\n",
        "    keras.layers.experimental.preprocessing.TextVectorization(max_tokens=vocab_size,output_mode='count'),\n",
        "    keras.layers.Dense(4,input_shape=(vocab_size,), activation='softmax')\n",
        "])\n",
        "print(\"Training vectorizer\")\n",
        "model.layers[0].adapt(ds_train.take(500).map(extract_text))\n",
        "model.compile(loss='sparse_categorical_crossentropy',optimizer='adam',metrics=['acc'])\n",
        "model.fit(ds_train.map(tupelize).batch(batch_size),validation_data=ds_test.map(tupelize).batch(batch_size))"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Term frequency - inverse document frequency (TF-IDF)\n",
        "\n",
        "In BoW representation, word occurrences are weighted using the same technique regardless of the word itself. However, it's clear that frequent words such as *a* and *in* are much less important for classification than specialized terms. In most NLP tasks some words are more relevant than others.\n",
        "\n",
        "**TF-IDF** stands for **term frequency - inverse document frequency**. It's a variation of bag-of-words, where instead of a binary 0/1 value indicating the appearance of a word in a document, a floating-point value is used, which is related to the frequency of the word occurrence in the corpus.\n",
        "\n",
        "More formally, the weight $w_{ij}$ of a word $i$ in the document $j$ is defined as:\n",
        "$$\n",
        "w_{ij} = tf_{ij}\\times\\log({N\\over df_i})\n",
        "$$\n",
        "where\n",
        "* $tf_{ij}$ is the number of occurrences of $i$ in $j$, i.e. the BoW value we have seen before\n",
        "* $N$ is the number of documents in the collection\n",
        "* $df_i$ is the number of documents containing the word $i$ in the whole collection\n",
        "\n",
        "The TF-IDF value $w_{ij}$ increases proportionally to the number of times a word appears in a document and is offset by the number of documents in the corpus that contains the word, which helps to adjust for the fact that some words appear more frequently than others. For example, if the word appears in *every* document in the collection, $df_i=N$, and $w_{ij}=0$, and those terms would be completely disregarded.\n",
        "\n",
        "You can easily create TF-IDF vectorization of text using Scikit Learn:"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 16,
      "metadata": {},
      "outputs": [
        {
          "data": {
            "text/plain": [
              "array([[0.43381609, 0.        , 0.43381609, 0.        , 0.65985664,\n",
              "        0.43381609, 0.        , 0.        , 0.        , 0.        ,\n",
              "        0.        , 0.        , 0.        , 0.        , 0.        ,\n",
              "        0.        ]])"
            ]
          },
          "execution_count": 16,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "from sklearn.feature_extraction.text import TfidfVectorizer\n",
        "vectorizer = TfidfVectorizer(ngram_range=(1,2))\n",
        "vectorizer.fit_transform(corpus)\n",
        "vectorizer.transform(['My dog likes hot dogs on a hot day.']).toarray()"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "In Keras, the `TextVectorization` layer can automatically compute TF-IDF frequencies by passing the `output_mode='tf-idf'` parameter. Let's repeat the code we used above to see if using TF-IDF increases accuracy: "
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 17,
      "metadata": {},
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Training vectorizer\n",
            "938/938 [==============================] - 12s 12ms/step - loss: 0.4197 - acc: 0.8662 - val_loss: 0.3432 - val_acc: 0.8849\n"
          ]
        },
        {
          "data": {
            "text/plain": [
              "<keras.callbacks.History at 0x20c729dfd30>"
            ]
          },
          "execution_count": 17,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "model = keras.models.Sequential([\n",
        "    keras.layers.experimental.preprocessing.TextVectorization(max_tokens=vocab_size,output_mode='tf-idf'),\n",
        "    keras.layers.Dense(4,input_shape=(vocab_size,), activation='softmax')\n",
        "])\n",
        "print(\"Training vectorizer\")\n",
        "model.layers[0].adapt(ds_train.take(500).map(extract_text))\n",
        "model.compile(loss='sparse_categorical_crossentropy',optimizer='adam',metrics=['acc'])\n",
        "model.fit(ds_train.map(tupelize).batch(batch_size),validation_data=ds_test.map(tupelize).batch(batch_size))"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Conclusion \n",
        "\n",
        "Even though TF-IDF representations provide frequency weights to different words, they are unable to represent meaning or order. As the famous linguist J. R. Firth said in 1935, \"The complete meaning of a word is always contextual, and no study of meaning apart from context can be taken seriously.\" We will learn how to capture contextual information from text using language modeling later in the course."
      ]
    }
  ],
  "metadata": {
    "interpreter": {
      "hash": "0cb620c6d4b9f7a635928804c26cf22403d89d98d79684e4529119355ee6d5a5"
    },
    "kernel_info": {
      "name": "conda-env-py37_tensorflow-py"
    },
    "kernelspec": {
      "display_name": "py37_tensorflow",
      "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.8.12"
    },
    "nteract": {
      "version": "nteract-front-end@1.0.0"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 4
}
