{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":21755,"databundleVersionId":1475600,"sourceType":"competition"}],"dockerImageVersionId":31193,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# I'm Something of a Painter: GAN Art Generator\n\n### Final Project - Machine Learning | Wendy Hielo\n\nThis project explores ***Generative Adversarial Networks (GANs)*** by training a model to generate Monet-Style artwork from scratch. \n\nA Gan consists of 2 neural networks that learn together. A ***generator*** that creates new images starting from random noise and a ***discriminator*** that judges whether images look \"real\" or \"fake\". \n\nDuring the training, the generator improves its ability to fool the discriminator, and the discriminator improves at spotting fakes. \n\nThe goal of this project was to learn how GANs work step-by-step, train a model on the Kaggle monet dataset, generate new artwork, evaluate progress, document the process and final results. ","metadata":{}},{"cell_type":"markdown","source":"### Setup and Imports\n\nIn this section I import all of the main libraries used throughout the project. \n\n* **NumPy** for numerical operations.\n* **Matplotlib** for plotting and visualizing images.\n* **TensorFlow / Keras** for building and training the GAN models.\n\nI also print out the Tensorflow version and verify that at least one GPU is available which is faster than using a CPU.\n","metadata":{}},{"cell_type":"code","source":"import os\nimport numpy as np # linear algebra\nimport matplotlib.pyplot as plt\nimport tensorflow as tf\nfrom tensorflow import keras\nfrom tensorflow.keras import layers\n\nprint(\"TensorFlow version:\", tf.__version__)\nprint(\"GPU available:\", tf.config.list_physical_devices('GPU'))\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-12-02T04:15:34.970768Z","iopub.execute_input":"2025-12-02T04:15:34.971411Z","iopub.status.idle":"2025-12-02T04:15:34.975914Z","shell.execute_reply.started":"2025-12-02T04:15:34.971388Z","shell.execute_reply":"2025-12-02T04:15:34.97521Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Training Configuration\n\nThis is where the main hyperparameters are defined for the GAN.\n\n* *IMG_SIZE* resizes all images to 64x64 pixels so that it is quicker to train\n* *CHANNELS* chooses whether images are black & white or in color. We need 3 channels for Red, Green, and Blue layers.\n* *LATENT_DIM* is the size of the random noise vector that starts the generator's process. Smaller numbers don't express much variety and a huge number is too complex and hard to train so 100 is a good middle ground.\n* *BATCH_SIZE* defines how many images the model processes in one training step\n* *EPOCHS* is the amount of full passes through the dataset the model makes. We will start at 30 for now.","metadata":{}},{"cell_type":"code","source":"IMG_SIZE = 64      # images will be resized to 64x64\nCHANNELS = 3       # RGB color images\nLATENT_DIM = 100   # random noise vector length\nBATCH_SIZE = 64\nEPOCHS = 100        # you can increase later\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-02T04:41:21.657669Z","iopub.execute_input":"2025-12-02T04:41:21.65841Z","iopub.status.idle":"2025-12-02T04:41:21.662011Z","shell.execute_reply.started":"2025-12-02T04:41:21.658385Z","shell.execute_reply":"2025-12-02T04:41:21.661159Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Loading and Preparing the Dataset \n\nIn this section I loaded the Monet image dataset provided by the Kaggle competition. Something important to note is that GANs don't require labels, so the dataset is loaded with no classification labels. \n\nEach image is then resized to 64x64 pixels, batched using TensorFlow, and then converted from pixel values in the range **[0, 255] → [-1, 1]**, which helps the GAN train more stably.\n\nAfter preprocessing, one batch is printed to confirm the dataset shape which in this case is (64, 64, 64, 3). 64 images per batch, 64x64 pixels, with 3 color channels. ","metadata":{}},{"cell_type":"code","source":"data_dir = \"/kaggle/input/gan-getting-started\"\n\ndataset = tf.keras.utils.image_dataset_from_directory(\n    data_dir,\n    label_mode=None,          # no labels, we just want the images\n    image_size=(IMG_SIZE, IMG_SIZE),\n    batch_size=BATCH_SIZE,\n    shuffle=True\n)\n\n# Normalize from [0,255] → [-1,1]\ndef normalize(img_batch):\n    img_batch = tf.cast(img_batch, tf.float32)\n    img_batch = (img_batch / 127.5) - 1.0\n    return img_batch\n\ndataset = dataset.map(normalize)\ndataset = dataset.prefetch(buffer_size=tf.data.AUTOTUNE)\n\nfor img_batch in dataset.take(1):\n    print(\"One batch shape:\", img_batch.shape)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Previewing the Dataset\n\nBefore we train the model I wanted to look at a few images from the dataset to give us a quick visual that the images loaded correctly, nothing was distorted, the sizes are correct, and the images are in color. \n\nBelow are 9 random images from the dataset after resizing and normalization (which is when its converted back to a visible range for display).","metadata":{}},{"cell_type":"code","source":"plt.figure(figsize=(6,6))\nfor images in dataset.take(1):\n    for i in range(9):\n        plt.subplot(3,3,i+1)\n        plt.imshow((images[i] + 1.0) / 2.0)  # convert back from [-1,1] → [0,1]\n        plt.axis(\"off\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-02T04:15:53.881513Z","iopub.execute_input":"2025-12-02T04:15:53.881774Z","iopub.status.idle":"2025-12-02T04:15:54.218037Z","shell.execute_reply.started":"2025-12-02T04:15:53.881742Z","shell.execute_reply":"2025-12-02T04:15:54.217283Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Building the Generator\n\nThe generator is the part of the GAN responsible for creating new images. It starts with a 100-dimensional random noise vector and over training, it learns how to turn that noise into something that looks like a Monet painting.\n\nThis model works in stages starting with a fully connected layer that reshapes the noise into a small feature map (8×8 pixels with many channels). Then, it uses **Conv2DTranspose layers** to gradually grow the image to 16×16, then 32×32, and finally 64×64 pixels. **Batch Normalization** and **LeakyReLU activation** help stabilize training and prevent the model from collapsing or producing identical images. The final layer uses a **tanh activation**, which produces pixel values in the range **[-1, 1]** which matches the normalization applied to the real dataset.\n\nThe generator’s entire goal is to produce images convincing enough that the discriminator cannot tell they are artificial.\n","metadata":{}},{"cell_type":"code","source":"def build_generator(latent_dim=LATENT_DIM):\n    model = keras.Sequential(name=\"generator\")\n\n    # Start from a 100-dim random vector → Dense layer → reshape into small image\n    model.add(layers.Dense(8 * 8 * 256, use_bias=False, input_shape=(latent_dim,)))\n    model.add(layers.BatchNormalization())\n    model.add(layers.LeakyReLU())\n    model.add(layers.Reshape((8, 8, 256)))  # now it's an 8×8 image with 256 channels\n\n    # Upsample to 16×16\n    model.add(layers.Conv2DTranspose(128, (5,5), strides=(2,2), padding=\"same\", use_bias=False))\n    model.add(layers.BatchNormalization())\n    model.add(layers.LeakyReLU())\n\n    # Upsample to 32×32\n    model.add(layers.Conv2DTranspose(64, (5,5), strides=(2,2), padding=\"same\", use_bias=False))\n    model.add(layers.BatchNormalization())\n    model.add(layers.LeakyReLU())\n\n    # Upsample to 64×64 (final image size)\n    model.add(layers.Conv2DTranspose(CHANNELS, (5,5), strides=(2,2), padding=\"same\",\n                                     use_bias=False, activation=\"tanh\"))\n    # Output: 64×64×3, pixel values in [-1, 1]\n    return model\n\n\n# Create generator and inspect shape\ngenerator = build_generator()\ngenerator.summary()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-02T04:16:00.074565Z","iopub.execute_input":"2025-12-02T04:16:00.075275Z","iopub.status.idle":"2025-12-02T04:16:00.170447Z","shell.execute_reply.started":"2025-12-02T04:16:00.075252Z","shell.execute_reply":"2025-12-02T04:16:00.169864Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Building the Discriminator\n\nThe discriminator acts as the critic in the GAN system. Its job is to look at an image and decide whether it came from the real Monet dataset or was created by the generator.\n\nThis model works in the opposite direction of the generator. Instead of starting small and growing to full image size, it takes a **64×64 image** and gradually compresses it to extract meaningful features. It uses **convolutional layers** to detect textures, edges, and shapes. **LeakyReLU activations** help prevent the model from “dying,” which can happen when normal ReLU blocks negative values. **Dropout layers** randomly turn off some neurons during training to prevent the model from memorizing the images and overfitting.\n\nThe final dense layer outputs a single score without an activation function. This score represents how confident the discriminator is that the image is real. Over training, the discriminator gets better at spotting fake images, while the generator improves to fool it.\n","metadata":{}},{"cell_type":"code","source":"def build_discriminator():\n    model = keras.Sequential(name=\"discriminator\")\n\n    # Step 1: Input layer — takes a 64×64×3 image\n    model.add(layers.Conv2D(64, (5,5), strides=(2,2), padding=\"same\",\n                            input_shape=(IMG_SIZE, IMG_SIZE, CHANNELS)))\n    model.add(layers.LeakyReLU())\n    model.add(layers.Dropout(0.3))\n\n    # Step 2: Downsample further\n    model.add(layers.Conv2D(128, (5,5), strides=(2,2), padding=\"same\"))\n    model.add(layers.LeakyReLU())\n    model.add(layers.Dropout(0.3))\n\n    # Step 3: Flatten + final output (real vs fake)\n    model.add(layers.Flatten())\n    model.add(layers.Dense(1))  # output: single value (logit)\n\n    return model\n\n\n# Create the discriminator and inspect its structure\ndiscriminator = build_discriminator()\ndiscriminator.summary()\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Training the GAN\n\nAfter the generator and discriminator are built, we need a way to train them together. A GAN trains in 2 alternating steps.\n\n---\n\n#### 1. Training the Discriminator\n\nThe discriminator sees a batch of **real images** (from the Monet dataset) and **fake images** (created by the generator).The real images are labeled as `1` and fake images as `0`. A small amount of random label noise is added (called **label smoothing**) to prevent the discriminator from becoming overconfident too early. The discriminator learns to correctly classify real vs. fake images.\n\n---\n\n#### 2. Training the Generator\n\nThe generator starts with random noise and creates fake images. These fake images are passed into the discriminator, but this time the labels are flipped to `1`, because we want the generator to *fool* the discriminator. If the discriminator believes the images are real, the generator receives a lower loss and improves.\n\n---\n\n#### Training Loop Summary\n\nThe generator and discriminator are constantly improving in response to each other. This back-and-forth dynamic is where the name **Generative Adversarial Network** comes from and the two models are opposites during training but work together to produce meaningful results.\n\n","metadata":{}},{"cell_type":"code","source":"class GAN(keras.Model):\n    def __init__(self, generator, discriminator, latent_dim):\n        super().__init__()\n        self.generator = generator\n        self.discriminator = discriminator\n        self.latent_dim = latent_dim\n\n    def compile(self, g_optimizer, d_optimizer, loss_fn):\n        super().compile()\n        self.g_optimizer = g_optimizer\n        self.d_optimizer = d_optimizer\n        self.loss_fn = loss_fn\n        self.d_loss_metric = keras.metrics.Mean(name=\"d_loss\")\n        self.g_loss_metric = keras.metrics.Mean(name=\"g_loss\")\n\n    @property\n    def metrics(self):\n        return [self.d_loss_metric, self.g_loss_metric]\n\n    def train_step(self, real_images):\n        #Train Discriminator\n        batch_size = tf.shape(real_images)[0]\n        random_noise = tf.random.normal(shape=(batch_size, self.latent_dim))\n        generated_images = self.generator(random_noise, training=True)\n\n        combined_images = tf.concat([real_images, generated_images], axis=0)\n        labels = tf.concat([\n            tf.ones((batch_size, 1)),   # real = 1\n            tf.zeros((batch_size, 1))   # fake = 0\n        ], axis=0)\n\n        # Label smoothing: add tiny random noise to labels\n        labels += 0.05 * tf.random.uniform(tf.shape(labels))\n\n        with tf.GradientTape() as tape:\n            predictions = self.discriminator(combined_images, training=True)\n            d_loss = self.loss_fn(labels, predictions)\n        grads = tape.gradient(d_loss, self.discriminator.trainable_weights)\n        self.d_optimizer.apply_gradients(zip(grads, self.discriminator.trainable_weights))\n\n        #Train Generator\n        random_noise = tf.random.normal(shape=(batch_size, self.latent_dim))\n        misleading_labels = tf.ones((batch_size, 1))  # wants D to think fakes = real\n\n        with tf.GradientTape() as tape:\n            fake_images = self.generator(random_noise, training=True)\n            predictions = self.discriminator(fake_images, training=True)\n            g_loss = self.loss_fn(misleading_labels, predictions)\n        grads = tape.gradient(g_loss, self.generator.trainable_weights)\n        self.g_optimizer.apply_gradients(zip(grads, self.generator.trainable_weights))\n\n        # Update metrics\n        self.d_loss_metric.update_state(d_loss)\n        self.g_loss_metric.update_state(g_loss)\n\n        return {\"d_loss\": self.d_loss_metric.result(), \"g_loss\": self.g_loss_metric.result()}\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Saving Generated Images During Training\n\nGANs improve gradually, and sometimes very unexpectedly so early outputs often look like random blobs before slowly forming textures, shapes, and eventually recognizable patterns.\n\nTo help visualize this progression, I added a custom callback that saves sample generated images at the end of each epoch. This allows me (or anyone reviewing the project) to watch the model improve.\n\nHow it works:\n\n- A fixed set of random noise vectors is generated each epoch.\n- These are passed through the generator to create images.\n- The images are scaled back from the normalized range **[-1, 1] → [0, 1]** so they display correctly.\n- A 3×3 grid of generated images is saved to a folder called `generated_images/`.\n- Over time, these saved snapshots create a visual timeline of the model’s learning progress.\n\nThis makes it easier to evaluate the GAN’s behavior and training stability — and also helps confirm whether things are improving or if the model is collapsing.\n","metadata":{}},{"cell_type":"code","source":"import os\nimport tensorflow as tf\nimport matplotlib.pyplot as plt\nfrom tensorflow.keras.callbacks import Callback\n\n# Create a directory to store generated preview images\nos.makedirs(\"generated_preview\", exist_ok=True)\n\nclass PreviewCallback(Callback):\n    \"\"\"Custom callback that saves a 3x3 grid of generated images after each epoch.\"\"\"\n    \n    def __init__(self, generator, latent_dim):\n        super().__init__()\n        self.generator = generator\n        self.latent_dim = latent_dim\n\n        # Use the same fixed noise every epoch → better visual comparison\n        self.fixed_noise = tf.random.normal((9, self.latent_dim))\n\n    def on_epoch_end(self, epoch, logs=None):\n        \"\"\"Generate sample preview images after each epoch.\"\"\"\n        generated_images = self.generator(self.fixed_noise, training=False)\n\n        # Convert images from [-1, 1] → [0, 1] for display\n        generated_images = (generated_images + 1.0) / 2.0\n\n        # Create 3×3 figure grid\n        fig, axes = plt.subplots(3, 3, figsize=(6, 6))\n        idx = 0\n\n        for i in range(3):\n            for j in range(3):\n                axes[i, j].imshow(generated_images[idx])\n                axes[i, j].axis(\"off\")\n                idx += 1\n\n        # Save the image with epoch number\n        fig.savefig(f\"generated_preview/epoch_{epoch + 1}.png\")\n        plt.close(fig)\n        print(f\"📁 Saved preview image: epoch_{epoch + 1}.png\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-02T04:42:33.429825Z","iopub.execute_input":"2025-12-02T04:42:33.43045Z","iopub.status.idle":"2025-12-02T04:42:33.437801Z","shell.execute_reply.started":"2025-12-02T04:42:33.430426Z","shell.execute_reply":"2025-12-02T04:42:33.437203Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Training the Model\n\nNow that the GAN is fully defined, the last step is to compile and train it.\n\nI start by creating an instance of the GAN using the **generator**, **discriminator**, and the previously defined latent dimension. Both models use the **Adam optimizer**, which is commonly used in GANs because it helps stabilize the training process. The loss function used is **Binary Crossentropy**, since the discriminator works like a binary classifier.\n\nDuring training, the generator and discriminator continuously improve together:\n\n- The **discriminator** attempts to correctly identify real images from fake ones.\n- The **generator** tries to fool the discriminator by creating more realistic images.\n\nThe `SaveGeneratedImages` callback saves a visual snapshot of the generator’s output after each epoch. This makes it easier to see how the model’s results evolve over time.\n\nTraining runs for the number of epochs defined earlier (`EPOCHS = 30`), and once complete, the `history` object stores the loss values for both networks, which can be used later for performance visualization.\n","metadata":{}},{"cell_type":"code","source":"# Instantiate the GAN model\ngan = GAN(generator=generator, discriminator=discriminator, latent_dim=LATENT_DIM)\n\n# Optimizers and loss\ng_optimizer = keras.optimizers.Adam(learning_rate=0.0002, beta_1=0.5)\nd_optimizer = keras.optimizers.Adam(learning_rate=0.0002, beta_1=0.5)\nloss_fn = keras.losses.BinaryCrossentropy(from_logits=True)\n\n# Compile the GAN\ngan.compile(g_optimizer=g_optimizer, d_optimizer=d_optimizer, loss_fn=loss_fn)\n\n# Train the model\nhistory = gan.fit(\n    dataset,\n    epochs=EPOCHS,\n    callbacks=[PreviewCallback(generator, LATENT_DIM)]\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-02T04:42:38.033772Z","iopub.execute_input":"2025-12-02T04:42:38.034035Z","iopub.status.idle":"2025-12-02T04:45:57.933995Z","shell.execute_reply.started":"2025-12-02T04:42:38.034016Z","shell.execute_reply":"2025-12-02T04:45:57.933274Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport tensorflow as tf\nfrom PIL import Image\n\noutput_dir = \"/kaggle/working/images\"\nos.makedirs(output_dir, exist_ok=True)\n\nNUM_IMAGES = 7500  # required: 7000-10000\nBATCH_SIZE = 100\nnum_batches = NUM_IMAGES // BATCH_SIZE\n\nprint(\"Generating final dataset...\")\n\nfor batch in range(num_batches):\n    noise = tf.random.normal((BATCH_SIZE, LATENT_DIM))  # FIXED\n    imgs = generator(noise, training=False)\n\n    # scale from [-1,1] -> [0,1]\n    imgs = (imgs + 1) / 2.0  \n\n    for i in range(BATCH_SIZE):\n        img = imgs[i]\n\n        # Resize first while still float\n        img = tf.image.resize(img, (256, 256))\n\n        # Scale to [0, 255]\n        img = tf.clip_by_value(img * 255.0, 0, 255)\n\n        # Convert to uint8 BEFORE PIL\n        img = tf.cast(img, tf.uint8)\n\n        # Convert to numpy array and save\n        img = Image.fromarray(img.numpy())\n        img.save(f\"{output_dir}/img_{batch * BATCH_SIZE + i}.jpg\")\n\n\nprint(\"✓ Finished generating images!\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-02T04:49:14.819123Z","iopub.execute_input":"2025-12-02T04:49:14.819821Z","iopub.status.idle":"2025-12-02T04:49:32.017035Z","shell.execute_reply.started":"2025-12-02T04:49:14.819793Z","shell.execute_reply":"2025-12-02T04:49:32.016289Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import zipfile\n\nzip_path = \"/kaggle/working/images.zip\"\nimage_folder = \"/kaggle/working/images\"\n\nwith zipfile.ZipFile(zip_path, \"w\") as zipf:\n    for root, dirs, files in os.walk(image_folder):\n        for file in files:\n            zipf.write(os.path.join(root, file), arcname=file)\n\nprint(\"✓ images.zip created successfully!\")\n","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}