{ "nbformat": 4, "nbformat_minor": 0, "metadata": { "accelerator": "GPU", "colab": { "name": "pix2pix_eager.ipynb", "version": "0.3.2", "provenance": [], "private_outputs": true, "collapsed_sections": [], "include_colab_link": true }, "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" } }, "cells": [ { "cell_type": "markdown", "metadata": { "id": "view-in-github", "colab_type": "text" }, "source": [ "\"Open" ] }, { "cell_type": "markdown", "metadata": { "colab_type": "text", "id": "0TD5ZrvEMbhZ" }, "source": [ "##### Copyright 2018 The TensorFlow Authors.\n", "\n", "Licensed under the Apache License, Version 2.0 (the \"License\").\n", "\n", "# Pix2Pix: An example with tf.keras and eager\n", "\n" ] }, { "cell_type": "markdown", "metadata": { "colab_type": "text", "id": "e1_Y75QXJS6h" }, "source": [ "## Import TensorFlow and enable eager execution" ] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "YfIk2es3hJEd", "colab": {} }, "source": [ "# Import TensorFlow >= 1.10 and enable eager execution\n", "import tensorflow as tf\n", "tf.enable_eager_execution()\n", "\n", "import os\n", "import time\n", "import numpy as np\n", "import matplotlib.pyplot as plt\n", "import PIL\n", "from IPython.display import clear_output, display" ], "execution_count": 0, "outputs": [] }, { "cell_type": "markdown", "metadata": { "colab_type": "text", "id": "iYn4MdZnKCey" }, "source": [ "## Load the dataset\n", "\n", "You can download this dataset and similar datasets from [here](https://people.eecs.berkeley.edu/~tinghuiz/projects/pix2pix/datasets). As mentioned in the [paper](https://arxiv.org/abs/1611.07004) we apply random jittering and mirroring to the training dataset.\n", "* In random jittering, the image is resized to `286 x 286` and then randomly cropped to `256 x 256`\n", "* In random mirroring, the image is randomly flipped horizontally i.e left to right." ] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "Kn-k8kTXuAlv", "colab": {} }, "source": [ "path_to_zip = tf.keras.utils.get_file('facades.tar.gz',\n", " cache_subdir=os.path.abspath('.'),\n", " origin='https://people.eecs.berkeley.edu/~tinghuiz/projects/pix2pix/datasets/facades.tar.gz', \n", " extract=True)\n", "\n", "PATH = os.path.join(os.path.dirname(path_to_zip), 'facades/')" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "2CbTEt448b4R", "colab": {} }, "source": [ "BUFFER_SIZE = 400\n", "BATCH_SIZE = 1\n", "IMG_WIDTH = 256\n", "IMG_HEIGHT = 256" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "tyaP4hLJ8b4W", "colab": {} }, "source": [ "def load_image(image_file, is_train):\n", " image = tf.read_file(image_file)\n", " image = tf.image.decode_jpeg(image)\n", "\n", " w = tf.shape(image)[1]\n", "\n", " w = w // 2\n", " real_image = image[:, :w, :]\n", " input_image = image[:, w:, :]\n", "\n", " input_image = tf.cast(input_image, tf.float32)\n", " real_image = tf.cast(real_image, tf.float32)\n", "\n", " if is_train:\n", " # random jittering\n", " \n", " # resizing to 286 x 286 x 3\n", " input_image = tf.image.resize_images(input_image, [286, 286], \n", " align_corners=True, \n", " method=tf.image.ResizeMethod.NEAREST_NEIGHBOR)\n", " real_image = tf.image.resize_images(real_image, [286, 286], \n", " align_corners=True, \n", " method=tf.image.ResizeMethod.NEAREST_NEIGHBOR)\n", " \n", " # randomly cropping to 256 x 256 x 3\n", " stacked_image = tf.stack([input_image, real_image], axis=0)\n", " cropped_image = tf.random_crop(stacked_image, size=[2, IMG_HEIGHT, IMG_WIDTH, 3])\n", " input_image, real_image = cropped_image[0], cropped_image[1]\n", "\n", " if np.random.random() > 0.5:\n", " # random mirroring\n", " input_image = tf.image.flip_left_right(input_image)\n", " real_image = tf.image.flip_left_right(real_image)\n", " else:\n", " input_image = tf.image.resize_images(input_image, size=[IMG_HEIGHT, IMG_WIDTH], \n", " align_corners=True, method=2)\n", " real_image = tf.image.resize_images(real_image, size=[IMG_HEIGHT, IMG_WIDTH], \n", " align_corners=True, method=2)\n", " \n", " # normalizing the images to [-1, 1]\n", " input_image = (input_image / 127.5) - 1\n", " real_image = (real_image / 127.5) - 1\n", "\n", " return input_image, real_image" ], "execution_count": 0, "outputs": [] }, { "cell_type": "markdown", "metadata": { "colab_type": "text", "id": "PIGN6ouoQxt3" }, "source": [ "## Use tf.data to create batches, map(do preprocessing) and shuffle the dataset" ] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "SQHmYSmk8b4b", "colab": {} }, "source": [ "train_dataset = tf.data.Dataset.list_files(PATH+'train/*.jpg')\n", "train_dataset = train_dataset.shuffle(BUFFER_SIZE)\n", "train_dataset = train_dataset.map(lambda x: load_image(x, True))\n", "train_dataset = train_dataset.batch(1)" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "MS9J0yA58b4g", "colab": {} }, "source": [ "test_dataset = tf.data.Dataset.list_files(PATH+'test/*.jpg')\n", "test_dataset = test_dataset.map(lambda x: load_image(x, False))\n", "test_dataset = test_dataset.batch(1)" ], "execution_count": 0, "outputs": [] }, { "cell_type": "markdown", "metadata": { "colab_type": "text", "id": "THY-sZMiQ4UV" }, "source": [ "## Write the generator and discriminator models\n", "\n", "* **Generator** \n", " * The architecture of generator is a modified U-Net.\n", " * Each block in the encoder is (Conv -> Batchnorm -> Leaky ReLU)\n", " * Each block in the decoder is (Transposed Conv -> Batchnorm -> Dropout(applied to the first 3 blocks) -> ReLU)\n", " * There are skip connections between the encoder and decoder (as in U-Net).\n", " \n", "* **Discriminator**\n", " * The Discriminator is a PatchGAN.\n", " * Each block in the discriminator is (Conv -> BatchNorm -> Leaky ReLU)\n", " * The shape of the output after the last layer is (batch_size, 30, 30, 1)\n", " * Each 30x30 patch of the output classifies a 70x70 portion of the input image (such an architecture is called a PatchGAN).\n", " * Discriminator receives 2 inputs.\n", " * Input image and the target image, which it should classify as real.\n", " * Input image and the generated image (output of generator), which it should classify as fake. \n", " * We concatenate these 2 inputs together in the code (`tf.concat([inp, tar], axis=-1)`)\n", "\n", "* Shape of the input travelling through the generator and the discriminator is in the comments in the code.\n", "\n", "To learn more about the architecture and the hyperparameters you can refer the [paper](https://arxiv.org/abs/1611.07004).\n", " " ] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "tqqvWxlw8b4l", "colab": {} }, "source": [ "OUTPUT_CHANNELS = 3" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "lFPI4Nu-8b4q", "colab": {} }, "source": [ "class Downsample(tf.keras.Model):\n", " \n", " def __init__(self, filters, size, apply_batchnorm=True):\n", " super(Downsample, self).__init__()\n", " self.apply_batchnorm = apply_batchnorm\n", " initializer = tf.random_normal_initializer(0., 0.02)\n", "\n", " self.conv1 = tf.keras.layers.Conv2D(filters, \n", " (size, size), \n", " strides=2, \n", " padding='same',\n", " kernel_initializer=initializer,\n", " use_bias=False)\n", " if self.apply_batchnorm:\n", " self.batchnorm = tf.keras.layers.BatchNormalization()\n", " \n", " def call(self, x, training):\n", " x = self.conv1(x)\n", " if self.apply_batchnorm:\n", " x = self.batchnorm(x, training=training)\n", " x = tf.nn.leaky_relu(x)\n", " return x \n", "\n", "\n", "class Upsample(tf.keras.Model):\n", " \n", " def __init__(self, filters, size, apply_dropout=False):\n", " super(Upsample, self).__init__()\n", " self.apply_dropout = apply_dropout\n", " initializer = tf.random_normal_initializer(0., 0.02)\n", "\n", " self.up_conv = tf.keras.layers.Conv2DTranspose(filters, \n", " (size, size), \n", " strides=2, \n", " padding='same',\n", " kernel_initializer=initializer,\n", " use_bias=False)\n", " self.batchnorm = tf.keras.layers.BatchNormalization()\n", " if self.apply_dropout:\n", " self.dropout = tf.keras.layers.Dropout(0.5)\n", "\n", " def call(self, x1, x2, training):\n", " x = self.up_conv(x1)\n", " x = self.batchnorm(x, training=training)\n", " if self.apply_dropout:\n", " x = self.dropout(x, training=training)\n", " x = tf.nn.relu(x)\n", " x = tf.concat([x, x2], axis=-1)\n", " return x\n", "\n", "\n", "class Generator(tf.keras.Model):\n", " \n", " def __init__(self):\n", " super(Generator, self).__init__()\n", " initializer = tf.random_normal_initializer(0., 0.02)\n", " \n", " self.down1 = Downsample(64, 4, apply_batchnorm=False)\n", " self.down2 = Downsample(128, 4)\n", " self.down3 = Downsample(256, 4)\n", " self.down4 = Downsample(512, 4)\n", " self.down5 = Downsample(512, 4)\n", " self.down6 = Downsample(512, 4)\n", " self.down7 = Downsample(512, 4)\n", " self.down8 = Downsample(512, 4)\n", "\n", " self.up1 = Upsample(512, 4, apply_dropout=True)\n", " self.up2 = Upsample(512, 4, apply_dropout=True)\n", " self.up3 = Upsample(512, 4, apply_dropout=True)\n", " self.up4 = Upsample(512, 4)\n", " self.up5 = Upsample(256, 4)\n", " self.up6 = Upsample(128, 4)\n", " self.up7 = Upsample(64, 4)\n", "\n", " self.last = tf.keras.layers.Conv2DTranspose(OUTPUT_CHANNELS, \n", " (4, 4), \n", " strides=2, \n", " padding='same',\n", " kernel_initializer=initializer)\n", " \n", " @tf.contrib.eager.defun\n", " def call(self, x, training):\n", " # x shape == (bs, 256, 256, 3) \n", " x1 = self.down1(x, training=training) # (bs, 128, 128, 64)\n", " x2 = self.down2(x1, training=training) # (bs, 64, 64, 128)\n", " x3 = self.down3(x2, training=training) # (bs, 32, 32, 256)\n", " x4 = self.down4(x3, training=training) # (bs, 16, 16, 512)\n", " x5 = self.down5(x4, training=training) # (bs, 8, 8, 512)\n", " x6 = self.down6(x5, training=training) # (bs, 4, 4, 512)\n", " x7 = self.down7(x6, training=training) # (bs, 2, 2, 512)\n", " x8 = self.down8(x7, training=training) # (bs, 1, 1, 512)\n", "\n", " x9 = self.up1(x8, x7, training=training) # (bs, 2, 2, 1024)\n", " x10 = self.up2(x9, x6, training=training) # (bs, 4, 4, 1024)\n", " x11 = self.up3(x10, x5, training=training) # (bs, 8, 8, 1024)\n", " x12 = self.up4(x11, x4, training=training) # (bs, 16, 16, 1024)\n", " x13 = self.up5(x12, x3, training=training) # (bs, 32, 32, 512)\n", " x14 = self.up6(x13, x2, training=training) # (bs, 64, 64, 256)\n", " x15 = self.up7(x14, x1, training=training) # (bs, 128, 128, 128)\n", "\n", " x16 = self.last(x15) # (bs, 256, 256, 3)\n", " x16 = tf.nn.tanh(x16)\n", "\n", " return x16" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "ll6aNeQx8b4v", "colab": {} }, "source": [ "class DiscDownsample(tf.keras.Model):\n", " \n", " def __init__(self, filters, size, apply_batchnorm=True):\n", " super(DiscDownsample, self).__init__()\n", " self.apply_batchnorm = apply_batchnorm\n", " initializer = tf.random_normal_initializer(0., 0.02)\n", "\n", " self.conv1 = tf.keras.layers.Conv2D(filters, \n", " (size, size), \n", " strides=2, \n", " padding='same',\n", " kernel_initializer=initializer,\n", " use_bias=False)\n", " if self.apply_batchnorm:\n", " self.batchnorm = tf.keras.layers.BatchNormalization()\n", " \n", " def call(self, x, training):\n", " x = self.conv1(x)\n", " if self.apply_batchnorm:\n", " x = self.batchnorm(x, training=training)\n", " x = tf.nn.leaky_relu(x)\n", " return x \n", "\n", "class Discriminator(tf.keras.Model):\n", " \n", " def __init__(self):\n", " super(Discriminator, self).__init__()\n", " initializer = tf.random_normal_initializer(0., 0.02)\n", " \n", " self.down1 = DiscDownsample(64, 4, False)\n", " self.down2 = DiscDownsample(128, 4)\n", " self.down3 = DiscDownsample(256, 4)\n", " \n", " # we are zero padding here with 1 because we need our shape to \n", " # go from (batch_size, 32, 32, 256) to (batch_size, 31, 31, 512)\n", " self.zero_pad1 = tf.keras.layers.ZeroPadding2D()\n", " self.conv = tf.keras.layers.Conv2D(512, \n", " (4, 4), \n", " strides=1, \n", " kernel_initializer=initializer, \n", " use_bias=False)\n", " self.batchnorm1 = tf.keras.layers.BatchNormalization()\n", " \n", " # shape change from (batch_size, 31, 31, 512) to (batch_size, 30, 30, 1)\n", " self.zero_pad2 = tf.keras.layers.ZeroPadding2D()\n", " self.last = tf.keras.layers.Conv2D(1, \n", " (4, 4), \n", " strides=1,\n", " kernel_initializer=initializer)\n", " \n", " @tf.contrib.eager.defun\n", " def call(self, inp, tar, training):\n", " # concatenating the input and the target\n", " x = tf.concat([inp, tar], axis=-1) # (bs, 256, 256, channels*2)\n", " x = self.down1(x, training=training) # (bs, 128, 128, 64)\n", " x = self.down2(x, training=training) # (bs, 64, 64, 128)\n", " x = self.down3(x, training=training) # (bs, 32, 32, 256)\n", "\n", " x = self.zero_pad1(x) # (bs, 34, 34, 256)\n", " x = self.conv(x) # (bs, 31, 31, 512)\n", " x = self.batchnorm1(x, training=training)\n", " x = tf.nn.leaky_relu(x)\n", " \n", " x = self.zero_pad2(x) # (bs, 33, 33, 512)\n", " # don't add a sigmoid activation here since\n", " # the loss function expects raw logits.\n", " x = self.last(x) # (bs, 30, 30, 1)\n", "\n", " return x" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "gDkA05NE6QMs", "colab": {} }, "source": [ "# The call function of Generator and Discriminator have been decorated\n", "# with tf.contrib.eager.defun()\n", "# We get a performance speedup if defun is used (~25 seconds per epoch)\n", "generator = Generator()\n", "discriminator = Discriminator()" ], "execution_count": 0, "outputs": [] }, { "cell_type": "markdown", "metadata": { "colab_type": "text", "id": "0FMYgY_mPfTi" }, "source": [ "## Define the loss functions and the optimizer\n", "\n", "* **Discriminator loss**\n", " * The discriminator loss function takes 2 inputs; **real images, generated images**\n", " * real_loss is a sigmoid cross entropy loss of the **real images** and an **array of ones(since these are the real images)**\n", " * generated_loss is a sigmoid cross entropy loss of the **generated images** and an **array of zeros(since these are the fake images)**\n", " * Then the total_loss is the sum of real_loss and the generated_loss\n", " \n", "* **Generator loss**\n", " * It is a sigmoid cross entropy loss of the generated images and an **array of ones**.\n", " * The [paper](https://arxiv.org/abs/1611.07004) also includes L1 loss which is MAE (mean absolute error) between the generated image and the target image.\n", " * This allows the generated image to become structurally similar to the target image.\n", " * The formula to calculate the total generator loss = gan_loss + LAMBDA * l1_loss, where LAMBDA = 100. This value was decided by the authors of the [paper](https://arxiv.org/abs/1611.07004)." ] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "cyhxTuvJyIHV", "colab": {} }, "source": [ "LAMBDA = 100" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "wkMNfBWlT-PV", "colab": {} }, "source": [ "def discriminator_loss(disc_real_output, disc_generated_output):\n", " real_loss = tf.losses.sigmoid_cross_entropy(multi_class_labels = tf.ones_like(disc_real_output), \n", " logits = disc_real_output)\n", " generated_loss = tf.losses.sigmoid_cross_entropy(multi_class_labels = tf.zeros_like(disc_generated_output), \n", " logits = disc_generated_output)\n", "\n", " total_disc_loss = real_loss + generated_loss\n", "\n", " return total_disc_loss" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "90BIcCKcDMxz", "colab": {} }, "source": [ "def generator_loss(disc_generated_output, gen_output, target):\n", " gan_loss = tf.losses.sigmoid_cross_entropy(multi_class_labels = tf.ones_like(disc_generated_output),\n", " logits = disc_generated_output) \n", " # mean absolute error\n", " l1_loss = tf.reduce_mean(tf.abs(target - gen_output))\n", "\n", " total_gen_loss = gan_loss + (LAMBDA * l1_loss)\n", "\n", " return total_gen_loss" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "iWCn_PVdEJZ7", "colab": {} }, "source": [ "generator_optimizer = tf.train.AdamOptimizer(2e-4, beta1=0.5)\n", "discriminator_optimizer = tf.train.AdamOptimizer(2e-4, beta1=0.5)" ], "execution_count": 0, "outputs": [] }, { "cell_type": "markdown", "metadata": { "colab_type": "text", "id": "aKUZnDiqQrAh" }, "source": [ "## Checkpoints (Object-based saving)" ] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "WJnftd5sQsv6", "colab": {} }, "source": [ "checkpoint_dir = './training_checkpoints'\n", "checkpoint_prefix = os.path.join(checkpoint_dir, \"ckpt\")\n", "checkpoint = tf.train.Checkpoint(generator_optimizer=generator_optimizer,\n", " discriminator_optimizer=discriminator_optimizer,\n", " generator=generator,\n", " discriminator=discriminator)" ], "execution_count": 0, "outputs": [] }, { "cell_type": "markdown", "metadata": { "colab_type": "text", "id": "Rw1fkAczTQYh" }, "source": [ "## Training\n", "\n", "* We start by iterating over the dataset\n", "* The generator gets the input image and we get a generated output.\n", "* The discriminator receives the input_image and the generated image as the first input. The second input is the input_image and the target_image.\n", "* Next, we calculate the generator and the discriminator loss.\n", "* Then, we calculate the gradients of loss with respect to both the generator and the discriminator variables(inputs) and apply those to the optimizer.\n", "\n", "## Generate Images\n", "\n", "* After training, its time to generate some images!\n", "* We pass images from the test dataset to the generator.\n", "* The generator will then translate the input image into the output we expect.\n", "* Last step is to plot the predictions and **voila!**" ] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "NS2GWywBbAWo", "colab": {} }, "source": [ "EPOCHS = 200" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "RmdVsmvhPxyy", "colab": {} }, "source": [ "def generate_images(model, test_input, tar):\n", " # the training=True is intentional here since\n", " # we want the batch statistics while running the model\n", " # on the test dataset. If we use training=False, we will get \n", " # the accumulated statistics learned from the training dataset\n", " # (which we don't want)\n", " prediction = model(test_input, training=True)\n", " plt.figure(figsize=(15,15))\n", "\n", " display_list = [test_input[0], tar[0], prediction[0]]\n", " title = ['Input Image', 'Ground Truth', 'Predicted Image']\n", "\n", " for i in range(3):\n", " plt.subplot(1, 3, i+1)\n", " plt.title(title[i])\n", " # getting the pixel values between [0, 1] to plot it.\n", " plt.imshow(display_list[i] * 0.5 + 0.5)\n", " plt.axis('off')\n", " plt.show()" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "2M7LmLtGEMQJ", "colab": {} }, "source": [ "def train(dataset, epochs): \n", " for epoch in range(epochs):\n", " start = time.time()\n", "\n", " for input_image, target in dataset:\n", "\n", " with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape:\n", " gen_output = generator(input_image, training=True)\n", "\n", " disc_real_output = discriminator(input_image, target, training=True)\n", " disc_generated_output = discriminator(input_image, gen_output, training=True)\n", "\n", " gen_loss = generator_loss(disc_generated_output, gen_output, target)\n", " disc_loss = discriminator_loss(disc_real_output, disc_generated_output)\n", "\n", " generator_gradients = gen_tape.gradient(gen_loss, \n", " generator.variables)\n", " discriminator_gradients = disc_tape.gradient(disc_loss, \n", " discriminator.variables)\n", "\n", " generator_optimizer.apply_gradients(zip(generator_gradients, \n", " generator.variables))\n", " discriminator_optimizer.apply_gradients(zip(discriminator_gradients, \n", " discriminator.variables))\n", "\n", " if epoch % 1 == 0:\n", " for inp, tar in test_dataset.take(3):\n", " generate_images(generator, inp, tar)\n", " \n", " # saving (checkpoint) the model every 20 epochs\n", " if (epoch + 1) % 20 == 0:\n", " checkpoint.save(file_prefix = checkpoint_prefix)\n", "\n", " print ('Time taken for epoch {} is {} sec\\n'.format(epoch + 1,\n", " time.time()-start))" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "a1zZmKmvOH85", "colab": {} }, "source": [ "train(train_dataset, EPOCHS)" ], "execution_count": 0, "outputs": [] }, { "cell_type": "markdown", "metadata": { "colab_type": "text", "id": "kz80bY3aQ1VZ" }, "source": [ "## Restore the latest checkpoint and test" ] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "4t4x69adQ5xb", "colab": {} }, "source": [ "# restoring the latest checkpoint in checkpoint_dir\n", "checkpoint.restore(tf.train.latest_checkpoint(checkpoint_dir))" ], "execution_count": 0, "outputs": [] }, { "cell_type": "markdown", "metadata": { "colab_type": "text", "id": "1RGysMU_BZhx" }, "source": [ "## Testing on the entire test dataset" ] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "KUgSnmy2nqSP", "colab": {} }, "source": [ "# Run the trained model on the entire test dataset\n", "for inp, tar in test_dataset:\n", " generate_images(generator, inp, tar)" ], "execution_count": 0, "outputs": [] }, { "cell_type": "code", "metadata": { "colab_type": "code", "id": "3AJXOByaZVOf", "colab": {} }, "source": [ "" ], "execution_count": 0, "outputs": [] } ] }