{"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":"code","source":"# This Python 3 environment comes with many helpful analytics libraries installed\n# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python\n# For example, here's several helpful packages to load\n\nimport numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\n\n# Input data files are available in the read-only \"../input/\" directory\n# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory\n\nimport os\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        # print(os.path.join(dirname, filename))\n        pass\n\n# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using \"Save & Run All\" \n# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session\n\n# Use the kagglehub client library to attach Kaggle resources like competitions, datasets, and models to your session\n# Learn more about kagglehub: https://github.com/Kaggle/kagglehub/blob/main/README.md\n\nimport kagglehub\n# kagglehub.dataset_download('<owner>/<dataset-slug>')","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install -q iterative-stratification","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport cv2\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport torchvision.models as models\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms\nfrom torch.cuda.amp import GradScaler, autocast\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom PIL import Image\nfrom tqdm import tqdm\nfrom sklearn.preprocessing import MultiLabelBinarizer\nfrom iterstrat.ml_stratifiers import MultilabelStratifiedShuffleSplit\nfrom sklearn.metrics import (\n    roc_auc_score, \n    average_precision_score, \n    precision_score, \n    recall_score, \n    f1_score, \n    accuracy_score\n)\nimport torch\nimport torch.nn as nn\nfrom torch.cuda.amp import GradScaler, autocast\nfrom torch.optim.lr_scheduler import CosineAnnealingLR\n\n# --- Configuration ---\nEPOCHS = 15\nBATCH_SIZE = 16 \nPATIENCE = 3\nLEARNING_RATE = 1e-4\nWEIGHT_DECAY = 1e-5\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nIMAGE_DIR = \"/kaggle/input/competitions/plant-pathology-2021-fgvc8/train_images\"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================================\n# 2. DATA LOADING & MULTI-HOT ENCODING\n# ============================================================================\ndf = pd.read_csv('/kaggle/input/competitions/plant-pathology-2021-fgvc8/train.csv')\n\n# Split the string labels into lists\ndf['label_list'] = df['labels'].apply(lambda x: x.split(' '))\n\n# Multi-hot encode the labels (Generates the 'classes' list we need later)\nmlb = MultiLabelBinarizer()\nencoded_labels = mlb.fit_transform(df['label_list'])\nclasses = list(mlb.classes_)\n\n# Combine original df with the new binary columns\nencoded_df = pd.DataFrame(encoded_labels, columns=classes)\ndf = pd.concat([df, encoded_df], axis=1)\n\nprint(f\"[*] Total unique classes identified: {classes}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================================\n# 3. TRANSFORMS & DATASET CLASS\n# ============================================================================\ntrain_transforms = transforms.Compose([\n    transforms.Resize((512, 512)), \n    transforms.RandomHorizontalFlip(p=0.5), \n    transforms.RandomVerticalFlip(p=0.5),\n    transforms.ToTensor(), \n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\nval_test_transforms = transforms.Compose([\n    transforms.Resize((512, 512)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\nclass PlantPathologyDataset(Dataset):\n    def __init__(self, dataframe, image_dir, classes, transform=None):\n        self.dataframe = dataframe\n        self.image_dir = image_dir\n        self.classes = classes\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.dataframe)\n\n    def apply_clahe(self, image_path):\n        img = cv2.imread(image_path)\n        if img is None: raise FileNotFoundError(f\"Cannot load image at {image_path}\")\n        lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)\n        l, a, b = cv2.split(lab)\n        clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n        cl = clahe.apply(l)\n        limg = cv2.merge((cl,a,b))\n        rgb_img = cv2.cvtColor(limg, cv2.COLOR_LAB2RGB)\n        return Image.fromarray(rgb_img)\n\n    def __getitem__(self, idx):\n        img_name = self.dataframe.iloc[idx]['image']\n        img_path = os.path.join(self.image_dir, img_name)\n        image = self.apply_clahe(img_path)\n        labels = self.dataframe.iloc[idx][self.classes].values.astype(np.float32)\n        label_tensor = torch.tensor(labels)\n        if self.transform: image = self.transform(image)\n        return image, label_tensor","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================================\n# 4. STRATIFIED SPLIT & DATALOADERS\n# ============================================================================\nY = df[classes].values\n\n# 80% Train / 20% Temp\nmsss1 = MultilabelStratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42)\ntrain_idx, temp_idx = next(msss1.split(df, Y))\ntrain_df = df.iloc[train_idx].reset_index(drop=True)\ntemp_df = df.iloc[temp_idx].reset_index(drop=True)\n\n# 10% Validation / 10% Test\nY_temp = temp_df[classes].values\nmsss2 = MultilabelStratifiedShuffleSplit(n_splits=1, test_size=0.5, random_state=42)\nval_idx, test_idx = next(msss2.split(temp_df, Y_temp))\nval_df = temp_df.iloc[val_idx].reset_index(drop=True)\ntest_df = temp_df.iloc[test_idx].reset_index(drop=True)\n\nprint(f\"[*] Data Split -> Train: {len(train_df)} | Val: {len(val_df)} | Test: {len(test_df)}\")\n\ntrain_dataset = PlantPathologyDataset(train_df, IMAGE_DIR, classes, train_transforms)\nval_dataset = PlantPathologyDataset(val_df, IMAGE_DIR, classes, val_test_transforms)\ntest_dataset = PlantPathologyDataset(test_df, IMAGE_DIR, classes, val_test_transforms)\n\ntrain_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=4)\nval_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=4)\ntest_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=4)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================================\n# 5. EMPIRICAL CO-OCCURRENCE MATRIX\n# ============================================================================\ndef compute_empirical_cooccurrence(labels: np.ndarray, epsilon: float = 1e-6) -> np.ndarray:\n    sum_j = labels.sum(axis=0)\n    sum_j_safe = np.where(sum_j == 0, 1, sum_j)\n    cooc_counts = np.matmul(labels.T, labels)\n    M = cooc_counts / sum_j_safe[np.newaxis, :]\n    return M + epsilon\n\ndisease_classes = [c for c in classes if c != 'healthy']\nreal_train_disease_labels = train_df[disease_classes].values\nM_empirical = compute_empirical_cooccurrence(real_train_disease_labels, epsilon=1e-6)\nprint(f\"[*] Real Empirical M calculated. Shape: {M_empirical.shape}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================================\n# 6. ARCHITECTURE: C2A GRAPH & BIFURCATED MODEL\n# ============================================================================\nclass C2AClinicalPriorGraph(nn.Module):\n    def __init__(self, M_numpy: np.ndarray, feature_dim: int):\n        super().__init__()\n        self.C, self.D = M_numpy.shape[0], feature_dim\n        self.theta = nn.Parameter(torch.tensor(np.log(M_numpy), dtype=torch.float32))\n        self.Wg = nn.Linear(self.D, self.D, bias=False)\n        \n    def forward(self, z: torch.Tensor) -> torch.Tensor:\n        A = torch.softmax(self.theta, dim=1) \n        z_proj = self.Wg(z) \n        messages = torch.matmul(A, z_proj)\n        return z + messages\n\nclass C2AModel(nn.Module):\n    def __init__(self, M_numpy: np.ndarray):\n        super().__init__()\n        self.feature_dim = 1024 \n        self.num_diseases = 5\n        \n        self.backbone = models.densenet121(weights=models.DenseNet121_Weights.IMAGENET1K_V1).features\n        self.gcg_pool = nn.AdaptiveAvgPool2d(1)\n        self.gcg_bottleneck = nn.Sequential(\n            nn.Conv2d(self.feature_dim, self.feature_dim // 16, kernel_size=1, bias=False),\n            nn.ReLU(inplace=True),\n            nn.Conv2d(self.feature_dim // 16, self.feature_dim, kernel_size=1, bias=False),\n            nn.Sigmoid()\n        )\n        \n        self.Wa = nn.Conv2d(self.feature_dim, self.num_diseases, kernel_size=1)\n        self.clinical_graph = C2AClinicalPriorGraph(M_numpy, self.feature_dim)\n        self.disease_classifiers = nn.Parameter(torch.Tensor(self.num_diseases, self.feature_dim))\n        self.disease_bias = nn.Parameter(torch.zeros(self.num_diseases))\n        nn.init.orthogonal_(self.disease_classifiers)\n        nn.init.orthogonal_(self.Wa.weight)\n        \n        self.healthy_pool = nn.AdaptiveAvgPool2d(1)\n        self.healthy_classifier = nn.Linear(self.feature_dim, 1)\n\n    def forward(self, x):\n        B = x.size(0)\n        \n        F_raw = F.relu(self.backbone(x), inplace=True)\n        g = self.gcg_pool(F_raw)\n        gate = self.gcg_bottleneck(g)\n        F_tilde = F_raw * gate \n        \n        _, D, H, W = F_tilde.shape\n        attn_logits = self.Wa(F_tilde).view(B, self.num_diseases, H * W)\n        attn_weights = F.softmax(attn_logits, dim=-1)\n        \n        F_flat = F_tilde.view(B, D, H * W).transpose(1, 2)\n        Z = torch.bmm(attn_weights, F_flat) \n        \n        Z_prime = self.clinical_graph(Z) \n        disease_logits = torch.sum(Z_prime * self.disease_classifiers.unsqueeze(0), dim=-1) + self.disease_bias\n        \n        F_healthy = self.healthy_pool(F_tilde).view(B, D)\n        healthy_logit = self.healthy_classifier(F_healthy)\n        \n        final_logits = torch.cat([\n            disease_logits[:, :2],  \n            healthy_logit,          \n            disease_logits[:, 2:]   \n        ], dim=1)\n        \n        return final_logits, attn_weights.view(B, self.num_diseases, H, W)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================================\n# 7. TRAINING & EVALUATION FUNCTIONS (WITH CUTMIX)\n# ============================================================================\n\ndef rand_bbox(size, lam):\n    \"\"\"Generates a random bounding box for CutMix.\"\"\"\n    W = size[2]\n    H = size[3]\n    cut_rat = np.sqrt(1. - lam)\n    cut_w = int(W * cut_rat)\n    cut_h = int(H * cut_rat)\n\n    # Uniform random coordinates for the center of the box\n    cx = np.random.randint(W)\n    cy = np.random.randint(H)\n\n    bbx1 = np.clip(cx - cut_w // 2, 0, W)\n    bby1 = np.clip(cy - cut_h // 2, 0, H)\n    bbx2 = np.clip(cx + cut_w // 2, 0, W)\n    bby2 = np.clip(cy + cut_h // 2, 0, H)\n\n    return bbx1, bby1, bbx2, bby2\n\ndef train_one_epoch_amp(model, dataloader, optimizer, criterion, scaler, device, cutmix_prob=0.5):\n    model.train()\n    running_loss = 0.0\n    pbar = tqdm(dataloader, desc=\"Training (with CutMix)\")\n    \n    for images, labels in pbar:\n        images, labels = images.to(device), labels.to(device)\n        optimizer.zero_grad()\n        \n        # --- CUTMIX LOGIC ---\n        r = np.random.rand(1)\n        if r < cutmix_prob:\n            # Generate a random blend factor lambda from a Beta distribution\n            lam = np.random.beta(1.0, 1.0)\n            \n            # Shuffle the batch to get pairs of images\n            rand_index = torch.randperm(images.size(0)).to(device)\n            labels_a = labels\n            labels_b = labels[rand_index]\n            \n            # Get the random bounding box\n            bbx1, bby1, bbx2, bby2 = rand_bbox(images.size(), lam)\n            \n            # Paste the patch from the shuffled image onto the original image\n            images[:, :, bbx1:bbx2, bby1:bby2] = images[rand_index, :, bbx1:bbx2, bby1:bby2]\n            \n            # Adjust lambda to match the exact pixel area of the patch\n            lam = 1 - ((bbx2 - bbx1) * (bby2 - bby1) / (images.size()[-1] * images.size()[-2]))\n            \n            # Blend the multi-hot labels mathematically\n            mixed_labels = lam * labels_a + (1 - lam) * labels_b\n            \n            # Forward pass with mixed labels\n            with torch.amp.autocast('cuda'):\n                logits, _ = model(images)\n                loss = criterion(logits, mixed_labels)\n        else:\n            # --- STANDARD LOGIC (50% of the time) ---\n            with torch.amp.autocast('cuda'):\n                logits, _ = model(images)\n                loss = criterion(logits, labels)\n        \n        # Backward Pass\n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n        \n        running_loss += loss.item()\n        pbar.set_postfix({'loss': loss.item()})\n        \n    return running_loss / len(dataloader)\n\n@torch.no_grad()\ndef evaluate(model, dataloader, device, threshold=0.5):\n    model.eval()\n    all_preds, all_labels = [], []\n    \n    pbar = tqdm(dataloader, desc=\"Evaluating\")\n    for images, labels in pbar:\n        images = images.to(device)\n        logits, _ = model(images)\n        probs = torch.sigmoid(logits).cpu().numpy()\n        all_preds.append(probs)\n        all_labels.append(labels.numpy())\n        \n    all_preds, all_labels = np.vstack(all_preds), np.vstack(all_labels)\n    \n    try:\n        macro_auroc = roc_auc_score(all_labels, all_preds, average='macro')\n        macro_map = average_precision_score(all_labels, all_preds, average='macro')\n    except ValueError:\n        macro_auroc, macro_map = 0.0, 0.0 \n        \n    binary_preds = (all_preds > threshold).astype(int)\n    macro_precision = precision_score(all_labels, binary_preds, average='macro', zero_division=0)\n    macro_recall = recall_score(all_labels, binary_preds, average='macro', zero_division=0)\n    macro_f1 = f1_score(all_labels, binary_preds, average='macro', zero_division=0)\n    exact_match_acc = accuracy_score(all_labels, binary_preds)\n        \n    return macro_auroc, macro_map, macro_precision, macro_recall, macro_f1, exact_match_acc","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================================\n# ASYMMETRIC LOSS (ASL) & AMP SETUP\n# ============================================================================\nimport torch\nimport torch.nn as nn\nfrom torch.cuda.amp import GradScaler, autocast\n\n# --- 1. Asymmetric Loss Definition ---\nclass AsymmetricLoss(nn.Module):\n    def __init__(self, gamma_neg=4, gamma_pos=1, clip=0.05, eps=1e-8):\n        super(AsymmetricLoss, self).__init__()\n        self.gamma_neg = gamma_neg\n        self.gamma_pos = gamma_pos\n        self.clip = clip\n        self.eps = eps\n\n    def forward(self, x, y):\n        # Calculate probabilities\n        x_sigmoid = torch.sigmoid(x)\n        xs_pos = x_sigmoid\n        xs_neg = 1 - x_sigmoid\n\n        # Asymmetric Clipping (Hard-discards easy negatives)\n        if self.clip is not None and self.clip > 0:\n            xs_neg = (xs_neg + self.clip).clamp(max=1)\n\n        # Basic Cross Entropy\n        los_pos = y * torch.log(xs_pos.clamp(min=self.eps))\n        los_neg = (1 - y) * torch.log(xs_neg.clamp(min=self.eps))\n        loss = los_pos + los_neg\n\n        # Asymmetric Focusing\n        pt0 = xs_pos * y\n        pt1 = xs_neg * (1 - y)  \n        pt = pt0 + pt1\n        \n        # Apply the gammas\n        one_sided_gamma = self.gamma_pos * y + self.gamma_neg * (1 - y)\n        one_sided_w = torch.pow((1 - pt).clamp(min=self.eps), one_sided_gamma)\n\n        loss *= one_sided_w\n        \n        # Sum over classes, mean over batch\n        return -loss.sum(dim=-1).mean()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import shutil\nimport os\n\n# ⚠️ REPLACE 'your-dataset-name' with the actual folder name in your Kaggle input\ninput_path = '/kaggle/input/datasets/nabinkoirala452/pt-pathology-v11-model/c2a_best_model.pth'\nworking_path = '/kaggle/working/c2a_best_model.pth'\n\nif os.path.exists(input_path):\n    shutil.copy(input_path, working_path)\n    print(\"[*] Model successfully copied to working directory!\")\nelse:\n    print(\"[!] Error: Check your input folder name.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================================\n# 8. EXECUTION PIPELINE\n# ============================================================================\n# --- 3. Load the pre-trained weights BEFORE DataParallel ---\nmodel = C2AModel(M_numpy=M_empirical)\nif os.path.exists(working_path):\n    pretrained_weights = torch.load(working_path, map_location=DEVICE)\n    # Handle DataParallel prefix if it was saved with it\n    new_state_dict = {k[7:] if k.startswith('module.') else k: v for k, v in pretrained_weights.items()}\n    model.load_state_dict(new_state_dict)\n    print(\"[*] Successfully loaded pre-trained weights into model!\")\n\n# --- 4. Setup Dual GPUs & Optimizer ---\nif torch.cuda.device_count() > 1:\n    print(f\"[*] Firing up {torch.cuda.device_count()} GPUs!\")\n    model = nn.DataParallel(model)\n\nmodel = model.to(DEVICE)\ncriterion = AsymmetricLoss(gamma_neg=4, gamma_pos=1, clip=0.05)\noptimizer = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE, weight_decay=WEIGHT_DECAY)\nscaler = GradScaler()\n\nbest_auroc = 0.9909\npatience_counter = 0\nEPOCHS=5\n\nfor epoch in range(EPOCHS):\n    print(f\"\\n--- Epoch {epoch+1}/{EPOCHS} ---\")\n    \n    train_loss = train_one_epoch_amp(model, train_loader, optimizer, criterion, scaler, DEVICE)\n    val_auroc, val_map, val_prec, val_rec, val_f1, val_acc = evaluate(model, val_loader, DEVICE)\n    \n    print(f\"Train Loss: {train_loss:.4f}\")\n    print(f\"Val AUROC: {val_auroc:.4f} | Val mAP: {val_map:.4f}\")\n    print(f\"Val F1: {val_f1:.4f} | Val Prec: {val_prec:.4f} | Val Rec: {val_rec:.4f} | Val Acc: {val_acc:.4f}\")\n    \n    # --- Early Stopping & Model Saving Logic ---\n    if val_auroc > best_auroc:\n        best_auroc = val_auroc\n        patience_counter = 0 # Reset patience when a new best is found\n        \n        model_to_save = model.module.state_dict() if isinstance(model, nn.DataParallel) else model.state_dict()\n        torch.save(model_to_save, 'c2a_best_model.pth')\n        print(f\"[*] 🟢 New best model saved with AUROC: {best_auroc:.4f}\")\n    else:\n        patience_counter += 1\n        print(f\"[*] 🟡 No improvement. Early Stopping Counter: {patience_counter}/{PATIENCE}\")\n        \n        if patience_counter >= PATIENCE:\n            print(f\"\\n[!] 🛑 Early stopping triggered at Epoch {epoch+1}!\")\n            break","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================================\n# FINAL RESEARCH INFERENCE: STANDARD VS DYNAMIC THRESHOLDING\n# ============================================================================\nprint(\"\\n\" + \"=\"*60)\nprint(\"🚀 RECOVERING EPOCH 15 MODEL FOR FINAL BENCHMARKING 🚀\")\nprint(\"=\"*60)\n\n# 1. Initialize and Load Model\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nmodel = C2AModel(M_numpy=M_empirical)\n\nif torch.cuda.device_count() > 1:\n    model = nn.DataParallel(model)\nmodel = model.to(DEVICE)\n\nbest_weights = torch.load('c2a_best_model.pth', map_location=DEVICE)\nif isinstance(model, nn.DataParallel):\n    model.module.load_state_dict(best_weights)\nelse:\n    new_state_dict = {k[7:] if k.startswith('module.') else k: v for k, v in best_weights.items()}\n    model.load_state_dict(new_state_dict)\n\nprint(\"[*] Successfully loaded c2a_best_model.pth weights!\\n\")\n\n# 2. Dynamic Thresholding Functions\n@torch.no_grad()\ndef find_optimal_thresholds(model, dataloader, device, classes):\n    model.eval()\n    all_preds, all_labels = [], []\n    for images, labels in tqdm(dataloader, desc=\"Sweeping Validation Thresholds\"):\n        logits, _ = model(images.to(device))\n        all_preds.append(torch.sigmoid(logits).cpu().numpy())\n        all_labels.append(labels.numpy())\n    all_preds, all_labels = np.vstack(all_preds), np.vstack(all_labels)\n    \n    best_thresholds = []\n    print(\"\\n--- Optimal Class Thresholds Discovered ---\")\n    for c in range(len(classes)):\n        best_f1, best_t = 0, 0.5\n        for t in np.arange(0.1, 0.9, 0.05):\n            preds = (all_preds[:, c] > t).astype(int)\n            f1 = f1_score(all_labels[:, c], preds, zero_division=0)\n            if f1 > best_f1: best_f1, best_t = f1, t\n        best_thresholds.append(best_t)\n        print(f\"  -> {classes[c].ljust(20)} | Threshold: {best_t:.2f}\")\n    return best_thresholds\n\n@torch.no_grad()\ndef evaluate_custom_thresholds(model, dataloader, device, thresholds=None):\n    model.eval()\n    all_preds, all_labels = [], []\n    for images, labels in tqdm(dataloader, desc=\"Evaluating Test Set\"):\n        logits, _ = model(images.to(device))\n        all_preds.append(torch.sigmoid(logits).cpu().numpy())\n        all_labels.append(labels.numpy())\n    all_preds, all_labels = np.vstack(all_preds), np.vstack(all_labels)\n    \n    binary_preds = np.zeros_like(all_preds)\n    for c in range(len(thresholds if thresholds else [0.5]*all_preds.shape[1])):\n        t = thresholds[c] if thresholds else 0.5\n        binary_preds[:, c] = (all_preds[:, c] > t).astype(int)\n    \n    mac_prec = precision_score(all_labels, binary_preds, average='macro', zero_division=0)\n    mac_rec = recall_score(all_labels, binary_preds, average='macro', zero_division=0)\n    mac_f1 = f1_score(all_labels, binary_preds, average='macro', zero_division=0)\n    exact_acc = accuracy_score(all_labels, binary_preds)\n    mac_auroc = roc_auc_score(all_labels, all_preds, average='macro')\n    mac_map = average_precision_score(all_labels, all_preds, average='macro')\n    \n    return mac_auroc, mac_map, mac_prec, mac_rec, mac_f1, exact_acc\n\n# ============================================================================\n# 3. EXECUTE BOTH EVALUATIONS\n# ============================================================================\n\n# RUN A: Standard 0.5 Threshold (WITHOUT Dynamic Thresholding)\nprint(\"Evaluating WITHOUT Dynamic Thresholding (Standard 0.5 Cutoff)...\")\nstd_auroc, std_map, std_prec, std_rec, std_f1, std_acc = evaluate_custom_thresholds(model, test_loader, DEVICE, thresholds=None)\n\n# RUN B: Dynamic Thresholding\noptimal_thresh = find_optimal_thresholds(model, val_loader, DEVICE, classes)\ndyn_auroc, dyn_map, dyn_prec, dyn_rec, dyn_f1, dyn_acc = evaluate_custom_thresholds(model, test_loader, DEVICE, thresholds=optimal_thresh)\n\n# ============================================================================\n# 4. FINAL SPREADSHEET OUTPUT\n# ============================================================================\nprint(\"\\n\" + \"=\"*60)\nprint(\"📊 FINAL ABLATION METRICS FOR SPREADSHEET 📊\")\nprint(\"=\"*60)\nprint(f\"METRIC           | WITHOUT DYNAMIC (0.5) | WITH DYNAMIC SWEEP\")\nprint(\"-\" * 60)\nprint(f\"Macro AUROC      | {std_auroc:.4f}                | {dyn_auroc:.4f}  (Unchanged)\")\nprint(f\"mAP              | {std_map:.4f}                | {dyn_map:.4f}  (Unchanged)\")\nprint(f\"Macro F1-Score   | {std_f1:.4f}                | {dyn_f1:.4f}\")\nprint(f\"Macro Precision  | {std_prec:.4f}                | {dyn_prec:.4f}\")\nprint(f\"Macro Recall     | {std_rec:.4f}                | {dyn_rec:.4f}\")\nprint(f\"Exact Match Acc  | {std_acc:.4f}                | {dyn_acc:.4f}\")\nprint(\"=\"*60)","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}