{"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":"gpu","dataSources":[{"sourceId":14774,"databundleVersionId":875431,"sourceType":"competition"}],"dockerImageVersionId":31240,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import os\nimport cv2\nimport glob\nimport time\nimport random\nimport numpy as np\nimport pandas as pd\nimport tensorflow as tf\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import classification_report, confusion_matrix, roc_auc_score, roc_curve, precision_score, recall_score, f1_score\nfrom tensorflow.keras.applications import EfficientNetB0\nfrom tensorflow.keras.layers import GlobalAveragePooling2D, Dense, Dropout, Conv2D, MaxPooling2D, BatchNormalization, Activation, Multiply, Add, Input, Layer\nfrom tensorflow.keras.models import Model\nfrom tensorflow.keras.optimizers import Adam\nfrom tensorflow.keras.callbacks import ModelCheckpoint, ReduceLROnPlateau, EarlyStopping\ntry:\n    from tensorflow.keras.optimizers import AdamW\nexcept ImportError:\n    AdamW = Adam # Fallback if tf version is old\nimport warnings\nimport math\n\nwarnings.filterwarnings('ignore')\n\n# ------------------------------------------------------------------------------\n# CONFIGURATION\n# ------------------------------------------------------------------------------\nSEED = 42\nIMG_SIZE = 224\nBATCH_SIZE = 16  # Adjust based on VRAM\nEPOCHS = 20\nLEARNING_RATE = 1e-4\n# Standard Kaggle Paths - Change these if running locally with different structure\nDATA_DIR = '/kaggle/input/aptos2019-blindness-detection'\nTRAIN_IMG_DIR = os.path.join(DATA_DIR, 'train_images')\nTEST_IMG_DIR = os.path.join(DATA_DIR, 'test_images')\nCSV_PATH = os.path.join(DATA_DIR, 'train.csv')\nOUTPUT_DIR = './'\nPREPROCESS_OUT_DIR = os.path.join(OUTPUT_DIR, 'preprocessing_outputs')\nPLOTS_DIR = os.path.join(OUTPUT_DIR, 'plots')\n\nos.makedirs(PREPROCESS_OUT_DIR, exist_ok=True)\nos.makedirs(PLOTS_DIR, exist_ok=True)\n\ndef seed_everything(seed=42):\n    random.seed(seed)\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    tf.random.set_seed(seed)\n\nseed_everything(SEED)\n\nprint(f\"TensorFlow Version: {tf.__version__}\")\nprint(f\"Num GPUs Available: {len(tf.config.list_physical_devices('GPU'))}\")\n\n# ------------------------------------------------------------------------------\n# DATA PREPROCESSING (CRITICAL)\n# ------------------------------------------------------------------------------\n\ndef crop_image_from_gray(img, tol=7):\n    \"\"\"\n    Crops the black borders around the circular fundus image.\n    \"\"\"\n    if img.ndim == 2:\n        mask = img > tol\n        return img[np.ix_(mask.any(1), mask.any(0))]\n    elif img.ndim == 3:\n        gray_img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)\n        mask = gray_img > tol\n        check_shape = img[:,:,0][np.ix_(mask.any(1), mask.any(0))].shape[0]\n        if (check_shape == 0): # image is too dark so that we crop out everything,\n            return img # return original image\n        else:\n            img1 = img[:,:,0][np.ix_(mask.any(1), mask.any(0))]\n            img2 = img[:,:,1][np.ix_(mask.any(1), mask.any(0))]\n            img3 = img[:,:,2][np.ix_(mask.any(1), mask.any(0))]\n            img = np.stack([img1, img2, img3], axis=-1)\n        return img\n\ndef apply_clahe(img):\n    \"\"\"\n    Applies CLAHE (Contrast Limited Adaptive Histogram Equalization)\n    to the green channel (often most informative for DR) or L channel in LAB.\n    Here we apply to L channel in LAB color space for better color preservation.\n    \"\"\"\n    lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB)\n    l, a, b = cv2.split(lab)\n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n    cl = clahe.apply(l)\n    limg = cv2.merge((cl, a, b))\n    final = cv2.cvtColor(limg, cv2.COLOR_LAB2RGB)\n    return final\n\ndef ben_graham_preprocessing(img, sigmaX=10):\n    \"\"\"\n    Applies Ben Graham's preprocessing method:\n    image = image * alpha + gaussian_blur * beta + gamma\n    \"\"\"\n    image = cv2.addWeighted(img, 4, cv2.GaussianBlur(img, (0, 0), sigmaX), -4, 128)\n    return image\n\ndef preprocess_pipeline(image_path, sigmaX=10, visualize=False):\n    \"\"\"\n    Reads image and applies the full pipeline:\n    1. Read\n    2. Crop black borders\n    3. Resize\n    4. Ben Graham (Color/Texture enhancement)\n    5. CLAHE (Contrast enhancement) - Optional, can be mixed.\n    Let's combine them: Crop -> Resize -> Ben Graham.\n    (Note: Ben Graham often replaces CLAHE, but we can chain them if careful.\n     The prompt asks for both. Applying CLAHE after Ben Graham might be too harsh,\n     so we will save visualizations of both distinct steps as requested,\n     but for the final model input, we'll try a robust combo: Crop -> Resize -> Ben Graham)\n    \"\"\"\n    try:\n        # Read\n        img = cv2.imread(image_path)\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        \n        # 1. Visualization Original\n        orig = img.copy()\n        \n        # 2. Crop\n        img_cropped = crop_image_from_gray(img)\n        img_cropped = cv2.resize(img_cropped, (IMG_SIZE, IMG_SIZE))\n        \n        # 3. CLAHE (Standalone for visualization)\n        img_clahe = apply_clahe(img_cropped)\n        \n        # 4. Ben Graham (Standalone for visualization & Usage)\n        img_ben = ben_graham_preprocessing(img_cropped, sigmaX=sigmaX)\n        \n        # Final Preprocessed Image (Using Ben Graham as it's standard for DR)\n        final_img = img_ben\n        \n        # Normalize to [0, 1]\n        final_img_norm = final_img.astype('float32') / 255.0\n\n        if visualize:\n            return orig, img_ben, img_clahe, final_img\n        else:\n            return final_img_norm\n            \n    except Exception as e:\n        print(f\"Error processing {image_path}: {e}\")\n        return np.zeros((IMG_SIZE, IMG_SIZE, 3))\n\ndef visualize_preprocessing_steps(df, n=3):\n    \"\"\"\n    Visualizes and saves the processing steps for N random images.\n    \"\"\"\n    sample_df = df.sample(n)\n    for idx, row in sample_df.iterrows():\n        img_id = row['id_code']\n        path = os.path.join(TRAIN_IMG_DIR, f\"{img_id}.png\")\n        \n        if not os.path.exists(path):\n            continue\n            \n        orig, ben, clahe, final = preprocess_pipeline(path, visualize=True)\n        \n        fig, axes = plt.subplots(1, 4, figsize=(20, 5))\n        axes[0].imshow(orig)\n        axes[0].set_title(\"Original\")\n        axes[0].axis('off')\n        \n        axes[1].imshow(ben)\n        axes[1].set_title(\"Ben Graham Processed\")\n        axes[1].axis('off')\n        \n        axes[2].imshow(clahe)\n        axes[2].set_title(\"CLAHE Processed\")\n        axes[2].axis('off')\n        \n        axes[3].imshow(final)\n        axes[3].set_title(\"Final Input (Resized)\")\n        axes[3].axis('off')\n        \n        save_path = os.path.join(PREPROCESS_OUT_DIR, f\"preprocess_{img_id}.png\")\n        plt.tight_layout()\n        plt.savefig(save_path)\n        plt.close()\n        print(f\"Saved preprocessing visualization to {save_path}\")\n\n# ------------------------------------------------------------------------------\n# DATAFRAME & GENERATORS\n# ------------------------------------------------------------------------------\n\ndef get_data_generators():\n    if not os.path.exists(CSV_PATH):\n        print(f\"WARNING: Dataset not found at {CSV_PATH}. Creating dummy data for syntax check.\")\n        # Dummy data for code validation if actual dataset isn't present in environment\n        df = pd.DataFrame({\n            'id_code': ['dummy1', 'dummy2', 'dummy3', 'dummy4'],\n            'diagnosis': [0, 1, 0, 1]\n        })\n    else:\n        df = pd.read_csv(CSV_PATH)\n    \n    # Binary Classification\n    df['binary_target'] = df['diagnosis'].apply(lambda x: 1 if x > 0 else 0)\n    df['file_path'] = df['id_code'].apply(lambda x: os.path.join(TRAIN_IMG_DIR, f\"{x}.png\"))\n    \n    train, val = train_test_split(df, test_size=0.2, random_state=SEED, stratify=df['binary_target'])\n    \n    print(f\"Training set: {len(train)}\")\n    print(f\"Validation set: {len(val)}\")\n    \n    # Custom Data Generator logic\n    # Using ImageDataGenerator is tricky with custom complex preprocessing (Ben Graham)\n    # We will build a simple Tf.data pipeline or a custom generator.\n    # For Kaggle limits, a custom generator yielding preprocessed batches is efficient.\n    \n    class DataGenerator(tf.keras.utils.Sequence):\n        def __init__(self, df, batch_size=16, shuffle=True, augment=False, mixup_alpha=0.2):\n            self.df = df\n            self.batch_size = batch_size\n            self.shuffle = shuffle\n            self.augment = augment\n            self.mixup_alpha = mixup_alpha\n            self.indices = np.arange(len(self.df))\n            self.on_epoch_end()\n            \n            # Augmentation layers\n            self.aug_layers = tf.keras.Sequential([\n                tf.keras.layers.RandomFlip(\"horizontal_and_vertical\"),\n                tf.keras.layers.RandomRotation(0.2), # Increased rotation\n                tf.keras.layers.RandomZoom(0.1),\n                tf.keras.layers.RandomContrast(0.1),\n                tf.keras.layers.RandomTranslation(0.1, 0.1)\n            ]) if augment else None\n\n        def __len__(self):\n            return int(np.floor(len(self.df) / self.batch_size))\n\n        def __getitem__(self, index):\n            indices = self.indices[index*self.batch_size:(index+1)*self.batch_size]\n            \n            X, y = self.__data_generation(indices)\n            \n            if self.augment:\n                # Apply Mixup with probability 0.5\n                if np.random.random() > 0.5:\n                    X, y = self.mixup(X, y)\n                else:\n                    X = self.aug_layers(X)\n                \n            return X, y\n            \n        def __data_generation(self, indices):\n            batch_df = self.df.iloc[indices]\n            X = np.empty((self.batch_size, IMG_SIZE, IMG_SIZE, 3), dtype=np.float32)\n            y = np.empty((self.batch_size), dtype=np.float32)\n            \n            for i, (_, row) in enumerate(batch_df.iterrows()):\n                if not os.path.exists(row['file_path']):\n                    img = np.random.rand(IMG_SIZE, IMG_SIZE, 3).astype(np.float32)\n                else:\n                    img = preprocess_pipeline(row['file_path'], visualize=False)\n                X[i,] = img\n                y[i] = row['binary_target']\n            return X, y\n\n        def mixup(self, X, y):\n            # Mixup implementation\n            batch_size = X.shape[0]\n            weight = np.random.beta(self.mixup_alpha, self.mixup_alpha, batch_size)\n            X_weight = weight.reshape(batch_size, 1, 1, 1)\n            y_weight = weight.reshape(batch_size)\n            \n            index = np.arange(batch_size)\n            np.random.shuffle(index)\n            \n            X_shuffled = X[index]\n            y_shuffled = y[index]\n            \n            X_mix = X_weight * X + (1 - X_weight) * X_shuffled\n            y_mix = y_weight * y + (1 - y_weight) * y_shuffled\n            \n            return X_mix, y_mix\n\n        def on_epoch_end(self):\n            if self.shuffle:\n                np.random.shuffle(self.indices)\n\n    train_gen = DataGenerator(train, batch_size=BATCH_SIZE, augment=True)\n    val_gen = DataGenerator(val, batch_size=BATCH_SIZE, augment=False)\n    \n    # Class Weights\n    neg, pos = np.bincount(df['binary_target'])\n    total = neg + pos\n    # Weight for class 0\n    weight0 = (1 / neg) * (total / 2.0)\n    # Weight for class 1\n    weight1 = (1 / pos) * (total / 2.0)\n    class_weights = {0: weight0, 1: weight1}\n    print(f\"Class Weights: {class_weights}\")\n    \n    return train_gen, val_gen, class_weights, df\n\n# ------------------------------------------------------------------------------\n# MODEL ARCHITECTURE: CBAM + EfficientNet\n# ------------------------------------------------------------------------------\n\nclass CBAMBlock(Layer):\n    \"\"\"\n    Convolutional Block Attention Module (CBAM)\n    Consists of Channel Attention Module (CAM) and Spatial Attention Module (SAM).\n    \"\"\"\n    def __init__(self, reduction_ratio=16, **kwargs):\n        super(CBAMBlock, self).__init__(**kwargs)\n        self.reduction_ratio = reduction_ratio\n\n    def build(self, input_shape):\n        channel_dims = input_shape[-1]\n        \n        # Channel Attention\n        self.global_avg_pool = GlobalAveragePooling2D()\n        self.global_max_pool = tf.keras.layers.GlobalMaxPooling2D()\n        \n        self.shared_dense_one = Dense(channel_dims // self.reduction_ratio, activation='relu', use_bias=True)\n        self.shared_dense_two = Dense(channel_dims, use_bias=True)\n        \n        # Spatial Attention\n        self.conv2d_spatial = Conv2D(1, (7, 7), padding='same', activation='sigmoid', use_bias=False)\n        \n        super(CBAMBlock, self).build(input_shape)\n\n    def call(self, inputs):\n        # Channel Attention\n        # Avg Pool Path\n        avg_pool = self.global_avg_pool(inputs)\n        avg_pool = tf.keras.layers.Reshape((1, 1, avg_pool.shape[1]))(avg_pool)\n        avg_out = self.shared_dense_two(self.shared_dense_one(avg_pool))\n        \n        # Max Pool Path\n        max_pool = self.global_max_pool(inputs)\n        max_pool = tf.keras.layers.Reshape((1, 1, max_pool.shape[1]))(max_pool)\n        max_out = self.shared_dense_two(self.shared_dense_one(max_pool))\n        \n        channel_attention = Activation('sigmoid')(Add()([avg_out, max_out]))\n        channel_refined = Multiply()([inputs, channel_attention])\n        \n        # Spatial Attention\n        avg_pool_spatial = tf.reduce_mean(channel_refined, axis=-1, keepdims=True)\n        max_pool_spatial = tf.reduce_max(channel_refined, axis=-1, keepdims=True)\n        concat_spatial = tf.concat([avg_pool_spatial, max_pool_spatial], axis=-1)\n        spatial_attention = self.conv2d_spatial(concat_spatial)\n        \n        # Final refinement\n        return Multiply()([channel_refined, spatial_attention])\n\ndef build_dr_model():\n    base_model = EfficientNetB0(weights='imagenet', include_top=False, input_shape=(IMG_SIZE, IMG_SIZE, 3))\n    \n    # Fine-tuning: Unfreeze top layers\n    base_model.trainable = True\n    # Freeze bottom N layers if necessary, but B0 is small enough to fine-tune generally.\n    # Let's freeze the first 50% for stability initially if we wanted, but standard transfer learning often works fine.\n    \n    x = base_model.output\n    \n    # Add CBAM Block\n    x = CBAMBlock(reduction_ratio=16, name=\"CBAM_Block\")(x)\n    \n    # Custom Head\n    x = GlobalAveragePooling2D()(x)\n    x = BatchNormalization()(x)\n    x = Dense(256, activation='relu')(x)\n    x = Dropout(0.4)(x)\n    outputs = Dense(1, activation='sigmoid')(x)\n    \n    model = Model(inputs=base_model.input, outputs=outputs)\n    \n    # Use AdamW if available, else Adam. Weight decay helps regularization.\n    metrics = ['accuracy', tf.keras.metrics.AUC(name='auc')]\n    \n    # Initial Compile (We might re-compile in training loop, but this is default)\n    model.compile(optimizer='adam', loss='binary_crossentropy', metrics=metrics)\n    \n    return model\n\n# ------------------------------------------------------------------------------\n# TRAINING\n# ------------------------------------------------------------------------------\n\ndef train_network():\n    print(\"\\n--- Starting Pipeline ---\")\n    \n    # 1. Setup Data\n    train_gen, val_gen, class_weights, full_df = get_data_generators()\n    \n    # 2. Visualize Preprocessing (Save artifacts)\n    visualize_preprocessing_steps(full_df, n=5)\n    \n    # 3. Build Model\n    model = build_dr_model()\n    model.summary()\n    \n    # 4. Callbacks\n    checkpoint = ModelCheckpoint(\n        os.path.join(OUTPUT_DIR, 'best_model.keras'), \n        monitor='val_auc', \n        verbose=1, \n        save_best_only=True,\n        mode='max'\n    )\n    reduce_lr = ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=3, verbose=1, min_lr=1e-6)\n    early_stop = EarlyStopping(monitor='val_loss', patience=6, verbose=1, restore_best_weights=True)\n    \n    callbacks = [checkpoint, reduce_lr, early_stop]\n    \n    # 5. Train - 2 STAGE STRATEGY\n    print(\"\\n--- Starting Training ---\")\n    \n    # STAGE 1: WARMUP (Head only)\n    print(\"\\n[Stage 1] Warmup: Training Head only...\")\n    for layer in model.layers[:-10]: # Freeze most layers \n        layer.trainable = False\n        \n    model.compile(\n        optimizer=Adam(learning_rate=1e-3), \n        loss=tf.keras.losses.BinaryCrossentropy(label_smoothing=0.0), # No smoothing in warmup\n        metrics=['accuracy', tf.keras.metrics.AUC(name='auc')]\n    )\n    \n    history_warmup = model.fit(\n        train_gen,\n        validation_data=val_gen,\n        epochs=3, # Short warmup\n        class_weight=class_weights,\n         verbose=1\n    )\n    \n    # STAGE 2: FINE-TUNING (Full Model)\n    print(\"\\n[Stage 2] Fine-tuning: Unfreezing entire model...\")\n    for layer in model.layers:\n        layer.trainable = True\n        \n    # Cosine Decay Learning Rate\n    # Starts at 1e-4, decays to 1e-6 over remaining EPOCHS\n    lr_scheduler = tf.keras.optimizers.schedules.CosineDecay(\n        initial_learning_rate=1e-4, \n        decay_steps=EPOCHS * len(train_gen), \n        alpha=0.01\n    )\n    \n    # Re-compile with AdamW (if available) and Label Smoothing\n    # Label Smoothing helps prevent overfitting and stabilizes validation loss\n    try:\n        opt = AdamW(learning_rate=lr_scheduler, weight_decay=1e-5)\n    except:\n        opt = Adam(learning_rate=lr_scheduler)\n\n    model.compile(\n        optimizer=opt, \n        loss=tf.keras.losses.BinaryCrossentropy(label_smoothing=0.1), \n        metrics=['accuracy', tf.keras.metrics.AUC(name='auc')]\n    )\n    \n    checkpoint = ModelCheckpoint(\n        os.path.join(OUTPUT_DIR, 'best_model.keras'), \n        monitor='val_auc', \n        verbose=1, \n        save_best_only=True,\n        mode='max'\n    )\n    \n    # We remove ReduceLROnPlateau because we are using CosineDecay\n    callbacks = [checkpoint]\n    \n    history_finetune = model.fit(\n        train_gen,\n        validation_data=val_gen,\n        epochs=EPOCHS,\n        class_weight=class_weights,\n        callbacks=callbacks\n    )\n    \n    # Combine histories for plotting\n    # (Simplified: just return finetune history as it's the main one, \n    # or we can stitch them manually if really needed, but usually Stage 2 is what matters)\n    return model, history_finetune, val_gen\n\n# ------------------------------------------------------------------------------\n# EVALUATION & PLOTTING\n# ------------------------------------------------------------------------------\n\ndef plot_history(history):\n    # Accuracy\n    plt.figure(figsize=(10, 5))\n    plt.plot(history.history['accuracy'], label='Train Accuracy')\n    plt.plot(history.history['val_accuracy'], label='Val Accuracy')\n    plt.title('Accuracy over Epochs')\n    plt.xlabel('Epochs')\n    plt.ylabel('Accuracy')\n    plt.legend()\n    plt.savefig(os.path.join(PLOTS_DIR, 'accuracy_plot.png'))\n    plt.close()\n    \n    # Loss\n    plt.figure(figsize=(10, 5))\n    plt.plot(history.history['loss'], label='Train Loss')\n    plt.plot(history.history['val_loss'], label='Val Loss')\n    plt.title('Loss over Epochs')\n    plt.xlabel('Epochs')\n    plt.ylabel('Loss')\n    plt.legend()\n    plt.savefig(os.path.join(PLOTS_DIR, 'loss_plot.png'))\n    plt.close()\n\ndef evaluate_model(model, val_gen):\n    print(\"\\n--- Evaluating Model ---\")\n    # Get all X, y from validation generator for sklearn metrics\n    # Note: Generator shuffles by default, but we need consistency for manual prediction loop\n    # We'll just iterate through the generator (it loops indefinitely if standard, but Sequence has len)\n    \n    y_true = []\n    y_pred_proba = []\n    \n    # Temporarily disable shuffle for evaluation\n    val_gen.shuffle = False\n    val_gen.on_epoch_end() # Reset order\n    \n    for i in range(len(val_gen)):\n        X_batch, y_batch = val_gen[i]\n        preds = model.predict(X_batch, verbose=0)\n        y_true.extend(y_batch)\n        y_pred_proba.extend(preds.flatten())\n    \n    y_true = np.array(y_true)\n    y_pred_proba = np.array(y_pred_proba)\n    y_pred = (y_pred_proba > 0.5).astype(int)\n    \n    # Metrics\n    acc = np.mean(y_true == y_pred)\n    prec = precision_score(y_true, y_pred, zero_division=0)\n    rec = recall_score(y_true, y_pred, zero_division=0)\n    f1 = f1_score(y_true, y_pred, zero_division=0)\n    try:\n        auc = roc_auc_score(y_true, y_pred_proba)\n    except:\n        auc = 0.5 # Single class case handle\n        \n    print(f\"\\nFinal Test Accuracy: {acc:.4f}\")\n    print(f\"Precision: {prec:.4f}\")\n    print(f\"Recall: {rec:.4f}\")\n    print(f\"F1 Score: {f1:.4f}\")\n    print(f\"ROC-AUC: {auc:.4f}\")\n    \n    # Confusion Matrix\n    cm = confusion_matrix(y_true, y_pred)\n    plt.figure(figsize=(6, 5))\n    sns.heatmap(cm, annot=True, fmt='d', cmap='Blues')\n    plt.title('Confusion Matrix')\n    plt.ylabel('True Label')\n    plt.xlabel('Predicted Label')\n    plt.savefig(os.path.join(PLOTS_DIR, 'confusion_matrix.png'))\n    plt.close()\n    \n    # ROC Curve\n    fpr, tpr, _ = roc_curve(y_true, y_pred_proba)\n    plt.figure(figsize=(8, 6))\n    plt.plot(fpr, tpr, label=f\"AUC = {auc:.4f}\")\n    plt.plot([0, 1], [0, 1], 'r--')\n    plt.title('ROC Curve')\n    plt.xlabel('False Positive Rate')\n    plt.ylabel('True Positive Rate')\n    plt.legend()\n    plt.savefig(os.path.join(PLOTS_DIR, 'roc_curve.png'))\n    plt.close()\n    \n    # Save Predictions\n    results_df = pd.DataFrame({\n        'True': y_true,\n        'Pred_Proba': y_pred_proba,\n        'Pred_Label': y_pred\n    })\n    results_df.to_csv(os.path.join(OUTPUT_DIR, 'predictions.csv'), index=False)\n    print(f\"Results saved to {os.path.join(OUTPUT_DIR, 'predictions.csv')}\")\n\nif __name__ == '__main__':\n    try:\n        model, history, val_gen = train_network()\n        plot_history(history)\n        evaluate_model(model, val_gen)\n        print(\"\\nPipeline execution complete.\")\n    except Exception as e:\n        print(f\"\\n[CRITICAL ERROR] Pipeline Failed: {e}\")\n        import traceback\n        traceback.print_exc()\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-01-04T12:51:38.425481Z","iopub.execute_input":"2026-01-04T12:51:38.426279Z","iopub.status.idle":"2026-01-04T16:55:04.797700Z","shell.execute_reply.started":"2026-01-04T12:51:38.426246Z","shell.execute_reply":"2026-01-04T16:55:04.797021Z"}},"outputs":[],"execution_count":null}]}