{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":14774,"databundleVersionId":875431}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import os\n\n# Search for the correct path\nprint(\"Searching for APTOS dataset...\")\nbase = \"/kaggle/input\"\n\nfor folder in os.listdir(base):\n    print(f\"  Found: {folder}\")\n    subfolder = os.path.join(base, folder)\n    for item in os.listdir(subfolder):\n        print(f\"    - {item}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:19:56.160424Z","iopub.execute_input":"2026-05-27T12:19:56.160871Z","iopub.status.idle":"2026-05-27T12:19:56.172352Z","shell.execute_reply.started":"2026-05-27T12:19:56.160828Z","shell.execute_reply":"2026-05-27T12:19:56.171525Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pathlib import Path\n\nTRAIN_CSV = Path(\"/kaggle/input/competitions/aptos2019-blindness-detection/train.csv\")\nTRAIN_IMG = Path(\"/kaggle/input/competitions/aptos2019-blindness-detection/train_images\")\n\nprint(f\"CSV exists   : {TRAIN_CSV.exists()}\")\nprint(f\"Images exist : {TRAIN_IMG.exists()}\")\nprint(f\"Image count  : {len(list(TRAIN_IMG.glob('*.png')))}\")\nprint(\"✅ Done!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:19:56.173730Z","iopub.execute_input":"2026-05-27T12:19:56.174087Z","iopub.status.idle":"2026-05-27T12:19:56.258126Z","shell.execute_reply.started":"2026-05-27T12:19:56.174047Z","shell.execute_reply":"2026-05-27T12:19:56.257489Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Install missing packages ───────────────────────────────────\n!pip install -q imbalanced-learn\nprint(\"✅ Packages ready.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:19:56.259213Z","iopub.execute_input":"2026-05-27T12:19:56.259496Z","iopub.status.idle":"2026-05-27T12:20:00.803081Z","shell.execute_reply.started":"2026-05-27T12:19:56.259467Z","shell.execute_reply":"2026-05-27T12:20:00.802099Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os, random, warnings, gc\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom pathlib import Path\n\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.utils.class_weight import compute_class_weight\nfrom sklearn.metrics import (\n    classification_report, confusion_matrix,\n    cohen_kappa_score, roc_curve, auc\n)\nfrom sklearn.preprocessing import label_binarize\n\nimport tensorflow as tf\nfrom tensorflow import keras\nfrom tensorflow.keras import layers, Model\nfrom tensorflow.keras.applications import EfficientNetB3\nfrom tensorflow.keras.callbacks import (\n    EarlyStopping, ModelCheckpoint,\n    ReduceLROnPlateau, CSVLogger\n)\n\nwarnings.filterwarnings(\"ignore\")\n\n# ════════════════════════════════════════════════════════════\n#  CONSTANTS\n# ════════════════════════════════════════════════════════════\n# ── Updated constants ──────────────────────────────────────────\nSEED        = 42\nIMG_SIZE    = 300    # ← changed from 224 to 300\nBATCH_SIZE  = 16     # ← changed from 32 to 16 (300x300 needs more memory)\nNUM_CLASSES = 5\nCLASS_NAMES = [\"No DR\", \"Mild\", \"Moderate\",\n               \"Severe\", \"Proliferative\"]\nCOLORS      = [\"#22c55e\", \"#84cc16\", \"#f59e0b\",\n               \"#ef4444\", \"#7f1d1d\"]\n\n# ── Paths (same as before) ─────────────────────────────────────\nTRAIN_CSV = Path(\"/kaggle/input/competitions/\"\n                 \"aptos2019-blindness-detection/train.csv\")\nTRAIN_IMG = Path(\"/kaggle/input/competitions/\"\n                 \"aptos2019-blindness-detection/train_images\")\n\nOUT         = Path(\"/kaggle/working\")\nOUT_MODELS  = OUT / \"models\"\nOUT_PLOTS   = OUT / \"plots\"\nOUT_GRADCAM = OUT / \"gradcam\"\nOUT_LOGS    = OUT / \"logs\"\n\nfor d in [OUT_MODELS, OUT_PLOTS, OUT_GRADCAM, OUT_LOGS]:\n    d.mkdir(parents=True, exist_ok=True)\n\n# ── Reproducibility ────────────────────────────────────────────\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntf.random.set_seed(SEED)\nos.environ[\"PYTHONHASHSEED\"]       = str(SEED)\nos.environ[\"TF_DETERMINISTIC_OPS\"] = \"1\"\n\nprint(\"Updated settings:\")\nprint(f\"  IMG_SIZE   : {IMG_SIZE}x{IMG_SIZE}  ← 300 now!\")\nprint(f\"  BATCH_SIZE : {BATCH_SIZE}           ← 16 now!\")\nprint(f\"  NUM_CLASSES: {NUM_CLASSES}\")\nprint(f\"  TensorFlow : {tf.__version__}\")\nprint(f\"  GPU        : {tf.config.list_physical_devices('GPU')}\")\nprint()\nprint(\"✅ Constants updated.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:20:00.805064Z","iopub.execute_input":"2026-05-27T12:20:00.805674Z","iopub.status.idle":"2026-05-27T12:20:30.111675Z","shell.execute_reply.started":"2026-05-27T12:20:00.805643Z","shell.execute_reply":"2026-05-27T12:20:30.110841Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Load CSV and build filepath column ────────────────────────\ndf = pd.read_csv(TRAIN_CSV)\ndf[\"filepath\"] = df[\"id_code\"].apply(\n    lambda x: str(TRAIN_IMG / f\"{x}.png\")\n)\n\nprint(f\"Total images : {len(df)}\")\nprint()\n\n# ── Class distribution ─────────────────────────────────────────\ndist = df[\"diagnosis\"].value_counts().sort_index()\nprint(\"Class distribution:\")\nprint(f\"{'Grade':<6} {'Name':<18} {'Count':>6}  {'%':>6}\")\nprint(\"─\" * 40)\nfor grade, count in dist.items():\n    pct = count / len(df) * 100\n    bar = \"█\" * int(pct / 2)\n    print(f\"  {grade:<4} {CLASS_NAMES[grade]:<18} \"\n          f\"{count:>6}  {pct:>5.1f}%  {bar}\")\nprint()\nprint(\"✅ Dataset reloaded.\")\n\n# ── Verify all image files exist ───────────────────────────────\nprint(\"Checking image files...\")\nmissing = [\n    row[\"filepath\"]\n    for _, row in df.iterrows()\n    if not os.path.exists(row[\"filepath\"])\n]\n\nif len(missing) == 0:\n    print(f\"✅ All {len(df)} image files verified.\")\nelse:\n    print(f\"❌ {len(missing)} missing files!\")\n    for m in missing[:3]:\n        print(f\"   {m}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:20:30.112727Z","iopub.execute_input":"2026-05-27T12:20:30.113355Z","iopub.status.idle":"2026-05-27T12:20:38.904412Z","shell.execute_reply.started":"2026-05-27T12:20:30.113332Z","shell.execute_reply":"2026-05-27T12:20:38.903515Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#processing funtions\ndef apply_clahe(bgr_image: np.ndarray) -> np.ndarray:\n    \"\"\"\n    Apply CLAHE to GREEN channel only.\n    Green channel has highest contrast for retinal\n    vessels, microaneurysms and haemorrhages.\n    \"\"\"\n    clahe   = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n    b, g, r = cv2.split(bgr_image)\n    return cv2.merge([b, clahe.apply(g), r])\n\n\ndef preprocess_image(filepath: str,\n                     img_size: int = IMG_SIZE) -> np.ndarray:\n    \"\"\"\n    Full preprocessing pipeline for one retinal image.\n    Steps: load → resize → CLAHE → BGR→RGB → keep 0-255 range\n\n    IMPORTANT: We keep pixels in 0-255 range (NOT 0-1).\n    EfficientNetB3 handles its own internal normalization.\n    Dividing by 255 caused the training collapse we saw before.\n\n    Returns: float32 array (img_size, img_size, 3) range 0-255\n    \"\"\"\n    img = cv2.imread(filepath)\n    if img is None:\n        raise FileNotFoundError(f\"Cannot load: {filepath}\")\n\n    img = cv2.resize(img, (img_size, img_size),\n                     interpolation=cv2.INTER_AREA)\n    img = apply_clahe(img)\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    img = img.astype(np.float32)  # 0-255, NO division\n    return img\n\n\n# ── Visual check ───────────────────────────────────────────────\ntest_path = df.iloc[0][\"filepath\"]\n\nraw = cv2.cvtColor(\n    cv2.resize(cv2.imread(test_path), (IMG_SIZE, IMG_SIZE)),\n    cv2.COLOR_BGR2RGB\n)\nenhanced = preprocess_image(test_path)\n# Normalize just for display (not for model)\nenhanced_display = enhanced / 255.0\n\nfig, axes = plt.subplots(1, 2, figsize=(10, 4))\nfig.suptitle(\"Preprocessing Check: Original vs CLAHE\",\n             fontsize=13, fontweight=\"bold\")\naxes[0].imshow(raw)\naxes[0].set_title(\"Original\")\naxes[0].axis(\"off\")\naxes[1].imshow(enhanced_display)\naxes[1].set_title(\"CLAHE Enhanced\")\naxes[1].axis(\"off\")\nplt.tight_layout()\nplt.savefig(str(OUT_PLOTS / \"01_preprocessing.png\"),\n            dpi=150, bbox_inches=\"tight\")\nplt.show()\n\n# ── Verify pixel range ─────────────────────────────────────────\nprint(f\"Pixel range : {enhanced.min():.1f} to {enhanced.max():.1f}\")\nprint(f\"Shape       : {enhanced.shape}\")\nprint(f\"Dtype       : {enhanced.dtype}\")\nprint()\nprint(\"✅ Preprocessing functions ready.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:20:38.905374Z","iopub.execute_input":"2026-05-27T12:20:38.905656Z","iopub.status.idle":"2026-05-27T12:20:39.979464Z","shell.execute_reply.started":"2026-05-27T12:20:38.905634Z","shell.execute_reply":"2026-05-27T12:20:39.978606Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Stratified 70/15/15 split ──────────────────────────────────\ntrain_df, temp_df = train_test_split(\n    df, test_size=0.30,\n    stratify=df[\"diagnosis\"],\n    random_state=SEED\n)\nval_df, test_df = train_test_split(\n    temp_df, test_size=0.50,\n    stratify=temp_df[\"diagnosis\"],\n    random_state=SEED\n)\n\ntrain_df = train_df.reset_index(drop=True)\nval_df   = val_df.reset_index(drop=True)\ntest_df  = test_df.reset_index(drop=True)\n\nprint(\"Dataset split:\")\nprint(f\"  Train : {len(train_df):5d} \"\n      f\"({len(train_df)/len(df)*100:.1f}%)\")\nprint(f\"  Val   : {len(val_df):5d} \"\n      f\"({len(val_df)/len(df)*100:.1f}%)\")\nprint(f\"  Test  : {len(test_df):5d} \"\n      f\"({len(test_df)/len(df)*100:.1f}%)\")\nprint()\n\nprint(\"Grade distribution per split:\")\nprint(f\"{'Grade':<6} {'Name':<18} \"\n      f\"{'Train':>7} {'Val':>7} {'Test':>7}\")\nprint(\"─\" * 48)\nfor i in range(NUM_CLASSES):\n    t  = len(train_df[train_df[\"diagnosis\"] == i])\n    v  = len(val_df[val_df[\"diagnosis\"] == i])\n    te = len(test_df[test_df[\"diagnosis\"] == i])\n    print(f\"  {i:<4} {CLASS_NAMES[i]:<18} \"\n          f\"{t:>7} {v:>7} {te:>7}\")\nprint()\n\n# ── Manual class weights (capped at 2.0) ──────────────────────\ngrade_counts = {\n    i: len(train_df[train_df[\"diagnosis\"] == i])\n    for i in range(NUM_CLASSES)\n}\nmax_count = max(grade_counts.values())\n\nclass_weights = {}\nfor i in range(NUM_CLASSES):\n    raw_w = max_count / grade_counts[i]\n    class_weights[i] = round(min(raw_w, 2.0), 4)\n\nprint(\"Class weights (capped at 2.0):\")\nfor i, w in class_weights.items():\n    bar = \"█\" * int(w * 10)\n    print(f\"  Grade {i} ({CLASS_NAMES[i]:<15}): \"\n          f\"{w:.4f}  {bar}\")\nprint()\nprint(\"✅ Split and class weights ready.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:20:39.980500Z","iopub.execute_input":"2026-05-27T12:20:39.980794Z","iopub.status.idle":"2026-05-27T12:20:40.010222Z","shell.execute_reply.started":"2026-05-27T12:20:39.980772Z","shell.execute_reply":"2026-05-27T12:20:40.009548Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#7. oversampling\nfrom imblearn.over_sampling import RandomOverSampler\n\nprint(\"Before oversampling:\")\nfor i in range(NUM_CLASSES):\n    count = len(train_df[train_df[\"diagnosis\"] == i])\n    print(f\"  Grade {i} ({CLASS_NAMES[i]:<15}): {count:5d}\")\n\n# ── Oversample minority classes ────────────────────────────────\nros   = RandomOverSampler(random_state=SEED)\nX_idx = train_df.index.values.reshape(-1, 1)\ny_idx = train_df[\"diagnosis\"].values\n\nX_res, y_res = ros.fit_resample(X_idx, y_idx)\n\n# Rebuild balanced train_df\ntrain_df_bal = train_df.iloc[\n    X_res.flatten()\n].reset_index(drop=True)\n\n# Shuffle after oversampling — prevents grade clustering\ntrain_df_bal = train_df_bal.sample(\n    frac=1, random_state=SEED\n).reset_index(drop=True)\n\nprint(\"\\nAfter oversampling + shuffle:\")\nfor i in range(NUM_CLASSES):\n    count = len(\n        train_df_bal[train_df_bal[\"diagnosis\"] == i]\n    )\n    print(f\"  Grade {i} ({CLASS_NAMES[i]:<15}): {count:5d}\")\n\nprint(f\"\\nTotal train samples : {len(train_df_bal)}\")\n\nprint(f\"\\nFirst 10 grades (should be mixed):\")\nprint(train_df_bal[\"diagnosis\"].head(10).tolist())\nprint()\nprint(\"✅ Oversampling done.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:20:40.011127Z","iopub.execute_input":"2026-05-27T12:20:40.011396Z","iopub.status.idle":"2026-05-27T12:20:41.303361Z","shell.execute_reply.started":"2026-05-27T12:20:40.011376Z","shell.execute_reply":"2026-05-27T12:20:41.302503Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#tf pipeline 300 x 300\ndef tf_load(filepath: tf.Tensor,\n            label: tf.Tensor):\n    \"\"\"\n    Load image — pixels kept at 0-255 range.\n    300x300 now instead of 224x224.\n    \"\"\"\n    raw   = tf.io.read_file(filepath)\n    image = tf.image.decode_png(raw, channels=3)\n    image = tf.image.resize(image, [IMG_SIZE, IMG_SIZE])\n    image = tf.cast(image, tf.float32)\n    # NO division by 255!\n    label = tf.one_hot(\n        tf.cast(label, tf.int32), NUM_CLASSES\n    )\n    return image, label\n\n\ndef tf_augment(image: tf.Tensor,\n               label: tf.Tensor):\n    \"\"\"\n    Augmentation for 0-255 range images.\n    Slightly stronger than before to help\n    with 300x300 variety.\n    \"\"\"\n    image = tf.image.random_flip_left_right(image)\n    image = tf.image.random_flip_up_down(image)\n    image = tf.image.random_brightness(\n        image, max_delta=25.5\n    )\n    image = tf.image.random_contrast(\n        image, lower=0.8, upper=1.2\n    )\n    image = tf.image.random_saturation(\n        image, lower=0.9, upper=1.1\n    )\n    image = tf.image.random_hue(\n        image, max_delta=0.05  # ← new! subtle hue shift\n    )\n    image = tf.clip_by_value(image, 0.0, 255.0)\n    return image, label\n\n\ndef build_dataset(\n    dataframe:  pd.DataFrame,\n    augment:    bool = False,\n    shuffle:    bool = False\n) -> tf.data.Dataset:\n    \"\"\"Build prefetched batched tf.data.Dataset.\"\"\"\n    paths  = dataframe[\"filepath\"].values\n    labels = dataframe[\"diagnosis\"].astype(\n        np.int32\n    ).values\n\n    ds = tf.data.Dataset.from_tensor_slices(\n        (paths, labels)\n    )\n\n    if shuffle:\n        ds = ds.shuffle(\n            buffer_size=len(dataframe),\n            seed=SEED,\n            reshuffle_each_iteration=True\n        )\n\n    ds = ds.map(\n        tf_load,\n        num_parallel_calls=tf.data.AUTOTUNE\n    )\n\n    if augment:\n        ds = ds.map(\n            tf_augment,\n            num_parallel_calls=tf.data.AUTOTUNE\n        )\n\n    return ds.batch(BATCH_SIZE).prefetch(\n        tf.data.AUTOTUNE\n    )\n\n\n# ── Build all three datasets ───────────────────────────────────\n# Train uses oversampled balanced data\ntrain_ds = build_dataset(\n    train_df_bal, augment=True,  shuffle=True\n)\nval_ds   = build_dataset(\n    val_df,       augment=False, shuffle=False\n)\ntest_ds  = build_dataset(\n    test_df,      augment=False, shuffle=False\n)\n\n# ── Verify everything ──────────────────────────────────────────\nprint(\"Verifying pipeline...\")\nfor imgs, lbls in train_ds.take(1):\n    print(f\"Image shape : {imgs.shape}\")\n    print(f\"Label shape : {lbls.shape}\")\n    print(f\"Pixel range : {imgs.numpy().min():.1f}\"\n          f\" to {imgs.numpy().max():.1f}\"\n          f\"  (must be 0 to ~255)\")\n    print(f\"First 5 labels (should be mixed):\")\n    for i in range(5):\n        g = np.argmax(lbls[i].numpy())\n        print(f\"  Sample {i}: Grade {g}\"\n              f\" ({CLASS_NAMES[g]})\")\n\nprint()\nprint(f\"Train batches : {len(train_ds)}\")\nprint(f\"Val   batches : {len(val_ds)}\")\nprint(f\"Test  batches : {len(test_ds)}\")\nprint()\nprint(\"✅ tf.data pipeline ready.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:20:41.305059Z","iopub.execute_input":"2026-05-27T12:20:41.306003Z","iopub.status.idle":"2026-05-27T12:20:45.113423Z","shell.execute_reply.started":"2026-05-27T12:20:41.305932Z","shell.execute_reply":"2026-05-27T12:20:45.112343Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Focal Loss function ────────────────────────────────────────\ndef focal_loss(gamma: float = 2.0,\n               alpha: float = 0.25):\n    \"\"\"\n    Focal Loss — designed for imbalanced classification.\n\n    How it works:\n    - Easy examples (model already confident) → small loss\n    - Hard examples (model uncertain) → large loss\n\n    This forces the model to focus on difficult grades\n    like Grade 3 (Severe) instead of just getting\n    easy Grade 0 correct.\n\n    gamma : focusing parameter\n            0.0 = same as crossentropy\n            2.0 = standard focal loss (recommended)\n    alpha : weight factor (0.25 is standard)\n    \"\"\"\n    def loss_fn(y_true, y_pred):\n        # Clip predictions to prevent log(0)\n        y_pred = tf.clip_by_value(y_pred, 1e-8, 1.0)\n\n        # Standard cross entropy\n        ce = -y_true * tf.math.log(y_pred)\n\n        # Probability of correct class\n        pt = tf.reduce_sum(\n            y_true * y_pred, axis=-1, keepdims=True\n        )\n\n        # Focal weight — down-weights easy examples\n        focal_weight = alpha * tf.pow(1.0 - pt, gamma)\n\n        # Final focal loss\n        loss = focal_weight * ce\n        return tf.reduce_mean(tf.reduce_sum(loss, axis=-1))\n\n    return loss_fn\n    \n# build model\ndef build_model(learning_rate: float = 1e-4):\n    \"\"\"\n    EfficientNetB3 transfer learning model.\n\n    Architecture:\n      Input (300x300x3)\n      → EfficientNetB3 [ImageNet, frozen Stage 1]\n      → GlobalAveragePooling2D\n      → BatchNormalization\n      → Dropout(0.4)\n      → Dense(256, relu)\n      → Dropout(0.2)\n      → Dense(5, softmax)\n\n    Note: EfficientNetB3 receives 0-255 pixels\n    and handles its own normalization internally.\n    \"\"\"\n    # Base backbone — frozen for Stage 1\n    base = EfficientNetB3(\n        weights=\"imagenet\",\n        include_top=False,\n        input_shape=(IMG_SIZE, IMG_SIZE, 3)\n    )\n    base.trainable = False\n\n    # Custom classification head\n    inputs  = keras.Input(shape=(IMG_SIZE, IMG_SIZE, 3))\n    x       = base(inputs, training=False)\n    x       = layers.GlobalAveragePooling2D()(x)\n    x       = layers.BatchNormalization()(x)\n    x       = layers.Dropout(0.4)(x)\n    x       = layers.Dense(256, activation=\"relu\")(x)\n    x       = layers.Dropout(0.2)(x)\n    outputs = layers.Dense(\n        NUM_CLASSES, activation=\"softmax\"\n    )(x)\n\n    model = Model(\n        inputs=inputs,\n        outputs=outputs,\n        name=\"DR_EfficientNetB3_FocalLoss\"\n    )\n\n   # Compile with Focal Loss instead of crossentropy\n    model.compile(\n        optimizer=keras.optimizers.Adam(\n            learning_rate=learning_rate\n        ),\n        loss=focal_loss(gamma=2.0, alpha=0.25),\n        metrics=[\"accuracy\"]\n    )\n    return model, base\n\n\n# ── Build fresh model ──────────────────────────────────────────\ngc.collect()\ntf.keras.backend.clear_session()\n\nmodel, base_model = build_model(learning_rate=1e-4)\n\n# Parameter summary\ntotal     = model.count_params()\ntrainable = sum(\n    tf.size(w).numpy()\n    for w in model.trainable_weights\n)\nfrozen = total - trainable\n\nprint(\"Model built with Focal Loss!\")\nprint()\nprint(f\"  Loss function: Focal Loss (gamma=2.0)\")\nprint(f\"  Input size   : {IMG_SIZE}x{IMG_SIZE}\")\nprint(f\"  Total params : {total:>12,}\")\nprint(f\"  Trainable    : {trainable:>12,}\")\nprint(f\"  Frozen       : {frozen:>12,}\")\nprint()\n\n# Sanity check\nfor imgs, lbls in val_ds.take(1):\n    preds = model(imgs, training=False)\n    print(\"Fresh model predictions (should be ~0.20):\")\n    for i, p in enumerate(preds[0].numpy()):\n        bar = \"█\" * int(p * 50)\n        print(f\"  Grade {i} ({CLASS_NAMES[i]:<15}): \"\n              f\"{p:.4f}\")\n    print(f\"  Sum : {preds[0].numpy().sum():.4f}\"\n          f\"  (must be 1.0)\")\n\nprint()\nprint(\"✅ Model ready for training.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:20:45.115865Z","iopub.execute_input":"2026-05-27T12:20:45.116331Z","iopub.status.idle":"2026-05-27T12:20:51.584214Z","shell.execute_reply.started":"2026-05-27T12:20:45.116308Z","shell.execute_reply":"2026-05-27T12:20:51.583424Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#stage 1 training\n# ── Callbacks ──────────────────────────────────────────────────\nCKPT_PATH = str(OUT_MODELS / \"best_model.keras\")\n\ncallbacks_s1 = [\n    ModelCheckpoint(\n        filepath=CKPT_PATH,\n        monitor=\"val_accuracy\",\n        save_best_only=True,\n        mode=\"max\",\n        verbose=1\n    ),\n    EarlyStopping(\n        monitor=\"val_accuracy\",\n        patience=7,\n        restore_best_weights=True,\n        verbose=1\n    ),\n    ReduceLROnPlateau(\n        monitor=\"val_loss\",\n        factor=0.5,\n        patience=3,\n        min_lr=1e-8,\n        verbose=1\n    ),\n    CSVLogger(\n        str(OUT_LOGS / \"training_log.csv\"),\n        append=False\n    )\n]\n\nprint(\"=\" * 55)\nprint(\"  STAGE 1 — Head only (base FROZEN)\")\nprint(f\"  Loss       : Focal Loss (gamma=2.0) ← new!\")\nprint(f\"  LR         : 1e-4\")\nprint(f\"  IMG size   : {IMG_SIZE}x{IMG_SIZE}  ← bigger!\")\nprint(f\"  Batch size : {BATCH_SIZE}\")\nprint(f\"  Max epochs : 25              ← more!\")\nprint(f\"  Train size : {len(train_df_bal)} (oversampled)\")\nprint(\"=\" * 55)\nprint()\n\nhistory_s1 = model.fit(\n    train_ds,\n    validation_data=val_ds,\n    epochs=25,\n    class_weight=class_weights,\n    callbacks=callbacks_s1,\n    verbose=1\n)\n\n# ── Prediction check ───────────────────────────────────────────\nprint(\"\\n── Prediction check after Stage 1 ──\")\ny_val_prob = model.predict(val_ds, verbose=0)\ny_val_pred = np.argmax(y_val_prob, axis=1)\ny_val_true = val_df[\"diagnosis\"].values\n\nprint(\"Prediction distribution:\")\nunique, counts = np.unique(y_val_pred, return_counts=True)\nfor g, c in zip(unique, counts):\n    print(f\"  Grade {g} ({CLASS_NAMES[g]:<15}): \"\n          f\"{c:4d} ({c/len(y_val_pred)*100:.1f}%)\")\n\ns1_acc = np.mean(y_val_pred == y_val_true)\nprint(f\"\\nVal accuracy : {s1_acc:.4f}\")\nprint(f\"Best val acc : \"\n      f\"{max(history_s1.history['val_accuracy']):.4f}\")\nprint()\nprint(\"✅ Stage 1 complete.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T12:20:51.585279Z","iopub.execute_input":"2026-05-27T12:20:51.585592Z","iopub.status.idle":"2026-05-27T13:14:20.151142Z","shell.execute_reply.started":"2026-05-27T12:20:51.585570Z","shell.execute_reply":"2026-05-27T13:14:20.150294Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Backup Stage 1 model first ─────────────────────────────────\nimport shutil\nshutil.copy(\n    str(OUT_MODELS / \"best_model.keras\"),\n    str(OUT_MODELS / \"best_model_stage1.keras\")\n)\nprint(\"✅ Stage 1 model backed up!\")\nprint(f\"   Val accuracy: 0.7614\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T13:14:20.152350Z","iopub.execute_input":"2026-05-27T13:14:20.152680Z","iopub.status.idle":"2026-05-27T13:14:20.184987Z","shell.execute_reply.started":"2026-05-27T13:14:20.152644Z","shell.execute_reply":"2026-05-27T13:14:20.184178Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#stage 2 fine tuning\n\n# ── Unfreeze top 30 layers ─────────────────────────────────────\nprint(\"Unfreezing top 30 layers...\")\nbase_model.trainable = True\n\nfor layer in base_model.layers[:-30]:\n    layer.trainable = False\n\ntrainable_now = sum(\n    tf.size(w).numpy()\n    for w in model.trainable_weights\n)\nprint(f\"Trainable params now: {trainable_now:,}\")\nprint(f\"Was 397,829 in Stage 1\")\nprint()\n\n# Recompile with Focal Loss at lower LR\nmodel.compile(\n    optimizer=keras.optimizers.Adam(\n        learning_rate=1e-5\n    ),\n    loss=focal_loss(gamma=2.0, alpha=0.25),\n    metrics=[\"accuracy\"]\n)\nprint(\"Recompiled with LR=1e-5 + Focal Loss\")\n\n# ── Callbacks ──────────────────────────────────────────────────\ncallbacks_s2 = [\n    ModelCheckpoint(\n        filepath=CKPT_PATH,\n        monitor=\"val_accuracy\",\n        save_best_only=True,\n        mode=\"max\",\n        verbose=1\n    ),\n    EarlyStopping(\n        monitor=\"val_accuracy\",\n        patience=8,\n        restore_best_weights=True,\n        verbose=1\n    ),\n    ReduceLROnPlateau(\n        monitor=\"val_loss\",\n        factor=0.5,\n        patience=3,\n        min_lr=1e-9,\n        verbose=1\n    ),\n    CSVLogger(\n        str(OUT_LOGS / \"training_log.csv\"),\n        append=True\n    )\n]\n\nprint()\nprint(\"=\" * 55)\nprint(\"  STAGE 2 — Fine-tuning top 30 layers\")\nprint(f\"  Loss       : Focal Loss (gamma=2.0)\")\nprint(f\"  LR         : 1e-5  (10x lower)\")\nprint(f\"  Max epochs : 30    (more room)\")\nprint(f\"  Starting   : val_acc=0.7614\")\nprint(\"=\" * 55)\nprint()\n\nhistory_s2 = model.fit(\n    train_ds,\n    validation_data=val_ds,\n    epochs=30,\n    class_weight=class_weights,\n    callbacks=callbacks_s2,\n    verbose=1\n)\n\n# ── Prediction check ───────────────────────────────────────────\nprint(\"\\n── Prediction check after Stage 2 ──\")\ny_val_prob = model.predict(val_ds, verbose=0)\ny_val_pred = np.argmax(y_val_prob, axis=1)\ny_val_true = val_df[\"diagnosis\"].values\n\nprint(\"Prediction distribution:\")\nunique, counts = np.unique(y_val_pred, return_counts=True)\nfor g, c in zip(unique, counts):\n    print(f\"  Grade {g} ({CLASS_NAMES[g]:<15}): \"\n          f\"{c:4d} ({c/len(y_val_pred)*100:.1f}%)\")\n\ns2_acc = np.mean(y_val_pred == y_val_true)\nprint(f\"\\nVal accuracy : {s2_acc:.4f}\")\nprint(f\"Best val acc : \"\n      f\"{max(history_s2.history['val_accuracy']):.4f}\")\nprint()\nprint(\"✅ Stage 2 complete.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T13:14:20.185989Z","iopub.execute_input":"2026-05-27T13:14:20.186319Z","iopub.status.idle":"2026-05-27T14:24:04.207282Z","shell.execute_reply.started":"2026-05-27T13:14:20.186286Z","shell.execute_reply":"2026-05-27T14:24:04.206600Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Load best model ────────────────────────────────────────────\nCKPT_PATH  = str(OUT_MODELS / \"best_model.keras\")\nbest_model = keras.models.load_model(\n    CKPT_PATH,\n    custom_objects={\"loss_fn\": focal_loss(\n        gamma=2.0, alpha=0.25\n    )}\n)\nprint(f\"✅ Model loaded: {CKPT_PATH}\")\n\n# ── Predict on test set ────────────────────────────────────────\nprint(\"\\nRunning predictions on test set...\")\ny_prob = best_model.predict(test_ds, verbose=1)\ny_pred = np.argmax(y_prob, axis=1)\ny_true = test_df[\"diagnosis\"].values\n\n# ── Core metrics ───────────────────────────────────────────────\naccuracy = np.mean(y_pred == y_true)\nqwk      = cohen_kappa_score(\n    y_true, y_pred, weights=\"quadratic\"\n)\n\nprint()\nprint(\"=\" * 50)\nprint(\"   FINAL TEST SET RESULTS\")\nprint(\"=\" * 50)\nprint(f\"  Accuracy  : {accuracy:.4f} ({accuracy*100:.2f}%)\")\nprint(f\"  QWK Score : {qwk:.4f}  (target ≥ 0.80)\")\nprint(\"=\" * 50)\nprint()\n\n# ── Classification report ──────────────────────────────────────\nprint(\"Classification Report:\")\nprint(classification_report(\n    y_true, y_pred,\n    target_names=CLASS_NAMES,\n    digits=4\n))\n\n# ── Prediction distribution ────────────────────────────────────\nprint(\"Prediction distribution on test set:\")\nunique, counts = np.unique(y_pred, return_counts=True)\nfor g, c in zip(unique, counts):\n    pct = c / len(y_pred) * 100\n    bar = \"█\" * int(pct / 2)\n    print(f\"  Grade {g} ({CLASS_NAMES[g]:<15}): \"\n          f\"{c:4d} ({pct:5.1f}%)  {bar}\")\nprint()\n\n# ── Compare with previous run ──────────────────────────────────\nprint(\"=\" * 50)\nprint(\"   COMPARISON WITH PREVIOUS RUN\")\nprint(\"=\" * 50)\nprint(f\"  Previous QWK  : 0.8446\")\nprint(f\"  Current  QWK  : {qwk:.4f}\")\nprint(f\"  Difference    : {qwk - 0.8446:+.4f}\")\nprint()\nprint(f\"  Previous Acc  : 0.7545\")\nprint(f\"  Current  Acc  : {accuracy:.4f}\")\nprint(f\"  Difference    : {accuracy - 0.7545:+.4f}\")\nprint(\"=\" * 50)\n\n# ── Save report ────────────────────────────────────────────────\nreport_text = \"\\n\".join([\n    \"=\" * 50,\n    \"DIABETIC RETINOPATHY — EVALUATION REPORT\",\n    \"=\" * 50,\n    f\"Model      : EfficientNetB3 + Focal Loss\",\n    f\"Dataset    : APTOS 2019\",\n    f\"IMG size   : {IMG_SIZE}x{IMG_SIZE}\",\n    f\"Test size  : {len(y_true)} samples\",\n    f\"\",\n    f\"RESULTS:\",\n    f\"  Accuracy  : {accuracy:.4f}\",\n    f\"  QWK Score : {qwk:.4f}\",\n    f\"\",\n    \"CLASSIFICATION REPORT:\",\n    classification_report(\n        y_true, y_pred,\n        target_names=CLASS_NAMES,\n        digits=4\n    )\n])\n\nreport_path = str(OUT / \"evaluation_report.txt\")\nwith open(report_path, \"w\") as f:\n    f.write(report_text)\nprint(f\"✅ Report saved!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T14:24:04.208352Z","iopub.execute_input":"2026-05-27T14:24:04.208689Z","iopub.status.idle":"2026-05-27T14:24:29.148147Z","shell.execute_reply.started":"2026-05-27T14:24:04.208666Z","shell.execute_reply":"2026-05-27T14:24:29.147501Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── TTA — predict each image 5 times ──────────────────────────\ndef tta_predict(model, dataset, n_augments=5):\n    \"\"\"\n    Test Time Augmentation.\n\n    Instead of predicting each image once,\n    we predict it N times with slight random\n    augmentations and average the probabilities.\n\n    This gives more stable predictions because:\n    - Single prediction can be unlucky\n    - Average of 5 predictions is more reliable\n    \"\"\"\n    all_probs = []\n\n    for i in range(n_augments):\n        print(f\"  TTA round {i+1}/{n_augments}...\")\n        round_probs = []\n\n        for imgs, _ in dataset:\n            # Apply slight augmentation\n            imgs_aug = imgs\n\n            # Random horizontal flip (50% chance)\n            if tf.random.uniform(()) > 0.5:\n                imgs_aug = tf.image.flip_left_right(imgs_aug)\n\n            # Random brightness shift\n            imgs_aug = tf.image.random_brightness(\n                imgs_aug, max_delta=15.0\n            )\n            imgs_aug = tf.clip_by_value(\n                imgs_aug, 0.0, 255.0\n            )\n\n            # Predict\n            probs = model(imgs_aug, training=False)\n            round_probs.append(probs.numpy())\n\n        all_probs.append(\n            np.concatenate(round_probs, axis=0)\n        )\n\n    # Average all rounds\n    avg_probs = np.mean(all_probs, axis=0)\n    return avg_probs\n\n\nprint(\"Running TTA (5 rounds)...\")\nprint(\"Each round predicts all 550 test images\")\nprint()\n\n# Run TTA\ny_prob_tta = tta_predict(\n    best_model, test_ds, n_augments=5\n)\ny_pred_tta = np.argmax(y_prob_tta, axis=1)\n\n# ── Compare results ────────────────────────────────────────────\naccuracy_tta = np.mean(y_pred_tta == y_true)\nqwk_tta      = cohen_kappa_score(\n    y_true, y_pred_tta, weights=\"quadratic\"\n)\n\nprint()\nprint(\"=\" * 50)\nprint(\"   TTA RESULTS\")\nprint(\"=\" * 50)\nprint(f\"  Accuracy  : {accuracy_tta:.4f} \"\n      f\"({accuracy_tta*100:.2f}%)\")\nprint(f\"  QWK Score : {qwk_tta:.4f}\")\nprint(\"=\" * 50)\nprint()\n\n# Compare\nprint(\"Comparison:\")\nprint(f\"  Without TTA: acc={accuracy:.4f}  \"\n      f\"QWK={qwk:.4f}\")\nprint(f\"  With TTA   : acc={accuracy_tta:.4f}  \"\n      f\"QWK={qwk_tta:.4f}\")\nprint(f\"  Difference : acc={accuracy_tta-accuracy:+.4f}  \"\n      f\"QWK={qwk_tta-qwk:+.4f}\")\nprint()\n\n# Classification report\nprint(\"Classification Report with TTA:\")\nprint(classification_report(\n    y_true, y_pred_tta,\n    target_names=CLASS_NAMES,\n    digits=4\n))\nprint(\"✅ TTA complete.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T14:24:29.149195Z","iopub.execute_input":"2026-05-27T14:24:29.149555Z","iopub.status.idle":"2026-05-27T14:26:18.784247Z","shell.execute_reply.started":"2026-05-27T14:24:29.149529Z","shell.execute_reply":"2026-05-27T14:26:18.783405Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Confusion Matrix ───────────────────────────────────────────\ncm = confusion_matrix(y_true, y_pred)\n\nfig, ax = plt.subplots(figsize=(9, 7))\nsns.heatmap(\n    cm,\n    annot=True,\n    fmt=\"d\",\n    cmap=\"Blues\",\n    xticklabels=CLASS_NAMES,\n    yticklabels=CLASS_NAMES,\n    linewidths=0.5,\n    annot_kws={\"size\": 12, \"weight\": \"bold\"}\n)\nax.set_title(\n    f\"Confusion Matrix — Test Set\\n\"\n    f\"Accuracy: {accuracy:.4f}  |  \"\n    f\"QWK: {qwk:.4f}\",\n    fontsize=13, fontweight=\"bold\", pad=15\n)\nax.set_ylabel(\"True Label\", fontsize=11)\nax.set_xlabel(\"Predicted Label\", fontsize=11)\nplt.xticks(rotation=30, ha=\"right\")\nplt.yticks(rotation=0)\nplt.tight_layout()\nplt.savefig(\n    str(OUT_PLOTS / \"02_confusion_matrix.png\"),\n    dpi=150, bbox_inches=\"tight\"\n)\nplt.show()\nprint(\"✅ Saved: 02_confusion_matrix.png\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T14:26:18.785245Z","iopub.execute_input":"2026-05-27T14:26:18.785682Z","iopub.status.idle":"2026-05-27T14:26:19.220999Z","shell.execute_reply.started":"2026-05-27T14:26:18.785653Z","shell.execute_reply":"2026-05-27T14:26:19.220321Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── AUC-ROC Curves ─────────────────────────────────────────────\ny_true_bin = label_binarize(\n    y_true, classes=list(range(NUM_CLASSES))\n)\n\nfig, ax = plt.subplots(figsize=(10, 7))\nax.plot(\n    [0, 1], [0, 1],\n    \"k--\", lw=1, alpha=0.5,\n    label=\"Random (AUC=0.50)\"\n)\n\nauc_scores = []\nfor i, (name, color) in enumerate(\n    zip(CLASS_NAMES, COLORS)\n):\n    fpr, tpr, _ = roc_curve(\n        y_true_bin[:, i], y_prob[:, i]\n    )\n    roc_auc = auc(fpr, tpr)\n    auc_scores.append(roc_auc)\n    ax.plot(\n        fpr, tpr,\n        color=color, lw=2.5,\n        label=f\"Grade {i}: {name} \"\n              f\"(AUC={roc_auc:.3f})\"\n    )\n\nax.set_title(\n    f\"Per-Class AUC-ROC — Test Set\\n\"\n    f\"Mean AUC: {np.mean(auc_scores):.3f}\",\n    fontsize=13, fontweight=\"bold\"\n)\nax.set_xlabel(\"False Positive Rate\", fontsize=11)\nax.set_ylabel(\"True Positive Rate\", fontsize=11)\nax.legend(loc=\"lower right\", fontsize=10)\nax.grid(True, alpha=0.3)\nax.set_xlim([0, 1])\nax.set_ylim([0, 1.02])\nplt.tight_layout()\nplt.savefig(\n    str(OUT_PLOTS / \"03_auc_roc.png\"),\n    dpi=150, bbox_inches=\"tight\"\n)\nplt.show()\n\nprint(\"AUC scores per grade:\")\nfor i, (name, score) in enumerate(\n    zip(CLASS_NAMES, auc_scores)\n):\n    bar = \"█\" * int(score * 20)\n    print(f\"  Grade {i} ({name:<15}): \"\n          f\"{score:.4f}  {bar}\")\nprint(f\"\\n  Mean AUC : {np.mean(auc_scores):.4f}\")\nprint()\nprint(\"✅ Saved: 03_auc_roc.png\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T14:26:19.222031Z","iopub.execute_input":"2026-05-27T14:26:19.222327Z","iopub.status.idle":"2026-05-27T14:26:19.694628Z","shell.execute_reply.started":"2026-05-27T14:26:19.222304Z","shell.execute_reply":"2026-05-27T14:26:19.693969Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Grad-CAM function ──────────────────────────────────────────\ndef make_gradcam(img_path: str,\n                 true_grade: int,\n                 pred_grade: int,\n                 save_path: str) -> None:\n    \"\"\"\n    Input-gradient saliency heatmap.\n    Pure TensorFlow — works with all versions.\n    Shows which retinal regions influenced prediction.\n    \"\"\"\n    # Prepare display image\n    img_display = cv2.cvtColor(\n        cv2.resize(\n            cv2.imread(img_path),\n            (IMG_SIZE, IMG_SIZE)\n        ),\n        cv2.COLOR_BGR2RGB\n    )\n\n    # Prepare model input\n    img_input  = preprocess_image(img_path)\n    img_tensor = tf.Variable(\n        img_input[np.newaxis, ...],\n        dtype=tf.float32\n    )\n\n    # Compute gradients\n    with tf.GradientTape() as tape:\n        tape.watch(img_tensor)\n        preds = best_model(\n            img_tensor, training=False\n        )\n        score = preds[:, pred_grade]\n\n    grads = tape.gradient(\n        score, img_tensor\n    ).numpy()[0]\n\n    # Build heatmap from squared gradients\n    heatmap = np.sum(grads ** 2, axis=-1)\n    heatmap = np.sqrt(heatmap)\n    heatmap = cv2.GaussianBlur(\n        heatmap, (15, 15), 0\n    )\n    heatmap = np.power(heatmap, 0.5)\n    heatmap = (\n        heatmap - heatmap.min()\n    ) / (heatmap.max() - heatmap.min() + 1e-8)\n\n    # Colorize\n    h_uint8 = np.uint8(255 * heatmap)\n    heatmap_color = cv2.cvtColor(\n        cv2.applyColorMap(\n            h_uint8, cv2.COLORMAP_JET\n        ),\n        cv2.COLOR_BGR2RGB\n    )\n\n    # Blend with original\n    alpha   = heatmap[:, :, np.newaxis]\n    overlay = np.clip(\n        (1 - alpha * 0.6) *\n        img_display.astype(float) +\n        (alpha * 0.6) *\n        heatmap_color.astype(float),\n        0, 255\n    ).astype(np.uint8)\n\n    # Plot\n    correct     = \"CORRECT\" if true_grade == pred_grade \\\n                  else \"WRONG\"\n    label_color = \"green\" if true_grade == pred_grade \\\n                  else \"red\"\n\n    fig, axes = plt.subplots(1, 3, figsize=(13, 4))\n    fig.patch.set_facecolor(\"#111111\")\n    fig.suptitle(\n        f\"[{correct}]  \"\n        f\"True: Grade {true_grade} \"\n        f\"({CLASS_NAMES[true_grade]})  |  \"\n        f\"Pred: Grade {pred_grade} \"\n        f\"({CLASS_NAMES[pred_grade]})\",\n        fontsize=12, fontweight=\"bold\",\n        color=label_color\n    )\n    for ax, im, title in zip(\n        axes,\n        [img_display, heatmap_color, overlay],\n        [\"Original Fundus\",\n         \"Saliency Heatmap\",\n         \"Overlay\"]\n    ):\n        ax.imshow(im)\n        ax.set_title(\n            title, fontsize=10,\n            color=\"white\", pad=5\n        )\n        ax.axis(\"off\")\n\n    plt.tight_layout(pad=1.5)\n    plt.savefig(\n        save_path, dpi=130,\n        bbox_inches=\"tight\",\n        facecolor=fig.get_facecolor()\n    )\n    plt.show()\n    plt.close()\n\n\n# ── Generate 2 Grad-CAM images per grade ──────────────────────\nprint(\"Generating Grad-CAM visualizations...\")\nprint(\"2 samples per grade = 10 images total\\n\")\n\nresults = []\nfor grade in range(NUM_CLASSES):\n    samples = test_df[\n        test_df[\"diagnosis\"] == grade\n    ].head(2)\n\n    print(f\"Grade {grade} — \"\n          f\"{CLASS_NAMES[grade]} \"\n          f\"({len(samples)} samples)\")\n\n    if len(samples) == 0:\n        print(f\"  No samples — skipping\")\n        continue\n\n    for n, (_, row) in enumerate(\n        samples.iterrows(), start=1\n    ):\n        # Get prediction\n        inp = preprocess_image(\n            row[\"filepath\"]\n        )[np.newaxis, ...]\n        pred_probs = best_model.predict(\n            inp, verbose=0\n        )\n        pred_grade = int(np.argmax(pred_probs))\n\n        save_path = str(\n            OUT_GRADCAM /\n            f\"gradcam_grade{grade}_sample{n}.png\"\n        )\n\n        status = \"CORRECT\" \\\n            if pred_grade == grade \\\n            else f\"WRONG → Grade {pred_grade}\"\n\n        print(f\"  Sample {n}: \"\n              f\"True={CLASS_NAMES[grade]:<15} \"\n              f\"Pred={CLASS_NAMES[pred_grade]:<15}\"\n              f\" [{status}]\")\n\n        make_gradcam(\n            row[\"filepath\"],\n            grade,\n            pred_grade,\n            save_path\n        )\n        results.append({\n            \"true\"   : grade,\n            \"pred\"   : pred_grade,\n            \"correct\": pred_grade == grade\n        })\n    print()\n\n# ── Summary ────────────────────────────────────────────────────\ncorrect_count = sum(\n    1 for r in results if r[\"correct\"]\n)\nprint(\"=\" * 45)\nprint(f\"Grad-CAM complete!\")\nprint(f\"Correct: {correct_count}/{len(results)}\"\n      f\" on these samples\")\nprint(f\"Saved to: {OUT_GRADCAM}\")\nprint(\"=\" * 45)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T14:26:19.695490Z","iopub.execute_input":"2026-05-27T14:26:19.695817Z","iopub.status.idle":"2026-05-27T14:26:44.767982Z","shell.execute_reply.started":"2026-05-27T14:26:19.695796Z","shell.execute_reply":"2026-05-27T14:26:44.767367Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 더 많은 샘플 확인\nfor grade in range(5):\n    # 5개 샘플씩 확인하여 가장 명확한 히트맵 선택\n    samples = test_df[test_df[\"diagnosis\"] == grade].head(5)\n    for n, (_, row) in enumerate(samples.iterrows(), start=1):\n        save_path = str(OUT_GRADCAM /\n            f\"gradcam_select_grade{grade}_s{n}.png\")\n        make_gradcam(row[\"filepath\"], grade,\n                     pred_grade, save_path)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T14:26:44.768876Z","iopub.execute_input":"2026-05-27T14:26:44.769271Z","iopub.status.idle":"2026-05-27T14:27:29.619621Z","shell.execute_reply.started":"2026-05-27T14:26:44.769247Z","shell.execute_reply":"2026-05-27T14:27:29.618829Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Check all files saved ──────────────────────────────────────\nprint(\"Files saved in /kaggle/working:\")\nprint()\n\ntotal_size = 0\nfor folder in [OUT_MODELS, OUT_PLOTS,\n               OUT_GRADCAM, OUT_LOGS, OUT]:\n    files = [f for f in Path(folder).iterdir()\n             if f.is_file()]\n    if files:\n        print(f\"📁 {folder.name}/\")\n        for f in sorted(files):\n            size_kb = f.stat().st_size / 1024\n            total_size += size_kb\n            print(f\"   📄 {f.name:<45} \"\n                  f\"{size_kb:>8.1f} KB\")\n        print()\n\nprint(f\"Total size: {total_size/1024:.1f} MB\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T14:27:29.624814Z","iopub.execute_input":"2026-05-27T14:27:29.625520Z","iopub.status.idle":"2026-05-27T14:27:29.633077Z","shell.execute_reply.started":"2026-05-27T14:27:29.625495Z","shell.execute_reply":"2026-05-27T14:27:29.632385Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Zip everything and download ────────────────────────────────\nimport shutil\nfrom IPython.display import FileLink\n\nprint(\"Creating zip file...\")\n\n# Zip all outputs\nshutil.make_archive(\n    \"/kaggle/working/dr_project_complete\",\n    \"zip\",\n    \"/kaggle/working\"\n)\n\nzip_size = Path(\n    \"/kaggle/working/dr_project_complete.zip\"\n).stat().st_size / (1024*1024)\n\nprint(f\"✅ Zip created: {zip_size:.1f} MB\")\nprint()\nprint(\"Click the link below to download:\")\ndisplay(FileLink(\n    \"/kaggle/working/dr_project_complete.zip\"\n))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T14:27:29.634072Z","iopub.execute_input":"2026-05-27T14:27:29.634739Z","iopub.status.idle":"2026-05-27T14:27:36.610060Z","shell.execute_reply.started":"2026-05-27T14:27:29.634716Z","shell.execute_reply":"2026-05-27T14:27:36.609275Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Save version to make files permanent ──────────────────────\n# On Kaggle, files only persist if you Save a Version\n\nprint(\"Important: To keep files after session ends:\")\nprint()\nprint(\"1. Click 'Save Version' button (top right)\")\nprint(\"2. Choose 'Save & Run All'\")\nprint(\"3. After it finishes → Output tab → download\")\nprint()\nprint(\"OR download right now from Output panel:\")\nprint(\"Right panel → Output → /kaggle/working/\")\nprint()\n\n# List all downloadable files\nimport os\nprint(\"Files available to download:\")\nfor root, dirs, files in os.walk(\"/kaggle/working\"):\n    # Skip the zip to avoid confusion\n    for file in files:\n        if not file.endswith(\".zip\"):\n            filepath = os.path.join(root, file)\n            size_mb = os.path.getsize(filepath) / (1024*1024)\n            rel_path = filepath.replace(\n                \"/kaggle/working/\", \"\"\n            )\n            print(f\"  {rel_path:<50} {size_mb:.1f} MB\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T14:27:36.611124Z","iopub.execute_input":"2026-05-27T14:27:36.611429Z","iopub.status.idle":"2026-05-27T14:27:36.618826Z","shell.execute_reply.started":"2026-05-27T14:27:36.611407Z","shell.execute_reply":"2026-05-27T14:27:36.618182Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Plot training curves ───────────────────────────────────────\n# Combine Stage 1 + Stage 2 history\ndef plot_training_curves(h1, h2):\n    \"\"\"\n    Plot combined Stage 1 + Stage 2 training curves.\n    Shows loss and accuracy for both train and val.\n    \"\"\"\n    # Combine histories\n    train_loss = (h1.history[\"loss\"] +\n                  h2.history[\"loss\"])\n    val_loss   = (h1.history[\"val_loss\"] +\n                  h2.history[\"val_loss\"])\n    train_acc  = (h1.history[\"accuracy\"] +\n                  h2.history[\"accuracy\"])\n    val_acc    = (h1.history[\"val_accuracy\"] +\n                  h2.history[\"val_accuracy\"])\n\n    epochs     = range(1, len(train_loss) + 1)\n    s1_end     = len(h1.history[\"loss\"])\n\n    fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n    fig.suptitle(\n        \"Training History — Stage 1 + Stage 2\\n\"\n        f\"Best Val Accuracy: \"\n        f\"{max(val_acc):.4f}  |  \"\n        f\"Best Val Loss: \"\n        f\"{min(val_loss):.4f}\",\n        fontsize=13, fontweight=\"bold\"\n    )\n\n    # ── Loss plot ──────────────────────────────────\n    axes[0].plot(\n        epochs, train_loss,\n        \"b-o\", ms=4, lw=1.5,\n        label=\"Train Loss\"\n    )\n    axes[0].plot(\n        epochs, val_loss,\n        \"r-o\", ms=4, lw=1.5,\n        label=\"Val Loss\"\n    )\n    axes[0].axvline(\n        s1_end + 0.5,\n        color=\"gray\",\n        linestyle=\"--\",\n        alpha=0.7,\n        label=f\"Stage 2 starts (epoch {s1_end+1})\"\n    )\n    axes[0].set_title(\n        \"Loss (Focal Loss)\",\n        fontsize=12\n    )\n    axes[0].set_xlabel(\"Epoch\")\n    axes[0].set_ylabel(\"Focal Loss Value\")\n    axes[0].legend(fontsize=9)\n    axes[0].grid(True, alpha=0.3)\n\n    # ── Accuracy plot ──────────────────────────────\n    axes[1].plot(\n        epochs, train_acc,\n        \"b-o\", ms=4, lw=1.5,\n        label=\"Train Accuracy\"\n    )\n    axes[1].plot(\n        epochs, val_acc,\n        \"r-o\", ms=4, lw=1.5,\n        label=\"Val Accuracy\"\n    )\n    axes[1].axvline(\n        s1_end + 0.5,\n        color=\"gray\",\n        linestyle=\"--\",\n        alpha=0.7,\n        label=f\"Stage 2 starts (epoch {s1_end+1})\"\n    )\n    # Mark best val accuracy\n    best_epoch = val_acc.index(max(val_acc)) + 1\n    best_acc   = max(val_acc)\n    axes[1].axhline(\n        best_acc,\n        color=\"green\",\n        linestyle=\":\",\n        alpha=0.5,\n        label=f\"Best val acc: {best_acc:.4f}\"\n    )\n    axes[1].scatter(\n        best_epoch, best_acc,\n        color=\"green\", s=100,\n        zorder=5\n    )\n    axes[1].set_title(\n        \"Accuracy\",\n        fontsize=12\n    )\n    axes[1].set_xlabel(\"Epoch\")\n    axes[1].set_ylabel(\"Accuracy\")\n    axes[1].legend(fontsize=9)\n    axes[1].grid(True, alpha=0.3)\n\n    plt.tight_layout()\n    plt.savefig(\n        str(OUT_PLOTS / \"04_training_curves.png\"),\n        dpi=150, bbox_inches=\"tight\"\n    )\n    plt.show()\n    print(\"✅ Saved: 04_training_curves.png\")\n\n    # ── Print summary ──────────────────────────────\n    print()\n    print(\"Training Summary:\")\n    print(f\"  Stage 1 epochs   : \"\n          f\"{len(h1.history['loss'])}\")\n    print(f\"  Stage 2 epochs   : \"\n          f\"{len(h2.history['loss'])}\")\n    print(f\"  Total epochs     : {len(train_loss)}\")\n    print()\n    print(f\"  Best val accuracy: {max(val_acc):.4f} \"\n          f\"(epoch {best_epoch})\")\n    print(f\"  Best val loss    : {min(val_loss):.4f}\")\n    print()\n    print(f\"  Final train acc  : \"\n          f\"{train_acc[-1]:.4f}\")\n    print(f\"  Final val acc    : \"\n          f\"{val_acc[-1]:.4f}\")\n    print(f\"  Overfitting gap  : \"\n          f\"{train_acc[-1] - val_acc[-1]:.4f}\")\n\n\n# Run the plot\nplot_training_curves(history_s1, history_s2)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-27T14:27:36.619796Z","iopub.execute_input":"2026-05-27T14:27:36.620093Z","iopub.status.idle":"2026-05-27T14:27:37.401182Z","shell.execute_reply.started":"2026-05-27T14:27:36.620072Z","shell.execute_reply":"2026-05-27T14:27:37.400390Z"}},"outputs":[],"execution_count":null}]}