{"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, \"-m\", \"pip\", \"install\", \"-q\", \"thop\"\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-26T11:42:39.888004Z","iopub.execute_input":"2026-08-26T11:42:39.888205Z","iopub.status.idle":"2026-08-26T11:43:01.115103Z","shell.execute_reply.started":"2026-08-26T11:42:39.888171Z","shell.execute_reply":"2026-08-26T11:43:01.11425Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 2 — Configuration**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 2\n# CONFIGURATION\n# Hybrid ConvNeXt–PoolFormer\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# STABILITY ANALYSIS — final selected configuration only\n# ------------------------------------------------------------\nRUN_STABILITY_ANALYSIS = True\nSTABILITY_SEEDS = [42, 123, 2026]\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]\nPOOLFORMER_DEPTH_CANDIDATES = [2, 4, 6]\n\n# ------------------------------------------------------------\n# BACKBONE — TETAP\n# ------------------------------------------------------------\nCONVNEXT_MODEL = \"convnext_tiny.fb_in1k\"\nPOOLFORMER_MODEL = \"poolformer_s36.sail_in1k\"  # last stage has 6 pretrained blocks -> matches depth candidates [2,4,6]\nCONVNEXT_PRETRAINED = True\nPOOLFORMER_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_poolformer_v5_deduplicated_stability\")\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(\"PoolFormer Depth Candidates:\", POOLFORMER_DEPTH_CANDIDATES)\nprint(\"Total Grid Search Experiments:\", len(LR_CANDIDATES) * len(DROPOUT_CANDIDATES) * len(POOLFORMER_DEPTH_CANDIDATES))\nprint(\"ConvNeXt:\", CONVNEXT_MODEL)\nprint(\"PoolFormer:\", POOLFORMER_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-26T11:43:01.116936Z","iopub.execute_input":"2026-08-26T11:43:01.11748Z","iopub.status.idle":"2026-08-26T11:43:01.133267Z","shell.execute_reply.started":"2026-08-26T11:43:01.117455Z","shell.execute_reply":"2026-08-26T11:43:01.132643Z"}},"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 finalized baseline notebooks\n# ============================================================\nBASE_DIR=Path(\"/kaggle/input\"); IMAGE_DIR=None; SPLIT_FILE=None\nfor root,dirs,files in os.walk(BASE_DIR):\n    rootp=Path(root)\n    if \"dataset_split.csv\" in files: SPLIT_FILE=rootp/\"dataset_split.csv\"\n    if \"train_images\" in dirs:\n        candidate=rootp/\"train_images\"\n        n=sum(len(list(candidate.glob(ext))) for ext in [\"*.png\",\"*.jpg\",\"*.jpeg\"])\n        if n>3000: IMAGE_DIR=candidate\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)}\")\ndataset_split[\"original_image_path\"]=dataset_split[\"image_id\"].astype(str).map(lambda x:str(IMAGE_DIR/f\"{x}.png\"))\ndataset_split[\"image_path\"]=dataset_split[\"original_image_path\"]\nmissing=(~dataset_split[\"original_image_path\"].map(lambda p:Path(p).exists())).sum()\nif missing: raise FileNotFoundError(f\"{missing} images missing\")\n\noriginal_count=len(dataset_split)\nhash_col=next((c for c in [\"sha256\",\"hash\",\"image_hash\"] if c in dataset_split.columns),None)\nif hash_col:\n    dataset_split[hash_col]=dataset_split[hash_col].astype(\"string\").str.strip().str.lower()\n    valid=dataset_split[hash_col].notna() & ~dataset_split[hash_col].isin([\"\",\"nan\",\"none\"])\n    dataset_split[\"_dedup_key\"]=np.where(valid,\"hash:\"+dataset_split[hash_col].astype(str),\"id:\"+dataset_split[\"image_id\"].astype(str))\n    priority={\"test\":0,\"validation\":1,\"train\":2}\n    dataset_split[\"_prio\"]=dataset_split[\"split\"].map(priority)\n    if dataset_split[\"_prio\"].isna().any(): raise ValueError(\"Unexpected split name\")\n    winner=dataset_split.groupby(\"_dedup_key\")[\"_prio\"].transform(\"min\")\n    drop_mask=dataset_split[\"_prio\"]!=winner\n    n_dropped=int(drop_mask.sum())\n    if n_dropped:\n        print(f\"Dropping {n_dropped} duplicate-hash rows (test > validation > train).\")\n        dataset_split=dataset_split.loc[~drop_mask].reset_index(drop=True)\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()\na=set(train_df._dedup_key); b=set(val_df._dedup_key); c=set(test_df._dedup_key)\noverlap_tv=a&b; overlap_tt=a&c; overlap_vt=b&c\nprint(\"Exact-hash overlap train-val/train-test/val-test:\",len(overlap_tv),len(overlap_tt),len(overlap_vt))\nif overlap_tv or overlap_tt or overlap_vt: raise RuntimeError(\"Identical-image leakage detected after filtering\")\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))\nif len(dataset_split)!=3596:\n    print(\"WARNING: final count is not 3,596. Verify identical source split and policy across models.\")\ndataset_split.drop(columns=[\"_dedup_key\"],errors=\"ignore\").to_csv(OUTPUT_DIR/\"dataset_split_filtered_used.csv\",index=False)\ndisplay(dataset_split.head())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T11:43:01.134213Z","iopub.execute_input":"2026-08-26T11:43:01.134469Z","iopub.status.idle":"2026-08-26T11:43:12.55795Z","shell.execute_reply.started":"2026-08-26T11:43:01.134448Z","shell.execute_reply":"2026-08-26T11:43:12.557245Z"}},"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-26T11:45:01.95919Z","iopub.execute_input":"2026-08-26T11:45:01.959543Z","iopub.status.idle":"2026-08-26T11:45:02.020663Z","shell.execute_reply.started":"2026-08-26T11:45:01.959514Z","shell.execute_reply":"2026-08-26T11:45:02.019976Z"}},"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-26T11:45:10.347089Z","iopub.execute_input":"2026-08-26T11:45:10.347736Z","iopub.status.idle":"2026-08-26T11:48:23.268493Z","shell.execute_reply.started":"2026-08-26T11:45:10.347707Z","shell.execute_reply":"2026-08-26T11:48:23.267784Z"}},"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-26T11:48:30.408841Z","iopub.execute_input":"2026-08-26T11:48:30.409173Z","iopub.status.idle":"2026-08-26T11:48:30.416096Z","shell.execute_reply.started":"2026-08-26T11:48:30.409148Z","shell.execute_reply":"2026-08-26T11:48:30.415226Z"}},"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-26T11:48:34.921103Z","iopub.execute_input":"2026-08-26T11:48:34.922054Z","iopub.status.idle":"2026-08-26T11:48:35.893232Z","shell.execute_reply.started":"2026-08-26T11:48:34.92201Z","shell.execute_reply":"2026-08-26T11:48:35.889099Z"}},"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-26T11:48:40.814172Z","iopub.execute_input":"2026-08-26T11:48:40.814622Z","iopub.status.idle":"2026-08-26T11:48:40.850226Z","shell.execute_reply.started":"2026-08-26T11:48:40.814567Z","shell.execute_reply":"2026-08-26T11:48:40.849188Z"}},"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              PoolFormer\n#              |                     |\n#        Local Feature         Global Feature\n#              |                     |\n#          Projection              Projection\n#              |                     |\n#              +----------+----------+\n#                         |\n#                   Concatenation\n#                         |\n#                     Classifier\n#\n# CATATAN ARSITEKTUR (berbeda dari ConvNeXt-MLP-Mixer):\n#\n#   PoolFormer.forward_features() mengembalikan PETA SPASIAL\n#   [B, C, H, W] (seperti ConvNeXt), BUKAN token sequence [B, N, C]\n#   seperti MLP-Mixer. Karena itu pooling globalnya memakai\n#   AdaptiveAvgPool2d + flatten (SAMA seperti branch ConvNeXt),\n#   BUKAN token-mean seperti Mixer. Ini bukan penyederhanaan -\n#   ini memang cara yang benar secara arsitektural untuk backbone\n#   spasial seperti PoolFormer.\n#\n#   PoolFormer di timm berstruktur BERJENJANG (4 stage + downsampling\n#   antar-stage), berbeda dari MLP-Mixer yang punya satu daftar blok\n#   datar. \"PoolFormer Depth\" di sini mengontrol jumlah blok yang\n#   dipakai pada STAGE TERAKHIR (paling dekat ke output/global\n#   representation), sementara stage 0-2 tetap penuh. poolformer_s36\n#   dipilih sebagai backbone karena stage terakhirnya punya 6 blok\n#   pretrained ImageNet -> truncation ke {2,4,6} valid tanpa\n#   menyentuh bobot yang tidak pernah dilatih (sama seperti Mixer\n#   yang di-truncate dari 12 blok pretrained-nya).\n# ============================================================\n\n\nclass ParallelHybridConvNeXtPoolFormer(nn.Module):\n\n    def __init__(\n        self,\n        num_classes=5,\n        poolformer_depth=4,\n        dropout_rate=0.3,\n        pretrained=True\n    ):\n        super().__init__()\n\n        if poolformer_depth not in [2, 4, 6]:\n            raise ValueError(\"PoolFormer Depth must be 2, 4, or 6.\")\n\n        self.poolformer_depth = poolformer_depth\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 — POOLFORMER-S36\n        # ----------------------------------------------------\n        self.poolformer = timm.create_model(\n            POOLFORMER_MODEL,\n            pretrained=pretrained,\n            num_classes=0\n        )\n\n        # Truncate ONLY the last stage's blocks (analogous to Mixer's\n        # full_blocks[:mixer_blocks] truncation). Stages 0-2 stay intact.\n        full_last_stage_blocks = list(\n            self.poolformer.stages[-1].blocks.children()\n        )\n        if poolformer_depth > len(full_last_stage_blocks):\n            raise ValueError(\"Requested PoolFormer Depth exceeds available blocks.\")\n\n        self.poolformer.stages[-1].blocks = nn.Sequential(\n            *full_last_stage_blocks[:poolformer_depth]\n        )\n\n        self.global_feature_dim = self.poolformer.num_features\n\n        # PoolFormer forward_features() returns [B, C, H, W]\n        # (spatial map) - pooled the SAME way as the ConvNeXt branch.\n        self.global_pool = nn.AdaptiveAvgPool2d(1)\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_map = self.poolformer.forward_features(x)\n        global_feature = self.global_pool(global_map).flatten(1)\n        global_projected = self.global_projector(global_feature)\n\n        return (\n            local_map,\n            local_feature,\n            local_projected,\n            global_map,\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_map,\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    poolformer_depth,\n    dropout_rate,\n    pretrained=True\n):\n    model = ParallelHybridConvNeXtPoolFormer(\n        num_classes=NUM_CLASSES,\n        poolformer_depth=poolformer_depth,\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-26T11:48:46.141044Z","iopub.execute_input":"2026-08-26T11:48:46.141703Z","iopub.status.idle":"2026-08-26T11:48:46.157518Z","shell.execute_reply.started":"2026-08-26T11:48:46.141674Z","shell.execute_reply":"2026-08-26T11:48:46.15656Z"}},"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    poolformer_depth=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_map,\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(\"PoolFormer Feature Map:\", global_map.shape)\nprint(\"PoolFormer 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 PoolFormer Depth (last stage):\", len(verification_model.poolformer.stages[-1].blocks))\nprint(\"ConvNeXt Pretrained:\", CONVNEXT_PRETRAINED)\nprint(\"PoolFormer Pretrained:\", POOLFORMER_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-26T11:48:52.552924Z","iopub.execute_input":"2026-08-26T11:48:52.553325Z","iopub.status.idle":"2026-08-26T11:49:06.196796Z","shell.execute_reply.started":"2026-08-26T11:48:52.553297Z","shell.execute_reply":"2026-08-26T11:49:06.196153Z"}},"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-26T11:49:12.706431Z","iopub.execute_input":"2026-08-26T11:49:12.70716Z","iopub.status.idle":"2026-08-26T11:49:12.716377Z","shell.execute_reply.started":"2026-08-26T11:49:12.70713Z","shell.execute_reply":"2026-08-26T11:49:12.715613Z"}},"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 PoolFormer Depth = 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_poolformer.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[\"PoolFormerDepth\"]), 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 poolformer_depth in POOLFORMER_DEPTH_CANDIDATES:\n    for dropout_rate in DROPOUT_CANDIDATES:\n        for learning_rate in LR_CANDIDATES:\n\n            config_key = (\n                int(poolformer_depth),\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\"PoolFormer Depth = {poolformer_depth}\")\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                poolformer_depth=poolformer_depth,\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                            \"PoolFormerDepth\": int(poolformer_depth),\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                                \"PoolFormerDepth\": int(poolformer_depth),\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                \"PoolFormerDepth\": int(poolformer_depth),\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[\"PoolFormerDepth\"]) == int(poolformer_depth)\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-26T11:49:17.720226Z","iopub.execute_input":"2026-08-26T11:49:17.72066Z","iopub.status.idle":"2026-08-26T14:50:47.065106Z","shell.execute_reply.started":"2026-08-26T11:49:17.720631Z","shell.execute_reply":"2026-08-26T14:50:47.064086Z"}},"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=[\"PoolFormerDepth\", \"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\", \"PoolFormerDepth\", \"Dropout\"])\n    .copy()\n)\n\nlr_individual[\"ExperimentWithinLR\"] = (\n    lr_individual.groupby(\"LearningRate\").cumcount() + 1\n)\n\nlr_individual[\"Combination\"] = (\n    \"B\" + lr_individual[\"PoolFormerDepth\"].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:50:53.83311Z","iopub.execute_input":"2026-08-26T14:50:53.834046Z","iopub.status.idle":"2026-08-26T14:50:55.411232Z","shell.execute_reply.started":"2026-08-26T14:50:53.834006Z","shell.execute_reply":"2026-08-26T14:50:55.410207Z"}},"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=[\"PoolFormerDepth\", \"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\", \"PoolFormerDepth\", \"LearningRate\"])\n    .copy()\n)\n\ndropout_individual[\"ExperimentWithinDropout\"] = (\n    dropout_individual.groupby(\"Dropout\").cumcount() + 1\n)\n\ndropout_individual[\"Combination\"] = (\n    \"B\" + dropout_individual[\"PoolFormerDepth\"].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:51:03.049856Z","iopub.execute_input":"2026-08-26T14:51:03.050853Z","iopub.status.idle":"2026-08-26T14:51:04.670838Z","shell.execute_reply.started":"2026-08-26T14:51:03.050811Z","shell.execute_reply":"2026-08-26T14:51:04.670234Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 14 — PoolFormer Depth Analysis**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 14\n# POOLFORMER DEPTH ANALYSIS\n# 9 INDIVIDUAL EXPERIMENTS PER DEPTH + MEAN/STD\n# ============================================================\n\npoolformer_table = grid_results_df.pivot_table(\n    values=\"BestValAcc\",\n    index=[\"Dropout\", \"LearningRate\"],\n    columns=\"PoolFormerDepth\"\n)\n\nprint(\"Validation Accuracy Table — setiap depth memiliki 9 kombinasi:\")\ndisplay(poolformer_table)\npoolformer_table.to_csv(OUTPUT_DIR / \"poolformer_depth_table.csv\")\n\npoolformer_individual = (\n    grid_results_df\n    .sort_values([\"PoolFormerDepth\", \"Dropout\", \"LearningRate\"])\n    .copy()\n)\n\npoolformer_individual[\"ExperimentWithinDepth\"] = (\n    poolformer_individual.groupby(\"PoolFormerDepth\").cumcount() + 1\n)\n\npoolformer_individual[\"Combination\"] = (\n    \"D\" + poolformer_individual[\"Dropout\"].astype(str)\n    + \"-LR\" + poolformer_individual[\"LearningRate\"].map(lambda x: f\"{x:g}\")\n)\n\ndisplay(\n    poolformer_individual[\n        [\n            \"PoolFormerDepth\",\n            \"ExperimentWithinDepth\",\n            \"Combination\",\n            \"BestValAcc\",\n            \"BestValF1\"\n        ]\n    ]\n)\n\npoolformer_individual.to_csv(\n    OUTPUT_DIR / \"poolformer_depth_9_individual_results.csv\",\n    index=False\n)\n\nplt.figure(figsize=(10, 6))\nfor d in sorted(poolformer_individual[\"PoolFormerDepth\"].unique()):\n    g = poolformer_individual[poolformer_individual[\"PoolFormerDepth\"] == d]\n    plt.plot(\n        g[\"ExperimentWithinDepth\"],\n        g[\"BestValAcc\"],\n        marker=\"o\",\n        label=f\"Depth={int(d)}\"\n    )\n\nplt.xticks(range(1, 10))\nplt.xlabel(\"Experiment index within each PoolFormer depth (1–9)\")\nplt.ylabel(\"Best Validation Accuracy\")\nplt.title(\"PoolFormer Depth Analysis — 9 Individual Experiments per Depth\")\nplt.grid(alpha=0.3)\nplt.legend()\nplt.tight_layout()\nplt.savefig(PLOT_DIR / \"poolformer_depth_9_individual_accuracy.png\", dpi=300)\nplt.show()\n\nplt.figure(figsize=(10, 6))\nfor d in sorted(poolformer_individual[\"PoolFormerDepth\"].unique()):\n    g = poolformer_individual[poolformer_individual[\"PoolFormerDepth\"] == d]\n    plt.plot(\n        g[\"ExperimentWithinDepth\"],\n        g[\"BestValF1\"],\n        marker=\"o\",\n        label=f\"Depth={int(d)}\"\n    )\n\nplt.xticks(range(1, 10))\nplt.xlabel(\"Experiment index within each PoolFormer depth (1–9)\")\nplt.ylabel(\"Best Validation Macro-F1\")\nplt.title(\"PoolFormer Depth Analysis — 9 Individual Macro-F1 Results per Depth\")\nplt.grid(alpha=0.3)\nplt.legend()\nplt.tight_layout()\nplt.savefig(PLOT_DIR / \"poolformer_depth_9_individual_f1.png\", dpi=300)\nplt.show()\n\npoolformer_summary = (\n    grid_results_df\n    .groupby(\"PoolFormerDepth\")\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(poolformer_summary)\npoolformer_summary.to_csv(\n    OUTPUT_DIR / \"poolformer_depth_summary_mean_std.csv\",\n    index=False\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:51:10.939023Z","iopub.execute_input":"2026-08-26T14:51:10.939524Z","iopub.status.idle":"2026-08-26T14:51:12.507271Z","shell.execute_reply.started":"2026-08-26T14:51:10.939493Z","shell.execute_reply":"2026-08-26T14:51:12.506391Z"}},"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[\"PoolFormerDepth\"])\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(\"PoolFormer Depth:\", 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:51:19.150319Z","iopub.execute_input":"2026-08-26T14:51:19.150832Z","iopub.status.idle":"2026-08-26T14:51:19.160978Z","shell.execute_reply.started":"2026-08-26T14:51:19.150803Z","shell.execute_reply":"2026-08-26T14:51:19.160233Z"}},"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:51:28.889884Z","iopub.execute_input":"2026-08-26T14:51:28.890793Z","iopub.status.idle":"2026-08-26T14:51:29.99907Z","shell.execute_reply.started":"2026-08-26T14:51:28.89076Z","shell.execute_reply":"2026-08-26T14:51:29.998051Z"}},"outputs":[],"execution_count":null},{"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–MLP-Mixer history CSV on Kaggle.\n# Example:\n# MLP_MIXER_HISTORY_CSV = \"/kaggle/input/nama-dataset/best_training_history_mlp_mixer.csv\"\nMLP_MIXER_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–PoolFormer\npoolformer_history = standardize_history(\n    pd.DataFrame(global_best_history),\n    \"ConvNeXt–PoolFormer\",\n)\n\nhistory_parts = [poolformer_history]\n\n# 2) Optional second notebook/history: ConvNeXt–MLP-Mixer\nif MLP_MIXER_HISTORY_CSV:\n    mlp_path = Path(MLP_MIXER_HISTORY_CSV)\n    if not mlp_path.exists():\n        raise FileNotFoundError(f\"CSV MLP-Mixer tidak ditemukan: {mlp_path}\")\n    mlp_history = standardize_history(\n        pd.read_csv(mlp_path),\n        \"ConvNeXt–MLP-Mixer\",\n    )\n    history_parts.append(mlp_history)\nelse:\n    print(\n        \"INFO: MLP_MIXER_HISTORY_CSV belum diisi. \"\n        \"Figure 3 sementara hanya menampilkan ConvNeXt–PoolFormer.\"\n    )\n\nfigure3_history = pd.concat(history_parts, ignore_index=True)\n\n# Enforce maximum requested rows: 10 per model, 20 total.\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\nfigure3_csv = OUTPUT_DIR / \"figure3_selected_hybrids_learning_curves.csv\"\nfigure3_history.to_csv(figure3_csv, index=False)\n\n# ------------------------------------------------------------\n# Three-panel Figure 3\n# ------------------------------------------------------------\nfig, axes = plt.subplots(1, 3, figsize=(18, 5.5))\ncolors = {\n    \"ConvNeXt–PoolFormer\": \"#1f77b4\",\n    \"ConvNeXt–MLP-Mixer\": \"#d62728\",\n}\n\nfor model_name, group in figure3_history.groupby(\"Model\", sort=False):\n    color = colors.get(model_name, None)\n\n    # (a) Loss curves\n    axes[0].plot(\n        group[\"Epoch\"], group[\"Train loss\"],\n        marker=\"o\", color=color, linestyle=\"-\",\n        label=f\"{model_name} — train\",\n    )\n    axes[0].plot(\n        group[\"Epoch\"], group[\"Validation loss\"],\n        marker=\"s\", color=color, linestyle=\"--\",\n        label=f\"{model_name} — validation\",\n    )\n\n    # (b) Accuracy curves\n    axes[1].plot(\n        group[\"Epoch\"], group[\"Train accuracy\"],\n        marker=\"o\", color=color, linestyle=\"-\",\n        label=f\"{model_name} — train\",\n    )\n    axes[1].plot(\n        group[\"Epoch\"], group[\"Validation accuracy\"],\n        marker=\"s\", color=color, linestyle=\"--\",\n        label=f\"{model_name} — validation\",\n    )\n\n    # (c) Validation macro-F1 curves\n    axes[2].plot(\n        group[\"Epoch\"], group[\"Validation macro-F1\"],\n        marker=\"D\", color=color, linewidth=2,\n        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]\n\nmax_epoch = int(figure3_history[\"Epoch\"].max())\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 CSV :\", figure3_csv)\nprint(\"Saved PNG :\", figure3_png)\nprint(\"Saved PDF :\", figure3_pdf)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:51:36.684869Z","iopub.execute_input":"2026-08-26T14:51:36.685649Z","iopub.status.idle":"2026-08-26T14:51:39.476238Z","shell.execute_reply.started":"2026-08-26T14:51:36.685608Z","shell.execute_reply":"2026-08-26T14:51:39.475328Z"}},"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    poolformer_depth=int(checkpoint[\"PoolFormerDepth\"]),\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:51:47.194103Z","iopub.execute_input":"2026-08-26T14:51:47.195239Z","iopub.status.idle":"2026-08-26T14:51:52.354262Z","shell.execute_reply.started":"2026-08-26T14:51:47.195208Z","shell.execute_reply":"2026-08-26T14:51:52.353118Z"}},"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-PoolFormer\",\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:51:56.233865Z","iopub.execute_input":"2026-08-26T14:51:56.23424Z","iopub.status.idle":"2026-08-26T14:51:56.292513Z","shell.execute_reply.started":"2026-08-26T14:51:56.234199Z","shell.execute_reply":"2026-08-26T14:51:56.291603Z"}},"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:52:16.309694Z","iopub.execute_input":"2026-08-26T14:52:16.310135Z","iopub.status.idle":"2026-08-26T14:52:16.344103Z","shell.execute_reply.started":"2026-08-26T14:52:16.310091Z","shell.execute_reply":"2026-08-26T14:52:16.343496Z"}},"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–PoolFormer 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:52:21.550827Z","iopub.execute_input":"2026-08-26T14:52:21.551604Z","iopub.status.idle":"2026-08-26T14:52:22.255035Z","shell.execute_reply.started":"2026-08-26T14:52:21.551573Z","shell.execute_reply":"2026-08-26T14:52:22.254412Z"}},"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:52:27.892027Z","iopub.execute_input":"2026-08-26T14:52:27.892526Z","iopub.status.idle":"2026-08-26T14:52:28.594097Z","shell.execute_reply.started":"2026-08-26T14:52:27.892491Z","shell.execute_reply":"2026-08-26T14:52:28.593406Z"}},"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:52:35.099733Z","iopub.execute_input":"2026-08-26T14:52:35.100012Z","iopub.status.idle":"2026-08-26T14:52:35.926498Z","shell.execute_reply.started":"2026-08-26T14:52:35.099991Z","shell.execute_reply":"2026-08-26T14:52:35.925519Z"}},"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 = 2 x MACs.\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())\npoolformer_params=sum(p.numel() for p in model.poolformer.parameters())\nmodel.eval()\ndummy_input=move_images_to_device(torch.randn(1,3,IMG_SIZE,IMG_SIZE),DEVICE)\nmacs,params_thop=profile(model,inputs=(dummy_input,),verbose=False)\napprox_flops=2*macs\n\ntemp_path=OUTPUT_DIR/\"temp_model.pth\"; torch.save(model.state_dict(),temp_path)\nmodel_size_mb=temp_path.stat().st_size/(1024**2); temp_path.unlink()\nend_to_end_test_ms_per_image=test_inference_time*1000/len(test_dataset)\n\nWARMUP=20; REPEATS=100\nif torch.cuda.is_available(): torch.cuda.reset_peak_memory_stats()\nwith torch.inference_mode():\n    for _ in range(WARMUP): _=model(dummy_input)\n    if torch.cuda.is_available(): torch.cuda.synchronize()\n    t0=time.perf_counter()\n    for _ in range(REPEATS): _=model(dummy_input)\n    if torch.cuda.is_available(): torch.cuda.synchronize()\nbatch1_latency_ms=(time.perf_counter()-t0)*1000/REPEATS\npeak_gpu_memory_mb=torch.cuda.max_memory_allocated()/(1024**2) if torch.cuda.is_available() else None\n\ncomplexity_df=pd.DataFrame([{\n \"Model\":\"ConvNeXt-PoolFormer\",\"Total_Parameters\":total_params,\"Trainable_Parameters\":trainable_params,\n \"ConvNeXt_Parameters\":convnext_params,\"PoolFormer_Parameters\":poolformer_params,\"THOP_Parameters\":params_thop,\n \"MACs\":macs,\"GMACs\":macs/1e9,\"Approx_FLOPs\":approx_flops,\"Approx_GFLOPs\":approx_flops/1e9,\n \"Model_Size_MB\":model_size_mb,\"EndToEnd_Test_ms_per_image\":end_to_end_test_ms_per_image,\n \"Batch1_ModelOnly_Latency_ms\":batch1_latency_ms,\"Peak_GPU_Memory_MB\":peak_gpu_memory_mb,\n \"Benchmark_Device\":str(DEVICE),\"Warmup_Runs\":WARMUP,\"Benchmark_Repetitions\":REPEATS\n}])\ndisplay(complexity_df)\ncomplexity_df.to_csv(OUTPUT_DIR/\"model_complexity.csv\",index=False)\ndel dummy_input\nif torch.cuda.is_available(): torch.cuda.empty_cache()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:52:44.165153Z","iopub.execute_input":"2026-08-26T14:52:44.166129Z","iopub.status.idle":"2026-08-26T14:52:49.908044Z","shell.execute_reply.started":"2026-08-26T14:52:44.166088Z","shell.execute_reply":"2026-08-26T14:52:49.907421Z"}},"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:52:55.117322Z","iopub.execute_input":"2026-08-26T14:52:55.117692Z","iopub.status.idle":"2026-08-26T14:52:57.281883Z","shell.execute_reply.started":"2026-08-26T14:52:55.117664Z","shell.execute_reply":"2026-08-26T14:52:57.28122Z"}},"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:53:03.831947Z","iopub.execute_input":"2026-08-26T14:53:03.832773Z","iopub.status.idle":"2026-08-26T14:53:04.406798Z","shell.execute_reply.started":"2026-08-26T14:53:03.832743Z","shell.execute_reply":"2026-08-26T14:53:04.40598Z"}},"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:53:14.179748Z","iopub.execute_input":"2026-08-26T14:53:14.180635Z","iopub.status.idle":"2026-08-26T14:53:14.214074Z","shell.execute_reply.started":"2026-08-26T14:53:14.180604Z","shell.execute_reply":"2026-08-26T14:53:14.213328Z"}},"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:53:20.367257Z","iopub.execute_input":"2026-08-26T14:53:20.367767Z","iopub.status.idle":"2026-08-26T14:53:21.707782Z","shell.execute_reply.started":"2026-08-26T14:53:20.367736Z","shell.execute_reply":"2026-08-26T14:53:21.706819Z"}},"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:53:32.151971Z","iopub.execute_input":"2026-08-26T14:53:32.152605Z","iopub.status.idle":"2026-08-26T14:53:32.213213Z","shell.execute_reply.started":"2026-08-26T14:53:32.152573Z","shell.execute_reply":"2026-08-26T14:53:32.212544Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# **CELL 28B — Stability Analysis: Repeated Final Runs**\n","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# CELL 28B — STABILITY ANALYSIS\n# Grid search is NOT repeated. The selected final configuration is\n# retrained with seeds 42, 123, and 2026. Checkpoints are selected\n# only by validation macro-F1, then evaluated on the test set.\n# ============================================================\nSTABILITY_DIR=OUTPUT_DIR/\"stability\"; STABILITY_DIR.mkdir(parents=True,exist_ok=True)\nstability_rows=[]; stability_summary_df=None; stability_summary_dict=None\n\ndef stability_test(evaluation_model,loader):\n    evaluation_model.eval(); ys=[]; ps=[]; probs=[]\n    with torch.inference_mode():\n        for images,labels in loader:\n            images=move_images_to_device(images,DEVICE)\n            with torch.autocast(device_type=DEVICE.type,dtype=torch.float16,enabled=USE_AMP):\n                outputs=evaluation_model(images)\n            prob=torch.softmax(outputs.float(),dim=1); pred=outputs.argmax(1)\n            ys.append(labels.cpu()); ps.append(pred.cpu()); probs.append(prob.cpu())\n    return torch.cat(ys).numpy(),torch.cat(ps).numpy(),torch.cat(probs).numpy()\n\nif RUN_STABILITY_ANALYSIS:\n    for stability_seed in STABILITY_SEEDS:\n        print(\"=\"*80); print(\"STABILITY SEED:\",stability_seed); print(\"=\"*80)\n        seed_everything(stability_seed)\n        generator=torch.Generator(); generator.manual_seed(stability_seed)\n        common=dict(batch_size=BATCH_SIZE,num_workers=NUM_WORKERS,pin_memory=DEVICE.type==\"cuda\",persistent_workers=NUM_WORKERS>0)\n        if NUM_WORKERS>0: common[\"prefetch_factor\"]=PREFETCH_FACTOR\n        stability_train_loader=DataLoader(train_dataset,shuffle=True,generator=generator,**common)\n\n        stability_model=build_model(poolformer_depth=BEST_BLOCK,dropout_rate=BEST_DROPOUT,pretrained=True)\n        stability_optimizer=make_optimizer(stability_model,BEST_LR)\n        stability_scaler=torch.amp.GradScaler(\"cuda\",enabled=USE_AMP)\n        stability_scheduler=torch.optim.lr_scheduler.CosineAnnealingLR(stability_optimizer,T_max=EPOCHS) if USE_LR_SCHEDULER else None\n        checkpoint_path=STABILITY_DIR/f\"best_seed_{stability_seed}.pth\"\n        best_key=(-1.0,-1.0); best_epoch=0; patience_counter=0; start=time.time()\n\n        for epoch in range(1,EPOCHS+1):\n            tl,ta,tf=train_one_epoch(stability_model,stability_train_loader,criterion,stability_optimizer,stability_scaler,DEVICE)\n            vr=validate(stability_model,val_loader,criterion,DEVICE)\n            key=(vr[\"f1\"],vr[\"accuracy\"])\n            if key>best_key:\n                best_key=key; best_epoch=epoch; patience_counter=0\n                torch.save({\"model_state_dict\":stability_model.state_dict(),\"Seed\":stability_seed,\"PoolFormerDepth\":BEST_BLOCK,\"Dropout\":BEST_DROPOUT,\"LearningRate\":BEST_LR,\"BestEpoch\":best_epoch,\"BestValF1\":best_key[0],\"BestValAcc\":best_key[1]},checkpoint_path)\n            else: patience_counter+=1\n            if stability_scheduler is not None: stability_scheduler.step()\n            print(f\"Seed={stability_seed} E{epoch}: TrainF1={tf:.4f} ValF1={vr['f1']:.4f} ValAcc={vr['accuracy']:.4f}\")\n            if patience_counter>=PATIENCE: break\n\n        checkpoint=torch.load(checkpoint_path,map_location=DEVICE)\n        stability_model.load_state_dict(checkpoint[\"model_state_dict\"])\n        sy,sp,sprob=stability_test(stability_model,test_loader)\n        row={\n          \"Seed\":stability_seed,\"Best_Epoch\":best_epoch,\"Best_Validation_Macro_F1\":best_key[0],\"Best_Validation_Accuracy\":best_key[1],\n          \"Test_Accuracy\":accuracy_score(sy,sp),\"Test_Precision_Macro\":precision_score(sy,sp,average=\"macro\",zero_division=0),\n          \"Test_Recall_Macro\":recall_score(sy,sp,average=\"macro\",zero_division=0),\"Test_Macro_F1\":f1_score(sy,sp,average=\"macro\",zero_division=0),\n          \"Test_Weighted_F1\":f1_score(sy,sp,average=\"weighted\",zero_division=0),\n          \"Test_Macro_ROC_AUC_OvR\":roc_auc_score(sy,sprob,multi_class=\"ovr\",average=\"macro\"),\n          \"Test_Weighted_ROC_AUC_OvR\":roc_auc_score(sy,sprob,multi_class=\"ovr\",average=\"weighted\"),\n          \"Training_Time_Seconds\":time.time()-start\n        }\n        stability_rows.append(row)\n        seed_pred=test_df[[\"image_id\",\"label\"]].copy().reset_index(drop=True).rename(columns={\"label\":\"true_label\"})\n        seed_pred[\"predicted_label\"]=sp; seed_pred[\"correct\"]=sy==sp; seed_pred[\"confidence\"]=sprob.max(1)\n        for k in range(NUM_CLASSES): seed_pred[f\"prob_class_{k}\"]=sprob[:,k]\n        seed_pred.to_csv(STABILITY_DIR/f\"test_predictions_seed_{stability_seed}.csv\",index=False)\n        del stability_model,stability_optimizer,stability_scaler,stability_scheduler,stability_train_loader,checkpoint\n        gc.collect()\n        if torch.cuda.is_available(): torch.cuda.empty_cache()\n\n    stability_df=pd.DataFrame(stability_rows)\n    stability_df.to_csv(OUTPUT_DIR/\"stability_repeated_runs.csv\",index=False); display(stability_df)\n    metric_cols=[\"Test_Accuracy\",\"Test_Precision_Macro\",\"Test_Recall_Macro\",\"Test_Macro_F1\",\"Test_Weighted_F1\",\"Test_Macro_ROC_AUC_OvR\",\"Test_Weighted_ROC_AUC_OvR\"]\n    summary=[]\n    for name in metric_cols:\n        values=stability_df[name].astype(float)\n        summary.append({\"Metric\":name,\"Mean\":values.mean(),\"Standard_Deviation\":values.std(ddof=1),\"Minimum\":values.min(),\"Maximum\":values.max(),\"Number_of_Runs\":len(values)})\n    stability_summary_df=pd.DataFrame(summary)\n    stability_summary_df.to_csv(OUTPUT_DIR/\"stability_mean_std.csv\",index=False); display(stability_summary_df)\n    stability_summary_dict={r[\"Metric\"]:{\"Mean\":float(r[\"Mean\"]),\"Standard_Deviation\":float(r[\"Standard_Deviation\"]),\"Minimum\":float(r[\"Minimum\"]),\"Maximum\":float(r[\"Maximum\"])} for _,r in stability_summary_df.iterrows()}\nelse:\n    print(\"Stability analysis disabled\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-26T14:53:39.401575Z","iopub.execute_input":"2026-08-26T14:53:39.401844Z","iopub.status.idle":"2026-08-26T15:13:31.964357Z","shell.execute_reply.started":"2026-08-26T14:53:39.401823Z","shell.execute_reply":"2026-08-26T15:13:31.963653Z"}},"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-26T15:13:38.841472Z","iopub.execute_input":"2026-08-26T15:13:38.842451Z","iopub.status.idle":"2026-08-26T15:13:43.20762Z","shell.execute_reply.started":"2026-08-26T15:13:38.842397Z","shell.execute_reply":"2026-08-26T15:13:43.206865Z"}},"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-26T15:13:46.198853Z","iopub.execute_input":"2026-08-26T15:13:46.199733Z","iopub.status.idle":"2026-08-26T15:13:46.301862Z","shell.execute_reply.started":"2026-08-26T15:13:46.199694Z","shell.execute_reply":"2026-08-26T15:13:46.301178Z"}},"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-26T15:13:49.983189Z","iopub.execute_input":"2026-08-26T15:13:49.983948Z","iopub.status.idle":"2026-08-26T15:13:49.988735Z","shell.execute_reply.started":"2026-08-26T15:13:49.983919Z","shell.execute_reply":"2026-08-26T15:13:49.987848Z"}},"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-26T15:13:53.392628Z","iopub.execute_input":"2026-08-26T15:13:53.393242Z","iopub.status.idle":"2026-08-26T15:13:53.406002Z","shell.execute_reply.started":"2026-08-26T15:13:53.39321Z","shell.execute_reply":"2026-08-26T15:13:53.405314Z"}},"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-26T15:14:11.914492Z","iopub.execute_input":"2026-08-26T15:14:11.914899Z","iopub.status.idle":"2026-08-26T15:14:20.716051Z","shell.execute_reply.started":"2026-08-26T15:14:11.914871Z","shell.execute_reply":"2026-08-26T15:14:20.715186Z"}},"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-26T15:14:29.942991Z","iopub.execute_input":"2026-08-26T15:14:29.94328Z","iopub.status.idle":"2026-08-26T15:15:03.0793Z","shell.execute_reply.started":"2026-08-26T15:14:29.943253Z","shell.execute_reply":"2026-08-26T15:15:03.078385Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# CELL 29 — LXT + ZENNIT GAMMA LRP\n# CUSTOM ConvNeXt + PoolFormer 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\ndef _lxt_groupnorm1_forward(self, x):\n    \"\"\"\n    PoolFormer uses GroupNorm1 (timm custom class = nn.GroupNorm with\n    num_groups=1), which normalizes over (C, H, W) jointly per sample -\n    NOT used by ConvNeXt/MLP-Mixer, so this handler is NEW for the\n    PoolFormer variant. Same LXT treatment as LayerNorm/LayerNorm2d:\n    stop gradient through the standard deviation.\n\n    Numerically verified equivalent to F.group_norm(x, 1, weight, bias, eps)\n    to < 1e-6 abs error.\n    \"\"\"\n    if x.ndim != 4:\n        raise RuntimeError(\n            f\"GroupNorm1 expected NCHW, got {tuple(x.shape)}\"\n        )\n\n    num_channels = x.shape[1]\n\n    mean = x.mean(dim=[1, 2, 3], keepdim=True)\n    centered = x - mean\n    variance = centered.pow(2).mean(dim=[1, 2, 3], 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.view(1, num_channels, 1, 1)\n    if self.bias is not None:\n        y = y + self.bias.view(1, num_channels, 1, 1)\n\n    return y\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    groupnorm_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        # PoolFormer's GroupNorm1 (nn.GroupNorm subclass, num_groups=1).\n        # NOT present in ConvNeXt-MLP-Mixer - new for this variant.\n        if isinstance(module, nn.GroupNorm) or \"groupnorm\" in cls_lower:\n            module.forward = types.MethodType(\n                _lxt_groupnorm1_forward,\n                module,\n            )\n            groupnorm_count += 1\n            rows.append({\n                \"Module\": name,\n                \"Type\": cls_name,\n                \"Rule\": \"LXT normalization identity rule\",\n                \"Patch\": \"stop gradient through std (GroupNorm1)\",\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    print(\"LXT GroupNorm1 patches :\", groupnorm_count)\n\n    if activation_count == 0:\n        raise RuntimeError(\"No nonlinear activation was patched.\")\n    if layernorm_count + layernorm2d_count + groupnorm_count == 0:\n        raise RuntimeError(\"No normalization 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-26T15:15:08.578541Z","iopub.execute_input":"2026-08-26T15:15:08.579553Z","iopub.status.idle":"2026-08-26T15:15:14.81544Z","shell.execute_reply.started":"2026-08-26T15:15:08.579518Z","shell.execute_reply":"2026-08-26T15:15:14.814259Z"}},"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-26T15:15:25.900303Z","iopub.execute_input":"2026-08-26T15:15:25.901119Z","iopub.status.idle":"2026-08-26T15:16:02.424104Z","shell.execute_reply.started":"2026-08-26T15:15:25.901084Z","shell.execute_reply":"2026-08-26T15:16:02.423418Z"}},"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-26T15:16:07.919839Z","iopub.execute_input":"2026-08-26T15:16:07.920543Z","iopub.status.idle":"2026-08-26T15:16:07.927758Z","shell.execute_reply.started":"2026-08-26T15:16:07.920509Z","shell.execute_reply":"2026-08-26T15:16:07.926899Z"}},"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-26T15:16:14.02724Z","iopub.execute_input":"2026-08-26T15:16:14.027626Z","iopub.status.idle":"2026-08-26T15:16:14.034189Z","shell.execute_reply.started":"2026-08-26T15:16:14.027598Z","shell.execute_reply":"2026-08-26T15:16:14.03353Z"}},"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-26T15:16:20.459744Z","iopub.execute_input":"2026-08-26T15:16:20.460601Z","iopub.status.idle":"2026-08-26T15:16:32.504761Z","shell.execute_reply.started":"2026-08-26T15:16:20.460551Z","shell.execute_reply":"2026-08-26T15:16:32.503793Z"}},"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-26T15:16:38.98538Z","iopub.execute_input":"2026-08-26T15:16:38.985861Z","iopub.status.idle":"2026-08-26T15:16:51.374512Z","shell.execute_reply.started":"2026-08-26T15:16:38.985828Z","shell.execute_reply":"2026-08-26T15:16:51.373769Z"}},"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-PoolFormer 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        \"PoolFormer\":\n            POOLFORMER_MODEL,\n\n        \"PoolFormer_Pretrained\":\n            True,\n\n        \"Active_PoolFormer_Depth\":\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_PoolFormer_Depth\":\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(batch1_latency_ms),\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\n\n# Add repeated-run stability results to the final summary.\nfinal_summary[\"Stability\"] = {\n    \"Enabled\": RUN_STABILITY_ANALYSIS,\n    \"Seeds\": STABILITY_SEEDS,\n    \"Number_of_Runs\": len(STABILITY_SEEDS) if RUN_STABILITY_ANALYSIS else 0,\n    \"Results\": stability_summary_dict\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-PoolFormer\",\n\n    \"Learning_Rate\":\n        BEST_LR,\n\n    \"Dropout\":\n        BEST_DROPOUT,\n\n    \"PoolFormer_Depth\":\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}\nPoolFormer Depth   : {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-26T15:16:59.274095Z","iopub.execute_input":"2026-08-26T15:16:59.274732Z","iopub.status.idle":"2026-08-26T15:16:59.30901Z","shell.execute_reply.started":"2026-08-26T15:16:59.2747Z","shell.execute_reply":"2026-08-26T15:16:59.30812Z"}},"outputs":[],"execution_count":null}]}