{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.13.15","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"GPU"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"46d099f9","cell_type":"markdown","source":"# Cat vs Dog Classification — Custom CNN (80/10/10 Split)\n\n- 80% training, 10% validation, 10% testing\n- Data augmentation to improve generalization\n- Custom CNN with Conv2D, BatchNormalization, MaxPooling, Dropout, and GlobalAveragePooling\n- Early stopping and learning-rate reduction\n- Final metrics, confusion matrix, error analysis, and sample predictions\n\n","metadata":{}},{"id":"c4ddc9af","cell_type":"code","source":"import os\nimport glob\nimport random\nimport zipfile\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport tensorflow as tf\n\nfrom PIL import Image\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import (\n    accuracy_score, precision_score, recall_score, f1_score,\n    confusion_matrix, classification_report, ConfusionMatrixDisplay\n)\n\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntf.random.set_seed(SEED)\n\nprint(\"TensorFlow version:\", tf.__version__)\nprint(\"GPU devices:\", tf.config.list_physical_devices(\"GPU\"))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:04:20.519024Z","iopub.execute_input":"2026-10-14T11:04:20.5193Z","iopub.status.idle":"2026-10-14T11:04:43.853225Z","shell.execute_reply.started":"2026-10-14T11:04:20.519271Z","shell.execute_reply":"2026-10-14T11:04:43.852372Z"}},"outputs":[],"execution_count":null},{"id":"98540a0c","cell_type":"code","source":"# Find Kaggle input archives and extract train.zip if needed.\nworking_dir = \"/kaggle/working/cat_dog_data\"\nos.makedirs(working_dir, exist_ok=True)\n\ntrain_archives = glob.glob(\"/kaggle/input/**/train.zip\", recursive=True)\nif train_archives:\n    archive = train_archives[0]\n    print(\"Found archive:\", archive)\n    with zipfile.ZipFile(archive, \"r\") as zf:\n        zf.extractall(working_dir)\nelse:\n    print(\"No train.zip found. Will look for images already extracted in Kaggle input.\")\n\nprint(\"Setup complete.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:04:43.854249Z","iopub.execute_input":"2026-10-14T11:04:43.85527Z","iopub.status.idle":"2026-10-14T11:04:51.131646Z","shell.execute_reply.started":"2026-10-14T11:04:43.855233Z","shell.execute_reply":"2026-10-14T11:04:51.130766Z"}},"outputs":[],"execution_count":null},{"id":"7955138c","cell_type":"code","source":"# Build a table of image paths and labels from filenames or class folders.\nall_paths = (\n    glob.glob(\"/kaggle/input/**/*.jpg\", recursive=True)\n    + glob.glob(\"/kaggle/input/**/*.jpeg\", recursive=True)\n    + glob.glob(working_dir + \"/**/*.jpg\", recursive=True)\n    + glob.glob(working_dir + \"/**/*.jpeg\", recursive=True)\n)\n\nrecords = []\nfor path in set(all_paths):\n    filename = os.path.basename(path).lower()\n    folders = [part.lower() for part in path.split(os.sep)]\n\n    if filename.startswith(\"cat.\"):\n        label = 0\n    elif filename.startswith(\"dog.\"):\n        label = 1\n    elif \"cat\" in folders:\n        label = 0\n    elif \"dog\" in folders:\n        label = 1\n    else:\n        continue\n\n    records.append((path, label))\n\ndata = pd.DataFrame(records, columns=[\"filepath\", \"label\"])\ndata = data.drop_duplicates(\"filepath\").reset_index(drop=True)\ndata[\"class_name\"] = data[\"label\"].map({0: \"Cat\", 1: \"Dog\"})\n\nif data.empty:\n    raise ValueError(\n        \"No labeled cat/dog images found. Attach the dataset in Kaggle \"\n        \"Notebook Settings > Add Input. If it is a ZIP, ensure it contains train.zip.\"\n    )\n\nprint(\"Total labeled images:\", len(data))\ndisplay(data[\"class_name\"].value_counts().to_frame(\"Image count\"))\ndisplay(data.head())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:04:51.133398Z","iopub.execute_input":"2026-10-14T11:04:51.13372Z","iopub.status.idle":"2026-10-14T11:04:51.334174Z","shell.execute_reply.started":"2026-10-14T11:04:51.133698Z","shell.execute_reply":"2026-10-14T11:04:51.333367Z"}},"outputs":[],"execution_count":null},{"id":"10d8b493","cell_type":"code","source":"# Inspect image dimensions and show a sample grid.\nfor path in data[\"filepath\"].sample(min(5, len(data)), random_state=SEED):\n    with Image.open(path) as img:\n        print(os.path.basename(path), \":\", img.size)\n\nsample = data.sample(min(10, len(data)), random_state=SEED)\nplt.figure(figsize=(12, 7))\nfor i, (_, row) in enumerate(sample.iterrows()):\n    with Image.open(row[\"filepath\"]) as img:\n        image = img.convert(\"RGB\")\n    plt.subplot(2, 5, i + 1)\n    plt.imshow(image)\n    plt.title(row[\"class_name\"])\n    plt.axis(\"off\")\nplt.suptitle(\"Sample Cats and Dogs\")\nplt.tight_layout()\nplt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:04:51.335327Z","iopub.execute_input":"2026-10-14T11:04:51.336035Z","iopub.status.idle":"2026-10-14T11:04:52.188286Z","shell.execute_reply.started":"2026-10-14T11:04:51.33601Z","shell.execute_reply":"2026-10-14T11:04:52.187355Z"}},"outputs":[],"execution_count":null},{"id":"2b2a6b18","cell_type":"code","source":"# Split data into 80% train, 10% validation, and 10% test.\n# First reserve 20% for validation + test, then divide that equally.\ntrain_data, temp_data = train_test_split(\n    data,\n    test_size=0.20,\n    random_state=SEED,\n    stratify=data[\"label\"]\n)\n\nval_data, test_data = train_test_split(\n    temp_data,\n    test_size=0.50,\n    random_state=SEED,\n    stratify=temp_data[\"label\"]\n)\n\nprint(f\"Training images:   {len(train_data)} ({len(train_data) / len(data):.1%})\")\nprint(f\"Validation images: {len(val_data)} ({len(val_data) / len(data):.1%})\")\nprint(f\"Test images:       {len(test_data)} ({len(test_data) / len(data):.1%})\")\n\nprint(\"\\\\nClass counts in each split:\")\nfor split_name, frame in [\n    (\"Training\", train_data),\n    (\"Validation\", val_data),\n    (\"Test\", test_data)\n]:\n    print(f\"\\\\n{split_name}:\")\n    display(frame[\"class_name\"].value_counts().to_frame(\"Count\"))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:04:52.189226Z","iopub.execute_input":"2026-10-14T11:04:52.189644Z","iopub.status.idle":"2026-10-14T11:04:52.231745Z","shell.execute_reply.started":"2026-10-14T11:04:52.189616Z","shell.execute_reply":"2026-10-14T11:04:52.23104Z"}},"outputs":[],"execution_count":null},{"id":"b96aacf6","cell_type":"code","source":"# Create efficient TensorFlow pipelines.\nIMG_SIZE = 160\nBATCH_SIZE = 32\nAUTOTUNE = tf.data.AUTOTUNE\n\ndef load_image(filepath, label):\n    image = tf.io.read_file(filepath)\n    image = tf.io.decode_image(image, channels=3, expand_animations=False)\n    image.set_shape([None, None, 3])\n    image = tf.image.resize(image, [IMG_SIZE, IMG_SIZE])\n    # Keep pixels in 0..255; the model rescales them to 0..1.\n    image = tf.cast(image, tf.float32)\n    return image, tf.cast(label, tf.float32)\n\ndef make_dataset(frame, training=False):\n    ds = tf.data.Dataset.from_tensor_slices((\n        frame[\"filepath\"].values,\n        frame[\"label\"].values.astype(\"float32\")\n    ))\n    if training:\n        ds = ds.shuffle(len(frame), seed=SEED)\n    return ds.map(load_image, num_parallel_calls=AUTOTUNE).batch(\n        BATCH_SIZE\n    ).prefetch(AUTOTUNE)\n\ntrain_ds = make_dataset(train_data, training=True)\nval_ds = make_dataset(val_data)\ntest_ds = make_dataset(test_data)\n\nprint(\"Image size:\", IMG_SIZE, \"x\", IMG_SIZE)\nprint(\"Batch size:\", BATCH_SIZE)\nprint(\"No pretrained weights are used in this notebook.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:04:52.232815Z","iopub.execute_input":"2026-10-14T11:04:52.23321Z","iopub.status.idle":"2026-10-14T11:04:55.098895Z","shell.execute_reply.started":"2026-10-14T11:04:52.233186Z","shell.execute_reply":"2026-10-14T11:04:55.098015Z"}},"outputs":[],"execution_count":null},{"id":"01a13e58","cell_type":"code","source":"# Build a custom CNN from scratch (no MobileNet, no ImageNet weights).\ndata_augmentation = tf.keras.Sequential([\n    tf.keras.layers.RandomFlip(\"horizontal\"),\n    tf.keras.layers.RandomRotation(0.08),\n    tf.keras.layers.RandomZoom(0.10),\n], name=\"data_augmentation\")\n\ninputs = tf.keras.Input(shape=(IMG_SIZE, IMG_SIZE, 3))\nx = data_augmentation(inputs)\nx = tf.keras.layers.Rescaling(1.0 / 255)(x)\n\nx = tf.keras.layers.Conv2D(32, 3, padding=\"same\", activation=\"relu\")(x)\nx = tf.keras.layers.BatchNormalization()(x)\nx = tf.keras.layers.MaxPooling2D()(x)\n\nx = tf.keras.layers.Conv2D(64, 3, padding=\"same\", activation=\"relu\")(x)\nx = tf.keras.layers.BatchNormalization()(x)\nx = tf.keras.layers.MaxPooling2D()(x)\nx = tf.keras.layers.Dropout(0.15)(x)\n\nx = tf.keras.layers.Conv2D(128, 3, padding=\"same\", activation=\"relu\")(x)\nx = tf.keras.layers.BatchNormalization()(x)\nx = tf.keras.layers.MaxPooling2D()(x)\nx = tf.keras.layers.Dropout(0.20)(x)\n\nx = tf.keras.layers.Conv2D(256, 3, padding=\"same\", activation=\"relu\")(x)\nx = tf.keras.layers.BatchNormalization()(x)\nx = tf.keras.layers.MaxPooling2D()(x)\n\nx = tf.keras.layers.GlobalAveragePooling2D()(x)\nx = tf.keras.layers.Dense(128, activation=\"relu\")(x)\nx = tf.keras.layers.Dropout(0.40)(x)\noutputs = tf.keras.layers.Dense(1, activation=\"sigmoid\")(x)\n\nmodel = tf.keras.Model(inputs, outputs, name=\"custom_cat_dog_cnn\")\nmodel.summary()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:04:55.100045Z","iopub.execute_input":"2026-10-14T11:04:55.100361Z","iopub.status.idle":"2026-10-14T11:04:56.056542Z","shell.execute_reply.started":"2026-10-14T11:04:55.100331Z","shell.execute_reply":"2026-10-14T11:04:56.055955Z"}},"outputs":[],"execution_count":null},{"id":"7cc31d1a","cell_type":"code","source":"# Compile and set callbacks to help control overfitting.\nmodel.compile(\n    optimizer=tf.keras.optimizers.Adam(learning_rate=0.001),\n    loss=\"binary_crossentropy\",\n    metrics=[\"accuracy\"]\n)\n\ncallbacks = [\n    tf.keras.callbacks.EarlyStopping(\n        monitor=\"val_loss\", patience=4, restore_best_weights=True\n    ),\n    tf.keras.callbacks.ReduceLROnPlateau(\n        monitor=\"val_loss\", factor=0.5, patience=2, min_lr=1e-6\n    )\n]\nprint(\"Compiled custom CNN. No pretrained model is used.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:04:56.057387Z","iopub.execute_input":"2026-10-14T11:04:56.057751Z","iopub.status.idle":"2026-10-14T11:04:56.072663Z","shell.execute_reply.started":"2026-10-14T11:04:56.057728Z","shell.execute_reply":"2026-10-14T11:04:56.071836Z"}},"outputs":[],"execution_count":null},{"id":"64039248","cell_type":"code","source":"# Stage 1: train the custom CNN.\nEPOCHS_HEAD = 10\n\nhistory_head = model.fit(\n    train_ds,\n    validation_data=val_ds,\n    epochs=EPOCHS_HEAD,\n    callbacks=callbacks\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:04:56.074966Z","iopub.execute_input":"2026-10-14T11:04:56.075261Z","iopub.status.idle":"2026-10-14T11:13:18.749059Z","shell.execute_reply.started":"2026-10-14T11:04:56.075242Z","shell.execute_reply":"2026-10-14T11:13:18.748358Z"}},"outputs":[],"execution_count":null},{"id":"24bd09c8","cell_type":"code","source":"# Stage 2: continue training the same custom CNN with a smaller learning rate.\n# This is not transfer learning; all convolutional layers were created here.\nmodel.compile(\n    optimizer=tf.keras.optimizers.Adam(learning_rate=0.0001),\n    loss=\"binary_crossentropy\",\n    metrics=[\"accuracy\"]\n)\n\nfine_callbacks = [\n    tf.keras.callbacks.EarlyStopping(\n        monitor=\"val_loss\", patience=3, restore_best_weights=True\n    ),\n    tf.keras.callbacks.ReduceLROnPlateau(\n        monitor=\"val_loss\", factor=0.5, patience=1, min_lr=1e-6\n    )\n]\n\nhistory_fine = model.fit(\n    train_ds,\n    validation_data=val_ds,\n    epochs=6,\n    callbacks=fine_callbacks\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:13:18.750117Z","iopub.execute_input":"2026-10-14T11:13:18.7507Z","iopub.status.idle":"2026-10-14T11:18:19.516424Z","shell.execute_reply.started":"2026-10-14T11:13:18.750674Z","shell.execute_reply":"2026-10-14T11:18:19.515806Z"}},"outputs":[],"execution_count":null},{"id":"f7f53e90","cell_type":"code","source":"# Plot training and validation accuracy/loss across both stages.\ndef combine_history(key):\n    first = history_head.history.get(key, [])\n    second = history_fine.history.get(key, [])\n    return list(first) + list(second)\n\nacc = combine_history(\"accuracy\")\nval_acc = combine_history(\"val_accuracy\")\nloss = combine_history(\"loss\")\nval_loss = combine_history(\"val_loss\")\nepochs = range(1, len(acc) + 1)\n\nfig, axes = plt.subplots(1, 2, figsize=(13, 5))\naxes[0].plot(epochs, acc, label=\"Training\")\naxes[0].plot(epochs, val_acc, label=\"Validation\")\naxes[0].set_title(\"Training vs Validation Accuracy\")\naxes[0].set_xlabel(\"Epoch\")\naxes[0].set_ylabel(\"Accuracy\")\naxes[0].legend()\naxes[0].grid(True)\n\naxes[1].plot(epochs, loss, label=\"Training\")\naxes[1].plot(epochs, val_loss, label=\"Validation\")\naxes[1].set_title(\"Training vs Validation Loss\")\naxes[1].set_xlabel(\"Epoch\")\naxes[1].set_ylabel(\"Loss\")\naxes[1].legend()\naxes[1].grid(True)\n\nplt.tight_layout()\nplt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:18:19.517636Z","iopub.execute_input":"2026-10-14T11:18:19.517965Z","iopub.status.idle":"2026-10-14T11:18:19.866686Z","shell.execute_reply.started":"2026-10-14T11:18:19.51793Z","shell.execute_reply":"2026-10-14T11:18:19.865907Z"}},"outputs":[],"execution_count":null},{"id":"c82d41d7","cell_type":"code","source":"# Final evaluation on the held-out test set.\n# The test set was not used to train the model or choose epochs.\ny_true = test_data[\"label\"].to_numpy()\ny_prob = model.predict(test_ds, verbose=1).ravel()\ny_pred = (y_prob >= 0.5).astype(int)\n\nmetrics = {\n    \"Accuracy\": accuracy_score(y_true, y_pred),\n    \"Precision (Dog)\": precision_score(y_true, y_pred, zero_division=0),\n    \"Recall (Dog)\": recall_score(y_true, y_pred, zero_division=0),\n    \"F1-score (Dog)\": f1_score(y_true, y_pred, zero_division=0)\n}\ndisplay(pd.DataFrame({\"Metric\": metrics.keys(), \"Score\": metrics.values()}))\n\nprint(classification_report(\n    y_true, y_pred, labels=[0, 1],\n    target_names=[\"Cat\", \"Dog\"], zero_division=0\n))\n\ncm = confusion_matrix(y_true, y_pred, labels=[0, 1])\nConfusionMatrixDisplay(cm, display_labels=[\"Cat\", \"Dog\"]).plot(\n    cmap=\"Blues\", values_format=\"d\"\n)\nplt.title(\"Test Set Confusion Matrix: Cat vs Dog\")\nplt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:18:19.867767Z","iopub.execute_input":"2026-10-14T11:18:19.868135Z","iopub.status.idle":"2026-10-14T11:18:21.572757Z","shell.execute_reply.started":"2026-10-14T11:18:19.868112Z","shell.execute_reply":"2026-10-14T11:18:21.571856Z"}},"outputs":[],"execution_count":null},{"id":"c76a7a96","cell_type":"code","source":"# Error analysis: display up to five incorrect test predictions.\nresults = test_data.reset_index(drop=True).copy()\nresults[\"predicted_label\"] = y_pred\nresults[\"dog_probability\"] = y_prob\nerrors = results[results[\"label\"] != results[\"predicted_label\"]]\n\nprint(\"Incorrect test predictions:\", len(errors))\nif len(errors):\n    examples = errors.head(5)\n    plt.figure(figsize=(15, 6))\n    for i, (_, row) in enumerate(examples.iterrows()):\n        with Image.open(row[\"filepath\"]) as im:\n            img = im.convert(\"RGB\")\n        true_name = \"Dog\" if row[\"label\"] == 1 else \"Cat\"\n        pred_name = \"Dog\" if row[\"predicted_label\"] == 1 else \"Cat\"\n        plt.subplot(1, len(examples), i + 1)\n        plt.imshow(img)\n        plt.title(f\"True: {true_name}\\\\nPredicted: {pred_name}\")\n        plt.axis(\"off\")\n        print(f\"{row['filepath']} | True: {true_name} | Predicted: {pred_name}\")\n    plt.suptitle(\"Misclassified Test Images\")\n    plt.tight_layout()\n    plt.show()\nelse:\n    print(\"No misclassified images in this test split.\")\n\nprint(\"Possible causes to inspect: pose, lighting, background, occlusion, and image quality.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:18:21.573926Z","iopub.execute_input":"2026-10-14T11:18:21.574295Z","iopub.status.idle":"2026-10-14T11:18:22.066641Z","shell.execute_reply.started":"2026-10-14T11:18:21.574259Z","shell.execute_reply":"2026-10-14T11:18:22.065701Z"}},"outputs":[],"execution_count":null},{"id":"d40b14ea","cell_type":"code","source":"# Predict five individual images from the held-out test set and save the model.\nsamples = test_data.sample(min(5, len(test_data)), random_state=SEED)\nplt.figure(figsize=(15, 6))\n\nfor i, (_, row) in enumerate(samples.iterrows()):\n    with Image.open(row[\"filepath\"]) as im:\n        original = im.convert(\"RGB\")\n        resized = original.resize((IMG_SIZE, IMG_SIZE))\n\n    arr = np.asarray(resized, dtype=np.float32)[None, ...]\n    dog_prob = float(model.predict(arr, verbose=0)[0, 0])\n    pred_name = \"Dog\" if dog_prob >= 0.5 else \"Cat\"\n    confidence = dog_prob if dog_prob >= 0.5 else 1 - dog_prob\n    true_name = \"Dog\" if row[\"label\"] == 1 else \"Cat\"\n\n    plt.subplot(1, len(samples), i + 1)\n    plt.imshow(original)\n    plt.title(f\"True: {true_name}\\\\nPred: {pred_name}\\\\nConfidence: {confidence:.1%}\")\n    plt.axis(\"off\")\n    print(f\"Image {i+1}: True={true_name}, Predicted={pred_name}, Confidence={confidence:.1%}\")\n\nplt.suptitle(\"Five Individual Test Predictions\")\nplt.tight_layout()\nplt.show()\n\nsave_path = \"/kaggle/working/custom_cat_dog_cnn_80_10_10.keras\"\nmodel.save(save_path)\nprint(\"Saved model:\", save_path)\n\nprint(\"\\\\nMethods used: custom CNN trained from scratch, data augmentation,\")\nprint(\"BatchNormalization, dropout, global average pooling, early stopping,\")\nprint(\"and learning-rate reduction.\")\nprint(\"\\\\nNo pretrained model or pretrained weights were used.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-14T11:18:22.06787Z","iopub.execute_input":"2026-10-14T11:18:22.068156Z","iopub.status.idle":"2026-10-14T11:18:23.415419Z","shell.execute_reply.started":"2026-10-14T11:18:22.068134Z","shell.execute_reply":"2026-10-14T11:18:23.414529Z"}},"outputs":[],"execution_count":null}]}