{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# **CELL 1 — Install & Import Libraries**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 1\n# IMPORT LIBRARIES\n# FINAL V4 — robust terhadap package optional\n# ============================================================\n\nimport os\nimport gc\nimport json\nimport time\nimport math\nimport random\nimport shutil\nimport platform\nimport warnings\nimport sys\nimport subprocess\nimport importlib.util\n\nfrom pathlib import Path\nfrom concurrent.futures import ThreadPoolExecutor\n\nimport numpy as np\nimport pandas as pd\n\nfrom PIL import Image\nfrom tqdm.auto import tqdm\n\nimport matplotlib.pyplot as plt\nimport seaborn as sns\n\nimport torch\nimport torch.nn as nn\n\nfrom torch.utils.data import (\n    Dataset,\n    DataLoader\n)\n\nfrom torchvision import transforms\n\n# ------------------------------------------------------------\n# CORE: timm wajib untuk backbone\n# ------------------------------------------------------------\nif importlib.util.find_spec(\"timm\") is None:\n    raise ImportError(\n        \"timm belum tersedia. Aktifkan Internet Kaggle lalu install: !pip install timm\"\n    )\nimport timm\n\n# ------------------------------------------------------------\n# OPTIONAL: torchinfo\n# ------------------------------------------------------------\ntry:\n    from torchinfo import summary\n    HAS_TORCHINFO = True\nexcept ImportError:\n    summary = None\n    HAS_TORCHINFO = False\n\n# ------------------------------------------------------------\n# THOP — required for comparable MACs/FLOPs reporting\n# ------------------------------------------------------------\nif importlib.util.find_spec(\"thop\") is None:\n    print(\"THOP not found; installing...\")\n    subprocess.check_call([\n        sys.executable,\n        \"-m\",\n        \"pip\",\n        \"install\",\n        \"-q\",\n        \"thop\"\n    ])\n\nfrom thop import profile\nHAS_THOP = True\n\n# ------------------------------------------------------------\n# METRICS\n# ------------------------------------------------------------\nfrom sklearn.metrics import (\n    accuracy_score,\n    precision_score,\n    recall_score,\n    f1_score,\n    roc_auc_score,\n    confusion_matrix,\n    classification_report,\n    roc_curve,\n    auc\n)\n\nfrom sklearn.preprocessing import (\n    label_binarize\n)\n\nfrom IPython.display import display\n\n# ------------------------------------------------------------\n# XAI — LRP WAJIB UNTUK BAGIAN XAI, tetapi training tidak boleh\n# gagal hanya karena Captum belum tersedia.\n# ------------------------------------------------------------\ntry:\n    from captum.attr import (\n        LRP,\n        LayerGradCam,\n        LayerAttribution\n    )\n\n    from captum.attr._utils.lrp_rules import (\n        EpsilonRule,\n        IdentityRule\n    )\n\n    HAS_CAPTUM = True\n\nexcept ImportError:\n    LRP = None\n    LayerGradCam = None\n    LayerAttribution = None\n    EpsilonRule = None\n    IdentityRule = None\n    HAS_CAPTUM = False\n\nwarnings.filterwarnings(\"ignore\")\n\nprint(\"=\" * 72)\nprint(\"FINAL V4 ENVIRONMENT\")\nprint(\"=\" * 72)\nprint(\"Python:\", platform.python_version())\nprint(\"PyTorch:\", torch.__version__)\nprint(\"timm:\", timm.__version__)\nprint(\"CUDA available:\", torch.cuda.is_available())\n\nif torch.cuda.is_available():\n    print(\"GPU:\", torch.cuda.get_device_name(0))\n    print(\"CUDA:\", torch.version.cuda)\n\nprint(\"torchinfo available:\", HAS_TORCHINFO)\nprint(\"THOP available:\", HAS_THOP)\nprint(\"Captum available:\", HAS_CAPTUM)\nprint(\"=\" * 72)\n\nif not HAS_CAPTUM:\n    print(\n        \"NOTE: Training/evaluation tetap dapat berjalan, tetapi CELL LRP/Grad-CAM \"\n        \"memerlukan Captum. Install Captum sebelum menjalankan bagian XAI.\"\n    )\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:15:58.089977Z","iopub.execute_input":"2026-08-26T12:15:58.090153Z","iopub.status.idle":"2026-08-26T12:16:16.209211Z","shell.execute_reply.started":"2026-08-26T12:15:58.090133Z","shell.execute_reply":"2026-08-26T12:16:16.208447Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 2 — Configuration**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 2\n# CONFIGURATION\n# Hybrid ConvNeXt–MLP-Mixer\n# PARALLEL DUAL-BRANCH — FULL END-TO-END FINE-TUNING\n# ============================================================\n\n# ------------------------------------------------------------\n# REPRODUCIBILITY\n# ------------------------------------------------------------\nSEED = 42\nSTRICT_REPRODUCIBILITY = True\n\n# ------------------------------------------------------------\n# TRAINING PROTOCOL\n#\n# UPDATE: Grid search awal (FAIR_BASELINE_PROTOCOL=True, tanpa\n# augmentasi/scheduler) menunjukkan overfitting signifikan\n# (Train Acc ~97.7% vs Val Macro-F1 ~0.69 pada konfigurasi\n# terbaik). Augmentasi ringan dan LR scheduler diaktifkan untuk\n# mengurangi overfitting pada dataset setelah exact-hash filtering dan\n# imbalanced. Perubahan ini WAJIB dilaporkan di bagian Metode\n# pada naskah (lihat catatan di bawah CELL 5 dan CELL 11).\n# ------------------------------------------------------------\nFAIR_BASELINE_PROTOCOL = False\nUSE_TTA_FOR_PRIMARY_RESULT = False\nUSE_SPECIAL_AUGMENTATION = True\n\n# ------------------------------------------------------------\n# LEARNING RATE SCHEDULER\n# CosineAnnealingLR per eksperimen (T_max = EPOCHS), stepped\n# setiap akhir epoch. Mengurangi overfitting akibat LR konstan\n# yang tidak decay meskipun val loss mulai naik.\n# ------------------------------------------------------------\nUSE_LR_SCHEDULER = True\n\n# ------------------------------------------------------------\n# IMAGE\n# ------------------------------------------------------------\nIMG_SIZE = 224\nNUM_CLASSES = 5\n\n# ------------------------------------------------------------\n# BATCH\n# Metodologi tetap: physical 16, accumulation 2, effective 32\n# ------------------------------------------------------------\nBATCH_SIZE = 16\nGRAD_ACCUM_STEPS = 2\nEFFECTIVE_BATCH_SIZE = BATCH_SIZE * GRAD_ACCUM_STEPS\n\n# ------------------------------------------------------------\n# TRAINING\n# ------------------------------------------------------------\nEPOCHS = 10\nPATIENCE = 3\nWEIGHT_DECAY = 1e-4\n\n# ------------------------------------------------------------\n# GRID SEARCH — TETAP 3 x 3 x 3 = 27 EXPERIMENTS\n# ------------------------------------------------------------\nLR_CANDIDATES = [1e-5, 3e-5, 1e-4]\nDROPOUT_CANDIDATES = [0.2, 0.3, 0.4]\nMIXER_BLOCK_CANDIDATES = [2, 4, 6]\n\n# ------------------------------------------------------------\n# BACKBONE — TETAP\n# ------------------------------------------------------------\nCONVNEXT_MODEL = \"convnext_tiny.fb_in1k\"\nMIXER_MODEL = \"mixer_b16_224.goog_in21k_ft_in1k\"\nCONVNEXT_PRETRAINED = True\nMIXER_PRETRAINED = True\n\n# ------------------------------------------------------------\n# FEATURE DIMENSION — TETAP\n# ------------------------------------------------------------\nLOCAL_PROJ_DIM = 256\nGLOBAL_PROJ_DIM = 256\nFUSED_DIM = LOCAL_PROJ_DIM + GLOBAL_PROJ_DIM\nCLASSIFIER_HIDDEN_DIM = 256\n\nCLASS_NAMES = {\n    0: \"No DR\",\n    1: \"Mild\",\n    2: \"Moderate\",\n    3: \"Severe\",\n    4: \"Proliferative DR\"\n}\n\n# ------------------------------------------------------------\n# DEVICE / GPU OPTIMIZATION\n# ------------------------------------------------------------\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nUSE_AMP = DEVICE.type == \"cuda\"\n\n# Memory-format optimization. Tidak mengubah arsitektur atau hyperparameter.\nUSE_CHANNELS_LAST = DEVICE.type == \"cuda\"\n\n# Fused AdamW dipakai hanya jika build PyTorch/GPU mendukung.\nUSE_FUSED_ADAMW = DEVICE.type == \"cuda\"\n\n# ------------------------------------------------------------\n# DATA PIPELINE\n# ------------------------------------------------------------\nCPU_COUNT = os.cpu_count() or 2\nNUM_WORKERS = min(4, CPU_COUNT)\nPREFETCH_FACTOR = 4\nPREPROCESS_WORKERS = min(4, CPU_COUNT)\n\n# ------------------------------------------------------------\n# OUTPUT DIRECTORY\n# ------------------------------------------------------------\nOUTPUT_DIR = Path(\"/kaggle/working/convnext_mlpmixer_v5_deduplicated\")\nCHECKPOINT_DIR = OUTPUT_DIR / \"checkpoints\"\nPLOT_DIR = OUTPUT_DIR / \"plots\"\nXAI_DIR = OUTPUT_DIR / \"xai\"\nPREPROCESSED_DIR = OUTPUT_DIR / f\"preprocessed_{IMG_SIZE}\"\n\nfor directory in [\n    OUTPUT_DIR,\n    CHECKPOINT_DIR,\n    PLOT_DIR,\n    XAI_DIR,\n    PREPROCESSED_DIR\n]:\n    directory.mkdir(parents=True, exist_ok=True)\n\nprint(\"=\" * 70)\nprint(\"CONFIGURATION\")\nprint(\"=\" * 70)\nprint(\"Device:\", DEVICE)\nprint(\"AMP Enabled:\", USE_AMP)\nprint(\"Channels Last:\", USE_CHANNELS_LAST)\nprint(\"Image Size:\", IMG_SIZE)\nprint(\"Number of Classes:\", NUM_CLASSES)\nprint(\"Physical Batch Size:\", BATCH_SIZE)\nprint(\"Gradient Accumulation:\", GRAD_ACCUM_STEPS)\nprint(\"Effective Batch Size:\", EFFECTIVE_BATCH_SIZE)\nprint(\"Maximum Epoch:\", EPOCHS)\nprint(\"Early Stopping Patience:\", PATIENCE)\nprint(\"Weight Decay:\", WEIGHT_DECAY)\nprint(\"Learning Rate Candidates:\", LR_CANDIDATES)\nprint(\"Dropout Candidates:\", DROPOUT_CANDIDATES)\nprint(\"Mixer Block Candidates:\", MIXER_BLOCK_CANDIDATES)\nprint(\"Total Grid Search Experiments:\", len(LR_CANDIDATES) * len(DROPOUT_CANDIDATES) * len(MIXER_BLOCK_CANDIDATES))\nprint(\"ConvNeXt:\", CONVNEXT_MODEL)\nprint(\"MLP-Mixer:\", MIXER_MODEL)\nprint(\"DataLoader Workers:\", NUM_WORKERS)\nprint(\"Prefetch Factor:\", PREFETCH_FACTOR)\nprint(\"Preprocessed Directory:\", PREPROCESSED_DIR)\nprint(\"Output Directory:\", OUTPUT_DIR)\nprint(\"=\" * 70)\n\nprint(\"Fair baseline protocol:\", FAIR_BASELINE_PROTOCOL)\nprint(\"Special augmentation:\", USE_SPECIAL_AUGMENTATION)\nprint(\"TTA used for primary result:\", USE_TTA_FOR_PRIMARY_RESULT)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:16:16.210642Z","iopub.execute_input":"2026-08-26T12:16:16.212071Z","iopub.status.idle":"2026-08-26T12:16:16.226389Z","shell.execute_reply.started":"2026-08-26T12:16:16.212045Z","shell.execute_reply":"2026-08-26T12:16:16.225621Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 3 — Dataset**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 3\n# DATASET + EXACT-HASH LEAKAGE HANDLING\n# Same policy as the finalized baseline notebooks\n# ============================================================\n\nBASE_DIR = Path(\"/kaggle/input\")\nIMAGE_DIR = None\nSPLIT_FILE = None\n\nfor root, dirs, files in os.walk(BASE_DIR):\n    root_path = Path(root)\n    if \"dataset_split.csv\" in files:\n        SPLIT_FILE = root_path / \"dataset_split.csv\"\n    if \"train_images\" in dirs:\n        candidate = root_path / \"train_images\"\n        image_count = sum(\n            len(list(candidate.glob(ext)))\n            for ext in [\"*.png\", \"*.jpg\", \"*.jpeg\"]\n        )\n        if image_count > 3000:\n            IMAGE_DIR = candidate\n\nprint(\"Image Directory:\", IMAGE_DIR)\nprint(\"Split File:\", SPLIT_FILE)\nif IMAGE_DIR is None or SPLIT_FILE is None:\n    raise FileNotFoundError(\"Dataset image directory or dataset_split.csv not found\")\n\ndataset_split = pd.read_csv(SPLIT_FILE)\nrequired = {\"image_id\", \"label\", \"split\"}\nif not required.issubset(dataset_split.columns):\n    raise ValueError(f\"Missing columns: {required - set(dataset_split.columns)}\")\n\ndataset_split[\"original_image_path\"] = (\n    dataset_split[\"image_id\"].astype(str)\n    .map(lambda x: str(IMAGE_DIR / f\"{x}.png\"))\n)\ndataset_split[\"image_path\"] = dataset_split[\"original_image_path\"]\nmissing = (~dataset_split[\"original_image_path\"].map(lambda p: Path(p).exists())).sum()\nif missing:\n    raise FileNotFoundError(f\"{missing} images missing\")\n\noriginal_count = len(dataset_split)\n\n# ------------------------------------------------------------\n# EXACT-HASH FILTERING — IDENTICAL TO BASELINE POLICY\n# For a hash appearing in multiple splits, keep only rows in the\n# highest-priority split: test > validation > train.\n# ------------------------------------------------------------\nhash_col = next(\n    (c for c in [\"sha256\", \"hash\", \"image_hash\"] if c in dataset_split.columns),\n    None\n)\n\nif hash_col:\n    # Normalize string hashes and exclude missing hash values from grouping.\n    dataset_split[hash_col] = dataset_split[hash_col].astype(\"string\").str.strip().str.lower()\n    valid_hash = dataset_split[hash_col].notna() & ~dataset_split[hash_col].isin([\"\", \"nan\", \"none\"])\n    dataset_split[\"_dedup_key\"] = np.where(\n        valid_hash,\n        \"hash:\" + dataset_split[hash_col].astype(str),\n        \"id:\" + dataset_split[\"image_id\"].astype(str)\n    )\n\n    split_priority = {\"test\": 0, \"validation\": 1, \"train\": 2}\n    dataset_split[\"_prio\"] = dataset_split[\"split\"].map(split_priority)\n    if dataset_split[\"_prio\"].isna().any():\n        raise ValueError(\"Unexpected split name found\")\n\n    winning_prio = dataset_split.groupby(\"_dedup_key\")[\"_prio\"].transform(\"min\")\n    is_dup_to_drop = dataset_split[\"_prio\"] != winning_prio\n    n_dropped = int(is_dup_to_drop.sum())\n\n    if n_dropped > 0:\n        print(\n            f\"Dropping {n_dropped} duplicate-hash rows \"\n            \"(priority: test > validation > train).\"\n        )\n        dataset_split = dataset_split.loc[~is_dup_to_drop].reset_index(drop=True)\n\n    dataset_split = dataset_split.drop(columns=[\"_prio\"])\nelse:\n    n_dropped = 0\n    dataset_split[\"_dedup_key\"] = \"id:\" + dataset_split[\"image_id\"].astype(str)\n    print(\"No hash column: exact duplicate leakage not verified\")\n\ntrain_df = dataset_split.query(\"split == 'train'\").reset_index(drop=True).copy()\nval_df = dataset_split.query(\"split == 'validation'\").reset_index(drop=True).copy()\ntest_df = dataset_split.query(\"split == 'test'\").reset_index(drop=True).copy()\n\n# Final cross-split overlap audit; must be 0/0/0.\noverlap_tv = set(train_df[\"_dedup_key\"]) & set(val_df[\"_dedup_key\"])\noverlap_tt = set(train_df[\"_dedup_key\"]) & set(test_df[\"_dedup_key\"])\noverlap_vt = set(val_df[\"_dedup_key\"]) & set(test_df[\"_dedup_key\"])\nprint(\n    \"Exact-hash overlap train-val/train-test/val-test:\",\n    len(overlap_tv), len(overlap_tt), len(overlap_vt)\n)\nif overlap_tv or overlap_tt or overlap_vt:\n    raise RuntimeError(\"Identical-image leakage detected after filtering\")\n\nprint(\"Original images:\", original_count)\nprint(\"Dropped cross-split duplicate rows:\", n_dropped)\nprint(\"Final images:\", len(dataset_split))\nprint(\"Final train/validation/test:\", len(train_df), len(val_df), len(test_df))\n\n# Expected to match finalized baselines (currently 3,596 images).\nif len(dataset_split) != 3596:\n    print(\n        \"WARNING: final count is not 3,596. Verify that every notebook uses \"\n        \"the same original dataset_split.csv and duplicate policy.\"\n    )\n\n# Save the exact filtered rows used by this experiment for audit.\naudit_split = dataset_split.drop(columns=[\"_dedup_key\"], errors=\"ignore\").copy()\naudit_split.to_csv(OUTPUT_DIR / \"dataset_split_filtered_used.csv\", index=False)\n\nprint(\"Dataset Shape After Filtering:\", dataset_split.shape)\ndisplay(dataset_split.head())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:16:16.227182Z","iopub.execute_input":"2026-08-26T12:16:16.227361Z","iopub.status.idle":"2026-08-26T12:16:24.412257Z","shell.execute_reply.started":"2026-08-26T12:16:16.227345Z","shell.execute_reply":"2026-08-26T12:16:24.411415Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 4 — Dataset Distribution**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 4\n# DATASET DISTRIBUTION\n# ============================================================\n\n\nprint(\n    \"=\" * 70\n)\n\nprint(\n    \"DATASET DISTRIBUTION\"\n)\n\nprint(\n    \"=\" * 70\n)\n\n\n# ------------------------------------------------------------\n# TOTAL\n# ------------------------------------------------------------\n\nprint(\n    \"\\nTotal Images:\"\n)\n\nprint(\n    len(dataset_split)\n)\n\n\n# ------------------------------------------------------------\n# SPLIT DISTRIBUTION\n# ------------------------------------------------------------\n\nsplit_distribution = (\n\n    dataset_split[\n        \"split\"\n    ]\n\n    .value_counts()\n\n    .reindex(\n        [\n            \"train\",\n            \"validation\",\n            \"test\"\n        ]\n    )\n\n)\n\n\nprint(\n    \"\\nTrain / Validation / Test:\"\n)\n\ndisplay(\n    split_distribution\n    .to_frame(\n        \"Count\"\n    )\n)\n\n\n# ------------------------------------------------------------\n# TOTAL CLASS DISTRIBUTION\n# ------------------------------------------------------------\n\nclass_distribution = (\n\n    dataset_split[\n        \"label\"\n    ]\n\n    .value_counts()\n\n    .sort_index()\n\n)\n\n\nclass_distribution_df = pd.DataFrame({\n\n    \"Class_ID\":\n        class_distribution.index,\n\n    \"Class_Name\":\n        [\n            CLASS_NAMES[i]\n            for i\n            in class_distribution.index\n        ],\n\n    \"Count\":\n        class_distribution.values\n\n})\n\n\nprint(\n    \"\\nTotal Per Class:\"\n)\n\ndisplay(\n    class_distribution_df\n)\n\n\n# ------------------------------------------------------------\n# CLASS DISTRIBUTION PER SPLIT\n# ------------------------------------------------------------\n\nclass_split_distribution = pd.crosstab(\n\n    dataset_split[\n        \"split\"\n    ],\n\n    dataset_split[\n        \"label\"\n    ]\n\n)\n\n\nclass_split_distribution = (\n\n    class_split_distribution\n\n    .reindex(\n\n        index=[\n            \"train\",\n            \"validation\",\n            \"test\"\n        ],\n\n        columns=[\n            0,\n            1,\n            2,\n            3,\n            4\n        ],\n\n        fill_value=0\n\n    )\n\n)\n\n\nclass_split_distribution.columns = [\n\n    CLASS_NAMES[i]\n\n    for i in range(\n        NUM_CLASSES\n    )\n\n]\n\n\nprint(\n    \"\\nPer-Class Distribution Per Split:\"\n)\n\ndisplay(\n    class_split_distribution\n)\n\n\n# ------------------------------------------------------------\n# BALANCED / IMBALANCED\n# ------------------------------------------------------------\n\ncounts = (\n    class_distribution\n    .reindex(\n        range(NUM_CLASSES),\n        fill_value=0\n    )\n    .values\n)\n\n\nIS_BALANCED = (\n    len(\n        np.unique(counts)\n    ) == 1\n)\n\n\nprint(\n    \"\\nDataset Status:\",\n    (\n        \"BALANCED\"\n\n        if IS_BALANCED\n\n        else \"IMBALANCED\"\n    )\n)\n\n\n# ------------------------------------------------------------\n# PATIENT-INDEPENDENT CHECK\n# ------------------------------------------------------------\n\npatient_columns = [\n\n    column\n\n    for column\n    in dataset_split.columns\n\n    if column.lower()\n    in [\n        \"patient_id\",\n        \"patient\",\n        \"patientid\",\n        \"subject_id\"\n    ]\n\n]\n\n\nif len(patient_columns) == 0:\n\n    print(\n        \"\\nPatient-level split \"\n        \"cannot be verified.\"\n    )\n\n    print(\n        \"No patient_id column \"\n        \"was found.\"\n    )\n\nelse:\n\n    patient_col = (\n        patient_columns[0]\n    )\n\n    train_patient = set(\n\n        dataset_split[\n            dataset_split.split\n            == \"train\"\n        ][\n            patient_col\n        ]\n\n    )\n\n    val_patient = set(\n\n        dataset_split[\n            dataset_split.split\n            == \"validation\"\n        ][\n            patient_col\n        ]\n\n    )\n\n    test_patient = set(\n\n        dataset_split[\n            dataset_split.split\n            == \"test\"\n        ][\n            patient_col\n        ]\n\n    )\n\n    print(\n        \"Train-Val overlap:\",\n        len(\n            train_patient\n            & val_patient\n        )\n    )\n\n    print(\n        \"Train-Test overlap:\",\n        len(\n            train_patient\n            & test_patient\n        )\n    )\n\n    print(\n        \"Val-Test overlap:\",\n        len(\n            val_patient\n            & test_patient\n        )\n    )\n\n\n# ------------------------------------------------------------\n# SAVE\n# ------------------------------------------------------------\n\nclass_distribution_df.to_csv(\n\n    OUTPUT_DIR /\n    \"dataset_class_distribution.csv\",\n\n    index=False\n\n)\n\n\nclass_split_distribution.to_csv(\n\n    OUTPUT_DIR /\n    \"dataset_class_split_distribution.csv\"\n\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:16:39.426624Z","iopub.execute_input":"2026-08-26T12:16:39.427246Z","iopub.status.idle":"2026-08-26T12:16:39.478545Z","shell.execute_reply.started":"2026-08-26T12:16:39.427215Z","shell.execute_reply":"2026-08-26T12:16:39.477832Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 5 — Transform**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 5\n# ONE-TIME PRE-RESIZE CACHE + TRANSFORM\n# FAIR BASELINE PREPROCESSING:\n# Resize 224x224, ToTensor, ImageNet normalization, tanpa augmentation.\n# Pre-resize cache hanya optimasi I/O; tidak mengubah piksel setelah resize.\n# ============================================================\n\nIMAGENET_MEAN = [0.485, 0.456, 0.406]\nIMAGENET_STD = [0.229, 0.224, 0.225]\n\n# Resize yang sama dengan pipeline training sebelumnya.\nresize_once = transforms.Resize((IMG_SIZE, IMG_SIZE))\n\n\ndef _cached_path(image_id):\n    return PREPROCESSED_DIR / f\"{image_id}.png\"\n\n\ndef _resize_one(item):\n    image_id, src_path = item\n    dst_path = _cached_path(image_id)\n\n    # Re-run notebook tidak melakukan resize ulang bila cache sudah ada.\n    if dst_path.exists():\n        return str(dst_path)\n\n    with Image.open(src_path) as img:\n        img = img.convert(\"RGB\")\n        img = resize_once(img)\n        # PNG lossless; compress_level rendah mempercepat write/decode.\n        img.save(dst_path, format=\"PNG\", compress_level=1)\n\n    return str(dst_path)\n\n\npreprocess_start = time.time()\nitems = list(zip(\n    dataset_split[\"image_id\"].astype(str).tolist(),\n    dataset_split[\"original_image_path\"].tolist()\n))\n\nwith ThreadPoolExecutor(max_workers=PREPROCESS_WORKERS) as executor:\n    cached_paths = list(\n        tqdm(\n            executor.map(_resize_one, items),\n            total=len(items),\n            desc=\"One-time resize cache\"\n        )\n    )\n\ndataset_split[\"image_path\"] = cached_paths\n\n# Safety check: semua cache harus ada dan sample harus 224x224.\nmissing_cache = (~dataset_split[\"image_path\"].map(lambda p: Path(p).exists())).sum()\nif missing_cache > 0:\n    raise RuntimeError(f\"Preprocessing cache gagal untuk {missing_cache} citra.\")\n\nsample_cache = Image.open(dataset_split.loc[0, \"image_path\"])\nassert sample_cache.size == (IMG_SIZE, IMG_SIZE)\nsample_cache.close()\n\npreprocess_time = time.time() - preprocess_start\nprint(f\"Pre-resize cache ready: {len(dataset_split)} images\")\nprint(f\"Preprocessing time: {preprocess_time / 60:.2f} minutes\")\nprint(\"Cache directory:\", PREPROCESSED_DIR)\n\n# PENTING: Resize dihapus dari transform karena sudah dilakukan sekali (cache).\n#\n# DUA TRANSFORM TERPISAH:\n#   - train_transform : augmentasi ringan (khusus data training)\n#   - eval_transform   : tanpa augmentasi (validation & test tetap\n#                         deterministik / apple-to-apple)\n#\n# Augmentasi dipilih yang secara klinis wajar untuk fundus image:\n#   - flip horizontal & vertikal (retina tidak punya orientasi\n#     \"atas/bawah\" yang baku, mata kiri/kanan bercermin)\n#   - rotasi ringan (retina discan dari sudut yang bervariasi)\n#   - color jitter ringan (variasi pencahayaan/kamera fundus)\n# Tidak memakai random crop agresif agar lesi di tepi tidak\n# terpotong hilang.\nif USE_SPECIAL_AUGMENTATION:\n    train_transform = transforms.Compose([\n        transforms.RandomHorizontalFlip(p=0.5),\n        transforms.RandomVerticalFlip(p=0.5),\n        transforms.RandomRotation(degrees=20),\n        transforms.ColorJitter(\n            brightness=0.2,\n            contrast=0.2,\n            saturation=0.1\n        ),\n        transforms.ToTensor(),\n        transforms.Normalize(\n            mean=IMAGENET_MEAN,\n            std=IMAGENET_STD\n        )\n    ])\nelse:\n    train_transform = transforms.Compose([\n        transforms.ToTensor(),\n        transforms.Normalize(\n            mean=IMAGENET_MEAN,\n            std=IMAGENET_STD\n        )\n    ])\n\neval_transform = transforms.Compose([\n    transforms.ToTensor(),\n    transforms.Normalize(\n        mean=IMAGENET_MEAN,\n        std=IMAGENET_STD\n    )\n])\n\n# Nama 'transform' dipertahankan (dipakai beberapa cell lain,\n# mis. LRP smoke test) -> mengacu ke eval_transform (tanpa augmentasi).\ntransform = eval_transform\n\nprint(\"\\nTrain transform:\")\nprint(train_transform)\nprint(\"\\nEval transform (validation/test):\")\nprint(eval_transform)\nprint(\"\\nData Augmentation (train only):\", USE_SPECIAL_AUGMENTATION)\nprint(\"Resize during each epoch: NO (cached once)\")\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:16:48.821416Z","iopub.execute_input":"2026-08-26T12:16:48.821777Z","iopub.status.idle":"2026-08-26T12:19:38.793987Z","shell.execute_reply.started":"2026-08-26T12:16:48.821742Z","shell.execute_reply":"2026-08-26T12:19:38.793194Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 6 — Dataset Class**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 6\n# DATASET CLASS\n# ============================================================\n\n\nclass APTOSDataset(\n    Dataset\n):\n\n\n    def __init__(\n\n        self,\n\n        dataframe,\n\n        transform=None\n\n    ):\n\n        self.df = (\n\n            dataframe\n\n            .reset_index(\n                drop=True\n            )\n\n            .copy()\n\n        )\n\n        self.transform = transform\n\n\n    def __len__(\n        self\n    ):\n\n        return len(\n            self.df\n        )\n\n\n    def __getitem__(\n\n        self,\n\n        idx\n\n    ):\n\n        row = (\n            self.df.iloc[\n                idx\n            ]\n        )\n\n\n        image = Image.open(\n\n            row[\n                \"image_path\"\n            ]\n\n        ).convert(\n            \"RGB\"\n        )\n\n\n        label = int(\n\n            row[\n                \"label\"\n            ]\n\n        )\n\n\n        if self.transform:\n\n            image = self.transform(\n                image\n            )\n\n\n        return (\n            image,\n            label\n        )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:19:54.149874Z","iopub.execute_input":"2026-08-26T12:19:54.150525Z","iopub.status.idle":"2026-08-26T12:19:54.156287Z","shell.execute_reply.started":"2026-08-26T12:19:54.150495Z","shell.execute_reply":"2026-08-26T12:19:54.155477Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 7 — DataLoader**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 7\n# DATASET SPLIT + DATALOADER\n# ============================================================\n\ntrain_df = dataset_split[dataset_split[\"split\"] == \"train\"].reset_index(drop=True).copy()\nval_df = dataset_split[dataset_split[\"split\"] == \"validation\"].reset_index(drop=True).copy()\ntest_df = dataset_split[dataset_split[\"split\"] == \"test\"].reset_index(drop=True).copy()\n\n# Train pakai train_transform (augmentasi bila USE_SPECIAL_AUGMENTATION=True).\n# Validation & test SELALU pakai eval_transform (tanpa augmentasi),\n# supaya evaluasi tetap deterministik dan apple-to-apple.\ntrain_dataset = APTOSDataset(train_df, train_transform)\nval_dataset = APTOSDataset(val_df, eval_transform)\ntest_dataset = APTOSDataset(test_df, eval_transform)\n\n# Semua input sudah kecil (224x224), sehingga worker hanya decode PNG kecil\n# + ToTensor + Normalize.\nloader_common = dict(\n    batch_size=BATCH_SIZE,\n    num_workers=NUM_WORKERS,\n    pin_memory=(DEVICE.type == \"cuda\"),\n    persistent_workers=(NUM_WORKERS > 0)\n)\n\nif NUM_WORKERS > 0:\n    loader_common[\"prefetch_factor\"] = PREFETCH_FACTOR\n\ntrain_loader = DataLoader(\n    train_dataset,\n    shuffle=True,\n    **loader_common\n)\n\nval_loader = DataLoader(\n    val_dataset,\n    shuffle=False,\n    **loader_common\n)\n\ntest_loader = DataLoader(\n    test_dataset,\n    shuffle=False,\n    **loader_common\n)\n\nprint(\"Train:\", len(train_dataset))\nprint(\"Validation:\", len(val_dataset))\nprint(\"Test:\", len(test_dataset))\nprint(\"DataLoader workers:\", NUM_WORKERS)\nprint(\"Prefetch factor:\", PREFETCH_FACTOR if NUM_WORKERS > 0 else \"N/A\")\n\nimages, labels = next(iter(train_loader))\nprint(\"\\nImage Shape:\", images.shape)\nprint(\"Label Shape:\", labels.shape)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:20:03.640221Z","iopub.execute_input":"2026-08-26T12:20:03.641075Z","iopub.status.idle":"2026-08-26T12:20:04.406198Z","shell.execute_reply.started":"2026-08-26T12:20:03.641042Z","shell.execute_reply":"2026-08-26T12:20:04.405207Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 8 — Class Weights**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 8\n# CLASS WEIGHTS\n# ============================================================\n\n\ntrain_class_counts = (\n\n    train_df[\n        \"label\"\n    ]\n\n    .value_counts()\n\n    .reindex(\n\n        range(\n            NUM_CLASSES\n        ),\n\n        fill_value=0\n\n    )\n\n)\n\n\nif (\n    train_class_counts\n    == 0\n).any():\n\n    raise ValueError(\n        \"Ada kelas yang tidak memiliki \"\n        \"data training.\"\n    )\n\n\nclass_weights = (\n\n    len(\n        train_df\n    )\n\n    /\n\n    (\n        NUM_CLASSES\n\n        *\n\n        train_class_counts\n    )\n\n)\n\n\nclass_weights_tensor = torch.tensor(\n\n    class_weights.values,\n\n    dtype=torch.float32\n\n).to(\n    DEVICE\n)\n\n\nclass_weight_df = pd.DataFrame({\n\n    \"Class_ID\":\n        range(\n            NUM_CLASSES\n        ),\n\n    \"Class_Name\":\n        [\n            CLASS_NAMES[i]\n\n            for i in range(\n                NUM_CLASSES\n            )\n        ],\n\n    \"Train_Count\":\n        train_class_counts.values,\n\n    \"Class_Weight\":\n        class_weights.values\n\n})\n\n\ndisplay(\n    class_weight_df\n)\n\n\nclass_weight_df.to_csv(\n\n    OUTPUT_DIR /\n    \"class_weights.csv\",\n\n    index=False\n\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:20:13.15343Z","iopub.execute_input":"2026-08-26T12:20:13.154026Z","iopub.status.idle":"2026-08-26T12:20:13.18143Z","shell.execute_reply.started":"2026-08-26T12:20:13.153974Z","shell.execute_reply":"2026-08-26T12:20:13.180586Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 9 — Parallel Hybrid Architecture**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 9\n# PARALLEL HYBRID ARCHITECTURE — TETAP\n#\n#                       IMAGE\n#                         |\n#              +----------+----------+\n#              |                     |\n#              v                     v\n#          ConvNeXt              MLP-Mixer\n#              |                     |\n#        Local Feature         Global Feature\n#              |                     |\n#          Projection              Projection\n#              |                     |\n#              +----------+----------+\n#                         |\n#                   Concatenation\n#                         |\n#                     Classifier\n# ============================================================\n\n\nclass ParallelHybridConvNeXtMLPMixer(nn.Module):\n\n    def __init__(\n        self,\n        num_classes=5,\n        mixer_blocks=4,\n        dropout_rate=0.3,\n        pretrained=True\n    ):\n        super().__init__()\n\n        if mixer_blocks not in [2, 4, 6]:\n            raise ValueError(\"Mixer Blocks must be 2, 4, or 6.\")\n\n        self.mixer_blocks = mixer_blocks\n        self.dropout_rate = dropout_rate\n\n        # ----------------------------------------------------\n        # BRANCH 1 — CONVNEXT-TINY\n        # ----------------------------------------------------\n        self.convnext = timm.create_model(\n            CONVNEXT_MODEL,\n            pretrained=pretrained,\n            num_classes=0\n        )\n\n        if hasattr(self.convnext, \"head\"):\n            self.convnext.head = nn.Identity()\n\n        self.local_feature_dim = self.convnext.num_features\n        self.local_pool = nn.AdaptiveAvgPool2d(1)\n\n        # ----------------------------------------------------\n        # BRANCH 2 — MLP-MIXER B/16\n        # ----------------------------------------------------\n        self.mixer = timm.create_model(\n            MIXER_MODEL,\n            pretrained=pretrained,\n            num_classes=0\n        )\n\n        full_blocks = list(self.mixer.blocks.children())\n        if mixer_blocks > len(full_blocks):\n            raise ValueError(\"Requested Mixer Blocks exceed available blocks.\")\n\n        self.mixer.blocks = nn.Sequential(*full_blocks[:mixer_blocks])\n        self.global_feature_dim = self.mixer.embed_dim\n\n        # ----------------------------------------------------\n        # PROJECTION + CLASSIFIER — TETAP\n        # ----------------------------------------------------\n        self.local_projector = nn.Sequential(\n            nn.Linear(self.local_feature_dim, LOCAL_PROJ_DIM),\n            nn.GELU()\n        )\n\n        self.global_projector = nn.Sequential(\n            nn.Linear(self.global_feature_dim, GLOBAL_PROJ_DIM),\n            nn.GELU()\n        )\n\n        self.classifier = nn.Sequential(\n            nn.Linear(FUSED_DIM, CLASSIFIER_HIDDEN_DIM),\n            nn.GELU(),\n            nn.Dropout(dropout_rate),\n            nn.Linear(CLASSIFIER_HIDDEN_DIM, num_classes)\n        )\n\n    def extract_features(self, x):\n        # PARALLEL secara arsitektural: kedua branch menerima input tensor yang sama.\n        local_map = self.convnext.forward_features(x)\n        local_feature = self.local_pool(local_map).flatten(1)\n        local_projected = self.local_projector(local_feature)\n\n        global_tokens = self.mixer.forward_features(x)\n        global_feature = global_tokens.mean(dim=1)\n        global_projected = self.global_projector(global_feature)\n\n        return (\n            local_map,\n            local_feature,\n            local_projected,\n            global_tokens,\n            global_feature,\n            global_projected\n        )\n\n    def forward(self, x):\n        (\n            local_map,\n            local_feature,\n            local_projected,\n            global_tokens,\n            global_feature,\n            global_projected\n        ) = self.extract_features(x)\n\n        fused = torch.cat(\n            [local_projected, global_projected],\n            dim=1\n        )\n\n        return self.classifier(fused)\n\n\ndef build_model(\n    mixer_blocks,\n    dropout_rate,\n    pretrained=True\n):\n    model = ParallelHybridConvNeXtMLPMixer(\n        num_classes=NUM_CLASSES,\n        mixer_blocks=mixer_blocks,\n        dropout_rate=dropout_rate,\n        pretrained=pretrained\n    ).to(DEVICE)\n\n    # channels_last mengubah layout memori, bukan arsitektur/model matematis.\n    if USE_CHANNELS_LAST and DEVICE.type == \"cuda\":\n        model = model.to(memory_format=torch.channels_last)\n\n    return model\n\n\ndef move_images_to_device(images, device=DEVICE):\n    images = images.to(device, non_blocking=True)\n    if USE_CHANNELS_LAST and device.type == \"cuda\" and images.ndim == 4:\n        images = images.contiguous(memory_format=torch.channels_last)\n    return images\n\n\nprint(\"Parallel architecture ready.\")\nprint(\"All backbones remain trainable during grid search.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:20:25.913353Z","iopub.execute_input":"2026-08-26T12:20:25.914075Z","iopub.status.idle":"2026-08-26T12:20:25.927663Z","shell.execute_reply.started":"2026-08-26T12:20:25.914048Z","shell.execute_reply":"2026-08-26T12:20:25.926838Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 10 — Architecture Verification**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 10\n# ARCHITECTURE VERIFICATION\n# ============================================================\n\nverification_model = build_model(\n    mixer_blocks=4,\n    dropout_rate=0.3,\n    pretrained=True\n)\nverification_model.eval()\n\nsample_images, sample_labels = next(iter(train_loader))\nsample_images = move_images_to_device(sample_images[:2])\n\nwith torch.inference_mode():\n    (\n        local_map,\n        local_feature,\n        local_projected,\n        global_tokens,\n        global_feature,\n        global_projected\n    ) = verification_model.extract_features(sample_images)\n\n    logits = verification_model(sample_images)\n\nprint(\"=\" * 70)\nprint(\"ARCHITECTURE VERIFICATION\")\nprint(\"=\" * 70)\nprint(\"\\nInput Image:\", sample_images.shape)\nprint(\"ConvNeXt Feature Map:\", local_map.shape)\nprint(\"ConvNeXt Raw Feature:\", local_feature.shape)\nprint(\"Local Projection:\", local_projected.shape)\nprint(\"MLP-Mixer Tokens:\", global_tokens.shape)\nprint(\"MLP-Mixer Raw Feature:\", global_feature.shape)\nprint(\"Global Projection:\", global_projected.shape)\n\nfused = torch.cat([local_projected, global_projected], dim=1)\nprint(\"Fused Feature:\", fused.shape)\nprint(\"Output:\", logits.shape)\nprint(\"Active Mixer Blocks:\", len(verification_model.mixer.blocks))\nprint(\"ConvNeXt Pretrained:\", CONVNEXT_PRETRAINED)\nprint(\"MLP-Mixer Pretrained:\", MIXER_PRETRAINED)\n\ntotal_params_verify = sum(p.numel() for p in verification_model.parameters())\ntrainable_params_verify = sum(p.numel() for p in verification_model.parameters() if p.requires_grad)\nprint(\"Total Parameters:\", f\"{total_params_verify:,}\")\nprint(\"Trainable Parameters:\", f\"{trainable_params_verify:,}\")\nprint(\"All parameters trainable:\", total_params_verify == trainable_params_verify)\n\n# Summary satu kali untuk dokumentasi arsitektur.\nif HAS_TORCHINFO:\n    summary(\n        verification_model,\n        input_size=(1, 3, IMG_SIZE, IMG_SIZE),\n        device=str(DEVICE)\n    )\nelse:\n    print(\"torchinfo tidak tersedia -> model summary skipped.\")\n\ndel verification_model\ngc.collect()\nif torch.cuda.is_available():\n    torch.cuda.empty_cache()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:20:37.71111Z","iopub.execute_input":"2026-08-26T12:20:37.711687Z","iopub.status.idle":"2026-08-26T12:20:58.511043Z","shell.execute_reply.started":"2026-08-26T12:20:37.711658Z","shell.execute_reply":"2026-08-26T12:20:58.510384Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# CELL 10B\n# REPRODUCIBILITY FUNCTION\n# ============================================================\n\n\ndef seed_everything(seed):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n\n    if torch.cuda.is_available():\n        torch.cuda.manual_seed(seed)\n        torch.cuda.manual_seed_all(seed)\n\n    if STRICT_REPRODUCIBILITY:\n        torch.backends.cudnn.deterministic = True\n        torch.backends.cudnn.benchmark = False\n    else:\n        # Optional speed mode; default notebook tetap reproducible.\n        torch.backends.cudnn.deterministic = False\n        torch.backends.cudnn.benchmark = True\n\n\nseed_everything(SEED)\nprint(\"Seed function ready.\")\nprint(\"Random Seed:\", SEED)\nprint(\"Strict reproducibility:\", STRICT_REPRODUCIBILITY)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:21:03.889971Z","iopub.execute_input":"2026-08-26T12:21:03.890571Z","iopub.status.idle":"2026-08-26T12:21:03.899347Z","shell.execute_reply.started":"2026-08-26T12:21:03.890545Z","shell.execute_reply":"2026-08-26T12:21:03.898656Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 11 — Grid Search**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 11\n# GRID SEARCH — FULL END-TO-END FINE-TUNING\n# 3 LR x 3 Dropout x 3 Mixer Blocks = 27 EXPERIMENTS\n# ============================================================\n\ncriterion = nn.CrossEntropyLoss(weight=class_weights_tensor)\n\n\ndef make_optimizer(model, learning_rate):\n    kwargs = dict(\n        lr=learning_rate,\n        weight_decay=WEIGHT_DECAY\n    )\n\n    if USE_FUSED_ADAMW and DEVICE.type == \"cuda\":\n        try:\n            return torch.optim.AdamW(\n                model.parameters(),\n                fused=True,\n                **kwargs\n            )\n        except (TypeError, RuntimeError):\n            pass\n\n    return torch.optim.AdamW(\n        model.parameters(),\n        **kwargs\n    )\n\n\ndef train_one_epoch(\n    model,\n    loader,\n    criterion,\n    optimizer,\n    scaler,\n    device\n):\n    model.train()\n\n    running_loss = torch.zeros((), device=device, dtype=torch.float32)\n    all_labels = []\n    all_preds = []\n\n    optimizer.zero_grad(set_to_none=True)\n\n    for batch_idx, (images, labels) in enumerate(\n        tqdm(loader, leave=False, mininterval=2.0)\n    ):\n        images = move_images_to_device(images, device)\n        labels = labels.to(device, non_blocking=True)\n\n        with torch.autocast(\n            device_type=device.type,\n            dtype=torch.float16,\n            enabled=USE_AMP\n        ):\n            outputs = model(images)\n            raw_loss = criterion(outputs, labels)\n            loss_scaled = raw_loss / GRAD_ACCUM_STEPS\n\n        scaler.scale(loss_scaled).backward()\n\n        should_step = (\n            ((batch_idx + 1) % GRAD_ACCUM_STEPS == 0)\n            or ((batch_idx + 1) == len(loader))\n        )\n\n        if should_step:\n            scaler.step(optimizer)\n            scaler.update()\n            optimizer.zero_grad(set_to_none=True)\n\n        preds = torch.argmax(outputs, dim=1)\n        running_loss += raw_loss.detach().float() * images.size(0)\n        all_labels.append(labels.detach())\n        all_preds.append(preds.detach())\n\n    epoch_loss = (running_loss / len(loader.dataset)).item()\n    all_labels = torch.cat(all_labels).cpu().numpy()\n    all_preds = torch.cat(all_preds).cpu().numpy()\n\n    epoch_acc = accuracy_score(all_labels, all_preds)\n    epoch_f1 = f1_score(\n        all_labels,\n        all_preds,\n        average=\"macro\",\n        zero_division=0\n    )\n\n    return epoch_loss, epoch_acc, epoch_f1\n\n\ndef validate(\n    model,\n    loader,\n    criterion,\n    device\n):\n    \"\"\"Validation grid search: hanya metric yang dipakai untuk selection.\n    Softmax/probabilities tidak dihitung di sini karena tidak dibutuhkan.\n    \"\"\"\n    model.eval()\n\n    running_loss = torch.zeros((), device=device, dtype=torch.float32)\n    all_labels = []\n    all_preds = []\n\n    with torch.inference_mode():\n        for images, labels in loader:\n            images = move_images_to_device(images, device)\n            labels = labels.to(device, non_blocking=True)\n\n            with torch.autocast(\n                device_type=device.type,\n                dtype=torch.float16,\n                enabled=USE_AMP\n            ):\n                outputs = model(images)\n                loss = criterion(outputs, labels)\n\n            preds = torch.argmax(outputs, dim=1)\n            running_loss += loss.detach().float() * images.size(0)\n            all_labels.append(labels)\n            all_preds.append(preds)\n\n    val_loss = (running_loss / len(loader.dataset)).item()\n    all_labels = torch.cat(all_labels).cpu().numpy()\n    all_preds = torch.cat(all_preds).cpu().numpy()\n\n    return {\n        \"loss\": val_loss,\n        \"accuracy\": accuracy_score(all_labels, all_preds),\n        \"f1\": f1_score(\n            all_labels,\n            all_preds,\n            average=\"macro\",\n            zero_division=0\n        )\n    }\n\n\n# ------------------------------------------------------------\n# STORAGE / RESUME\n# ------------------------------------------------------------\nGRID_PROGRESS_PATH = OUTPUT_DIR / \"grid_search_progress.json\"\nGLOBAL_BEST_PATH = CHECKPOINT_DIR / \"best_convnext_mlpmixer.pth\"\nGLOBAL_BEST_HISTORY_PATH = OUTPUT_DIR / \"global_best_history.json\"\n\nif GRID_PROGRESS_PATH.exists():\n    with open(GRID_PROGRESS_PATH) as f:\n        grid_results = json.load(f)\n    print(f\"Resuming grid search: {len(grid_results)} experiment(s) completed.\")\nelse:\n    grid_results = []\n\ndone_configs = {\n    (int(r[\"MixerBlock\"]), float(r[\"Dropout\"]), float(r[\"LearningRate\"]))\n    for r in grid_results\n}\n\nif grid_results:\n    _best_so_far = max(\n        grid_results,\n        key=lambda r: (r[\"BestValF1\"], r[\"BestValAcc\"])\n    )\n    global_best_f1 = float(_best_so_far[\"BestValF1\"])\n    global_best_acc = float(_best_so_far[\"BestValAcc\"])\n    global_best_config = _best_so_far.copy()\nelse:\n    global_best_f1 = -1.0\n    global_best_acc = -1.0\n    global_best_config = None\n\nif GLOBAL_BEST_HISTORY_PATH.exists():\n    with open(GLOBAL_BEST_HISTORY_PATH) as f:\n        global_best_history = json.load(f)\nelse:\n    global_best_history = None\n\n# Resume hanya aman bila progress, checkpoint, dan history berasal dari output yang sama.\nif grid_results and (not GLOBAL_BEST_PATH.exists() or global_best_history is None):\n    raise RuntimeError(\n        \"grid_search_progress.json ditemukan tetapi checkpoint/history global best tidak lengkap. \"\n        \"Pulihkan seluruh folder output dari run sebelumnya atau mulai grid search dari awal.\"\n    )\n\nexperiment_number = len(grid_results)\ngrid_start_time = time.time()\n\n# ============================================================\n# GRID LOOP\n# ============================================================\nfor mixer_blocks in MIXER_BLOCK_CANDIDATES:\n    for dropout_rate in DROPOUT_CANDIDATES:\n        for learning_rate in LR_CANDIDATES:\n\n            config_key = (\n                int(mixer_blocks),\n                float(dropout_rate),\n                float(learning_rate)\n            )\n\n            if config_key in done_configs:\n                continue\n\n            experiment_number += 1\n            print(\"\\n\" + \"=\" * 80)\n            print(f\"EXPERIMENT {experiment_number}/27\")\n            print(f\"Mixer Block = {mixer_blocks}\")\n            print(f\"Dropout = {dropout_rate}\")\n            print(f\"Learning Rate = {learning_rate}\")\n            print(\"FULL END-TO-END FINE-TUNING\")\n            print(\"=\" * 80)\n\n            seed_everything(SEED)\n\n            if torch.cuda.is_available():\n                torch.cuda.empty_cache()\n                torch.cuda.reset_peak_memory_stats()\n\n            # SEMUA PARAMETER TRAINABLE — tidak ada freeze.\n            model = build_model(\n                mixer_blocks=mixer_blocks,\n                dropout_rate=dropout_rate,\n                pretrained=True\n            )\n\n            optimizer = make_optimizer(model, learning_rate)\n            scaler = torch.amp.GradScaler(\"cuda\", enabled=USE_AMP)\n\n            # LR scheduler: cosine decay per eksperimen, mengurangi\n            # overfitting akibat LR konstan sepanjang training.\n            scheduler = (\n                torch.optim.lr_scheduler.CosineAnnealingLR(\n                    optimizer,\n                    T_max=EPOCHS\n                )\n                if USE_LR_SCHEDULER\n                else None\n            )\n\n            best_val_f1 = -1.0\n            best_val_acc = -1.0\n            best_val_loss = np.inf\n            best_epoch = 0\n            patience_counter = 0\n\n            history = {\n                \"epoch\": [],\n                \"train_loss\": [],\n                \"train_acc\": [],\n                \"train_f1\": [],\n                \"val_loss\": [],\n                \"val_acc\": [],\n                \"val_f1\": []\n            }\n\n            experiment_start = time.time()\n\n            for epoch in range(1, EPOCHS + 1):\n                epoch_start = time.time()\n\n                train_loss, train_acc, train_f1 = train_one_epoch(\n                    model,\n                    train_loader,\n                    criterion,\n                    optimizer,\n                    scaler,\n                    DEVICE\n                )\n\n                val_result = validate(\n                    model,\n                    val_loader,\n                    criterion,\n                    DEVICE\n                )\n\n                history[\"epoch\"].append(epoch)\n                history[\"train_loss\"].append(float(train_loss))\n                history[\"train_acc\"].append(float(train_acc))\n                history[\"train_f1\"].append(float(train_f1))\n                history[\"val_loss\"].append(float(val_result[\"loss\"]))\n                history[\"val_acc\"].append(float(val_result[\"accuracy\"]))\n                history[\"val_f1\"].append(float(val_result[\"f1\"]))\n\n                current_key = (\n                    val_result[\"f1\"],\n                    val_result[\"accuracy\"]\n                )\n                best_key = (best_val_f1, best_val_acc)\n\n                if current_key > best_key:\n                    best_val_f1 = float(val_result[\"f1\"])\n                    best_val_acc = float(val_result[\"accuracy\"])\n                    best_val_loss = float(val_result[\"loss\"])\n                    best_epoch = int(epoch)\n                    patience_counter = 0\n\n                    # Hindari checkpoint lokal besar setiap improvement.\n                    # Tulis model penuh hanya bila menjadi GLOBAL BEST.\n                    if current_key > (global_best_f1, global_best_acc):\n                        global_best_f1 = best_val_f1\n                        global_best_acc = best_val_acc\n                        global_best_config = {\n                            \"Experiment\": experiment_number,\n                            \"MixerBlock\": int(mixer_blocks),\n                            \"Dropout\": float(dropout_rate),\n                            \"LearningRate\": float(learning_rate),\n                            \"BestEpoch\": best_epoch,\n                            \"BestValLoss\": best_val_loss,\n                            \"BestValAcc\": best_val_acc,\n                            \"BestValF1\": best_val_f1\n                        }\n\n                        torch.save(\n                            {\n                                \"model_state_dict\": model.state_dict(),\n                                \"MixerBlock\": int(mixer_blocks),\n                                \"Dropout\": float(dropout_rate),\n                                \"LearningRate\": float(learning_rate),\n                                \"BestEpoch\": best_epoch,\n                                \"BestValF1\": best_val_f1,\n                                \"BestValAcc\": best_val_acc,\n                                \"BestValLoss\": best_val_loss\n                            },\n                            GLOBAL_BEST_PATH\n                        )\n                        print(\"NEW GLOBAL BEST — checkpoint saved\")\n                else:\n                    patience_counter += 1\n\n                epoch_seconds = time.time() - epoch_start\n                print(\n                    f\"Epoch {epoch}/{EPOCHS} | \"\n                    f\"Train Loss={train_loss:.4f} | \"\n                    f\"Train Acc={train_acc:.4f} | \"\n                    f\"Train F1={train_f1:.4f} | \"\n                    f\"Val Loss={val_result['loss']:.4f} | \"\n                    f\"Val Acc={val_result['accuracy']:.4f} | \"\n                    f\"Val F1={val_result['f1']:.4f} | \"\n                    f\"Time={epoch_seconds / 60:.2f} min\"\n                )\n\n                if scheduler is not None:\n                    scheduler.step()\n\n                if patience_counter >= PATIENCE:\n                    print(\"Early stopping.\")\n                    break\n\n            training_time = time.time() - experiment_start\n\n            if torch.cuda.is_available():\n                peak_gpu_memory = torch.cuda.max_memory_allocated() / (1024 ** 2)\n            else:\n                peak_gpu_memory = None\n\n            result = {\n                \"Experiment\": experiment_number,\n                \"MixerBlock\": int(mixer_blocks),\n                \"Dropout\": float(dropout_rate),\n                \"LearningRate\": float(learning_rate),\n                \"BestEpoch\": best_epoch,\n                \"BestValLoss\": best_val_loss,\n                \"BestValAcc\": best_val_acc,\n                \"BestValF1\": best_val_f1,\n                \"EpochsRun\": len(history[\"epoch\"]),\n                \"TrainingTimeSeconds\": float(training_time),\n                \"PeakGPUMemoryMB\": None if peak_gpu_memory is None else float(peak_gpu_memory)\n            }\n\n            grid_results.append(result)\n            done_configs.add(config_key)\n\n            # Jika current experiment adalah pemegang global best,\n            # simpan full history sampai early stopping untuk learning curves.\n            if (\n                global_best_config is not None\n                and int(global_best_config[\"MixerBlock\"]) == int(mixer_blocks)\n                and float(global_best_config[\"Dropout\"]) == float(dropout_rate)\n                and float(global_best_config[\"LearningRate\"]) == float(learning_rate)\n                and float(global_best_config[\"BestValF1\"]) == float(best_val_f1)\n                and float(global_best_config[\"BestValAcc\"]) == float(best_val_acc)\n            ):\n                global_best_history = {k: list(v) for k, v in history.items()}\n                with open(GLOBAL_BEST_HISTORY_PATH, \"w\") as f:\n                    json.dump(global_best_history, f, indent=2)\n\n            pd.DataFrame(grid_results).to_csv(\n                OUTPUT_DIR / \"grid_search_results.csv\",\n                index=False\n            )\n\n            with open(GRID_PROGRESS_PATH, \"w\") as f:\n                json.dump(grid_results, f, indent=2)\n\n            del model, optimizer, scaler, scheduler\n            gc.collect()\n            if torch.cuda.is_available():\n                torch.cuda.empty_cache()\n\n# ============================================================\n# GRID SEARCH COMPLETED\n# ============================================================\ngrid_session_time = time.time() - grid_start_time\n\ngrid_results_df = pd.DataFrame(grid_results)\ngrid_results_df = (\n    grid_results_df\n    .sort_values(by=[\"BestValF1\", \"BestValAcc\"], ascending=False)\n    .reset_index(drop=True)\n)\n\ndisplay(grid_results_df)\n\ngrid_results_df.to_csv(\n    OUTPUT_DIR / \"grid_search_results_sorted.csv\",\n    index=False\n)\n\nrecorded_training_hours = grid_results_df[\"TrainingTimeSeconds\"].sum() / 3600\nprint(\"\\nCurrent-session grid wall time:\", grid_session_time / 3600, \"hours\")\nprint(\"Recorded training time (all completed experiments):\", recorded_training_hours, \"hours\")\nprint(\"Completed experiments:\", len(grid_results_df), \"/ 27\")\n\nif len(grid_results_df) != 27:\n    print(\"WARNING: Grid search belum lengkap. Jangan lanjut ke final evaluation sebelum 27 eksperimen selesai.\")\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T12:21:08.739935Z","iopub.execute_input":"2026-08-26T12:21:08.740335Z","iopub.status.idle":"2026-08-26T14:01:49.745439Z","shell.execute_reply.started":"2026-08-26T12:21:08.740308Z","shell.execute_reply":"2026-08-26T14:01:49.744686Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 12 — Learning Rate Analysis**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 12\n# LEARNING RATE ANALYSIS\n# 9 INDIVIDUAL EXPERIMENTS PER LEARNING RATE + MEAN/STD\n# ============================================================\n\nlr_table = grid_results_df.pivot_table(\n    values=\"BestValAcc\",\n    index=[\"MixerBlock\", \"Dropout\"],\n    columns=\"LearningRate\"\n)\n\nprint(\"Validation Accuracy Table — setiap LR memiliki 9 kombinasi:\")\ndisplay(lr_table)\nlr_table.to_csv(OUTPUT_DIR / \"learning_rate_table.csv\")\n\nlr_individual = (\n    grid_results_df\n    .sort_values([\"LearningRate\", \"MixerBlock\", \"Dropout\"])\n    .copy()\n)\n\nlr_individual[\"ExperimentWithinLR\"] = (\n    lr_individual.groupby(\"LearningRate\").cumcount() + 1\n)\n\nlr_individual[\"Combination\"] = (\n    \"B\" + lr_individual[\"MixerBlock\"].astype(str)\n    + \"-D\" + lr_individual[\"Dropout\"].astype(str)\n)\n\ndisplay(\n    lr_individual[\n        [\n            \"LearningRate\",\n            \"ExperimentWithinLR\",\n            \"Combination\",\n            \"BestValAcc\",\n            \"BestValF1\"\n        ]\n    ]\n)\n\nlr_individual.to_csv(\n    OUTPUT_DIR / \"learning_rate_9_individual_results.csv\",\n    index=False\n)\n\n# ------------------------------------------------------------\n# ACCURACY — 3 garis, masing-masing 9 titik\n# ------------------------------------------------------------\nplt.figure(figsize=(10, 6))\n\nfor lr in sorted(lr_individual[\"LearningRate\"].unique()):\n    g = lr_individual[lr_individual[\"LearningRate\"] == lr]\n    plt.plot(\n        g[\"ExperimentWithinLR\"],\n        g[\"BestValAcc\"],\n        marker=\"o\",\n        label=f\"LR={lr:g}\"\n    )\n\nplt.xticks(range(1, 10))\nplt.xlabel(\"Experiment index within each learning rate (1–9)\")\nplt.ylabel(\"Best Validation Accuracy\")\nplt.title(\"Learning Rate Analysis — 9 Individual Experiments per LR\")\nplt.grid(alpha=0.3)\nplt.legend()\nplt.tight_layout()\nplt.savefig(\n    PLOT_DIR / \"learning_rate_9_individual_accuracy.png\",\n    dpi=300\n)\nplt.show()\n\n# ------------------------------------------------------------\n# MACRO-F1 — 3 garis, masing-masing 9 titik\n# ------------------------------------------------------------\nplt.figure(figsize=(10, 6))\n\nfor lr in sorted(lr_individual[\"LearningRate\"].unique()):\n    g = lr_individual[lr_individual[\"LearningRate\"] == lr]\n    plt.plot(\n        g[\"ExperimentWithinLR\"],\n        g[\"BestValF1\"],\n        marker=\"o\",\n        label=f\"LR={lr:g}\"\n    )\n\nplt.xticks(range(1, 10))\nplt.xlabel(\"Experiment index within each learning rate (1–9)\")\nplt.ylabel(\"Best Validation Macro-F1\")\nplt.title(\"Learning Rate Analysis — 9 Individual Macro-F1 Results per LR\")\nplt.grid(alpha=0.3)\nplt.legend()\nplt.tight_layout()\nplt.savefig(\n    PLOT_DIR / \"learning_rate_9_individual_f1.png\",\n    dpi=300\n)\nplt.show()\n\n# ------------------------------------------------------------\n# MEAN / STD tetap sebagai ringkasan tambahan\n# ------------------------------------------------------------\nlr_summary = (\n    grid_results_df\n    .groupby(\"LearningRate\")\n    .agg(\n        Mean_Accuracy=(\"BestValAcc\", \"mean\"),\n        Std_Accuracy=(\"BestValAcc\", \"std\"),\n        Mean_F1=(\"BestValF1\", \"mean\"),\n        Std_F1=(\"BestValF1\", \"std\")\n    )\n    .reset_index()\n)\n\nprint(\"Mean/Std summary:\")\ndisplay(lr_summary)\nlr_summary.to_csv(\n    OUTPUT_DIR / \"learning_rate_summary_mean_std.csv\",\n    index=False\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:02:00.410197Z","iopub.execute_input":"2026-08-26T14:02:00.410478Z","iopub.status.idle":"2026-08-26T14:02:01.809733Z","shell.execute_reply.started":"2026-08-26T14:02:00.410446Z","shell.execute_reply":"2026-08-26T14:02:01.808856Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 13 — Dropout Analysis**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 13\n# DROPOUT ANALYSIS\n# 9 INDIVIDUAL EXPERIMENTS PER DROPOUT + MEAN/STD\n# ============================================================\n\ndropout_table = grid_results_df.pivot_table(\n    values=\"BestValAcc\",\n    index=[\"MixerBlock\", \"LearningRate\"],\n    columns=\"Dropout\"\n)\n\nprint(\"Validation Accuracy Table — setiap dropout memiliki 9 kombinasi:\")\ndisplay(dropout_table)\ndropout_table.to_csv(OUTPUT_DIR / \"dropout_table.csv\")\n\ndropout_individual = (\n    grid_results_df\n    .sort_values([\"Dropout\", \"MixerBlock\", \"LearningRate\"])\n    .copy()\n)\n\ndropout_individual[\"ExperimentWithinDropout\"] = (\n    dropout_individual.groupby(\"Dropout\").cumcount() + 1\n)\n\ndropout_individual[\"Combination\"] = (\n    \"B\" + dropout_individual[\"MixerBlock\"].astype(str)\n    + \"-LR\" + dropout_individual[\"LearningRate\"].map(lambda x: f\"{x:g}\")\n)\n\ndisplay(\n    dropout_individual[\n        [\n            \"Dropout\",\n            \"ExperimentWithinDropout\",\n            \"Combination\",\n            \"BestValAcc\",\n            \"BestValF1\"\n        ]\n    ]\n)\n\ndropout_individual.to_csv(\n    OUTPUT_DIR / \"dropout_9_individual_results.csv\",\n    index=False\n)\n\nplt.figure(figsize=(10, 6))\nfor d in sorted(dropout_individual[\"Dropout\"].unique()):\n    g = dropout_individual[dropout_individual[\"Dropout\"] == d]\n    plt.plot(\n        g[\"ExperimentWithinDropout\"],\n        g[\"BestValAcc\"],\n        marker=\"o\",\n        label=f\"Dropout={d:g}\"\n    )\n\nplt.xticks(range(1, 10))\nplt.xlabel(\"Experiment index within each dropout (1–9)\")\nplt.ylabel(\"Best Validation Accuracy\")\nplt.title(\"Dropout Analysis — 9 Individual Experiments per Dropout\")\nplt.grid(alpha=0.3)\nplt.legend()\nplt.tight_layout()\nplt.savefig(PLOT_DIR / \"dropout_9_individual_accuracy.png\", dpi=300)\nplt.show()\n\nplt.figure(figsize=(10, 6))\nfor d in sorted(dropout_individual[\"Dropout\"].unique()):\n    g = dropout_individual[dropout_individual[\"Dropout\"] == d]\n    plt.plot(\n        g[\"ExperimentWithinDropout\"],\n        g[\"BestValF1\"],\n        marker=\"o\",\n        label=f\"Dropout={d:g}\"\n    )\n\nplt.xticks(range(1, 10))\nplt.xlabel(\"Experiment index within each dropout (1–9)\")\nplt.ylabel(\"Best Validation Macro-F1\")\nplt.title(\"Dropout Analysis — 9 Individual Macro-F1 Results per Dropout\")\nplt.grid(alpha=0.3)\nplt.legend()\nplt.tight_layout()\nplt.savefig(PLOT_DIR / \"dropout_9_individual_f1.png\", dpi=300)\nplt.show()\n\ndropout_summary = (\n    grid_results_df\n    .groupby(\"Dropout\")\n    .agg(\n        Mean_Accuracy=(\"BestValAcc\", \"mean\"),\n        Std_Accuracy=(\"BestValAcc\", \"std\"),\n        Mean_F1=(\"BestValF1\", \"mean\"),\n        Std_F1=(\"BestValF1\", \"std\")\n    )\n    .reset_index()\n)\n\nprint(\"Mean/Std summary:\")\ndisplay(dropout_summary)\ndropout_summary.to_csv(\n    OUTPUT_DIR / \"dropout_summary_mean_std.csv\",\n    index=False\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:02:08.898717Z","iopub.execute_input":"2026-08-26T14:02:08.89927Z","iopub.status.idle":"2026-08-26T14:02:10.237633Z","shell.execute_reply.started":"2026-08-26T14:02:08.899242Z","shell.execute_reply":"2026-08-26T14:02:10.237021Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 14 — Mixer Block Analysis**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 14\n# MIXER BLOCK ANALYSIS\n# 9 INDIVIDUAL EXPERIMENTS PER BLOCK + MEAN/STD\n# ============================================================\n\nmixer_table = grid_results_df.pivot_table(\n    values=\"BestValAcc\",\n    index=[\"Dropout\", \"LearningRate\"],\n    columns=\"MixerBlock\"\n)\n\nprint(\"Validation Accuracy Table — setiap block memiliki 9 kombinasi:\")\ndisplay(mixer_table)\nmixer_table.to_csv(OUTPUT_DIR / \"mixer_block_table.csv\")\n\nmixer_individual = (\n    grid_results_df\n    .sort_values([\"MixerBlock\", \"Dropout\", \"LearningRate\"])\n    .copy()\n)\n\nmixer_individual[\"ExperimentWithinBlock\"] = (\n    mixer_individual.groupby(\"MixerBlock\").cumcount() + 1\n)\n\nmixer_individual[\"Combination\"] = (\n    \"D\" + mixer_individual[\"Dropout\"].astype(str)\n    + \"-LR\" + mixer_individual[\"LearningRate\"].map(lambda x: f\"{x:g}\")\n)\n\ndisplay(\n    mixer_individual[\n        [\n            \"MixerBlock\",\n            \"ExperimentWithinBlock\",\n            \"Combination\",\n            \"BestValAcc\",\n            \"BestValF1\"\n        ]\n    ]\n)\n\nmixer_individual.to_csv(\n    OUTPUT_DIR / \"mixer_block_9_individual_results.csv\",\n    index=False\n)\n\nplt.figure(figsize=(10, 6))\nfor b in sorted(mixer_individual[\"MixerBlock\"].unique()):\n    g = mixer_individual[mixer_individual[\"MixerBlock\"] == b]\n    plt.plot(\n        g[\"ExperimentWithinBlock\"],\n        g[\"BestValAcc\"],\n        marker=\"o\",\n        label=f\"Blocks={int(b)}\"\n    )\n\nplt.xticks(range(1, 10))\nplt.xlabel(\"Experiment index within each Mixer depth (1–9)\")\nplt.ylabel(\"Best Validation Accuracy\")\nplt.title(\"Mixer Block Analysis — 9 Individual Experiments per Block\")\nplt.grid(alpha=0.3)\nplt.legend()\nplt.tight_layout()\nplt.savefig(PLOT_DIR / \"mixer_block_9_individual_accuracy.png\", dpi=300)\nplt.show()\n\nplt.figure(figsize=(10, 6))\nfor b in sorted(mixer_individual[\"MixerBlock\"].unique()):\n    g = mixer_individual[mixer_individual[\"MixerBlock\"] == b]\n    plt.plot(\n        g[\"ExperimentWithinBlock\"],\n        g[\"BestValF1\"],\n        marker=\"o\",\n        label=f\"Blocks={int(b)}\"\n    )\n\nplt.xticks(range(1, 10))\nplt.xlabel(\"Experiment index within each Mixer depth (1–9)\")\nplt.ylabel(\"Best Validation Macro-F1\")\nplt.title(\"Mixer Block Analysis — 9 Individual Macro-F1 Results per Block\")\nplt.grid(alpha=0.3)\nplt.legend()\nplt.tight_layout()\nplt.savefig(PLOT_DIR / \"mixer_block_9_individual_f1.png\", dpi=300)\nplt.show()\n\nmixer_summary = (\n    grid_results_df\n    .groupby(\"MixerBlock\")\n    .agg(\n        Mean_Accuracy=(\"BestValAcc\", \"mean\"),\n        Std_Accuracy=(\"BestValAcc\", \"std\"),\n        Mean_F1=(\"BestValF1\", \"mean\"),\n        Std_F1=(\"BestValF1\", \"std\")\n    )\n    .reset_index()\n)\n\nprint(\"Mean/Std summary:\")\ndisplay(mixer_summary)\nmixer_summary.to_csv(\n    OUTPUT_DIR / \"mixer_block_summary_mean_std.csv\",\n    index=False\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:02:17.472638Z","iopub.execute_input":"2026-08-26T14:02:17.473067Z","iopub.status.idle":"2026-08-26T14:02:18.797885Z","shell.execute_reply.started":"2026-08-26T14:02:17.473038Z","shell.execute_reply":"2026-08-26T14:02:18.797078Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 15 — Select Best Configuration**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 15\n# SELECT BEST CONFIGURATION\n# ============================================================\n\nif len(grid_results_df) != 27:\n    raise RuntimeError(\n        f\"Grid search belum lengkap: {len(grid_results_df)}/27 eksperimen. \"\n        \"Selesaikan CELL 11 terlebih dahulu.\"\n    )\n\nbest_config = grid_results_df.iloc[0]\n\nBEST_BLOCK = int(best_config[\"MixerBlock\"])\nBEST_DROPOUT = float(best_config[\"Dropout\"])\nBEST_LR = float(best_config[\"LearningRate\"])\nBEST_VAL_F1 = float(best_config[\"BestValF1\"])\nBEST_VAL_ACC = float(best_config[\"BestValAcc\"])\nBEST_EPOCH = int(best_config[\"BestEpoch\"])\n\nif not GLOBAL_BEST_PATH.exists():\n    raise FileNotFoundError(\"Checkpoint global best tidak ditemukan.\")\n\nif global_best_history is None and GLOBAL_BEST_HISTORY_PATH.exists():\n    with open(GLOBAL_BEST_HISTORY_PATH) as f:\n        global_best_history = json.load(f)\n\nif global_best_history is None:\n    raise RuntimeError(\"History global best tidak ditemukan.\")\n\nprint(\"=\" * 70)\nprint(\"BEST CONFIGURATION\")\nprint(\"=\" * 70)\nprint(\"Mixer Block:\", BEST_BLOCK)\nprint(\"Dropout:\", BEST_DROPOUT)\nprint(\"Learning Rate:\", BEST_LR)\nprint(\"Best Epoch:\", BEST_EPOCH)\nprint(\"Validation Macro-F1:\", BEST_VAL_F1)\nprint(\"Validation Accuracy:\", BEST_VAL_ACC)\nprint(\"Checkpoint:\", GLOBAL_BEST_PATH)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:02:27.364552Z","iopub.execute_input":"2026-08-26T14:02:27.36507Z","iopub.status.idle":"2026-08-26T14:02:27.373756Z","shell.execute_reply.started":"2026-08-26T14:02:27.365025Z","shell.execute_reply":"2026-08-26T14:02:27.372949Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 16 — Learning Curves**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 16\n# LEARNING CURVES\n# BEST CONFIGURATION\n# ============================================================\n\n\nhistory_df = pd.DataFrame(\n\n    global_best_history\n\n)\n\n\ndisplay(\n    history_df\n)\n\n\nhistory_df.to_csv(\n\n    OUTPUT_DIR /\n\n    \"best_training_history.csv\",\n\n    index=False\n\n)\n\n\n# ------------------------------------------------------------\n# ACCURACY\n# ------------------------------------------------------------\n\nplt.figure(\n    figsize=(8,5)\n)\n\n\nplt.plot(\n\n    history_df[\n        \"epoch\"\n    ],\n\n    history_df[\n        \"train_acc\"\n    ],\n\n    marker=\"o\",\n\n    label=\"Train Accuracy\"\n\n)\n\n\nplt.plot(\n\n    history_df[\n        \"epoch\"\n    ],\n\n    history_df[\n        \"val_acc\"\n    ],\n\n    marker=\"o\",\n\n    label=\"Validation Accuracy\"\n\n)\n\n\nplt.xlabel(\n    \"Epoch\"\n)\n\nplt.ylabel(\n    \"Accuracy\"\n)\n\nplt.title(\n    \"Training and Validation Accuracy\"\n)\n\nplt.legend()\n\nplt.grid(\n    alpha=0.3\n)\n\nplt.tight_layout()\n\n\nplt.savefig(\n\n    PLOT_DIR /\n\n    \"learning_curve_accuracy.png\",\n\n    dpi=300\n\n)\n\n\nplt.show()\n\n\n# ------------------------------------------------------------\n# LOSS\n# ------------------------------------------------------------\n\nplt.figure(\n    figsize=(8,5)\n)\n\n\nplt.plot(\n\n    history_df[\n        \"epoch\"\n    ],\n\n    history_df[\n        \"train_loss\"\n    ],\n\n    marker=\"o\",\n\n    label=\"Train Loss\"\n\n)\n\n\nplt.plot(\n\n    history_df[\n        \"epoch\"\n    ],\n\n    history_df[\n        \"val_loss\"\n    ],\n\n    marker=\"o\",\n\n    label=\"Validation Loss\"\n\n)\n\n\nplt.xlabel(\n    \"Epoch\"\n)\n\nplt.ylabel(\n    \"Loss\"\n)\n\nplt.title(\n    \"Training and Validation Loss\"\n)\n\nplt.legend()\n\nplt.grid(\n    alpha=0.3\n)\n\nplt.tight_layout()\n\n\nplt.savefig(\n\n    PLOT_DIR /\n\n    \"learning_curve_loss.png\",\n\n    dpi=300\n\n)\n\n\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:02:38.994735Z","iopub.execute_input":"2026-08-26T14:02:38.995173Z","iopub.status.idle":"2026-08-26T14:02:39.877198Z","shell.execute_reply.started":"2026-08-26T14:02:38.995147Z","shell.execute_reply":"2026-08-26T14:02:39.876538Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Figure 3 — Learning curves dua hybrid terpilih\n\nCell berikut mengekspor training history dengan kolom yang dibutuhkan dan membuat tiga panel: **loss**, **accuracy**, dan **validation macro-F1**.\n\n- History **ConvNeXt–MLP-Mixer** diambil langsung dari `global_best_history`.\n- Untuk grafik gabungan, isi `POOLFORMER_HISTORY_CSV` dengan path CSV history ConvNeXt–PoolFormer di Kaggle.\n- Karena grid search memakai early stopping, jumlah baris per model dapat kurang dari 10. CSV memakai epoch yang benar-benar dijalankan, maksimal 10 baris per model dan 20 baris total.\n","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# FIGURE 3 — SELECTED HYBRIDS LEARNING CURVES\n# Required columns:\n# Epoch, Train loss, Validation loss, Train accuracy,\n# Validation accuracy, Validation macro-F1\n# ============================================================\n\n# Optional: point this to the ConvNeXt–PoolFormer history CSV on Kaggle.\n# Example:\n# POOLFORMER_HISTORY_CSV = \"/kaggle/input/nama-dataset/best_training_history_poolformer.csv\"\nPOOLFORMER_HISTORY_CSV = None\n\nFIGURE3_COLUMNS = [\n    \"Model\",\n    \"Epoch\",\n    \"Train loss\",\n    \"Validation loss\",\n    \"Train accuracy\",\n    \"Validation accuracy\",\n    \"Validation macro-F1\",\n]\n\n\ndef standardize_history(df, model_name):\n    \"\"\"Standardize common training-history column names for Figure 3.\"\"\"\n    aliases = {\n        \"Epoch\": [\"Epoch\", \"epoch\"],\n        \"Train loss\": [\"Train loss\", \"train_loss\", \"loss\"],\n        \"Validation loss\": [\"Validation loss\", \"val_loss\", \"validation_loss\"],\n        \"Train accuracy\": [\"Train accuracy\", \"train_acc\", \"train_accuracy\", \"accuracy\"],\n        \"Validation accuracy\": [\"Validation accuracy\", \"val_acc\", \"val_accuracy\"],\n        \"Validation macro-F1\": [\n            \"Validation macro-F1\", \"val_f1\", \"val_macro_f1\",\n            \"validation_f1\", \"validation_macro_f1\"\n        ],\n    }\n\n    rename_map = {}\n    for target, candidates in aliases.items():\n        source = next((name for name in candidates if name in df.columns), None)\n        if source is None:\n            raise ValueError(\n                f\"Kolom untuk '{target}' tidak ditemukan pada history {model_name}. \"\n                f\"Kolom tersedia: {list(df.columns)}\"\n            )\n        rename_map[source] = target\n\n    result = df.rename(columns=rename_map)[list(aliases)].copy()\n    result.insert(0, \"Model\", model_name)\n    result = result[FIGURE3_COLUMNS].sort_values(\"Epoch\").head(EPOCHS)\n    return result.reset_index(drop=True)\n\n\n# 1) Current notebook: ConvNeXt–MLP-Mixer\nmlp_mixer_history = standardize_history(\n    pd.DataFrame(global_best_history),\n    \"ConvNeXt–MLP-Mixer\",\n)\n\nhistory_parts = [mlp_mixer_history]\n\n# 2) Optional second notebook/history: ConvNeXt–PoolFormer\nif POOLFORMER_HISTORY_CSV:\n    poolformer_path = Path(POOLFORMER_HISTORY_CSV)\n    if not poolformer_path.exists():\n        raise FileNotFoundError(f\"CSV PoolFormer tidak ditemukan: {poolformer_path}\")\n    poolformer_history = standardize_history(\n        pd.read_csv(poolformer_path),\n        \"ConvNeXt–PoolFormer\",\n    )\n    history_parts.append(poolformer_history)\nelse:\n    print(\n        \"INFO: POOLFORMER_HISTORY_CSV belum diisi. \"\n        \"Figure 3 sementara hanya menampilkan ConvNeXt–MLP-Mixer.\"\n    )\n\nfigure3_history = pd.concat(history_parts, ignore_index=True)\nfigure3_history = (\n    figure3_history\n    .sort_values([\"Model\", \"Epoch\"])\n    .groupby(\"Model\", group_keys=False)\n    .head(10)\n    .reset_index(drop=True)\n)\n\nif len(figure3_history) > 20:\n    raise RuntimeError(\"Figure 3 history melebihi batas 20 baris.\")\n\nprint(\"Jumlah baris per model:\")\ndisplay(figure3_history.groupby(\"Model\").size().rename(\"Rows\").reset_index())\ndisplay(figure3_history)\n\n# Separate MLP-Mixer export is directly reusable by the PoolFormer notebook.\nmlp_csv = OUTPUT_DIR / \"figure3_convnext_mlp_mixer_history.csv\"\nmlp_mixer_history.to_csv(mlp_csv, index=False)\n\ncombined_csv = OUTPUT_DIR / \"figure3_selected_hybrids_learning_curves.csv\"\nfigure3_history.to_csv(combined_csv, index=False)\n\n# ------------------------------------------------------------\n# Three-panel Figure 3\n# ------------------------------------------------------------\nfig, axes = plt.subplots(1, 3, figsize=(18, 5.5))\ncolors = {\n    \"ConvNeXt–MLP-Mixer\": \"#d62728\",\n    \"ConvNeXt–PoolFormer\": \"#1f77b4\",\n}\n\nfor model_name, group in figure3_history.groupby(\"Model\", sort=False):\n    color = colors.get(model_name, None)\n\n    axes[0].plot(\n        group[\"Epoch\"], group[\"Train loss\"], marker=\"o\",\n        color=color, linestyle=\"-\", label=f\"{model_name} — train\"\n    )\n    axes[0].plot(\n        group[\"Epoch\"], group[\"Validation loss\"], marker=\"s\",\n        color=color, linestyle=\"--\", label=f\"{model_name} — validation\"\n    )\n\n    axes[1].plot(\n        group[\"Epoch\"], group[\"Train accuracy\"], marker=\"o\",\n        color=color, linestyle=\"-\", label=f\"{model_name} — train\"\n    )\n    axes[1].plot(\n        group[\"Epoch\"], group[\"Validation accuracy\"], marker=\"s\",\n        color=color, linestyle=\"--\", label=f\"{model_name} — validation\"\n    )\n\n    axes[2].plot(\n        group[\"Epoch\"], group[\"Validation macro-F1\"], marker=\"D\",\n        color=color, linewidth=2, label=model_name\n    )\n\npanel_settings = [\n    (\"(a) Loss curves\", \"Loss\"),\n    (\"(b) Accuracy curves\", \"Accuracy\"),\n    (\"(c) Validation macro-F1 curves\", \"Macro-F1\"),\n]\nmax_epoch = int(figure3_history[\"Epoch\"].max())\n\nfor ax, (title, ylabel) in zip(axes, panel_settings):\n    ax.set_title(title, fontweight=\"bold\")\n    ax.set_xlabel(\"Epoch\")\n    ax.set_ylabel(ylabel)\n    ax.set_xticks(range(1, max_epoch + 1))\n    ax.grid(alpha=0.25)\n    ax.legend(fontsize=8)\n\nfig.suptitle(\n    \"Figure 3. Training history of selected hybrid models\",\n    fontsize=14,\n    fontweight=\"bold\",\n)\nfig.tight_layout(rect=[0, 0, 1, 0.94])\n\nfigure3_png = PLOT_DIR / \"figure3_selected_hybrids_learning_curves.png\"\nfigure3_pdf = PLOT_DIR / \"figure3_selected_hybrids_learning_curves.pdf\"\nfig.savefig(figure3_png, dpi=300, bbox_inches=\"tight\")\nfig.savefig(figure3_pdf, bbox_inches=\"tight\")\nplt.show()\n\nprint(\"Saved MLP CSV      :\", mlp_csv)\nprint(\"Saved combined CSV :\", combined_csv)\nprint(\"Saved PNG          :\", figure3_png)\nprint(\"Saved PDF          :\", figure3_pdf)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:02:48.894206Z","iopub.execute_input":"2026-08-26T14:02:48.894468Z","iopub.status.idle":"2026-08-26T14:02:51.138832Z","shell.execute_reply.started":"2026-08-26T14:02:48.894446Z","shell.execute_reply":"2026-08-26T14:02:51.138031Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 17 — Final Test Evaluation**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 17\n# LOAD BEST MODEL + FINAL TEST\n# ============================================================\n\ncheckpoint = torch.load(\n    GLOBAL_BEST_PATH,\n    map_location=DEVICE\n)\n\n# pretrained=False: bobot langsung diisi dari checkpoint final,\n# sehingga tidak perlu memuat pretrained weights sekali lagi.\nmodel = build_model(\n    mixer_blocks=int(checkpoint[\"MixerBlock\"]),\n    dropout_rate=float(checkpoint[\"Dropout\"]),\n    pretrained=False\n)\n\nmodel.load_state_dict(checkpoint[\"model_state_dict\"])\nmodel.eval()\n\nprint(\"Best model loaded\")\n\n# ------------------------------------------------------------\n# TEST\n# ------------------------------------------------------------\ny_true = []\ny_pred = []\ny_prob = []\n\nif torch.cuda.is_available():\n    torch.cuda.synchronize()\n\nstart_test = time.time()\n\nwith torch.inference_mode():\n    for images, labels in tqdm(test_loader):\n        images = move_images_to_device(images, DEVICE)\n\n        with torch.autocast(\n            device_type=DEVICE.type,\n            dtype=torch.float16,\n            enabled=USE_AMP\n        ):\n            outputs = model(images)\n\n        probabilities = torch.softmax(outputs.float(), dim=1)\n        predictions = torch.argmax(outputs, dim=1)\n\n        y_true.extend(labels.numpy())\n        y_pred.extend(predictions.cpu().numpy())\n        y_prob.extend(probabilities.cpu().numpy())\n\nif torch.cuda.is_available():\n    torch.cuda.synchronize()\n\ntest_inference_time = time.time() - start_test\n\ny_true = np.array(y_true)\ny_pred = np.array(y_pred)\ny_prob = np.array(y_prob)\n\nprint(\"Test samples:\", len(y_true))\nprint(\"Total inference time:\", test_inference_time)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:04:57.658157Z","iopub.execute_input":"2026-08-26T14:04:57.65922Z","iopub.status.idle":"2026-08-26T14:05:01.381394Z","shell.execute_reply.started":"2026-08-26T14:04:57.659189Z","shell.execute_reply":"2026-08-26T14:05:01.380558Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 18 — Overall Metrics**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 18\n# OVERALL METRICS\n# ============================================================\n\n\ntest_accuracy = accuracy_score(\n\n    y_true,\n\n    y_pred\n\n)\n\n\nprecision_macro = precision_score(\n\n    y_true,\n\n    y_pred,\n\n    average=\"macro\",\n\n    zero_division=0\n\n)\n\n\nrecall_macro = recall_score(\n\n    y_true,\n\n    y_pred,\n\n    average=\"macro\",\n\n    zero_division=0\n\n)\n\n\nf1_macro = f1_score(\n\n    y_true,\n\n    y_pred,\n\n    average=\"macro\",\n\n    zero_division=0\n\n)\n\n\nprecision_weighted = precision_score(\n\n    y_true,\n\n    y_pred,\n\n    average=\"weighted\",\n\n    zero_division=0\n\n)\n\n\nrecall_weighted = recall_score(\n\n    y_true,\n\n    y_pred,\n\n    average=\"weighted\",\n\n    zero_division=0\n\n)\n\n\nf1_weighted = f1_score(\n\n    y_true,\n\n    y_pred,\n\n    average=\"weighted\",\n\n    zero_division=0\n\n)\n\n\nroc_auc_macro = roc_auc_score(\n\n    y_true,\n\n    y_prob,\n\n    multi_class=\"ovr\",\n\n    average=\"macro\"\n\n)\n\n\nroc_auc_weighted = roc_auc_score(\n\n    y_true,\n\n    y_prob,\n\n    multi_class=\"ovr\",\n\n    average=\"weighted\"\n\n)\n\n\noverall_metrics = pd.DataFrame([{\n\n    \"Model\":\n        \"ConvNeXt-MLP-Mixer\",\n\n    \"Accuracy\":\n        test_accuracy,\n\n    \"Precision_macro\":\n        precision_macro,\n\n    \"Recall_macro\":\n        recall_macro,\n\n    \"F1_macro\":\n        f1_macro,\n\n    \"Precision_weighted\":\n        precision_weighted,\n\n    \"Recall_weighted\":\n        recall_weighted,\n\n    \"F1_weighted\":\n        f1_weighted,\n\n    \"ROC_AUC_macro_OvR\":\n        roc_auc_macro,\n\n    \"ROC_AUC_weighted_OvR\":\n        roc_auc_weighted\n\n}])\n\n\ndisplay(\n    overall_metrics\n)\n\n\noverall_metrics.to_csv(\n\n    OUTPUT_DIR /\n\n    \"overall_metrics.csv\",\n\n    index=False\n\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:05:05.845056Z","iopub.execute_input":"2026-08-26T14:05:05.845546Z","iopub.status.idle":"2026-08-26T14:05:05.891558Z","shell.execute_reply.started":"2026-08-26T14:05:05.845504Z","shell.execute_reply":"2026-08-26T14:05:05.891039Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 19 — Per-Class Metrics**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 19\n# PER-CLASS METRICS\n# ============================================================\n\n\nreport = classification_report(\n\n    y_true,\n\n    y_pred,\n\n    labels=[\n        0,\n        1,\n        2,\n        3,\n        4\n    ],\n\n    target_names=list(\n\n        CLASS_NAMES.values()\n\n    ),\n\n    output_dict=True,\n\n    zero_division=0\n\n)\n\n\nreport_df = pd.DataFrame(\n\n    report\n\n).transpose()\n\n\ndisplay(\n    report_df\n)\n\n\nper_class_df = report_df.loc[\n\n    list(\n        CLASS_NAMES.values()\n    )\n\n]\n\n\ndisplay(\n    per_class_df\n)\n\n\nper_class_df.to_csv(\n\n    OUTPUT_DIR /\n\n    \"per_class_metrics.csv\"\n\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:05:15.05905Z","iopub.execute_input":"2026-08-26T14:05:15.0593Z","iopub.status.idle":"2026-08-26T14:05:15.087778Z","shell.execute_reply.started":"2026-08-26T14:05:15.059281Z","shell.execute_reply":"2026-08-26T14:05:15.087202Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 20 — Confusion Matrix**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 20\n# CONFUSION MATRIX\n# ============================================================\n\n\ncm = confusion_matrix(\n\n    y_true,\n\n    y_pred,\n\n    labels=[\n        0,\n        1,\n        2,\n        3,\n        4\n    ]\n\n)\n\n\nplt.figure(\n    figsize=(8,7)\n)\n\n\nsns.heatmap(\n\n    cm,\n\n    annot=True,\n\n    fmt=\"d\",\n\n    xticklabels=list(\n        CLASS_NAMES.values()\n    ),\n\n    yticklabels=list(\n        CLASS_NAMES.values()\n    )\n\n)\n\n\nplt.xlabel(\n    \"Predicted\"\n)\n\nplt.ylabel(\n    \"Actual\"\n)\n\nplt.title(\n    \"ConvNeXt–MLP-Mixer Confusion Matrix\"\n)\n\nplt.tight_layout()\n\n\nplt.savefig(\n\n    PLOT_DIR /\n\n    \"confusion_matrix.png\",\n\n    dpi=300\n\n)\n\n\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:05:20.011598Z","iopub.execute_input":"2026-08-26T14:05:20.012288Z","iopub.status.idle":"2026-08-26T14:05:20.617654Z","shell.execute_reply.started":"2026-08-26T14:05:20.012256Z","shell.execute_reply":"2026-08-26T14:05:20.617011Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 21 — Normalized Confusion Matrix**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 21\n# NORMALIZED CONFUSION MATRIX\n# ============================================================\n\n\nrow_sum = (\n\n    cm.sum(\n        axis=1,\n        keepdims=True\n    )\n\n)\n\n\ncm_normalized = np.divide(\n\n    cm.astype(float),\n\n    row_sum,\n\n    out=np.zeros_like(\n\n        cm,\n\n        dtype=float\n\n    ),\n\n    where=row_sum != 0\n\n)\n\n\nplt.figure(\n    figsize=(8,7)\n)\n\n\nsns.heatmap(\n\n    cm_normalized,\n\n    annot=True,\n\n    fmt=\".2f\",\n\n    vmin=0,\n\n    vmax=1,\n\n    xticklabels=list(\n        CLASS_NAMES.values()\n    ),\n\n    yticklabels=list(\n        CLASS_NAMES.values()\n    )\n\n)\n\n\nplt.xlabel(\n    \"Predicted\"\n)\n\nplt.ylabel(\n    \"Actual\"\n)\n\nplt.title(\n    \"Normalized Confusion Matrix\"\n)\n\nplt.tight_layout()\n\n\nplt.savefig(\n\n    PLOT_DIR /\n\n    \"normalized_confusion_matrix.png\",\n\n    dpi=300\n\n)\n\n\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:05:26.529487Z","iopub.execute_input":"2026-08-26T14:05:26.529963Z","iopub.status.idle":"2026-08-26T14:05:27.195567Z","shell.execute_reply.started":"2026-08-26T14:05:26.529935Z","shell.execute_reply":"2026-08-26T14:05:27.194871Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 22 — Per-Class ROC-AUC**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 22\n# PER-CLASS ROC-AUC\n# ONE-VS-REST\n# ============================================================\n\n\ny_true_bin = label_binarize(\n\n    y_true,\n\n    classes=[\n        0,\n        1,\n        2,\n        3,\n        4\n    ]\n\n)\n\n\nroc_results = {}\n\n\nplt.figure(\n    figsize=(9,7)\n)\n\n\nfor i in range(\n    NUM_CLASSES\n):\n\n\n    fpr, tpr, _ = roc_curve(\n\n        y_true_bin[\n            :,\n            i\n        ],\n\n        y_prob[\n            :,\n            i\n        ]\n\n    )\n\n\n    class_auc = auc(\n\n        fpr,\n\n        tpr\n\n    )\n\n\n    roc_results[\n        CLASS_NAMES[i]\n    ] = class_auc\n\n\n    plt.plot(\n\n        fpr,\n\n        tpr,\n\n        label=(\n\n            f\"{CLASS_NAMES[i]} \"\n            f\"AUC={class_auc:.4f}\"\n\n        )\n\n    )\n\n\nplt.plot(\n\n    [\n        0,\n        1\n    ],\n\n    [\n        0,\n        1\n    ],\n\n    linestyle=\"--\",\n\n    label=\"Random\"\n\n)\n\n\nplt.xlabel(\n    \"False Positive Rate\"\n)\n\nplt.ylabel(\n    \"True Positive Rate\"\n)\n\nplt.title(\n    \"Per-Class ROC Curve\"\n)\n\nplt.legend()\n\nplt.grid(\n    alpha=0.3\n)\n\nplt.tight_layout()\n\n\nplt.savefig(\n\n    PLOT_DIR /\n\n    \"per_class_roc.png\",\n\n    dpi=300\n\n)\n\n\nplt.show()\n\n\nroc_df = pd.DataFrame({\n\n    \"Class\":\n        list(\n            roc_results.keys()\n        ),\n\n    \"ROC_AUC\":\n        list(\n            roc_results.values()\n        )\n\n})\n\n\ndisplay(\n    roc_df\n)\n\n\nroc_df.to_csv(\n\n    OUTPUT_DIR /\n\n    \"per_class_auc.csv\",\n\n    index=False\n\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:05:34.013523Z","iopub.execute_input":"2026-08-26T14:05:34.014133Z","iopub.status.idle":"2026-08-26T14:05:34.689028Z","shell.execute_reply.started":"2026-08-26T14:05:34.014106Z","shell.execute_reply":"2026-08-26T14:05:34.688483Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 23 — Model Complexity**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 23\n# MODEL COMPLEXITY — COMPARABLE WITH BASELINES\n# THOP returns MACs; approximate FLOPs are reported as 2 x MACs.\n# ============================================================\n\ntotal_params = sum(p.numel() for p in model.parameters())\ntrainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)\nconvnext_params = sum(p.numel() for p in model.convnext.parameters())\nmixer_params = sum(p.numel() for p in model.mixer.parameters())\n\nmodel.eval()\ndummy_input = torch.randn(1, 3, IMG_SIZE, IMG_SIZE)\ndummy_input = move_images_to_device(dummy_input, DEVICE)\n\nmacs, params_thop = profile(\n    model,\n    inputs=(dummy_input,),\n    verbose=False\n)\napprox_flops = 2 * macs\n\n# Serialized state-dict size.\ntemp_path = OUTPUT_DIR / \"temp_model.pth\"\ntorch.save(model.state_dict(), temp_path)\nmodel_size_mb = temp_path.stat().st_size / (1024 ** 2)\ntemp_path.unlink()\n\n# Original end-to-end test time (includes loader/transfer/batched inference).\nend_to_end_test_ms_per_image = test_inference_time * 1000 / len(test_dataset)\n\n# Standardized model-only, batch-1 latency — same protocol as baselines.\nWARMUP = 20\nREPEATS = 100\nif torch.cuda.is_available():\n    torch.cuda.reset_peak_memory_stats()\n\nwith torch.inference_mode():\n    for _ in range(WARMUP):\n        _ = model(dummy_input)\n    if torch.cuda.is_available():\n        torch.cuda.synchronize()\n    benchmark_start = time.perf_counter()\n    for _ in range(REPEATS):\n        _ = model(dummy_input)\n    if torch.cuda.is_available():\n        torch.cuda.synchronize()\n\nbatch1_latency_ms = (\n    (time.perf_counter() - benchmark_start) * 1000 / REPEATS\n)\npeak_gpu_memory_mb = (\n    torch.cuda.max_memory_allocated() / (1024 ** 2)\n    if torch.cuda.is_available()\n    else None\n)\n\ncomplexity_df = pd.DataFrame([{\n    \"Model\": \"ConvNeXt-MLP-Mixer\",\n    \"Total_Parameters\": total_params,\n    \"Trainable_Parameters\": trainable_params,\n    \"ConvNeXt_Parameters\": convnext_params,\n    \"MLP_Mixer_Parameters\": mixer_params,\n    \"THOP_Parameters\": params_thop,\n    \"MACs\": macs,\n    \"GMACs\": macs / 1e9,\n    \"Approx_FLOPs\": approx_flops,\n    \"Approx_GFLOPs\": approx_flops / 1e9,\n    \"Model_Size_MB\": model_size_mb,\n    \"EndToEnd_Test_ms_per_image\": end_to_end_test_ms_per_image,\n    \"Batch1_ModelOnly_Latency_ms\": batch1_latency_ms,\n    \"Peak_GPU_Memory_MB\": peak_gpu_memory_mb,\n    \"Benchmark_Device\": str(DEVICE),\n    \"Warmup_Runs\": WARMUP,\n    \"Benchmark_Repetitions\": REPEATS\n}])\n\ndisplay(complexity_df)\ncomplexity_df.to_csv(OUTPUT_DIR / \"model_complexity.csv\", index=False)\n\ndel dummy_input\nif torch.cuda.is_available():\n    torch.cuda.empty_cache()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:05:43.533976Z","iopub.execute_input":"2026-08-26T14:05:43.534754Z","iopub.status.idle":"2026-08-26T14:05:47.395602Z","shell.execute_reply.started":"2026-08-26T14:05:43.534724Z","shell.execute_reply":"2026-08-26T14:05:47.394928Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 24 — Bootstrap 95% CI**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 24\n# BOOTSTRAP 95% CONFIDENCE INTERVAL\n# ============================================================\n\n\ndef bootstrap_ci(\n\n    y_true,\n\n    y_pred,\n\n    metric_function,\n\n    n_bootstrap=1000,\n\n    seed=42\n\n):\n\n\n    rng = np.random.default_rng(\n        seed\n    )\n\n\n    scores = []\n\n\n    n = len(\n        y_true\n    )\n\n\n    for _ in range(\n        n_bootstrap\n    ):\n\n\n        indices = rng.choice(\n\n            n,\n\n            size=n,\n\n            replace=True\n\n        )\n\n\n        score = metric_function(\n\n            y_true[\n                indices\n            ],\n\n            y_pred[\n                indices\n            ]\n\n        )\n\n\n        scores.append(\n            score\n        )\n\n\n    lower = np.percentile(\n\n        scores,\n\n        2.5\n\n    )\n\n\n    upper = np.percentile(\n\n        scores,\n\n        97.5\n\n    )\n\n\n    return (\n\n        lower,\n\n        upper\n\n    )\n\n\n# ------------------------------------------------------------\n# ACCURACY CI\n# ------------------------------------------------------------\n\nacc_ci_lower, acc_ci_upper = (\n\n    bootstrap_ci(\n\n        y_true,\n\n        y_pred,\n\n        accuracy_score\n\n    )\n\n)\n\n\n# ------------------------------------------------------------\n# MACRO F1 CI\n# ------------------------------------------------------------\n\nf1_ci_lower, f1_ci_upper = (\n\n    bootstrap_ci(\n\n        y_true,\n\n        y_pred,\n\n        lambda a, b:\n\n        f1_score(\n\n            a,\n\n            b,\n\n            average=\"macro\",\n\n            zero_division=0\n\n        )\n\n    )\n\n)\n\n\nbootstrap_df = pd.DataFrame([\n\n    {\n\n        \"Metric\":\n            \"Accuracy\",\n\n        \"Value\":\n            test_accuracy,\n\n        \"CI_Lower\":\n            acc_ci_lower,\n\n        \"CI_Upper\":\n            acc_ci_upper\n\n    },\n\n    {\n\n        \"Metric\":\n            \"Macro F1\",\n\n        \"Value\":\n            f1_macro,\n\n        \"CI_Lower\":\n            f1_ci_lower,\n\n        \"CI_Upper\":\n            f1_ci_upper\n\n    }\n\n])\n\n\ndisplay(\n    bootstrap_df\n)\n\n\nbootstrap_df.to_csv(\n\n    OUTPUT_DIR /\n\n    \"bootstrap_95_ci.csv\",\n\n    index=False\n\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:05:53.664898Z","iopub.execute_input":"2026-08-26T14:05:53.665193Z","iopub.status.idle":"2026-08-26T14:05:55.508894Z","shell.execute_reply.started":"2026-08-26T14:05:53.665173Z","shell.execute_reply":"2026-08-26T14:05:55.508295Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 25 — Calibration: Brier Score + ECE**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 25\n# CALIBRATION\n# BRIER SCORE + EXPECTED CALIBRATION ERROR\n# ============================================================\n\n\n# ------------------------------------------------------------\n# MULTICLASS BRIER SCORE\n# ------------------------------------------------------------\n\ndef multiclass_brier_score(\n\n    y_true,\n\n    y_prob,\n\n    num_classes\n\n):\n\n\n    y_one_hot = np.eye(\n\n        num_classes\n\n    )[\n        y_true\n    ]\n\n\n    score = np.mean(\n\n        np.sum(\n\n            (\n                y_prob -\n                y_one_hot\n            ) ** 2,\n\n            axis=1\n\n        )\n\n    )\n\n\n    return score\n\n\n# ------------------------------------------------------------\n# EXPECTED CALIBRATION ERROR\n# ------------------------------------------------------------\n\ndef expected_calibration_error(\n\n    y_true,\n\n    y_prob,\n\n    n_bins=15\n\n):\n\n\n    confidence = np.max(\n\n        y_prob,\n\n        axis=1\n\n    )\n\n\n    predictions = np.argmax(\n\n        y_prob,\n\n        axis=1\n\n    )\n\n\n    correct = (\n\n        predictions\n        == y_true\n\n    )\n\n\n    bins = np.linspace(\n\n        0,\n\n        1,\n\n        n_bins + 1\n\n    )\n\n\n    ece = 0\n\n\n    reliability = []\n\n\n    for i in range(\n        n_bins\n    ):\n\n\n        lower = bins[i]\n\n        upper = bins[i + 1]\n\n\n        if i == (\n            n_bins - 1\n        ):\n\n\n            mask = (\n\n                (\n                    confidence\n                    >= lower\n                )\n\n                &\n\n                (\n                    confidence\n                    <= upper\n                )\n\n            )\n\n\n        else:\n\n\n            mask = (\n\n                (\n                    confidence\n                    >= lower\n                )\n\n                &\n\n                (\n                    confidence\n                    < upper\n                )\n\n            )\n\n\n        count = mask.sum()\n\n\n        if count == 0:\n\n            continue\n\n\n        bin_accuracy = (\n\n            correct[\n                mask\n            ]\n\n            .mean()\n\n        )\n\n\n        bin_confidence = (\n\n            confidence[\n                mask\n            ]\n\n            .mean()\n\n        )\n\n\n        weight = (\n\n            count\n\n            /\n\n            len(\n                y_true\n            )\n\n        )\n\n\n        ece += (\n\n            weight\n\n            *\n\n            abs(\n\n                bin_accuracy\n\n                -\n\n                bin_confidence\n\n            )\n\n        )\n\n\n        reliability.append({\n\n            \"Bin_Lower\":\n                lower,\n\n            \"Bin_Upper\":\n                upper,\n\n            \"Count\":\n                count,\n\n            \"Accuracy\":\n                bin_accuracy,\n\n            \"Confidence\":\n                bin_confidence\n\n        })\n\n\n    return (\n\n        ece,\n\n        pd.DataFrame(\n            reliability\n        )\n\n    )\n\n\nbrier_score = multiclass_brier_score(\n\n    y_true,\n\n    y_prob,\n\n    NUM_CLASSES\n\n)\n\n\nece_score, reliability_df = (\n\n    expected_calibration_error(\n\n        y_true,\n\n        y_prob,\n\n        n_bins=15\n\n    )\n\n)\n\n\nprint(\n    \"Multiclass Brier Score:\",\n    brier_score\n)\n\n\nprint(\n    \"ECE:\",\n    ece_score\n)\n\n\ndisplay(\n    reliability_df\n)\n\n\nreliability_df.to_csv(\n\n    OUTPUT_DIR /\n\n    \"reliability_bins.csv\",\n\n    index=False\n\n)\n\n\n# ------------------------------------------------------------\n# RELIABILITY DIAGRAM\n# ------------------------------------------------------------\n\nplt.figure(\n    figsize=(7,6)\n)\n\n\nplt.plot(\n\n    [\n        0,\n        1\n    ],\n\n    [\n        0,\n        1\n    ],\n\n    linestyle=\"--\",\n\n    label=\"Perfect Calibration\"\n\n)\n\n\nplt.plot(\n\n    reliability_df[\n        \"Confidence\"\n    ],\n\n    reliability_df[\n        \"Accuracy\"\n    ],\n\n    marker=\"o\",\n\n    label=\"Model\"\n\n)\n\n\nplt.xlabel(\n    \"Mean Confidence\"\n)\n\nplt.ylabel(\n    \"Observed Accuracy\"\n)\n\nplt.title(\n    \"Reliability Diagram\"\n)\n\nplt.legend()\n\nplt.grid(\n    alpha=0.3\n)\n\nplt.tight_layout()\n\n\nplt.savefig(\n\n    PLOT_DIR /\n\n    \"reliability_diagram.png\",\n\n    dpi=300\n\n)\n\n\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:06:04.000982Z","iopub.execute_input":"2026-08-26T14:06:04.001236Z","iopub.status.idle":"2026-08-26T14:06:04.492506Z","shell.execute_reply.started":"2026-08-26T14:06:04.001216Z","shell.execute_reply":"2026-08-26T14:06:04.491845Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 26 — Save Raw Predictions**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 26\n# SAVE RAW TEST PREDICTIONS\n# ============================================================\n\n\nprediction_df = test_df[\n    [\n        \"image_id\",\n        \"image_path\",\n        \"label\"\n    ]\n].copy()\n\n\nprediction_df.rename(\n\n    columns={\n\n        \"label\":\n            \"true_label\"\n\n    },\n\n    inplace=True\n\n)\n\n\nprediction_df[\n    \"predicted_label\"\n] = y_pred\n\n\nprediction_df[\n    \"true_class\"\n] = [\n\n    CLASS_NAMES[\n        int(label)\n    ]\n\n    for label in y_true\n\n]\n\n\nprediction_df[\n    \"predicted_class\"\n] = [\n\n    CLASS_NAMES[\n        int(label)\n    ]\n\n    for label in y_pred\n\n]\n\n\nprediction_df[\n    \"confidence\"\n] = np.max(\n\n    y_prob,\n\n    axis=1\n\n)\n\n\nprediction_df[\n    \"correct\"\n] = (\n\n    y_true\n    == y_pred\n\n)\n\n\nfor i in range(\n    NUM_CLASSES\n):\n\n\n    prediction_df[\n\n        f\"prob_class_{i}\"\n\n    ] = (\n\n        y_prob[\n            :,\n            i\n        ]\n\n    )\n\n\ndisplay(\n    prediction_df.head()\n)\n\n\nprediction_df.to_csv(\n\n    OUTPUT_DIR /\n\n    \"raw_test_predictions.csv\",\n\n    index=False\n\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:06:15.728152Z","iopub.execute_input":"2026-08-26T14:06:15.728407Z","iopub.status.idle":"2026-08-26T14:06:15.756529Z","shell.execute_reply.started":"2026-08-26T14:06:15.728387Z","shell.execute_reply":"2026-08-26T14:06:15.755833Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 27 — Correct Prediction Analysis**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 27\n# CORRECT PREDICTION ANALYSIS\n# ONE SAMPLE PER CLASS\n# ============================================================\n\n\ncorrect_df = (\n\n    prediction_df[\n\n        prediction_df[\n            \"correct\"\n        ]\n\n    ]\n\n    .sort_values(\n\n        by=\"confidence\",\n\n        ascending=False\n\n    )\n\n)\n\n\ncorrect_examples = []\n\n\nfor class_id in range(\n    NUM_CLASSES\n):\n\n\n    subset = correct_df[\n\n        correct_df[\n            \"true_label\"\n        ]\n\n        == class_id\n\n    ]\n\n\n    if len(\n        subset\n    ) > 0:\n\n\n        correct_examples.append(\n\n            subset.iloc[\n                0\n            ]\n\n        )\n\n\ncorrect_examples_df = pd.DataFrame(\n\n    correct_examples\n\n)\n\n\ndisplay(\n    correct_examples_df\n)\n\n\ncorrect_examples_df.to_csv(\n\n    OUTPUT_DIR /\n\n    \"correct_examples.csv\",\n\n    index=False\n\n)\n\n\n# ------------------------------------------------------------\n# VISUALIZATION\n# ------------------------------------------------------------\n\nfig, axes = plt.subplots(\n\n    1,\n\n    len(\n        correct_examples_df\n    ),\n\n    figsize=(\n\n        4 *\n        len(\n            correct_examples_df\n        ),\n\n        4\n\n    )\n\n)\n\n\nif len(\n    correct_examples_df\n) == 1:\n\n    axes = [\n        axes\n    ]\n\n\nfor ax, (\n    _,\n    row\n) in zip(\n\n    axes,\n\n    correct_examples_df\n    .iterrows()\n\n):\n\n\n    image = Image.open(\n\n        row[\n            \"image_path\"\n        ]\n\n    ).convert(\n        \"RGB\"\n    )\n\n\n    ax.imshow(\n        image\n    )\n\n\n    ax.set_title(\n\n        f\"True: \"\n        f\"{row['true_class']}\\n\"\n\n        f\"Pred: \"\n        f\"{row['predicted_class']}\\n\"\n\n        f\"Conf: \"\n        f\"{row['confidence']:.3f}\"\n\n    )\n\n\n    ax.axis(\n        \"off\"\n    )\n\n\nplt.tight_layout()\n\n\nplt.savefig(\n\n    PLOT_DIR /\n\n    \"correct_prediction_examples.png\",\n\n    dpi=300\n\n)\n\n\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:06:24.396287Z","iopub.execute_input":"2026-08-26T14:06:24.396539Z","iopub.status.idle":"2026-08-26T14:06:25.593569Z","shell.execute_reply.started":"2026-08-26T14:06:24.396519Z","shell.execute_reply":"2026-08-26T14:06:25.592577Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 28 — Misclassification Analysis**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 28\n# MISCLASSIFICATION ANALYSIS\n# ============================================================\n\n\nmisclassified_df = (\n\n    prediction_df[\n\n        ~prediction_df[\n            \"correct\"\n        ]\n\n    ]\n\n    .copy()\n\n)\n\n\nmisclassified_df = (\n\n    misclassified_df\n\n    .sort_values(\n\n        by=\"confidence\",\n\n        ascending=False\n\n    )\n\n)\n\n\ndisplay(\n    misclassified_df.head(\n        20\n    )\n)\n\n\nmisclassified_df.to_csv(\n\n    OUTPUT_DIR /\n\n    \"misclassified_predictions.csv\",\n\n    index=False\n\n)\n\n\n# ------------------------------------------------------------\n# MISCLASSIFICATION PATTERN\n# ------------------------------------------------------------\n\nmisclassification_pattern = (\n\n    misclassified_df\n\n    .groupby(\n\n        [\n\n            \"true_class\",\n\n            \"predicted_class\"\n\n        ]\n\n    )\n\n    .size()\n\n    .reset_index(\n\n        name=\"Count\"\n\n    )\n\n    .sort_values(\n\n        by=\"Count\",\n\n        ascending=False\n\n    )\n\n)\n\n\ndisplay(\n    misclassification_pattern\n)\n\n\nmisclassification_pattern.to_csv(\n\n    OUTPUT_DIR /\n\n    \"misclassification_pattern.csv\",\n\n    index=False\n\n)\n\n\n# ------------------------------------------------------------\n# MODERATE DR ANALYSIS\n# ------------------------------------------------------------\n\nmoderate_errors = (\n\n    misclassified_df[\n\n        misclassified_df[\n            \"true_label\"\n        ] == 2\n\n    ]\n\n    .copy()\n\n)\n\n\nprint(\n    \"Total Misclassified:\",\n    len(\n        misclassified_df\n    )\n)\n\n\nprint(\n    \"Moderate Misclassified:\",\n    len(\n        moderate_errors\n    )\n)\n\n\ndisplay(\n    moderate_errors.head(\n        20\n    )\n)\n\n\nmoderate_errors.to_csv(\n\n    OUTPUT_DIR /\n\n    \"moderate_misclassification.csv\",\n\n    index=False\n\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:06:33.757747Z","iopub.execute_input":"2026-08-26T14:06:33.758223Z","iopub.status.idle":"2026-08-26T14:06:33.802684Z","shell.execute_reply.started":"2026-08-26T14:06:33.758196Z","shell.execute_reply":"2026-08-26T14:06:33.801848Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 29 — LRP**","metadata":{}},{"cell_type":"code","source":"!pip install captum","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:06:53.398693Z","iopub.execute_input":"2026-08-26T14:06:53.399297Z","iopub.status.idle":"2026-08-26T14:06:57.704277Z","shell.execute_reply.started":"2026-08-26T14:06:53.399268Z","shell.execute_reply":"2026-08-26T14:06:57.703286Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import captum\n\nprint(\n    \"Captum version:\",\n    captum.__version__\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:07:04.111915Z","iopub.execute_input":"2026-08-26T14:07:04.112566Z","iopub.status.idle":"2026-08-26T14:07:04.202899Z","shell.execute_reply.started":"2026-08-26T14:07:04.112533Z","shell.execute_reply":"2026-08-26T14:07:04.202276Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import importlib.util\n\nprint(\n    \"captum installed:\",\n    importlib.util.find_spec(\"captum\") is not None\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:07:08.24922Z","iopub.execute_input":"2026-08-26T14:07:08.249714Z","iopub.status.idle":"2026-08-26T14:07:08.254509Z","shell.execute_reply.started":"2026-08-26T14:07:08.24969Z","shell.execute_reply":"2026-08-26T14:07:08.253951Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# CELL 1\n# IMPORT LIBRARIES\n# FINAL V4 — robust terhadap package optional\n# ============================================================\n\nimport os\nimport gc\nimport json\nimport time\nimport math\nimport random\nimport shutil\nimport platform\nimport warnings\nimport sys\nimport subprocess\nimport importlib.util\n\nfrom pathlib import Path\nfrom concurrent.futures import ThreadPoolExecutor\n\nimport numpy as np\nimport pandas as pd\n\nfrom PIL import Image\nfrom tqdm.auto import tqdm\n\nimport matplotlib.pyplot as plt\nimport seaborn as sns\n\nimport torch\nimport torch.nn as nn\n\nfrom torch.utils.data import (\n    Dataset,\n    DataLoader\n)\n\nfrom torchvision import transforms\n\n# ------------------------------------------------------------\n# CORE: timm wajib untuk backbone\n# ------------------------------------------------------------\nif importlib.util.find_spec(\"timm\") is None:\n    raise ImportError(\n        \"timm belum tersedia. Aktifkan Internet Kaggle lalu install: !pip install timm\"\n    )\nimport timm\n\n# ------------------------------------------------------------\n# OPTIONAL: torchinfo\n# ------------------------------------------------------------\ntry:\n    from torchinfo import summary\n    HAS_TORCHINFO = True\nexcept ImportError:\n    summary = None\n    HAS_TORCHINFO = False\n\n# ------------------------------------------------------------\n# OPTIONAL: THOP — complexity analysis\n# ------------------------------------------------------------\ntry:\n    from thop import profile\n    HAS_THOP = True\nexcept ImportError:\n    profile = None\n    HAS_THOP = False\n\n# ------------------------------------------------------------\n# METRICS\n# ------------------------------------------------------------\nfrom sklearn.metrics import (\n    accuracy_score,\n    precision_score,\n    recall_score,\n    f1_score,\n    roc_auc_score,\n    confusion_matrix,\n    classification_report,\n    roc_curve,\n    auc\n)\n\nfrom sklearn.preprocessing import (\n    label_binarize\n)\n\nfrom IPython.display import display\n\n# ------------------------------------------------------------\n# XAI — LRP WAJIB UNTUK BAGIAN XAI, tetapi training tidak boleh\n# gagal hanya karena Captum belum tersedia.\n# ------------------------------------------------------------\ntry:\n    from captum.attr import (\n        LRP,\n        LayerGradCam,\n        LayerAttribution\n    )\n\n    from captum.attr._utils.lrp_rules import (\n        EpsilonRule,\n        IdentityRule\n    )\n\n    HAS_CAPTUM = True\n\nexcept ImportError:\n    LRP = None\n    LayerGradCam = None\n    LayerAttribution = None\n    EpsilonRule = None\n    IdentityRule = None\n    HAS_CAPTUM = False\n\nwarnings.filterwarnings(\"ignore\")\n\nprint(\"=\" * 72)\nprint(\"FINAL V4 ENVIRONMENT\")\nprint(\"=\" * 72)\nprint(\"Python:\", platform.python_version())\nprint(\"PyTorch:\", torch.__version__)\nprint(\"timm:\", timm.__version__)\nprint(\"CUDA available:\", torch.cuda.is_available())\n\nif torch.cuda.is_available():\n    print(\"GPU:\", torch.cuda.get_device_name(0))\n    print(\"CUDA:\", torch.version.cuda)\n\nprint(\"torchinfo available:\", HAS_TORCHINFO)\nprint(\"THOP available:\", HAS_THOP)\nprint(\"Captum available:\", HAS_CAPTUM)\nprint(\"=\" * 72)\n\nif not HAS_CAPTUM:\n    print(\n        \"NOTE: Training/evaluation tetap dapat berjalan, tetapi CELL LRP/Grad-CAM \"\n        \"memerlukan Captum. Install Captum sebelum menjalankan bagian XAI.\"\n    )\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:07:11.77121Z","iopub.execute_input":"2026-08-26T14:07:11.771593Z","iopub.status.idle":"2026-08-26T14:07:11.783512Z","shell.execute_reply.started":"2026-08-26T14:07:11.771567Z","shell.execute_reply":"2026-08-26T14:07:11.782711Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"%pip install -q lxt==2.1\n\nimport lxt\nimport importlib.metadata\n\nprint(\"LXT version:\", importlib.metadata.version(\"lxt\"))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:07:18.591264Z","iopub.execute_input":"2026-08-26T14:07:18.591856Z","iopub.status.idle":"2026-08-26T14:07:27.950904Z","shell.execute_reply.started":"2026-08-26T14:07:18.591784Z","shell.execute_reply":"2026-08-26T14:07:27.950143Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# LXT / TRANSFORMERS COMPATIBILITY FIX V2\n# ============================================================\n\nimport sys\nimport subprocess\nimport importlib\nimport importlib.metadata\n\nprint(\"Installing LXT-compatible Hugging Face stack...\")\n\nsubprocess.check_call([\n    sys.executable,\n    \"-m\",\n    \"pip\",\n    \"install\",\n    \"-q\",\n    \"--no-deps\",\n    \"--force-reinstall\",\n\n    \"transformers==4.52.4\",\n    \"tokenizers>=0.21,<0.22\",\n    \"huggingface-hub>=0.30.0,<1.0\",\n])\n\n\n# ------------------------------------------------------------\n# Remove versions already loaded in RAM\n# ------------------------------------------------------------\n\nprefixes = (\n    \"transformers\",\n    \"tokenizers\",\n    \"huggingface_hub\",\n    \"lxt\",\n)\n\nfor module_name in list(sys.modules.keys()):\n\n    if any(\n        module_name == prefix\n        or module_name.startswith(prefix + \".\")\n        for prefix in prefixes\n    ):\n        del sys.modules[module_name]\n\n\nimportlib.invalidate_caches()\n\n\n# ------------------------------------------------------------\n# Check installed package versions\n# ------------------------------------------------------------\n\nprint(\"\\nInstalled package metadata:\")\n\nfor package_name in (\n    \"transformers\",\n    \"tokenizers\",\n    \"huggingface-hub\",\n    \"lxt\",\n):\n\n    try:\n\n        print(\n            f\"{package_name:16s}:\",\n            importlib.metadata.version(\n                package_name\n            )\n        )\n\n    except Exception:\n\n        print(\n            f\"{package_name:16s}: NOT FOUND\"\n        )\n\n\n# ------------------------------------------------------------\n# Import verification\n# ------------------------------------------------------------\n\nprint(\"\\nImport verification...\")\n\nimport transformers\nimport tokenizers\nimport huggingface_hub\nimport lxt\n\n\nprint(\n    \"transformers import    : OK\",\n    transformers.__version__\n)\n\nprint(\n    \"tokenizers import      : OK\",\n    tokenizers.__version__\n)\n\nprint(\n    \"huggingface_hub import : OK\",\n    huggingface_hub.__version__\n)\n\n\n# Symbol yang sebelumnya dibutuhkan LXT\nfrom transformers.pytorch_utils import (\n    find_pruneable_heads_and_indices\n)\n\n\n# Import LXT yang diperlukan CELL 29\nfrom lxt.efficient import (\n    monkey_patch_zennit\n)\n\nfrom lxt.efficient.rules import (\n    identity_rule_implicit,\n    stop_gradient\n)\n\n\nprint(\n    \"find_pruneable_heads_and_indices : OK\"\n)\n\nprint(\n    \"monkey_patch_zennit              : OK\"\n)\n\nprint(\n    \"identity_rule_implicit           : OK\"\n)\n\nprint(\n    \"stop_gradient                    : OK\"\n)\n\nprint(\n    \"\\nLXT compatibility check: PASSED\"\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:07:34.174631Z","iopub.execute_input":"2026-08-26T14:07:34.174956Z","iopub.status.idle":"2026-08-26T14:08:02.670918Z","shell.execute_reply.started":"2026-08-26T14:07:34.174924Z","shell.execute_reply":"2026-08-26T14:08:02.670245Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# CELL 29 — LXT + ZENNIT GAMMA LRP\n# CUSTOM ConvNeXt + MLP-Mixer LRP\n# ============================================================\n\nimport copy, types, gc, importlib.metadata\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\n\ntry:\n    import lxt\n    from lxt.efficient.rules import identity_rule_implicit, stop_gradient\n    from lxt.efficient import monkey_patch_zennit\nexcept Exception as e:\n    raise ImportError(\"Install LXT first: %pip install -q lxt\") from e\n\ntry:\n    import zennit\n    from zennit.composites import LayerMapComposite\n    import zennit.rules as z_rules\nexcept Exception as e:\n    raise ImportError(\"Install Zennit first: %pip install -q zennit\") from e\n\nprint(\"LXT version   :\", importlib.metadata.version(\"lxt\"))\nprint(\"Zennit version:\", importlib.metadata.version(\"zennit\"))\n\n# LXT's official vision Gamma workflow requires this conversion.\nif not globals().get(\"_LXT_ZENNIT_MONKEY_PATCH_APPLIED\", False):\n    monkey_patch_zennit(verbose=False)\n    _LXT_ZENNIT_MONKEY_PATCH_APPLIED = True\n    print(\"monkey_patch_zennit: APPLIED\")\nelse:\n    print(\"monkey_patch_zennit: already applied\")\n\n# Initial values only. CELL 29A can replace these using validation data.\nLRP_CONV_GAMMA = globals().get(\"LRP_CONV_GAMMA\", 0.25)\nLRP_LINEAR_GAMMA = globals().get(\"LRP_LINEAR_GAMMA\", 0.05)\nLRP_NUMERICAL_EPS = 1e-12\n\n_LRP_XAI_MODEL_CACHE = None\n_LRP_AUDIT_CACHE = None\n_LRP_SOURCE_MODEL_ID = None\nLAST_LRP_DIAGNOSTICS = None\n\n\n# ============================================================\n# 1. LXT nonlinear rule\n# ============================================================\n\ndef _lxt_activation_forward(self, x):\n    return identity_rule_implicit(self._lxt_original_forward, x)\n\n\ndef _patch_activation(module):\n    if hasattr(module, \"_lxt_original_forward\"):\n        return False\n\n    module._lxt_original_forward = module.forward\n    module.forward = types.MethodType(\n        _lxt_activation_forward,\n        module,\n    )\n    return True\n\n\n# ============================================================\n# 2. LXT LayerNorm rule\n# ============================================================\n\ndef _lxt_layernorm_forward(self, x):\n    normalized_shape = tuple(\n        self.normalized_shape\n        if isinstance(self.normalized_shape, (tuple, list))\n        else (self.normalized_shape,)\n    )\n\n    if len(normalized_shape) != 1:\n        raise RuntimeError(\n            f\"Unsupported normalized_shape={normalized_shape}\"\n        )\n\n    mean = x.mean(dim=-1, keepdim=True)\n    centered = x - mean\n    variance = centered.pow(2).mean(dim=-1, keepdim=True)\n    std = torch.sqrt(variance + self.eps)\n\n    # LXT normalization relevance rule:\n    # stop gradient through denominator / variance.\n    y = centered / stop_gradient(std)\n\n    if self.weight is not None:\n        y = y * self.weight\n    if self.bias is not None:\n        y = y + self.bias\n\n    return y\n\n\ndef _lxt_layernorm2d_forward(self, x):\n    if x.ndim != 4:\n        raise RuntimeError(\n            f\"LayerNorm2d expected NCHW, got {tuple(x.shape)}\"\n        )\n\n    y = x.permute(0, 2, 3, 1)\n\n    mean = y.mean(dim=-1, keepdim=True)\n    centered = y - mean\n    variance = centered.pow(2).mean(dim=-1, keepdim=True)\n    std = torch.sqrt(variance + self.eps)\n\n    y = centered / stop_gradient(std)\n\n    if self.weight is not None:\n        y = y * self.weight\n    if self.bias is not None:\n        y = y + self.bias\n\n    return y.permute(0, 3, 1, 2)\n\n\n# ============================================================\n# 3. Patch deep XAI copy only\n# ============================================================\n\ndef patch_model_for_lrp(xai_model):\n    rows = []\n    activation_count = 0\n    layernorm_count = 0\n    layernorm2d_count = 0\n\n    for name, module in xai_model.named_modules():\n        if name == \"\":\n            continue\n\n        cls_name = module.__class__.__name__\n        cls_lower = cls_name.lower()\n\n        # TIMM LayerNorm2d must be checked first.\n        if \"layernorm2d\" in cls_lower:\n            module.forward = types.MethodType(\n                _lxt_layernorm2d_forward,\n                module,\n            )\n            layernorm2d_count += 1\n            rows.append({\n                \"Module\": name,\n                \"Type\": cls_name,\n                \"Rule\": \"LXT normalization identity rule\",\n                \"Patch\": \"stop gradient through std (LayerNorm2d)\",\n            })\n            continue\n\n        if isinstance(module, nn.LayerNorm) or \"layernorm\" in cls_lower:\n            module.forward = types.MethodType(\n                _lxt_layernorm_forward,\n                module,\n            )\n            layernorm_count += 1\n            rows.append({\n                \"Module\": name,\n                \"Type\": cls_name,\n                \"Rule\": \"LXT normalization identity rule\",\n                \"Patch\": \"stop gradient through std (LayerNorm)\",\n            })\n            continue\n\n        is_activation = (\n            isinstance(module, (nn.GELU, nn.SiLU, nn.ReLU6))\n            or \"gelu\" in cls_lower\n            or \"silu\" in cls_lower\n        )\n\n        if is_activation:\n            if _patch_activation(module):\n                activation_count += 1\n            rows.append({\n                \"Module\": name,\n                \"Type\": cls_name,\n                \"Rule\": \"LXT activation identity rule\",\n                \"Patch\": \"identity_rule_implicit(original_forward, x)\",\n            })\n\n    print(\"LXT activation patches :\", activation_count)\n    print(\"LXT LayerNorm patches  :\", layernorm_count)\n    print(\"LXT LayerNorm2d patches:\", layernorm2d_count)\n\n    if activation_count == 0:\n        raise RuntimeError(\"No nonlinear activation was patched.\")\n    if layernorm_count + layernorm2d_count == 0:\n        raise RuntimeError(\"No LayerNorm module was patched.\")\n\n    return pd.DataFrame(rows)\n\n\n# ============================================================\n# 4. Build forward-equivalent XAI model\n# ============================================================\n\ndef get_lrp_xai_model(base_model, sample_input):\n    global _LRP_XAI_MODEL_CACHE, _LRP_AUDIT_CACHE, _LRP_SOURCE_MODEL_ID\n\n    if (\n        _LRP_XAI_MODEL_CACHE is not None\n        and _LRP_SOURCE_MODEL_ID == id(base_model)\n    ):\n        return _LRP_XAI_MODEL_CACHE, _LRP_AUDIT_CACHE\n\n    print(\"\\n\" + \"=\" * 70)\n    print(\"PREPARING LXT + GAMMA LRP MODEL COPY\")\n    print(\"=\" * 70)\n\n    base_model.eval()\n    xai_model = copy.deepcopy(base_model)\n    xai_model.eval()\n\n    # The XAI copy is not trained.\n    for p in xai_model.parameters():\n        p.requires_grad_(False)\n\n    audit_df = patch_model_for_lrp(xai_model)\n\n    with torch.no_grad():\n        original_logits = base_model(sample_input).detach().float()\n        xai_logits = xai_model(sample_input).detach().float()\n\n    diff = original_logits - xai_logits\n\n    max_abs_diff = float(diff.abs().max().item())\n    mean_abs_diff = float(diff.abs().mean().item())\n\n    prediction_preserved = bool(\n        torch.equal(\n            original_logits.argmax(dim=1),\n            xai_logits.argmax(dim=1),\n        )\n    )\n\n    print(\"Max |original-XAI logit| :\", max_abs_diff)\n    print(\"Mean |logit difference|  :\", mean_abs_diff)\n    print(\"Prediction preserved      :\", prediction_preserved)\n\n    if not torch.allclose(\n        original_logits,\n        xai_logits,\n        rtol=1e-4,\n        atol=1e-5,\n    ):\n        raise RuntimeError(\n            \"XAI model is not forward-equivalent to the trained model.\"\n        )\n\n    if not prediction_preserved:\n        raise RuntimeError(\n            \"Predicted class changed after LRP patch.\"\n        )\n\n    audit_df = pd.concat([\n        audit_df,\n        pd.DataFrame([{\n            \"Module\": \"__MODEL_EQUIVALENCE__\",\n            \"Type\": \"Audit\",\n            \"Rule\": \"Forward-equivalence\",\n            \"Patch\": (\n                f\"max_abs_diff={max_abs_diff:.8e}; \"\n                f\"mean_abs_diff={mean_abs_diff:.8e}; \"\n                f\"prediction_preserved={prediction_preserved}\"\n            ),\n        }])\n    ], ignore_index=True)\n\n    _LRP_XAI_MODEL_CACHE = xai_model\n    _LRP_AUDIT_CACHE = audit_df\n    _LRP_SOURCE_MODEL_ID = id(base_model)\n\n    return xai_model, audit_df\n\n\n# ============================================================\n# 5. Signed LRP normalization\n# ============================================================\n\ndef normalize_signed_lrp(relevance_2d):\n    relevance_2d = np.asarray(relevance_2d, dtype=np.float32)\n\n    if not np.isfinite(relevance_2d).all():\n        raise RuntimeError(\"Signed LRP map contains NaN/Inf.\")\n\n    scale = float(np.max(np.abs(relevance_2d)))\n\n    if scale <= 1e-12:\n        return np.zeros_like(relevance_2d, dtype=np.float32)\n\n    return relevance_2d / scale\n\n\n# ============================================================\n# 6. Public LRP function\n# ============================================================\n\ndef generate_lrp(\n    base_model,\n    input_tensor,\n    target_class,\n    conv_gamma=None,\n    linear_gamma=None,\n):\n    \"\"\"\n    Returns:\n        signed_heatmap [-1,+1],\n        conservation_residual,\n        diagnostics_df\n    \"\"\"\n\n    global LAST_LRP_DIAGNOSTICS\n\n    if conv_gamma is None:\n        conv_gamma = float(LRP_CONV_GAMMA)\n\n    if linear_gamma is None:\n        linear_gamma = float(LRP_LINEAR_GAMMA)\n\n    xai_model, _ = get_lrp_xai_model(\n        base_model,\n        input_tensor.detach(),\n    )\n\n    xai_model.eval()\n    xai_model.zero_grad(set_to_none=True)\n\n    x = (\n        input_tensor\n        .detach()\n        .contiguous()\n        .float()\n        .clone()\n    )\n    x.requires_grad_(True)\n\n    # Official LXT vision pattern:\n    # Conv2d / Linear -> Zennit Gamma rules,\n    # converted to efficient Input*Gradient by monkey_patch_zennit().\n    composite = LayerMapComposite([\n        (nn.Conv2d, z_rules.Gamma(float(conv_gamma))),\n        (nn.Linear, z_rules.Gamma(float(linear_gamma))),\n    ])\n\n    registered = False\n\n    try:\n        composite.register(xai_model)\n        registered = True\n\n        with torch.enable_grad():\n            logits = xai_model(x)\n\n            target_class = int(target_class)\n\n            if not 0 <= target_class < logits.shape[1]:\n                raise ValueError(\"target_class outside output range.\")\n\n            target_score = logits[0, target_class]\n            target_score.backward()\n\n    finally:\n        if registered:\n            composite.remove()\n\n    if x.grad is None:\n        raise RuntimeError(\"LRP backward completed but x.grad is None.\")\n\n    gradient = x.grad.detach().float()\n\n    if not torch.isfinite(gradient).all():\n        raise RuntimeError(\"LRP gradient contains NaN/Inf.\")\n\n    signed_relevance_rgb = x.detach() * gradient\n\n    if not torch.isfinite(signed_relevance_rgb).all():\n        raise RuntimeError(\"LRP relevance contains NaN/Inf.\")\n\n    signed_relevance_2d = (\n        signed_relevance_rgb[0]\n        .sum(dim=0)\n        .cpu()\n        .numpy()\n    )\n\n    signed_heatmap = normalize_signed_lrp(\n        signed_relevance_2d\n    )\n\n    target_logit = float(\n        target_score.detach().float().cpu().item()\n    )\n\n    signed_relevance_sum = float(\n        signed_relevance_rgb\n        .flatten(1)\n        .sum(dim=1)[0]\n        .cpu()\n        .item()\n    )\n\n    conservation_residual = float(\n        target_logit - signed_relevance_sum\n    )\n\n    relative_residual = float(\n        abs(conservation_residual)\n        / (abs(target_logit) + LRP_NUMERICAL_EPS)\n    )\n\n    abs_rel = np.abs(signed_relevance_2d)\n\n    diagnostics_df = pd.DataFrame([{\n        \"Target_Class\": target_class,\n        \"Target_Logit\": target_logit,\n        \"Signed_Relevance_Sum\": signed_relevance_sum,\n        \"Conservation_Residual\": conservation_residual,\n        \"Relative_Residual\": relative_residual,\n        \"Conv_Gamma\": float(conv_gamma),\n        \"Linear_Gamma\": float(linear_gamma),\n        \"Gradient_Min\": float(gradient.min().cpu().item()),\n        \"Gradient_Max\": float(gradient.max().cpu().item()),\n        \"Relevance_Min\": float(signed_relevance_rgb.min().cpu().item()),\n        \"Relevance_Max\": float(signed_relevance_rgb.max().cpu().item()),\n        \"Abs_Relevance_Mean\": float(abs_rel.mean()),\n        \"Abs_Relevance_Max\": float(abs_rel.max()),\n        \"Positive_Pixel_Fraction\": float((signed_relevance_2d > 0).mean()),\n        \"Negative_Pixel_Fraction\": float((signed_relevance_2d < 0).mean()),\n        \"Finite_Gradient\": bool(torch.isfinite(gradient).all()),\n        \"Finite_Relevance\": bool(torch.isfinite(signed_relevance_rgb).all()),\n    }])\n\n    LAST_LRP_DIAGNOSTICS = diagnostics_df.copy()\n\n    return signed_heatmap, conservation_residual, diagnostics_df\n\n\n# ============================================================\n# 7. Smoke test — prefer validation sample\n# ============================================================\n\nif \"val_dataset\" in globals():\n    _smoke_dataset = val_dataset\n    _smoke_source = \"validation\"\nelif \"test_dataset\" in globals():\n    _smoke_dataset = test_dataset\n    _smoke_source = \"test fallback\"\nelse:\n    raise NameError(\"val_dataset / test_dataset not found.\")\n\n_smoke_image, _smoke_label = _smoke_dataset[0]\n\n_smoke_input = move_images_to_device(\n    _smoke_image.unsqueeze(0),\n    DEVICE,\n)\n\nwith torch.no_grad():\n    _smoke_logits = model(_smoke_input)\n    _smoke_target = int(\n        _smoke_logits.argmax(dim=1).item()\n    )\n\n_, lrp_patch_audit_df = get_lrp_xai_model(\n    model,\n    _smoke_input,\n)\n\nlrp_patch_audit_df.to_csv(\n    XAI_DIR / \"lxt_gamma_lrp_patch_audit.csv\",\n    index=False,\n)\n\n(\n    _smoke_heatmap,\n    _smoke_residual,\n    _smoke_diag,\n) = generate_lrp(\n    model,\n    _smoke_input,\n    _smoke_target,\n)\n\nprint(\"\\n\" + \"=\" * 70)\nprint(\"LXT + GAMMA LRP SMOKE TEST: SUCCESS\")\nprint(\"=\" * 70)\nprint(\"Source                   :\", _smoke_source)\nprint(\"True class               :\", CLASS_NAMES[int(_smoke_label)])\nprint(\"Target / predicted class :\", CLASS_NAMES[_smoke_target])\nprint(\n    \"Signed heatmap range     :\",\n    (float(_smoke_heatmap.min()), float(_smoke_heatmap.max()))\n)\nprint(\"Conservation residual    :\", _smoke_residual)\nprint(\"\\nLRP numerical diagnostics:\")\ndisplay(_smoke_diag)\nprint(\"=\" * 70)\n\n_smoke_diag.to_csv(\n    XAI_DIR / \"lxt_gamma_lrp_smoke_diagnostics.csv\",\n    index=False,\n)\n\ndel _smoke_input, _smoke_logits\ngc.collect()\n\nif torch.cuda.is_available():\n    torch.cuda.empty_cache()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:08:12.115686Z","iopub.execute_input":"2026-08-26T14:08:12.116712Z","iopub.status.idle":"2026-08-26T14:08:16.881505Z","shell.execute_reply.started":"2026-08-26T14:08:12.116682Z","shell.execute_reply":"2026-08-26T14:08:16.880837Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# CELL 29A — VALIDATION-ONLY GAMMA SELECTION\n#\n# Run AFTER CELL 29 and BEFORE final CELL 31 / 31B.\n# Gamma must NOT be tuned on test examples.\n# ============================================================\n\nimport itertools\nimport numpy as np\nimport pandas as pd\nimport torch\n\nLRP_GAMMA_VALIDATION_SAMPLES = 10\n\n# Reduced practical grid using values from the official LXT\n# vision Gamma-rule tutorial.\nCONV_GAMMA_CANDIDATES = [0.10, 0.25]\nLINEAR_GAMMA_CANDIDATES = [0.00, 0.05, 0.10]\n\nAPPLY_RECOMMENDED_GAMMA = True\n\nif \"val_loader\" not in globals():\n    raise NameError(\n        \"val_loader is required. Do not tune Gamma on the test set.\"\n    )\n\n\n# ============================================================\n# 1. Fixed validation subset\n# ============================================================\n\nvalidation_samples = []\n\nfor images, labels in val_loader:\n    for i in range(images.shape[0]):\n        validation_samples.append(\n            (\n                images[i:i + 1].detach().cpu(),\n                int(labels[i].detach().cpu().item()),\n            )\n        )\n\n        if len(validation_samples) >= LRP_GAMMA_VALIDATION_SAMPLES:\n            break\n\n    if len(validation_samples) >= LRP_GAMMA_VALIDATION_SAMPLES:\n        break\n\nprint(\n    \"Validation images used:\",\n    len(validation_samples)\n)\n\n\n# ============================================================\n# 2. Secondary fragmentation indicator\n# ============================================================\n\ndef normalized_total_variation(heatmap):\n    heatmap = np.asarray(\n        heatmap,\n        dtype=np.float32,\n    )\n\n    tv_h = np.mean(\n        np.abs(\n            heatmap[1:, :]\n            - heatmap[:-1, :]\n        )\n    )\n\n    tv_w = np.mean(\n        np.abs(\n            heatmap[:, 1:]\n            - heatmap[:, :-1]\n        )\n    )\n\n    return float(tv_h + tv_w)\n\n\n# ============================================================\n# 3. Validation sweep\n# ============================================================\n\nrows = []\n\npairs = list(\n    itertools.product(\n        CONV_GAMMA_CANDIDATES,\n        LINEAR_GAMMA_CANDIDATES,\n    )\n)\n\nprint(\"Gamma pairs:\", pairs)\n\nfor conv_gamma, linear_gamma in pairs:\n\n    print(\n        \"\\nTesting:\",\n        f\"Conv={conv_gamma}\",\n        f\"Linear={linear_gamma}\",\n    )\n\n    for sample_id, (image_cpu, true_label) in enumerate(\n        validation_samples\n    ):\n\n        input_tensor = move_images_to_device(\n            image_cpu,\n            DEVICE,\n        )\n\n        with torch.no_grad():\n            logits = model(input_tensor)\n            predicted_class = int(\n                logits.argmax(dim=1).item()\n            )\n\n        try:\n            (\n                heatmap,\n                residual,\n                diagnostics_df,\n            ) = generate_lrp(\n                model,\n                input_tensor,\n                predicted_class,\n                conv_gamma=conv_gamma,\n                linear_gamma=linear_gamma,\n            )\n\n            d = diagnostics_df.iloc[0].to_dict()\n\n            finite = bool(\n                d[\"Finite_Gradient\"]\n                and d[\"Finite_Relevance\"]\n            )\n\n            rows.append({\n                \"Sample\": sample_id,\n                \"True_Class\": true_label,\n                \"Predicted_Class\": predicted_class,\n                \"Conv_Gamma\": conv_gamma,\n                \"Linear_Gamma\": linear_gamma,\n                \"Finite\": finite,\n                \"Target_Logit\": d[\"Target_Logit\"],\n                \"Signed_Relevance_Sum\": d[\"Signed_Relevance_Sum\"],\n                \"Conservation_Residual\": d[\"Conservation_Residual\"],\n                \"Relative_Residual\": d[\"Relative_Residual\"],\n                \"Normalized_Total_Variation\":\n                    normalized_total_variation(heatmap),\n                \"Error\": None,\n            })\n\n        except Exception as e:\n            rows.append({\n                \"Sample\": sample_id,\n                \"True_Class\": true_label,\n                \"Predicted_Class\": predicted_class,\n                \"Conv_Gamma\": conv_gamma,\n                \"Linear_Gamma\": linear_gamma,\n                \"Finite\": False,\n                \"Target_Logit\": np.nan,\n                \"Signed_Relevance_Sum\": np.nan,\n                \"Conservation_Residual\": np.nan,\n                \"Relative_Residual\": np.nan,\n                \"Normalized_Total_Variation\": np.nan,\n                \"Error\": str(e),\n            })\n\n\ngamma_validation_detail_df = pd.DataFrame(rows)\n\ngamma_validation_summary_df = (\n    gamma_validation_detail_df\n    .groupby(\n        [\"Conv_Gamma\", \"Linear_Gamma\"],\n        as_index=False,\n    )\n    .agg(\n        Finite_Rate=(\"Finite\", \"mean\"),\n        Median_Relative_Residual=(\"Relative_Residual\", \"median\"),\n        Mean_Relative_Residual=(\"Relative_Residual\", \"mean\"),\n        Median_Total_Variation=(\"Normalized_Total_Variation\", \"median\"),\n    )\n    .sort_values(\n        by=[\n            \"Finite_Rate\",\n            \"Median_Relative_Residual\",\n            \"Median_Total_Variation\",\n        ],\n        ascending=[False, True, True],\n    )\n    .reset_index(drop=True)\n)\n\nprint(\"\\n\" + \"=\" * 70)\nprint(\"VALIDATION-ONLY GAMMA SUMMARY\")\nprint(\"=\" * 70)\ndisplay(gamma_validation_summary_df)\n\n\n# ============================================================\n# 4. Recommendation\n#\n# Primary: finite attribution.\n# Secondary: lower median relative residual.\n# Tie-break: lower normalized total variation.\n#\n# Total variation is a noise indicator, not a faithfulness proof.\n# ============================================================\n\nfully_finite = gamma_validation_summary_df[\n    gamma_validation_summary_df[\"Finite_Rate\"] == 1.0\n]\n\nif len(fully_finite) == 0:\n    raise RuntimeError(\n        \"No Gamma configuration was finite for all validation samples.\"\n    )\n\nrecommended = fully_finite.iloc[0]\n\nRECOMMENDED_LRP_CONV_GAMMA = float(\n    recommended[\"Conv_Gamma\"]\n)\n\nRECOMMENDED_LRP_LINEAR_GAMMA = float(\n    recommended[\"Linear_Gamma\"]\n)\n\nprint(\"\\nRecommended validation configuration:\")\nprint(\n    \"Conv2d gamma =\",\n    RECOMMENDED_LRP_CONV_GAMMA\n)\nprint(\n    \"Linear gamma =\",\n    RECOMMENDED_LRP_LINEAR_GAMMA\n)\nprint(\n    \"Median relative residual =\",\n    float(recommended[\"Median_Relative_Residual\"])\n)\nprint(\n    \"Median total variation =\",\n    float(recommended[\"Median_Total_Variation\"])\n)\n\nif APPLY_RECOMMENDED_GAMMA:\n    LRP_CONV_GAMMA = RECOMMENDED_LRP_CONV_GAMMA\n    LRP_LINEAR_GAMMA = RECOMMENDED_LRP_LINEAR_GAMMA\n\n    print(\"\\nApplied for later XAI cells:\")\n    print(\"LRP_CONV_GAMMA  =\", LRP_CONV_GAMMA)\n    print(\"LRP_LINEAR_GAMMA =\", LRP_LINEAR_GAMMA)\n\n\n# ============================================================\n# 5. Save\n# ============================================================\n\ngamma_validation_detail_df.to_csv(\n    XAI_DIR / \"lrp_gamma_validation_detail.csv\",\n    index=False,\n)\n\ngamma_validation_summary_df.to_csv(\n    XAI_DIR / \"lrp_gamma_validation_summary.csv\",\n    index=False,\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:08:37.150192Z","iopub.execute_input":"2026-08-26T14:08:37.150614Z","iopub.status.idle":"2026-08-26T14:08:55.617479Z","shell.execute_reply.started":"2026-08-26T14:08:37.150587Z","shell.execute_reply":"2026-08-26T14:08:55.616781Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 30 — Grad-CAM**","metadata":{}},{"cell_type":"code","source":"if not HAS_CAPTUM:\n    raise ImportError(\"Captum diperlukan untuk Grad-CAM pada CELL 30.\")\n\n# ============================================================\n# CELL 30\n# GRAD-CAM\n# CONVNEXT BRANCH\n# ============================================================\n\n\ndef generate_gradcam(\n\n    model,\n\n    input_tensor,\n\n    target_class\n\n):\n\n\n    model.eval()\n\n\n    # --------------------------------------------------------\n    # LAST CONVNEXT BLOCK\n    # LAST DEPTHWISE CONVOLUTION\n    # --------------------------------------------------------\n\n    target_layer = (\n\n        model\n\n        .convnext\n\n        .stages[-1]\n\n        .blocks[-1]\n\n        .conv_dw\n\n    )\n\n\n    gradcam = LayerGradCam(\n\n        model,\n\n        target_layer\n\n    )\n\n\n    attribution = (\n\n        gradcam.attribute(\n\n            input_tensor,\n\n            target=int(\n                target_class\n            ),\n\n            relu_attributions=True\n\n        )\n\n    )\n\n\n    # --------------------------------------------------------\n    # UPSAMPLE TO ORIGINAL INPUT SIZE\n    # --------------------------------------------------------\n\n    attribution = (\n\n        LayerAttribution\n\n        .interpolate(\n\n            attribution,\n\n            (\n                IMG_SIZE,\n                IMG_SIZE\n            ),\n\n            interpolate_mode=\"bilinear\"\n\n        )\n\n    )\n\n\n    heatmap = (\n\n        attribution\n\n        .detach()\n\n        .cpu()\n\n        .numpy()[0, 0]\n\n    )\n\n\n    heatmap = normalize_heatmap(\n\n        heatmap\n\n    )\n\n\n    return heatmap\n\n\nprint(\n    \"Grad-CAM function ready.\"\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:09:02.159225Z","iopub.execute_input":"2026-08-26T14:09:02.159984Z","iopub.status.idle":"2026-08-26T14:09:02.166534Z","shell.execute_reply.started":"2026-08-26T14:09:02.159952Z","shell.execute_reply":"2026-08-26T14:09:02.16581Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# CELL 30 PATCH\n# GRAD-CAM HEATMAP NORMALIZATION HELPER\n#\n# Run before CELL 31.\n# If generate_gradcam() is already defined, this helper is enough\n# to fix the earlier NameError.\n# ============================================================\n\ndef normalize_heatmap(heatmap):\n    \"\"\"\n    Normalize Grad-CAM heatmap to [0, 1].\n    \"\"\"\n    heatmap = np.asarray(\n        heatmap,\n        dtype=np.float32,\n    )\n\n    if not np.isfinite(heatmap).all():\n        raise RuntimeError(\n            \"Grad-CAM heatmap contains NaN/Inf.\"\n        )\n\n    heatmap = np.maximum(\n        heatmap,\n        0.0,\n    )\n\n    maximum = float(\n        heatmap.max()\n    )\n\n    if maximum > 1e-12:\n        heatmap = heatmap / maximum\n    else:\n        heatmap = np.zeros_like(\n            heatmap,\n            dtype=np.float32,\n        )\n\n    return heatmap\n\n\nprint(\n    \"normalize_heatmap ready:\",\n    callable(normalize_heatmap)\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:09:08.003696Z","iopub.execute_input":"2026-08-26T14:09:08.004159Z","iopub.status.idle":"2026-08-26T14:09:08.010669Z","shell.execute_reply.started":"2026-08-26T14:09:08.004128Z","shell.execute_reply":"2026-08-26T14:09:08.009647Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 31 — LRP vs Grad-CAM Visualization**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 31 — FINAL\n# SIGNED LRP VS GRAD-CAM — CORRECT PREDICTIONS\n#\n# LRP:\n#   BLUE  = negative relevance / evidence against target\n#   WHITE = near-zero relevance\n#   RED   = positive relevance / evidence supporting target\n#\n# No Gaussian blur / cosmetic smoothing.\n# ============================================================\n\nfrom PIL import Image\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport torch\n\n\ndef prepare_image(image_path):\n    original_image = Image.open(\n        image_path\n    ).convert(\"RGB\")\n\n    input_tensor = transform(\n        original_image\n    ).unsqueeze(0)\n\n    input_tensor = move_images_to_device(\n        input_tensor,\n        DEVICE,\n    )\n\n    display_image = (\n        np.array(\n            original_image.resize(\n                (IMG_SIZE, IMG_SIZE)\n            )\n        )\n        .astype(np.float32)\n        / 255.0\n    )\n\n    return (\n        original_image,\n        display_image,\n        input_tensor,\n    )\n\n\nxai_results = []\n\nfor _, row in correct_examples_df.iterrows():\n\n    (\n        original_image,\n        display_image,\n        input_tensor,\n    ) = prepare_image(\n        row[\"image_path\"]\n    )\n\n    # --------------------------------------------------------\n    # Prediction\n    # --------------------------------------------------------\n    with torch.no_grad():\n        outputs = model(\n            input_tensor\n        )\n\n        probabilities = torch.softmax(\n            outputs,\n            dim=1,\n        )\n\n        predicted_class = int(\n            probabilities.argmax(\n                dim=1\n            ).item()\n        )\n\n        confidence = float(\n            probabilities[\n                0,\n                predicted_class\n            ].item()\n        )\n\n    # --------------------------------------------------------\n    # LRP\n    # --------------------------------------------------------\n    try:\n        (\n            lrp_heatmap,\n            lrp_residual,\n            lrp_diagnostics_df,\n        ) = generate_lrp(\n            model,\n            input_tensor,\n            predicted_class,\n            conv_gamma=LRP_CONV_GAMMA,\n            linear_gamma=LRP_LINEAR_GAMMA,\n        )\n\n        lrp_diag = (\n            lrp_diagnostics_df\n            .iloc[0]\n            .to_dict()\n        )\n\n        lrp_relative_residual = float(\n            lrp_diag[\n                \"Relative_Residual\"\n            ]\n        )\n\n        lrp_status = \"SUCCESS\"\n        lrp_error = None\n\n    except Exception as e:\n        lrp_heatmap = None\n        lrp_residual = None\n        lrp_relative_residual = None\n        lrp_diag = {}\n        lrp_status = \"FAILED\"\n        lrp_error = str(e)\n\n        print(\n            \"\\nLRP FAILED for:\",\n            row[\"image_id\"]\n        )\n        print(lrp_error)\n\n    # --------------------------------------------------------\n    # Grad-CAM\n    # --------------------------------------------------------\n    try:\n        gradcam_heatmap = generate_gradcam(\n            model,\n            input_tensor,\n            predicted_class,\n        )\n\n        gradcam_status = \"SUCCESS\"\n        gradcam_error = None\n\n    except Exception as e:\n        gradcam_heatmap = None\n        gradcam_status = \"FAILED\"\n        gradcam_error = str(e)\n\n        print(\n            \"\\nGrad-CAM FAILED for:\",\n            row[\"image_id\"]\n        )\n        print(gradcam_error)\n\n    # --------------------------------------------------------\n    # Figure\n    # --------------------------------------------------------\n    fig, axes = plt.subplots(\n        1,\n        3,\n        figsize=(15, 5),\n    )\n\n    # Original\n    axes[0].imshow(\n        display_image\n    )\n\n    axes[0].set_title(\n        f\"Original\\n\"\n        f\"True: {row['true_class']}\\n\"\n        f\"Pred: {CLASS_NAMES[predicted_class]}\\n\"\n        f\"Confidence: {confidence:.3f}\"\n    )\n\n    axes[0].axis(\"off\")\n\n    # Signed LRP — standalone, not overlaid.\n    if lrp_heatmap is not None:\n\n        lrp_im = axes[1].imshow(\n            lrp_heatmap,\n            cmap=\"bwr\",\n            vmin=-1,\n            vmax=1,\n        )\n\n        axes[1].set_title(\n            \"LRP — signed relevance\\n\"\n            f\"Relative residual: \"\n            f\"{lrp_relative_residual:.3e}\"\n        )\n\n        colorbar = fig.colorbar(\n            lrp_im,\n            ax=axes[1],\n            fraction=0.046,\n            pad=0.04,\n        )\n\n        colorbar.set_label(\n            \"Negative  ←  relevance  →  Positive\"\n        )\n\n    else:\n        axes[1].text(\n            0.5,\n            0.5,\n            \"LRP FAILED\\nDo not report this figure\",\n            ha=\"center\",\n            va=\"center\",\n            transform=axes[1].transAxes,\n        )\n\n        axes[1].set_title(\n            \"LRP FAILED\"\n        )\n\n    axes[1].axis(\"off\")\n\n    # Grad-CAM overlay\n    axes[2].imshow(\n        display_image\n    )\n\n    if gradcam_heatmap is not None:\n        axes[2].imshow(\n            gradcam_heatmap,\n            cmap=\"jet\",\n            alpha=0.5,\n            vmin=0,\n            vmax=1,\n        )\n\n        axes[2].set_title(\n            \"Grad-CAM\\n\"\n            \"ConvNeXt last convolutional layer\"\n        )\n    else:\n        axes[2].set_title(\n            \"Grad-CAM FAILED\"\n        )\n\n    axes[2].axis(\"off\")\n\n    plt.tight_layout()\n\n    output_path = (\n        XAI_DIR\n        / (\n            f\"correct_\"\n            f\"{row['image_id']}_\"\n            f\"signed_lrp_gradcam.png\"\n        )\n    )\n\n    plt.savefig(\n        output_path,\n        dpi=300,\n        bbox_inches=\"tight\",\n    )\n\n    plt.show()\n    plt.close(fig)\n\n    # --------------------------------------------------------\n    # Save numerical metadata\n    # --------------------------------------------------------\n    xai_results.append({\n        \"Image_ID\": row[\"image_id\"],\n        \"Type\": \"Correct\",\n        \"True_Class\": row[\"true_class\"],\n        \"Predicted_Class\":\n            CLASS_NAMES[predicted_class],\n        \"Confidence\": confidence,\n\n        \"LRP_Status\": lrp_status,\n        \"LRP_Conv_Gamma\":\n            float(LRP_CONV_GAMMA),\n        \"LRP_Linear_Gamma\":\n            float(LRP_LINEAR_GAMMA),\n        \"LRP_Conservation_Residual\":\n            lrp_residual,\n        \"LRP_Relative_Residual\":\n            lrp_relative_residual,\n        \"LRP_Target_Logit\":\n            lrp_diag.get(\"Target_Logit\"),\n        \"LRP_Signed_Relevance_Sum\":\n            lrp_diag.get(\n                \"Signed_Relevance_Sum\"\n            ),\n        \"LRP_Finite_Gradient\":\n            lrp_diag.get(\n                \"Finite_Gradient\"\n            ),\n        \"LRP_Finite_Relevance\":\n            lrp_diag.get(\n                \"Finite_Relevance\"\n            ),\n        \"LRP_Error\": lrp_error,\n\n        \"GradCAM_Status\":\n            gradcam_status,\n        \"GradCAM_Error\":\n            gradcam_error,\n\n        \"Figure\":\n            str(output_path),\n    })\n\n\nxai_results_df = pd.DataFrame(\n    xai_results\n)\n\ndisplay(\n    xai_results_df\n)\n\nxai_results_df.to_csv(\n    XAI_DIR\n    / \"xai_results_correct.csv\",\n    index=False,\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:09:13.362862Z","iopub.execute_input":"2026-08-26T14:09:13.363274Z","iopub.status.idle":"2026-08-26T14:09:22.826529Z","shell.execute_reply.started":"2026-08-26T14:09:13.363246Z","shell.execute_reply":"2026-08-26T14:09:22.825842Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **Cell 31B — XAI untuk misclassification**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 31B — FINAL\n# SIGNED LRP VS GRAD-CAM\n# MISCLASSIFIED IMAGES\n# PRIORITIZE MODERATE DR\n#\n# Requires:\n# - CELL 29  : LXT + Gamma LRP\n# - CELL 29A : validation-only Gamma selection\n# - CELL 30  : Grad-CAM + normalize_heatmap\n# - CELL 31  : prepare_image()\n#\n# LRP interpretation:\n# BLUE  = negative relevance\n# WHITE = near-zero relevance\n# RED   = positive relevance\n#\n# IMPORTANT:\n# LRP is displayed as a standalone signed relevance map.\n# No Gaussian blur / smoothing.\n# ============================================================\n\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport torch\n\n\n# ============================================================\n# 1. VALIDATION\n# ============================================================\n\nrequired_variables = [\n    \"moderate_errors\",\n    \"misclassified_df\",\n    \"model\",\n    \"CLASS_NAMES\",\n    \"XAI_DIR\",\n    \"LRP_CONV_GAMMA\",\n    \"LRP_LINEAR_GAMMA\",\n]\n\nfor variable_name in required_variables:\n\n    if variable_name not in globals():\n\n        raise NameError(\n            f\"Required variable '{variable_name}' \"\n            f\"is not defined.\"\n        )\n\n\nif \"prepare_image\" not in globals():\n\n    raise NameError(\n        \"prepare_image() is not defined. \"\n        \"Run CELL 31 first or define prepare_image().\"\n    )\n\n\nif \"generate_lrp\" not in globals():\n\n    raise NameError(\n        \"generate_lrp() is not defined. \"\n        \"Run CELL 29 first.\"\n    )\n\n\nif \"generate_gradcam\" not in globals():\n\n    raise NameError(\n        \"generate_gradcam() is not defined. \"\n        \"Run CELL 30 first.\"\n    )\n\n\n# ============================================================\n# 2. SELECT MISCLASSIFIED EXAMPLES\n#\n# Moderate DR = class index 2\n# Moderate errors are placed first.\n# ============================================================\n\nnon_moderate_errors = (\n\n    misclassified_df[\n\n        misclassified_df[\n            \"true_label\"\n        ] != 2\n\n    ]\n\n)\n\n\nwrong_xai_df = (\n\n    pd.concat(\n\n        [\n\n            moderate_errors,\n\n            non_moderate_errors\n\n        ],\n\n        ignore_index=True\n\n    )\n\n    .drop_duplicates(\n\n        subset=[\n            \"image_id\"\n        ]\n\n    )\n\n    .head(\n        5\n    )\n\n    .reset_index(\n        drop=True\n    )\n\n)\n\n\nprint(\n    \"=\" * 70\n)\n\nprint(\n    \"MISCLASSIFIED XAI EXAMPLES\"\n)\n\nprint(\n    \"=\" * 70\n)\n\nprint(\n    \"Number of examples:\",\n    len(\n        wrong_xai_df\n    )\n)\n\nprint(\n    \"LRP Conv Gamma:\",\n    LRP_CONV_GAMMA\n)\n\nprint(\n    \"LRP Linear Gamma:\",\n    LRP_LINEAR_GAMMA\n)\n\ndisplay(\n    wrong_xai_df\n)\n\n\n# ============================================================\n# 3. GENERATE EXPLANATIONS\n# ============================================================\n\nxai_wrong_results = []\n\n\nfor _, row in (\n\n    wrong_xai_df\n\n    .iterrows()\n\n):\n\n\n    # --------------------------------------------------------\n    # PREPARE IMAGE\n    # --------------------------------------------------------\n\n    (\n\n        original_image,\n\n        display_image,\n\n        input_tensor\n\n    ) = prepare_image(\n\n        row[\n            \"image_path\"\n        ]\n\n    )\n\n\n    # --------------------------------------------------------\n    # PREDICTION\n    # --------------------------------------------------------\n\n    with torch.no_grad():\n\n\n        outputs = model(\n\n            input_tensor\n\n        )\n\n\n        probabilities = torch.softmax(\n\n            outputs,\n\n            dim=1\n\n        )\n\n\n        predicted_class = int(\n\n            torch.argmax(\n\n                probabilities,\n\n                dim=1\n\n            ).item()\n\n        )\n\n\n        confidence = float(\n\n            probabilities[\n\n                0,\n\n                predicted_class\n\n            ].item()\n\n        )\n\n\n    # --------------------------------------------------------\n    # TRUE CLASS\n    #\n    # Prefer existing true_class string.\n    # If unavailable, derive from true_label.\n    # --------------------------------------------------------\n\n    if (\n\n        \"true_class\"\n\n        in row.index\n\n        and pd.notna(\n\n            row[\n                \"true_class\"\n            ]\n\n        )\n\n    ):\n\n        true_class_name = (\n\n            row[\n                \"true_class\"\n            ]\n\n        )\n\n    else:\n\n        true_class_name = (\n\n            CLASS_NAMES[\n\n                int(\n\n                    row[\n                        \"true_label\"\n                    ]\n\n                )\n\n            ]\n\n        )\n\n\n    # ========================================================\n    # 4. LRP\n    # ========================================================\n\n    try:\n\n\n        (\n\n            lrp_heatmap,\n\n            lrp_residual,\n\n            lrp_diagnostics_df\n\n        ) = generate_lrp(\n\n            model,\n\n            input_tensor,\n\n            predicted_class,\n\n            conv_gamma=\n                LRP_CONV_GAMMA,\n\n            linear_gamma=\n                LRP_LINEAR_GAMMA\n\n        )\n\n\n        # ----------------------------------------------\n        # Extract numerical diagnostics\n        # ----------------------------------------------\n\n        lrp_diag = (\n\n            lrp_diagnostics_df\n\n            .iloc[\n                0\n            ]\n\n            .to_dict()\n\n        )\n\n\n        lrp_relative_residual = float(\n\n            lrp_diag[\n                \"Relative_Residual\"\n            ]\n\n        )\n\n\n        lrp_target_logit = float(\n\n            lrp_diag[\n                \"Target_Logit\"\n            ]\n\n        )\n\n\n        lrp_relevance_sum = float(\n\n            lrp_diag[\n                \"Signed_Relevance_Sum\"\n            ]\n\n        )\n\n\n        lrp_finite_gradient = bool(\n\n            lrp_diag[\n                \"Finite_Gradient\"\n            ]\n\n        )\n\n\n        lrp_finite_relevance = bool(\n\n            lrp_diag[\n                \"Finite_Relevance\"\n            ]\n\n        )\n\n\n        lrp_status = (\n            \"SUCCESS\"\n        )\n\n\n        lrp_error = None\n\n\n    except Exception as e:\n\n\n        lrp_heatmap = None\n\n        lrp_residual = None\n\n        lrp_relative_residual = None\n\n        lrp_target_logit = None\n\n        lrp_relevance_sum = None\n\n        lrp_finite_gradient = None\n\n        lrp_finite_relevance = None\n\n\n        lrp_status = (\n            \"FAILED\"\n        )\n\n\n        lrp_error = str(\n            e\n        )\n\n\n        print()\n\n        print(\n            \"LRP FAILED for:\",\n            row[\n                \"image_id\"\n            ]\n        )\n\n        print(\n            lrp_error\n        )\n\n\n    # ========================================================\n    # 5. GRAD-CAM\n    # ========================================================\n\n    try:\n\n\n        gradcam_heatmap = (\n\n            generate_gradcam(\n\n                model,\n\n                input_tensor,\n\n                predicted_class\n\n            )\n\n        )\n\n\n        gradcam_status = (\n            \"SUCCESS\"\n        )\n\n\n        gradcam_error = None\n\n\n    except Exception as e:\n\n\n        gradcam_heatmap = None\n\n\n        gradcam_status = (\n            \"FAILED\"\n        )\n\n\n        gradcam_error = str(\n            e\n        )\n\n\n        print()\n\n        print(\n            \"GRAD-CAM FAILED for:\",\n            row[\n                \"image_id\"\n            ]\n        )\n\n        print(\n            gradcam_error\n        )\n\n\n    # ========================================================\n    # 6. VISUALIZATION\n    # ========================================================\n\n    fig, axes = plt.subplots(\n\n        1,\n\n        3,\n\n        figsize=(\n            15,\n            5\n        )\n\n    )\n\n\n    # --------------------------------------------------------\n    # ORIGINAL\n    # --------------------------------------------------------\n\n    axes[\n        0\n    ].imshow(\n\n        display_image\n\n    )\n\n\n    axes[\n        0\n    ].set_title(\n\n        f\"Original — Misclassified\\n\"\n\n        f\"True: \"\n        f\"{true_class_name}\\n\"\n\n        f\"Pred: \"\n        f\"{CLASS_NAMES[predicted_class]}\\n\"\n\n        f\"Confidence: \"\n        f\"{confidence:.3f}\"\n\n    )\n\n\n    axes[\n        0\n    ].axis(\n        \"off\"\n    )\n\n\n    # --------------------------------------------------------\n    # SIGNED LRP\n    #\n    # IMPORTANT:\n    # Do NOT overlay original image.\n    #\n    # BLUE  = negative relevance\n    # WHITE = near-zero relevance\n    # RED   = positive relevance\n    # --------------------------------------------------------\n\n    if lrp_heatmap is not None:\n\n\n        lrp_image = axes[\n            1\n        ].imshow(\n\n            lrp_heatmap,\n\n            cmap=\"bwr\",\n\n            vmin=-1,\n\n            vmax=1\n\n        )\n\n\n        axes[\n            1\n        ].set_title(\n\n            \"LRP — Signed Relevance\\n\"\n\n            f\"Relative Residual: \"\n            f\"{lrp_relative_residual:.3e}\"\n\n        )\n\n\n        lrp_colorbar = (\n\n            fig.colorbar(\n\n                lrp_image,\n\n                ax=axes[\n                    1\n                ],\n\n                fraction=0.046,\n\n                pad=0.04\n\n            )\n\n        )\n\n\n        lrp_colorbar.set_label(\n\n            \"Negative  ←  Relevance  →  Positive\"\n\n        )\n\n\n    else:\n\n\n        axes[\n            1\n        ].text(\n\n            0.5,\n\n            0.5,\n\n            \"LRP FAILED\\n\"\n            \"Do not report this figure\",\n\n            ha=\"center\",\n\n            va=\"center\",\n\n            transform=\n                axes[\n                    1\n                ].transAxes\n\n        )\n\n\n        axes[\n            1\n        ].set_title(\n\n            \"LRP FAILED\"\n\n        )\n\n\n    axes[\n        1\n    ].axis(\n        \"off\"\n    )\n\n\n    # --------------------------------------------------------\n    # GRAD-CAM\n    # --------------------------------------------------------\n\n    axes[\n        2\n    ].imshow(\n\n        display_image\n\n    )\n\n\n    if gradcam_heatmap is not None:\n\n\n        axes[\n            2\n        ].imshow(\n\n            gradcam_heatmap,\n\n            cmap=\"jet\",\n\n            alpha=0.5,\n\n            vmin=0,\n\n            vmax=1\n\n        )\n\n\n        axes[\n            2\n        ].set_title(\n\n            \"Grad-CAM\\n\"\n            \"ConvNeXt Last Conv Layer\"\n\n        )\n\n\n    else:\n\n\n        axes[\n            2\n        ].text(\n\n            0.5,\n\n            0.5,\n\n            \"Grad-CAM FAILED\",\n\n            ha=\"center\",\n\n            va=\"center\",\n\n            transform=\n                axes[\n                    2\n                ].transAxes\n\n        )\n\n\n        axes[\n            2\n        ].set_title(\n\n            \"Grad-CAM FAILED\"\n\n        )\n\n\n    axes[\n        2\n    ].axis(\n        \"off\"\n    )\n\n\n    # --------------------------------------------------------\n    # FINALIZE FIGURE\n    # --------------------------------------------------------\n\n    plt.tight_layout()\n\n\n    output_path = (\n\n        XAI_DIR\n\n        /\n\n        (\n\n            f\"wrong_\"\n\n            f\"{row['image_id']}_\"\n\n            f\"signed_lrp_gradcam.png\"\n\n        )\n\n    )\n\n\n    plt.savefig(\n\n        output_path,\n\n        dpi=300,\n\n        bbox_inches=\"tight\"\n\n    )\n\n\n    plt.show()\n\n\n    plt.close(\n        fig\n    )\n\n\n    # ========================================================\n    # 7. SAVE NUMERICAL RESULTS\n    # ========================================================\n\n    xai_wrong_results.append({\n\n        \"Image_ID\":\n            row[\n                \"image_id\"\n            ],\n\n        \"Type\":\n            \"Misclassified\",\n\n        \"True_Class\":\n            true_class_name,\n\n        \"Predicted_Class\":\n            CLASS_NAMES[\n                predicted_class\n            ],\n\n        \"Confidence\":\n            confidence,\n\n\n        # ----------------------------------------------\n        # LRP\n        # ----------------------------------------------\n\n        \"LRP_Status\":\n            lrp_status,\n\n        \"LRP_Conv_Gamma\":\n            float(\n                LRP_CONV_GAMMA\n            ),\n\n        \"LRP_Linear_Gamma\":\n            float(\n                LRP_LINEAR_GAMMA\n            ),\n\n        \"LRP_Target_Logit\":\n            lrp_target_logit,\n\n        \"LRP_Signed_Relevance_Sum\":\n            lrp_relevance_sum,\n\n        \"LRP_Conservation_Residual\":\n            lrp_residual,\n\n        \"LRP_Relative_Residual\":\n            lrp_relative_residual,\n\n        \"LRP_Finite_Gradient\":\n            lrp_finite_gradient,\n\n        \"LRP_Finite_Relevance\":\n            lrp_finite_relevance,\n\n        \"LRP_Error\":\n            lrp_error,\n\n\n        # ----------------------------------------------\n        # GRAD-CAM\n        # ----------------------------------------------\n\n        \"GradCAM_Status\":\n            gradcam_status,\n\n        \"GradCAM_Error\":\n            gradcam_error,\n\n\n        # ----------------------------------------------\n        # FIGURE\n        # ----------------------------------------------\n\n        \"Figure\":\n            str(\n                output_path\n            )\n\n    })\n\n\n# ============================================================\n# 8. RESULT DATAFRAME\n# ============================================================\n\nxai_wrong_results_df = (\n\n    pd.DataFrame(\n\n        xai_wrong_results\n\n    )\n\n)\n\n\nprint()\n\nprint(\n    \"=\" * 70\n)\n\nprint(\n    \"MISCLASSIFIED XAI RESULTS\"\n)\n\nprint(\n    \"=\" * 70\n)\n\n\ndisplay(\n\n    xai_wrong_results_df\n\n)\n\n\n# ============================================================\n# 9. SAVE CSV\n# ============================================================\n\nxai_wrong_results_df.to_csv(\n\n    XAI_DIR\n\n    /\n\n    \"xai_results_misclassified.csv\",\n\n    index=False\n\n)\n\n\nprint()\n\nprint(\n    \"Saved:\"\n)\n\nprint(\n\n    XAI_DIR\n\n    /\n\n    \"xai_results_misclassified.csv\"\n\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:09:33.947168Z","iopub.execute_input":"2026-08-26T14:09:33.948035Z","iopub.status.idle":"2026-08-26T14:09:43.405852Z","shell.execute_reply.started":"2026-08-26T14:09:33.948001Z","shell.execute_reply":"2026-08-26T14:09:43.405215Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 32 — Final Experiment Summary**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 32\n# FINAL EXPERIMENT SUMMARY\n# ============================================================\n\n\nfinal_summary = {\n\n     \"Model\":\n        \"Parallel ConvNeXt-MLP-Mixer V4\",\n\n    # --------------------------------------------------------\n    # ARCHITECTURE\n    # --------------------------------------------------------\n\n    \"Architecture\": {\n\n        \"Design\":\n            \"Parallel Dual-Branch\",\n\n        \"ConvNeXt\":\n            CONVNEXT_MODEL,\n\n        \"ConvNeXt_Pretrained\":\n            True,\n\n        \"MLP_Mixer\":\n            MIXER_MODEL,\n\n        \"MLP_Mixer_Pretrained\":\n            True,\n\n        \"Active_Mixer_Blocks\":\n            BEST_BLOCK,\n\n        \"Local_Raw_Dimension\":\n            768,\n\n        \"Global_Raw_Dimension\":\n            768,\n\n        \"Local_Projection\":\n            LOCAL_PROJ_DIM,\n\n        \"Global_Projection\":\n            GLOBAL_PROJ_DIM,\n\n        \"Fused_Dimension\":\n            FUSED_DIM,\n\n        \"Classifier_Hidden\":\n            CLASSIFIER_HIDDEN_DIM\n\n    },\n\n    # --------------------------------------------------------\n    # DATA\n    # --------------------------------------------------------\n\n    \"Dataset\": {\n\n        \"Total\":\n            len(\n                dataset_split\n            ),\n\n        \"Train\":\n            len(\n                train_df\n            ),\n\n        \"Validation\":\n            len(\n                val_df\n            ),\n\n        \"Test\":\n            len(\n                test_df\n            ),\n\n        \"Balanced\":\n            IS_BALANCED,\n\n        \"Image_Size\":\n            IMG_SIZE,\n\n        \"Augmentation\": {\n            \"Enabled\": USE_SPECIAL_AUGMENTATION,\n            \"Horizontal_Flip_p\": 0.5,\n            \"Vertical_Flip_p\": 0.5,\n            \"Rotation_Degrees\": 20,\n            \"Brightness_Jitter\": 0.2,\n            \"Contrast_Jitter\": 0.2,\n            \"Saturation_Jitter\": 0.1\n        },\n\n        \"Pre_Resize_Cache\":\n            True,\n\n        \"Resize_Per_Epoch\":\n            False\n\n    },\n\n    # --------------------------------------------------------\n    # TRAINING\n    # --------------------------------------------------------\n\n    \"Training\": {\n\n        \"Seed\":\n            SEED,\n\n        \"Physical_Batch\":\n            BATCH_SIZE,\n\n        \"Gradient_Accumulation\":\n            GRAD_ACCUM_STEPS,\n\n        \"Effective_Batch\":\n            EFFECTIVE_BATCH_SIZE,\n\n        \"Maximum_Epochs\":\n            EPOCHS,\n\n        \"Patience\":\n            PATIENCE,\n\n        \"Optimizer\":\n            \"AdamW\",\n\n        \"Weight_Decay\":\n            WEIGHT_DECAY,\n\n        \"Loss\":\n            \"Weighted CrossEntropy\",\n\n        \"Scheduler\":\n            (\"CosineAnnealingLR\" if USE_LR_SCHEDULER else \"None\"),\n\n        \"Full_End_To_End_Finetuning\":\n            True,\n\n        \"Backbone_Frozen_During_Grid\":\n            False,\n\n        \"AMP\":\n            USE_AMP,\n\n        \"Channels_Last\":\n            USE_CHANNELS_LAST,\n\n        \"Best_Learning_Rate\":\n            BEST_LR,\n\n        \"Best_Dropout\":\n            BEST_DROPOUT,\n\n        \"Best_Mixer_Blocks\":\n            BEST_BLOCK,\n\n        \"Best_Epoch\":\n            BEST_EPOCH,\n\n        \"Best_Validation_F1\":\n            BEST_VAL_F1,\n\n        \"Best_Validation_Accuracy\":\n            BEST_VAL_ACC\n\n    },\n\n    # --------------------------------------------------------\n    # TEST METRICS\n    # --------------------------------------------------------\n\n    \"Test\": {\n\n        \"Accuracy\":\n            test_accuracy,\n\n        \"Precision_Macro\":\n            precision_macro,\n\n        \"Recall_Macro\":\n            recall_macro,\n\n        \"F1_Macro\":\n            f1_macro,\n\n        \"ROC_AUC_Macro\":\n            roc_auc_macro,\n\n        \"Accuracy_CI_95\": [\n\n            acc_ci_lower,\n\n            acc_ci_upper\n\n        ],\n\n        \"F1_CI_95\": [\n\n            f1_ci_lower,\n\n            f1_ci_upper\n\n        ]\n\n    },\n\n    # --------------------------------------------------------\n    # CALIBRATION\n    # --------------------------------------------------------\n\n    \"Calibration\": {\n\n        \"Brier_Score\":\n            brier_score,\n\n        \"ECE\":\n            ece_score\n\n    },\n\n    # --------------------------------------------------------\n    # COMPLEXITY\n    # --------------------------------------------------------\n\n    \"Complexity\": {\n\n        \"Parameters\":\n            int(\n                total_params\n            ),\n\n        \"Trainable_Parameters\":\n            int(\n                trainable_params\n            ),\n\n        \"MACs\":\n            float(\n                macs\n            ),\n\n        \"Approx_GFLOPs\":\n            float(\n                approx_flops\n                / 1e9\n            ),\n\n        \"Model_Size_MB\":\n            float(\n                model_size_mb\n            ),\n\n        \"Inference_ms_per_image\":\n            float(\n                batch1_latency_ms\n            ),\n\n        \"Peak_GPU_Memory_MB\":\n            (\n                None\n\n                if peak_gpu_memory_mb\n                is None\n\n                else float(\n                    peak_gpu_memory_mb\n                )\n            )\n\n    },\n\n    # --------------------------------------------------------\n    # XAI\n    # --------------------------------------------------------\n\n    \"XAI\": {\n\n        \"LRP\":\n            (\n                \"LXT + Zennit Gamma-rule \"\n                \"Layer-wise Relevance Propagation\"\n            ),\n\n        \"Grad_CAM\":\n            (\n                \"ConvNeXt final \"\n                \"depthwise convolution\"\n            ),\n\n        \"Clinical_Validation\":\n            False\n\n    }\n\n}\n\n\nwith open(\n\n    OUTPUT_DIR /\n\n    \"final_experiment_summary.json\",\n\n    \"w\"\n\n) as f:\n\n\n    json.dump(\n\n        final_summary,\n\n        f,\n\n        indent=4\n\n    )\n\n\n# ------------------------------------------------------------\n# FINAL TABLE\n# ------------------------------------------------------------\n\nfinal_table = pd.DataFrame([{\n\n    \"Model\":\n        \"ConvNeXt-MLP-Mixer\",\n\n    \"Learning_Rate\":\n        BEST_LR,\n\n    \"Dropout\":\n        BEST_DROPOUT,\n\n    \"Mixer_Block\":\n        BEST_BLOCK,\n\n    \"Validation_F1\":\n        BEST_VAL_F1,\n\n    \"Validation_Accuracy\":\n        BEST_VAL_ACC,\n\n    \"Test_Accuracy\":\n        test_accuracy,\n\n    \"Precision_Macro\":\n        precision_macro,\n\n    \"Recall_Macro\":\n        recall_macro,\n\n    \"F1_Macro\":\n        f1_macro,\n\n    \"ROC_AUC_Macro\":\n        roc_auc_macro,\n\n    \"Brier\":\n        brier_score,\n\n    \"ECE\":\n        ece_score,\n\n    \"Parameters\":\n        total_params,\n\n    \"GFLOPs\":\n        approx_flops / 1e9,\n\n    \"Model_Size_MB\":\n        model_size_mb,\n\n    \"Inference_ms\":\n        batch1_latency_ms\n\n}])\n\n\ndisplay(\n    final_table\n)\n\n\nfinal_table.to_csv(\n\n    OUTPUT_DIR /\n\n    \"final_summary.csv\",\n\n    index=False\n\n)\n\n\nprint(\n    \"\\n\"\n    + \"=\" * 80\n)\n\nprint(\n    \"EXPERIMENT COMPLETED\"\n)\n\nprint(\n    \"=\" * 80\n)\n\n\nprint(\n    f\"\"\"\nBEST CONFIGURATION\n------------------\nLearning Rate      : {BEST_LR}\nDropout            : {BEST_DROPOUT}\nMixer Blocks       : {BEST_BLOCK}\nBest Epoch         : {BEST_EPOCH}\n\nVALIDATION\n----------\nMacro F1           : {BEST_VAL_F1:.4f}\nAccuracy           : {BEST_VAL_ACC:.4f}\n\nTEST\n----\nAccuracy           : {test_accuracy:.4f}\nMacro Precision    : {precision_macro:.4f}\nMacro Recall       : {recall_macro:.4f}\nMacro F1           : {f1_macro:.4f}\nMacro ROC-AUC      : {roc_auc_macro:.4f}\n\n95% CI\n------\nAccuracy           : ({acc_ci_lower:.4f}, {acc_ci_upper:.4f})\nMacro F1           : ({f1_ci_lower:.4f}, {f1_ci_upper:.4f})\n\nCALIBRATION\n-----------\nBrier Score        : {brier_score:.6f}\nECE                : {ece_score:.6f}\n\nCOMPLEXITY\n----------\nParameters         : {total_params:,}\nApprox GFLOPs      : {approx_flops / 1e9:.3f}\nModel Size         : {model_size_mb:.2f} MB\nInference / Image  : {batch1_latency_ms:.3f} ms\n\nOUTPUT DIRECTORY\n----------------\n{OUTPUT_DIR}\n\"\"\"\n)\n\nprint(\"\\nFAIRNESS NOTE\")\nprint(\"-------------\")\nprint(\"Input size / split / seed / optimizer family / weight decay / max epochs / patience\")\nprint(\"dipertahankan konsisten dengan baseline protocol.\")\nprint(\"The same augmentation and cosine scheduler protocol must be used by all compared models.\")\nprint(\"LRP is retained as the primary XAI method; Grad-CAM is a comparison method.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:09:53.553189Z","iopub.execute_input":"2026-08-26T14:09:53.553467Z","iopub.status.idle":"2026-08-26T14:09:53.583766Z","shell.execute_reply.started":"2026-08-26T14:09:53.553445Z","shell.execute_reply":"2026-08-26T14:09:53.583164Z"}},"outputs":[],"execution_count":null}]}