{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":65542,"databundleVersionId":7217587}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"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\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","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-03-26T18:05:38.572565Z","iopub.execute_input":"2026-03-26T18:05:38.573345Z","iopub.status.idle":"2026-03-26T18:05:40.195637Z","shell.execute_reply.started":"2026-03-26T18:05:38.573313Z","shell.execute_reply":"2026-03-26T18:05:40.194993Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import kagglehub\nkagglehub.competition_download('congenital-heart-disease')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-26T18:05:04.370963Z","iopub.execute_input":"2026-03-26T18:05:04.371586Z","iopub.status.idle":"2026-03-26T18:05:12.349713Z","shell.execute_reply.started":"2026-03-26T18:05:04.371553Z","shell.execute_reply":"2026-03-26T18:05:12.349088Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\n\nprint(os.listdir(\"/kaggle/input/competitions\"))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-26T18:06:30.163364Z","iopub.execute_input":"2026-03-26T18:06:30.164197Z","iopub.status.idle":"2026-03-26T18:06:30.168259Z","shell.execute_reply.started":"2026-03-26T18:06:30.164163Z","shell.execute_reply":"2026-03-26T18:06:30.167497Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport shutil\nimport random\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\nimport cv2\nimport numpy as np\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nfrom sklearn.model_selection import train_test_split\nfrom tqdm import tqdm\nimport gc\n\n# ==========================================\n# 1. Dataset Splitter (Prevents Data Leakage)\n# ==========================================\ndef split_dataset(source_dir, train_dir, test_dir, split_ratio=0.85):\n    if os.path.exists(train_dir): shutil.rmtree(train_dir)\n    if os.path.exists(test_dir): shutil.rmtree(test_dir)\n        \n    os.makedirs(train_dir, exist_ok=True)\n    os.makedirs(test_dir, exist_ok=True)\n\n    classes = [\"ASD\", \"Normal\", \"VSD\"]\n    total_train, total_test = 0, 0\n\n    for cls in classes:\n        os.makedirs(os.path.join(train_dir, cls), exist_ok=True)\n        os.makedirs(os.path.join(test_dir, cls), exist_ok=True)\n\n        class_path = os.path.join(source_dir, cls)\n        if not os.path.isdir(class_path):\n            continue\n\n        images = os.listdir(class_path)\n        random.seed(42)  # Ensures the split is perfectly reproducible\n        random.shuffle(images)\n\n        split_idx = int(len(images) * split_ratio)\n        train_images = images[:split_idx]\n        test_images = images[split_idx:]\n\n        for img in train_images:\n            shutil.copy(os.path.join(class_path, img), os.path.join(train_dir, cls, img))\n            total_train += 1\n            \n        for img in test_images:\n            shutil.copy(os.path.join(class_path, img), os.path.join(test_dir, cls, img))\n            total_test += 1\n\n    print(\"=\" * 40)\n    print(f\"SPLIT COMPLETE! Train Images: {total_train} | Test Images: {total_test}\")\n    print(\"=\" * 40)\n\n# ==========================================\n# 2. Dataset Loader & Augmentations\n# ==========================================\nclass XrayDataset(Dataset):\n    def __init__(self, root_dir, transform=None):\n        self.root_dir = root_dir\n        self.transform = transform\n        self.classes = sorted([\"ASD\", \"Normal\", \"VSD\"])\n        self.image_paths = []\n        self.labels = []\n        for label, c in enumerate(self.classes):\n            folder = os.path.join(root_dir, c)\n            if not os.path.isdir(folder):\n                continue\n            for img_name in os.listdir(folder):\n                self.image_paths.append(os.path.join(folder, img_name))\n                self.labels.append(label)\n\n    def __len__(self):\n        return len(self.image_paths)\n\n    def __getitem__(self, idx):\n        img_path = self.image_paths[idx]\n        image = cv2.imread(img_path)\n        if image is None:\n            raise Exception(f\"Failed to load image: {img_path}\")\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        label = self.labels[idx]\n        if self.transform:\n            image = self.transform(image=image)['image']\n        return image, label\n\nIMG_SIZE = 384\n\ntrain_transform = A.Compose([\n    A.LongestMaxSize(max_size=IMG_SIZE),\n    A.PadIfNeeded(min_height=IMG_SIZE, min_width=IMG_SIZE, border_mode=cv2.BORDER_CONSTANT, fill=0),\n    A.CLAHE(clip_limit=4.0, tile_grid_size=(8, 8), p=1.0),\n    A.HorizontalFlip(p=0.5),\n    A.Rotate(limit=20, p=0.5),\n    A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.4),\n    A.GaussianBlur(blur_limit=(3, 7), p=0.3),\n    A.ImageCompression(quality_range=(50, 100), p=0.3),\n    A.CoarseDropout(num_holes_range=(1, 4), hole_height_range=(8, 16), hole_width_range=(8, 16), p=0.5),\n    A.ShiftScaleRotate(shift_limit=0.1, scale_limit=0.15, rotate_limit=15, p=0.4),\n    A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n    ToTensorV2()\n])\n\nval_transform = A.Compose([\n    A.LongestMaxSize(max_size=IMG_SIZE),\n    A.PadIfNeeded(min_height=IMG_SIZE, min_width=IMG_SIZE, border_mode=cv2.BORDER_CONSTANT, fill=0),\n    A.CLAHE(clip_limit=4.0, tile_grid_size=(8, 8), p=1.0),\n    A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n    ToTensorV2()\n])\n\nclass AugmentedSubset(Dataset):\n    def __init__(self, base_dataset, indices, transform=None):\n        self.base = base_dataset\n        self.indices = indices\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.indices)\n\n    def __getitem__(self, idx):\n        real_idx = self.indices[idx]\n        img_path = self.base.image_paths[real_idx]\n        label = self.base.labels[real_idx]\n        image = cv2.imread(img_path)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        if self.transform:\n            image = self.transform(image=image)['image']\n        return image, label\n\n# ==========================================\n# 3. Model Architecture (DenseNet + CBAM Base)\n# ==========================================\nclass FocalLoss(nn.Module):\n    def __init__(self, alpha=1, gamma=2, label_smoothing=0.1):\n        super(FocalLoss, self).__init__()\n        self.alpha = alpha\n        self.gamma = gamma\n        self.label_smoothing = label_smoothing\n\n    def forward(self, inputs, targets):\n        ce_loss = F.cross_entropy(inputs, targets, reduction='none', label_smoothing=self.label_smoothing)\n        pt = torch.exp(-ce_loss)\n        return (self.alpha * (1 - pt) ** self.gamma * ce_loss).mean()\n\nclass CBAM(nn.Module):\n    def __init__(self, channels):\n        super(CBAM, self).__init__()\n        self.avg_pool = nn.AdaptiveAvgPool2d(1)\n        self.max_pool = nn.AdaptiveMaxPool2d(1)\n        self.fc = nn.Sequential(\n            nn.Linear(channels, channels // 8), nn.ReLU(), nn.Linear(channels // 8, channels)\n        )\n        self.spatial = nn.Conv2d(2, 1, kernel_size=7, padding=3)\n\n    def forward(self, x):\n        b, c, h, w = x.size()\n        y_avg = self.avg_pool(x).view(b, c)\n        y_max = self.max_pool(x).view(b, c)\n        y = torch.sigmoid(self.fc(y_avg) + self.fc(y_max)).view(b, c, 1, 1)\n        x = x * y\n        s = torch.cat([torch.mean(x, dim=1, keepdim=True), torch.max(x, dim=1, keepdim=True)[0]], dim=1)\n        return x * torch.sigmoid(self.spatial(s))\n\nclass DenseNetWithCBAM(nn.Module):\n    def __init__(self, num_classes=3):\n        super(DenseNetWithCBAM, self).__init__()\n        self.densenet = models.densenet121(weights=models.DenseNet121_Weights.IMAGENET1K_V1)\n        num_ftrs = self.densenet.classifier.in_features\n        self.features = self.densenet.features\n        self.cbam = CBAM(channels=num_ftrs)\n        self.global_pool = nn.AdaptiveAvgPool2d(1)\n        self.classifier = nn.Sequential(\n            nn.Dropout(0.4), nn.Linear(num_ftrs, 256), nn.ReLU(),\n            nn.Dropout(0.2), nn.Linear(256, num_classes)\n        )\n\n    def forward(self, x):\n        x = self.features(x)\n        x = self.cbam(x)\n        x = self.global_pool(x)\n        return self.classifier(torch.flatten(x, 1))\n\n# ==========================================\n# 4. Training Engine\n# ==========================================\ndef evaluate(model, val_loader, criterion, device):\n    model.eval()\n    val_loss, val_correct, val_total = 0.0, 0, 0\n    with torch.no_grad():\n        for images, labels in val_loader:\n            images, labels = images.to(device), labels.to(device)\n            with torch.amp.autocast('cuda'):\n                outputs = model(images)\n                loss = criterion(outputs, labels)\n            val_loss += loss.item() * images.size(0)\n            val_total += labels.size(0)\n            val_correct += (torch.max(outputs, 1)[1] == labels).sum().item()\n    return val_correct / val_total, val_loss / val_total\n\ndef train_model(data_dir, epochs=40):\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    dataset = XrayDataset(root_dir=data_dir)\n    labels = np.array(dataset.labels)\n    \n    # SINGLE TRAIN/VAL SPLIT (REPLACING 5-FOLD)\n    train_idx, val_idx = train_test_split(\n        np.arange(len(labels)), test_size=0.2, stratify=labels, random_state=42\n    )\n    \n    batch_size = 16 \n\n    print(f\"\\n========================================\\nSINGLE FOLD TRAINING\\n========================================\")\n    train_ds = AugmentedSubset(dataset, train_idx, transform=train_transform)\n    val_ds = AugmentedSubset(dataset, val_idx, transform=val_transform)\n    train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=2, pin_memory=True)\n    val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=2, pin_memory=True)\n\n    model = DenseNetWithCBAM(num_classes=3).to(device)\n    criterion = FocalLoss(gamma=2.0, label_smoothing=0.1)\n    scaler = torch.amp.GradScaler('cuda')\n    best_val_acc = 0.0\n    model_save_path = \"best_model_dense_single.pth\"\n\n    # Phase 1: Training Head Only\n    print(\"--- Phase 1: Training Classifier Head ---\")\n    for param in model.features.parameters(): param.requires_grad = False\n    optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, weight_decay=1e-4)\n    \n    for epoch in range(5):\n        model.train()\n        for images, labels_batch in tqdm(train_loader, leave=False, desc=f\"Ph1 Ep[{epoch+1}/5]\"):\n            images, labels_batch = images.to(device), labels_batch.to(device)\n            optimizer.zero_grad()\n            with torch.amp.autocast('cuda'):\n                loss = criterion(model(images), labels_batch)\n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n            \n        t_acc, _ = evaluate(model, train_loader, criterion, device)\n        v_acc, v_loss = evaluate(model, val_loader, criterion, device)\n        print(f\"  Ph1 Ep[{epoch+1}/5] | Train Acc: {t_acc:.4f} | Val Acc: {v_acc:.4f} | Val Loss: {v_loss:.4f}\")\n        if v_acc >= best_val_acc:\n            best_val_acc = v_acc\n            torch.save(model.state_dict(), model_save_path)\n    \n    # Phase 2: Full Fine-tuning\n    print(\"--- Phase 2: Full Model Fine-tuning ---\")\n    for param in model.features.parameters(): param.requires_grad = True\n    optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4, weight_decay=1e-4)\n    scheduler = torch.optim.lr_scheduler.OneCycleLR(optimizer, max_lr=2e-4, total_steps=len(train_loader)*(epochs-5), pct_start=0.3)\n    model.load_state_dict(torch.load(model_save_path, weights_only=True))\n    patience_counter = 0\n\n    for epoch in range(epochs - 5):\n        model.train()\n        for images, labels_batch in tqdm(train_loader, leave=False, desc=f\"Ph2 Ep[{epoch+1}/{epochs-5}]\"):\n            images, labels_batch = images.to(device), labels_batch.to(device)\n            optimizer.zero_grad()\n            with torch.amp.autocast('cuda'):\n                loss = criterion(model(images), labels_batch)\n            scaler.scale(loss).backward()\n            scaler.unscale_(optimizer)\n            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)\n            scaler.step(optimizer)\n            scaler.update()\n            scheduler.step()\n            \n        t_acc, _ = evaluate(model, train_loader, criterion, device)\n        v_acc, v_loss = evaluate(model, val_loader, criterion, device)\n        print(f\"  Ph2 Ep[{epoch+1}/{epochs-5}] | Train: {t_acc:.4f} | Val: {v_acc:.4f} | VLoss: {v_loss:.4f}\")\n        \n        if v_acc > best_val_acc:\n            best_val_acc = v_acc\n            patience_counter = 0\n            torch.save(model.state_dict(), model_save_path)\n            print(f\"  >> New best accuracy: {best_val_acc:.4f}\")\n        else:\n            patience_counter += 1\n            if patience_counter >= 10: \n                print(\"Early stopping triggered.\")\n                break\n                \n    print(f\"\\nTraining Complete. Best Val Acc: {best_val_acc:.4f}\")\n    del model, optimizer, scheduler, scaler, train_loader, val_loader\n    gc.collect()\n    torch.cuda.empty_cache()\n\n# ==========================================\n# 5. Single Model Evaluation Engine (TTA)\n# ==========================================\ndef evaluate_single(data_dir, batch_size=16):\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Evaluating Single DenseNet with TTA (Blind Holdout Set)\")\n    print(\"=\" * 60)\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    \n    # Define TTA Transforms (Test Time Augmentation)\n    t1 = val_transform \n    \n    t2 = A.Compose([\n        A.LongestMaxSize(max_size=IMG_SIZE),\n        A.PadIfNeeded(min_height=IMG_SIZE, min_width=IMG_SIZE, border_mode=cv2.BORDER_CONSTANT, fill=0),\n        A.HorizontalFlip(p=1.0), \n        A.CLAHE(clip_limit=4.0, tile_grid_size=(8, 8), p=1.0),\n        A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n        ToTensorV2()\n    ])\n    \n    t3 = A.Compose([\n        A.LongestMaxSize(max_size=IMG_SIZE),\n        A.PadIfNeeded(min_height=IMG_SIZE, min_width=IMG_SIZE, border_mode=cv2.BORDER_CONSTANT, fill=0),\n        A.ShiftScaleRotate(shift_limit=0.0, scale_limit=0.0, rotate_limit=15, p=1.0), \n        A.CLAHE(clip_limit=4.0, tile_grid_size=(8, 8), p=1.0),\n        A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n        ToTensorV2()\n    ])\n    \n    tta_transforms = [t1, t2, t3]\n\n    class TTADataset(Dataset):\n        def __init__(self, root_dir):\n            self.base = XrayDataset(root_dir=root_dir, transform=None)\n            \n        def __len__(self): return len(self.base)\n        \n        def __getitem__(self, idx):\n            image, label = self.base[idx] \n            \n            img_t1 = tta_transforms[0](image=image)['image']\n            img_t2 = tta_transforms[1](image=image)['image']\n            img_t3 = tta_transforms[2](image=image)['image']\n            \n            return torch.stack([img_t1, img_t2, img_t3]), label\n\n    dataset = TTADataset(root_dir=data_dir)\n    loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=2)\n\n    model_path = \"best_model_dense_single.pth\"\n    if not os.path.exists(model_path):\n        print(f\"Error: {model_path} not found!\")\n        return\n\n    model = DenseNetWithCBAM(num_classes=3).to(device)\n    model.load_state_dict(torch.load(model_path, map_location=device, weights_only=True))\n    model.eval()\n\n    correct, total = 0, 0\n    with torch.no_grad():\n        for images_stack, labels in tqdm(loader, desc=\"Testing (TTA)\"):\n            labels = labels.to(device)\n            b_size = labels.size(0)\n            \n            # Predict using TTA\n            final_probs = torch.zeros((b_size, 3), device=device)\n            \n            for t_idx in range(3):\n                images = images_stack[:, t_idx, :, :, :].to(device)\n                with torch.amp.autocast('cuda'):\n                    outputs = model(images)\n                    probs = torch.softmax(outputs, dim=1)\n                    final_probs += probs\n                        \n            final_probs /= 3\n            \n            _, predicted = torch.max(final_probs, 1)\n            total += b_size\n            correct += (predicted == labels).sum().item()\n\n    final_acc = correct / total\n    print(\"\\n\" + \"=\" * 60)\n    print(f\"FINAL SINGLE MODEL TEST ACCURACY (WITH TTA): {final_acc:.4f} ({final_acc*100:.2f}%)\")\n    print(\"=\" * 60)\n\n# ==========================================\n# 6. Kaggle Master Execution Runner\n# ==========================================\nif __name__ == \"__main__\":\n    # Path confirmed by user screenshot\n    KAGGLE_INPUT = \"/kaggle/input/competitions/congenital-heart-disease\"\n    \n    # Try using kagglehub if the path doesn't exist (useful for notebooks)\n    if not os.path.exists(KAGGLE_INPUT):\n        try:\n            import kagglehub\n            print(\"KAGGLE_INPUT not found. Attempting to download via kagglehub...\")\n            KAGGLE_INPUT = kagglehub.competition_download('congenital-heart-disease')\n            print(f\"Downloaded via kagglehub: {KAGGLE_INPUT}\")\n        except (ImportError, Exception):\n            pass\n\n    TRAIN_DIR = \"./train_data\"\n    TEST_DIR = \"./test_data\"\n    \n    # Check if input directory exists, else use local data folders ASD, Normal, VSD if they exist\n    if not os.path.exists(KAGGLE_INPUT):\n        print(f\"Warning: {KAGGLE_INPUT} not found. Searching for local class folders...\")\n        # Fallback to current directory if ASD, Normal, VSD are here\n        if os.path.isdir(\"ASD\") and os.path.isdir(\"Normal\") and os.path.isdir(\"VSD\"):\n            KAGGLE_INPUT = \".\"\n            print(\"Using current directory as source.\")\n        else:\n            print(\"Error: Could not find source data.\")\n            exit()\n\n    print(\"--- 1. Splitting Data to Prevent Leakage ---\")\n    split_dataset(KAGGLE_INPUT, TRAIN_DIR, TEST_DIR, split_ratio=0.85)\n    \n    print(\"--- 2. Training Single DenseNet Model ---\")\n    train_model(data_dir=TRAIN_DIR, epochs=40)\n    \n    print(\"--- 3. Evaluating Single Model ---\")\n    evaluate_single(data_dir=TEST_DIR)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-26T18:07:41.07792Z","iopub.execute_input":"2026-03-26T18:07:41.078238Z","iopub.status.idle":"2026-03-26T18:14:38.551697Z","shell.execute_reply.started":"2026-03-26T18:07:41.078209Z","shell.execute_reply":"2026-03-26T18:14:38.550886Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==========================================\n# CELL: Full Metrics Report (Paste after training)\n# ==========================================\nimport numpy as np\nimport matplotlib.pyplot as plt\nfrom sklearn.metrics import (\n    classification_report, confusion_matrix, roc_auc_score,\n    roc_curve, auc, precision_recall_fscore_support\n)\nfrom itertools import cycle\n\ndef full_metrics_report(data_dir=\"./test_data\", batch_size=16):\n    print(\"\\n\" + \"=\" * 60)\n    print(\"FULL METRICS REPORT (DenseNet Ensemble + TTA)\")\n    print(\"=\" * 60)\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    \n    # TTA Transforms (same as training script)\n    t1 = val_transform\n    t2 = A.Compose([\n        A.LongestMaxSize(max_size=IMG_SIZE),\n        A.PadIfNeeded(min_height=IMG_SIZE, min_width=IMG_SIZE, border_mode=cv2.BORDER_CONSTANT, fill=0),\n        A.HorizontalFlip(p=1.0),\n        A.CLAHE(clip_limit=4.0, tile_grid_size=(8, 8), p=1.0),\n        A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n        ToTensorV2()\n    ])\n    t3 = A.Compose([\n        A.LongestMaxSize(max_size=IMG_SIZE),\n        A.PadIfNeeded(min_height=IMG_SIZE, min_width=IMG_SIZE, border_mode=cv2.BORDER_CONSTANT, fill=0),\n        A.ShiftScaleRotate(shift_limit=0.0, scale_limit=0.0, rotate_limit=15, p=1.0),\n        A.CLAHE(clip_limit=4.0, tile_grid_size=(8, 8), p=1.0),\n        A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n        ToTensorV2()\n    ])\n    tta_transforms = [t1, t2, t3]\n\n    class TTADataset(Dataset):\n        def __init__(self, root_dir):\n            self.base = XrayDataset(root_dir=root_dir, transform=None)\n        def __len__(self): return len(self.base)\n        def __getitem__(self, idx):\n            image, label = self.base[idx]\n            imgs = [t(image=image)['image'] for t in tta_transforms]\n            return torch.stack(imgs), label\n\n    dataset = TTADataset(root_dir=data_dir)\n    loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=2)\n\n    # Load models\n    models_list = []\n    for i in range(1, 6):\n        path = f\"best_model_dense_fold{i}.pth\"\n        if os.path.exists(path):\n            m = DenseNetWithCBAM(num_classes=3).to(device)\n            m.load_state_dict(torch.load(path, map_location=device, weights_only=True))\n            m.eval()\n            models_list.append(m)\n\n    all_probs = []\n    all_labels = []\n    all_preds = []\n\n    with torch.no_grad():\n        for images_stack, labels in tqdm(loader, desc=\"Computing Metrics\"):\n            labels_dev = labels.to(device)\n            b = labels_dev.size(0)\n            ensemble_probs = torch.zeros((b, 3), device=device)\n            \n            for t_idx in range(3):\n                images = images_stack[:, t_idx, :, :, :].to(device)\n                for model in models_list:\n                    with torch.amp.autocast('cuda'):\n                        ensemble_probs += torch.softmax(model(images), dim=1)\n            \n            ensemble_probs /= (len(models_list) * 3)\n            _, predicted = torch.max(ensemble_probs, 1)\n            \n            all_probs.append(ensemble_probs.cpu().numpy())\n            all_labels.append(labels.numpy())\n            all_preds.append(predicted.cpu().numpy())\n\n    all_probs = np.concatenate(all_probs)\n    all_labels = np.concatenate(all_labels)\n    all_preds = np.concatenate(all_preds)\n    \n    class_names = [\"ASD\", \"Normal\", \"VSD\"]\n    n_classes = 3\n\n    # --- 1. Classification Report ---\n    print(\"\\n📊 CLASSIFICATION REPORT:\")\n    print(classification_report(all_labels, all_preds, target_names=class_names, digits=4))\n\n    # --- 2. Per-Class Sensitivity & Specificity ---\n    print(\"📋 PER-CLASS SENSITIVITY & SPECIFICITY:\")\n    cm = confusion_matrix(all_labels, all_preds)\n    for i, cls in enumerate(class_names):\n        tp = cm[i, i]\n        fn = cm[i, :].sum() - tp\n        fp = cm[:, i].sum() - tp\n        tn = cm.sum() - tp - fn - fp\n        sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0\n        specificity = tn / (tn + fp) if (tn + fp) > 0 else 0\n        print(f\"  {cls}: Sensitivity={sensitivity:.4f}, Specificity={specificity:.4f}\")\n\n    # --- 3. AUROC ---\n    from sklearn.preprocessing import label_binarize\n    y_bin = label_binarize(all_labels, classes=[0, 1, 2])\n    \n    auroc_per_class = {}\n    for i, cls in enumerate(class_names):\n        auroc_per_class[cls] = roc_auc_score(y_bin[:, i], all_probs[:, i])\n    \n    macro_auroc = roc_auc_score(y_bin, all_probs, multi_class='ovr', average='macro')\n    \n    print(f\"\\n🎯 AUROC (per class):\")\n    for cls, val in auroc_per_class.items():\n        print(f\"  {cls}: {val:.4f}\")\n    print(f\"  Macro AUROC: {macro_auroc:.4f}\")\n\n    # --- 4. Confusion Matrix Plot ---\n    fig, ax = plt.subplots(figsize=(8, 6))\n    im = ax.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues)\n    ax.set_title(\"Confusion Matrix (DenseNet + TTA)\", fontsize=14, fontweight='bold')\n    plt.colorbar(im)\n    ax.set_xticks(range(n_classes)); ax.set_yticks(range(n_classes))\n    ax.set_xticklabels(class_names); ax.set_yticklabels(class_names)\n    ax.set_xlabel(\"Predicted\"); ax.set_ylabel(\"True\")\n    \n    for i in range(n_classes):\n        for j in range(n_classes):\n            color = \"white\" if cm[i, j] > cm.max() / 2 else \"black\"\n            ax.text(j, i, str(cm[i, j]), ha=\"center\", va=\"center\", color=color, fontsize=16)\n    \n    plt.tight_layout()\n    plt.savefig(\"/kaggle/working/confusion_matrix.png\", dpi=150)\n    plt.show()\n    print(\"✅ Confusion matrix saved to /kaggle/working/confusion_matrix.png\")\n\n    # --- 5. ROC Curve Plot ---\n    fig, ax = plt.subplots(figsize=(8, 6))\n    colors = ['#e74c3c', '#2ecc71', '#3498db']\n    \n    for i, (cls, color) in enumerate(zip(class_names, colors)):\n        fpr, tpr, _ = roc_curve(y_bin[:, i], all_probs[:, i])\n        roc_auc = auc(fpr, tpr)\n        ax.plot(fpr, tpr, color=color, lw=2, label=f\"{cls} (AUC = {roc_auc:.3f})\")\n    \n    ax.plot([0, 1], [0, 1], 'k--', lw=1)\n    ax.set_xlabel(\"False Positive Rate\"); ax.set_ylabel(\"True Positive Rate\")\n    ax.set_title(\"ROC Curves (DenseNet + TTA)\", fontsize=14, fontweight='bold')\n    ax.legend(loc=\"lower right\")\n    plt.tight_layout()\n    plt.savefig(\"/kaggle/working/roc_curves.png\", dpi=150)\n    plt.show()\n    print(\"✅ ROC curves saved to /kaggle/working/roc_curves.png\")\n\n    # --- Summary ---\n    accuracy = (all_preds == all_labels).mean()\n    print(\"\\n\" + \"=\" * 60)\n    print(\"FINAL SUMMARY\")\n    print(f\"  Accuracy:    {accuracy:.4f} ({accuracy*100:.2f}%)\")\n    print(f\"  Macro AUROC: {macro_auroc:.4f}\")\n    print(\"=\" * 60)\n\n# RUN IT\nfull_metrics_report(data_dir=\"./test_data\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-26T18:15:43.887053Z","iopub.execute_input":"2026-03-26T18:15:43.887863Z","iopub.status.idle":"2026-03-26T18:15:46.784848Z","shell.execute_reply.started":"2026-03-26T18:15:43.887826Z","shell.execute_reply":"2026-03-26T18:15:46.783862Z"}},"outputs":[],"execution_count":null}]}