{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","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":24800,"datasetId":1042002,"databundleVersionId":1831594}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# 🫁 Chest X-ray Abnormalities Detection — VinBigData\n**Bài tập lớn: Huấn luyện và so sánh mô hình nhận dạng bệnh X-quang ngực**\n\n| Mục | Chi tiết |\n|-----|----------|\n| Dataset | [VinBigData Chest X-ray](https://www.kaggle.com/competitions/vinbigdata-chest-xray-abnormalities-detection) |\n| Task | Multi-label classification (14 loại bất thường) |\n| Models | DenseNet-121, ResNet-50, EfficientNet-B0 + Ensemble |\n| Metrics | AUC-ROC (per-class + macro), F1, Sensitivity, Specificity |\n","metadata":{}},{"cell_type":"markdown","source":"## 1. Cài đặt dependencies","metadata":{}},{"cell_type":"code","source":"!pip install -q timm pydicom grad-cam","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T17:39:14.96866Z","iopub.execute_input":"2026-05-12T17:39:14.969376Z","iopub.status.idle":"2026-05-12T17:39:19.02351Z","shell.execute_reply.started":"2026-05-12T17:39:14.969336Z","shell.execute_reply":"2026-05-12T17:39:19.022565Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Imports & Cấu hình","metadata":{}},{"cell_type":"code","source":"import gc\nimport json\nimport os\nimport random\nimport time\nimport warnings\nfrom pathlib import Path\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport pydicom\nimport timm\nimport torch\nimport torch.nn as nn\nfrom IPython.display import display  \nfrom PIL import Image\nfrom sklearn.metrics import f1_score, roc_auc_score\nfrom sklearn.model_selection import train_test_split\nfrom torch.utils.data import DataLoader, Dataset\nfrom torchvision import transforms\nfrom tqdm.auto import tqdm\nimport seaborn as sns\n\nwarnings.filterwarnings('ignore')\n\n# ── Seed ────────────────────────────────────────────────────────────────────\nSEED = 42\ndef seed_everything(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.benchmark = True\nseed_everything(SEED)\n\n# ── Paths ───────────────────────────────────────────────────────────────────\nDATA_ROOT  = Path('/kaggle/input/competitions/vinbigdata-chest-xray-abnormalities-detection')\nCSV_PATH   = DATA_ROOT / 'train.csv'\nIMAGE_ROOT = DATA_ROOT / 'train'\nOUT_DIR    = Path('/kaggle/working/results')\nOUT_DIR.mkdir(parents=True, exist_ok=True)\n\n# ── Hyperparameters (tối ưu RAM + tốc độ) ───────────────────────────────────\nMODELS       = ['densenet121', 'resnet50', 'efficientnet_b0']\nEPOCHS       = 8\nIMAGE_SIZE   = 224\nBATCH_SIZE   = 16           # ↓ từ 32→16: giảm ~50% VRAM/RAM mỗi batch\nVAL_SIZE     = 0.20\nNUM_WORKERS  = 2\nLR           = 1e-4\nWEIGHT_DECAY = 1e-4\nMAX_POS_WEIGHT = 12.0\nPATIENCE     = 3\n# CACHE_IMAGES: tắt ProcessPoolExecutor pool, dùng sequential cache để kiểm soát RAM\nCACHE_IMAGES  = True\nCACHE_SIZE    = IMAGE_SIZE\nCACHE_DIR     = Path(f'/kaggle/working/vinbig_cache_{CACHE_SIZE}')\n\n# ── Device ──────────────────────────────────────────────────────────────────\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint('Device:', device)\nif torch.cuda.is_available():\n    for i in range(torch.cuda.device_count()):\n        print(f'  GPU {i}:', torch.cuda.get_device_name(i))\n\nprint('CSV:', CSV_PATH, '| exists:', CSV_PATH.exists())\nprint('Images:', IMAGE_ROOT, '| exists:', IMAGE_ROOT.exists())\nprint('Output:', OUT_DIR)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T17:39:19.025679Z","iopub.execute_input":"2026-05-12T17:39:19.025997Z","iopub.status.idle":"2026-05-12T17:39:40.604936Z","shell.execute_reply.started":"2026-05-12T17:39:19.025953Z","shell.execute_reply":"2026-05-12T17:39:40.603831Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. Đọc nhãn & tạo multi-label DataFrame","metadata":{}},{"cell_type":"code","source":"raw = pd.read_csv(CSV_PATH)\ndisplay(raw.head())\nprint('Shape:', raw.shape)\nprint('Columns:', raw.columns.tolist())\n\n# class_id 14 = 'No finding' → chỉ giữ 14 loại bất thường (0–13)\nabnormal = raw[raw['class_id'] != 14].copy()\nCLASSES = (\n    abnormal[['class_id', 'class_name']]\n    .drop_duplicates()\n    .sort_values('class_id')['class_name']\n    .tolist()\n)\nprint(f'\\nSố class bất thường: {len(CLASSES)}')\nprint(CLASSES)\n\n# Tạo image-level multi-label frame\nimage_ids = sorted(raw['image_id'].astype(str).unique())\ndf = pd.DataFrame({'image_id': image_ids})\nfor c in CLASSES:\n    df[c] = 0\n\npairs = abnormal[['image_id', 'class_name']].drop_duplicates()\nfor image_id, class_name in pairs.itertuples(index=False):\n    df.loc[df['image_id'] == image_id, class_name] = 1\n\n# Kiểm tra file tồn tại\ndf['path'] = df['image_id'].map(lambda x: str(IMAGE_ROOT / f'{x}.dicom'))\ndf = df[df['path'].map(lambda p: Path(p).exists())].reset_index(drop=True)\ndf['any_positive'] = (df[CLASSES].sum(axis=1) > 0).astype(int)\n\nprint(f'\\nTổng ảnh: {len(df)}')\nprint(f'Ảnh có bất thường: {int(df[\"any_positive\"].sum())}')\nprint(f'Ảnh bình thường: {int((df[\"any_positive\"]==0).sum())}')\ndisplay(df.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T17:39:40.606116Z","iopub.execute_input":"2026-05-12T17:39:40.607106Z","iopub.status.idle":"2026-05-12T17:40:53.705366Z","shell.execute_reply.started":"2026-05-12T17:39:40.607077Z","shell.execute_reply":"2026-05-12T17:40:53.704612Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. EDA — Phân bố nhãn","metadata":{}},{"cell_type":"code","source":"label_counts = df[CLASSES].sum().sort_values(ascending=False)\ndisplay(label_counts.to_frame('positive_count'))\n\nfig, axes = plt.subplots(1, 2, figsize=(16, 5))\n\n# Bar chart phân bố nhãn\nlabel_counts.sort_values().plot(kind='barh', ax=axes[0], color='steelblue')\naxes[0].set_xlabel('Số ảnh dương tính')\naxes[0].set_title('Phân bố nhãn (label distribution)')\n\n# Pie chart normal vs abnormal\ncounts = [int((df['any_positive']==0).sum()), int(df['any_positive'].sum())]\naxes[1].pie(counts, labels=['Bình thường', 'Bất thường'],\n            autopct='%1.1f%%', colors=['#2ecc71','#e74c3c'], startangle=90)\naxes[1].set_title('Tỉ lệ bình thường / bất thường')\n\nplt.tight_layout()\nplt.savefig(OUT_DIR / 'label_distribution.png', dpi=160)\nplt.show()\nprint('Saved: label_distribution.png')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T17:40:53.707279Z","iopub.execute_input":"2026-05-12T17:40:53.707639Z","iopub.status.idle":"2026-05-12T17:40:54.512793Z","shell.execute_reply.started":"2026-05-12T17:40:53.707613Z","shell.execute_reply":"2026-05-12T17:40:54.512022Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. Đọc DICOM & Cache ảnh thành JPEG","metadata":{}},{"cell_type":"code","source":"# ── Hàm đọc ảnh ─────────────────────────────────────────────────────────────\ndef read_dicom_rgb(path, target_size=None):\n    \"\"\"Đọc DICOM → PIL Image RGB, chuẩn hoá histogram.\"\"\"\n    ds  = pydicom.dcmread(path)\n    arr = ds.pixel_array.astype(np.float32)\n    slope     = float(getattr(ds, 'RescaleSlope', 1.0))\n    intercept = float(getattr(ds, 'RescaleIntercept', 0.0))\n    arr = arr * slope + intercept\n    if getattr(ds, 'PhotometricInterpretation', '') == 'MONOCHROME1':\n        arr = arr.max() - arr\n    step_y = max(1, arr.shape[0] // 512)\n    step_x = max(1, arr.shape[1] // 512)\n    sample = arr[::step_y, ::step_x]\n    lo, hi = np.percentile(sample, (1, 99))\n    arr = np.clip((arr - lo) / max(hi - lo, 1e-6), 0, 1)\n    arr = (arr * 255).astype(np.uint8)\n    img = Image.fromarray(arr, mode='L')\n    if target_size is not None:\n        img = img.resize((target_size, target_size), Image.BILINEAR)\n    return img.convert('RGB')\n\n\ndef read_xray(path):\n    \"\"\"Đọc DICOM hoặc ảnh thông thường.\"\"\"\n    path = str(path)\n    if path.lower().endswith(('.dcm', '.dicom')):\n        return read_dicom_rgb(path)\n    return Image.open(path).convert('RGB')\n\n\n# ── Cache DICOM → JPEG ───────────────────────────────────────────────────────\n# Dùng ProcessPoolExecutor với:\n#   • max_workers=2  → chỉ 2 process song song, mỗi process ~1-2 GB RAM\n#   • chunksize=32   → gom task thành batch nhỏ, giảm IPC overhead\n#   • map() trả về iterator → main process nhận từng kết quả,\n#     KHÔNG buffer toàn bộ 15k kết quả vào RAM cùng lúc\n# Worker function (module-level) chỉ nhận (image_id, path) string,\n# không pickle context lớn của notebook.\n\ndef _cache_worker(args):\n    \"\"\"Top-level worker — chỉ import những gì cần, không kéo cả notebook context.\"\"\"\n    import gc as _gc\n    import numpy as _np\n    import pydicom as _dcm\n    from pathlib import Path as _Path\n    from PIL import Image as _Img\n\n    image_id, dicom_path, cache_dir_str, cache_size = args\n    out_path = _Path(cache_dir_str) / f'{image_id}.jpg'\n    if out_path.exists() and out_path.stat().st_size > 0:\n        return str(out_path)\n\n    ds  = _dcm.dcmread(dicom_path)\n    arr = ds.pixel_array.astype(_np.float32)\n    slope     = float(getattr(ds, 'RescaleSlope', 1.0))\n    intercept = float(getattr(ds, 'RescaleIntercept', 0.0))\n    arr = arr * slope + intercept\n    if getattr(ds, 'PhotometricInterpretation', '') == 'MONOCHROME1':\n        arr = arr.max() - arr\n    step_y = max(1, arr.shape[0] // 512)\n    step_x = max(1, arr.shape[1] // 512)\n    sample = arr[::step_y, ::step_x]\n    lo, hi = _np.percentile(sample, (1, 99))\n    arr = _np.clip((arr - lo) / max(hi - lo, 1e-6), 0, 1)\n    arr = (arr * 255).astype(_np.uint8)\n    img = _Img.fromarray(arr, mode='L')\n    img = img.resize((cache_size, cache_size), _Img.BILINEAR)\n    img = img.convert('RGB')\n    img.save(str(out_path), quality=90)\n    # Dọn sạch trong worker — không giữ lại\n    del ds, arr, sample, img\n    _gc.collect()\n    return str(out_path)\n\n\nif CACHE_IMAGES:\n    from concurrent.futures import ProcessPoolExecutor, as_completed\n\n    CACHE_DIR.mkdir(parents=True, exist_ok=True)\n    items = [\n        (row.image_id, row.path, str(CACHE_DIR), CACHE_SIZE)\n        for row in df[['image_id', 'path']].itertuples(index=False)\n    ]\n    print(f'Cache {len(items)} ảnh → {CACHE_DIR}')\n    print(f'Workers: 2 | Chunksize: 32 (RAM-safe)')\n    t0 = time.time()\n\n    cache_paths = [None] * len(items)\n    # submit() + as_completed để tqdm hoạt động đúng\n    with ProcessPoolExecutor(max_workers=2) as ex:\n        future_to_idx = {ex.submit(_cache_worker, arg): i for i, arg in enumerate(items)}\n        for future in tqdm(as_completed(future_to_idx), total=len(items), desc='Caching'):\n            idx = future_to_idx[future]\n            cache_paths[idx] = future.result()\n\n    df['path'] = cache_paths\n    gc.collect()\n    print(f'Cache xong: {(time.time()-t0)/60:.1f} phút')\n    print('Ví dụ path:', df['path'].iloc[0])\nelse:\n    print('CACHE_IMAGES=False — đọc DICOM trực tiếp lúc training (chậm hơn).')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T17:40:54.513898Z","iopub.execute_input":"2026-05-12T17:40:54.514298Z","iopub.status.idle":"2026-05-12T20:24:01.808836Z","shell.execute_reply.started":"2026-05-12T17:40:54.514272Z","shell.execute_reply":"2026-05-12T20:24:01.807954Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. Train / Validation Split","metadata":{}},{"cell_type":"code","source":"train_df, valid_df = train_test_split(\n    df,\n    test_size=VAL_SIZE,\n    random_state=SEED,\n    stratify=df['any_positive']  # giữ tỉ lệ normal/abnormal\n)\ntrain_df = train_df.reset_index(drop=True)\nvalid_df = valid_df.reset_index(drop=True)\n\nprint(f'Train: {len(train_df)} | Valid: {len(valid_df)}')\nprint(f'Train positives: {int(train_df[\"any_positive\"].sum())} ({train_df[\"any_positive\"].mean()*100:.1f}%)')\nprint(f'Valid positives: {int(valid_df[\"any_positive\"].sum())} ({valid_df[\"any_positive\"].mean()*100:.1f}%)')\n\ndataset_summary = {\n    'num_images': int(len(df)),\n    'num_train': int(len(train_df)),\n    'num_valid': int(len(valid_df)),\n    'classes': CLASSES,\n    'num_classes': len(CLASSES),\n}\nwith open(OUT_DIR / 'dataset_summary.json', 'w') as f:\n    json.dump(dataset_summary, f, indent=2, ensure_ascii=False)\nprint('Saved: dataset_summary.json')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T22:28:21.2041Z","iopub.status.idle":"2026-05-12T22:28:21.204531Z","shell.execute_reply.started":"2026-05-12T22:28:21.204313Z","shell.execute_reply":"2026-05-12T22:28:21.204337Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Dataset & DataLoader","metadata":{}},{"cell_type":"code","source":"class VinBigDataset(Dataset):\n    def __init__(self, frame, classes, transform=None):\n        self.frame    = frame.reset_index(drop=True)\n        self.classes  = classes\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.frame)\n\n    def __getitem__(self, idx):\n        row = self.frame.iloc[idx]\n        img = read_xray(row['path'])\n        if self.transform:\n            img = self.transform(img)\n        y = torch.tensor(row[self.classes].values.astype(np.float32))\n        return img, y\n\n\nmean = (0.485, 0.456, 0.406)\nstd  = (0.229, 0.224, 0.225)\n\ntrain_tfms = transforms.Compose([\n    transforms.RandomResizedCrop(IMAGE_SIZE, scale=(0.85, 1.0), ratio=(0.9, 1.1)),\n    transforms.RandomHorizontalFlip(p=0.5),\n    transforms.RandomRotation(degrees=7),\n    transforms.ColorJitter(brightness=0.2, contrast=0.2),\n    transforms.ToTensor(),\n    transforms.Normalize(mean, std),\n])\n\nvalid_tfms = transforms.Compose([\n    transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean, std),\n])\n\n\ndef make_loader(dataset, batch_size, shuffle, num_workers):\n    \"\"\"\n    ✅ RAM-SAFE DataLoader:\n    - persistent_workers=False  → worker process bị kill sau mỗi epoch,\n                                   giải phóng RAM thay vì giữ mãi\n    - prefetch_factor=2 chỉ khi num_workers>0 (giữ nguyên)\n    - pin_memory chỉ bật khi CUDA để tránh lock RAM trên CPU-only\n    \"\"\"\n    kwargs = dict(\n        batch_size=batch_size,\n        shuffle=shuffle,\n        num_workers=num_workers,\n        pin_memory=(device.type == 'cuda'),\n        persistent_workers=False,   # ✅ Tắt: giải phóng worker RAM sau mỗi epoch\n    )\n    if num_workers > 0:\n        kwargs['prefetch_factor'] = 2\n    return DataLoader(dataset, **kwargs)\n\n\n# ✅ KHÔNG tạo loader toàn cục ở đây — mỗi model sẽ tự tạo loader riêng\n# để đảm bảo RAM worker được giải phóng hoàn toàn giữa các model.\nprint('Dataset & transforms defined ✓')\nprint(f'  Train: {len(train_df)} | Valid: {len(valid_df)}')\n\n# Kiểm tra shape nhanh (1 batch test)\n_ds = VinBigDataset(train_df.head(BATCH_SIZE*2), CLASSES, train_tfms)\n_ldr = DataLoader(_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)\nx, y = next(iter(_ldr))\nprint(f'Batch images: {x.shape}  |  Labels: {y.shape}')\ndel _ds, _ldr, x, y\ngc.collect()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T20:24:01.849532Z","iopub.execute_input":"2026-05-12T20:24:01.849962Z","iopub.status.idle":"2026-05-12T20:24:02.724619Z","shell.execute_reply.started":"2026-05-12T20:24:01.849916Z","shell.execute_reply":"2026-05-12T20:24:02.723786Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. Metrics & Training Helpers","metadata":{}},{"cell_type":"code","source":"def compute_pos_weight(frame, classes):\n    \"\"\"BCE pos_weight để bù mất cân bằng nhãn.\"\"\"\n    y   = frame[classes].values.astype(np.float32)\n    pos = y.sum(axis=0)\n    neg = len(y) - pos\n    w   = neg / np.maximum(pos, 1.0)\n    w   = np.clip(w, 1.0, MAX_POS_WEIGHT)\n    return torch.tensor(w, dtype=torch.float32)\n\n\n@torch.no_grad()\ndef evaluate(model, loader, criterion):\n    model.eval()\n    losses, probs, targets = [], [], []\n    for images, y in tqdm(loader, desc='Valid', leave=False):\n        images = images.to(device, non_blocking=True)\n        y      = y.to(device, non_blocking=True)\n        logits = model(images)\n        loss   = criterion(logits, y)\n        losses.append(loss.item())\n        probs.append(torch.sigmoid(logits).cpu().numpy())\n        targets.append(y.cpu().numpy())\n    return float(np.mean(losses)), np.vstack(probs), np.vstack(targets)\n\n\ndef per_class_metrics(y_true, y_prob, classes):\n    \"\"\"Tính AUC, F1, Sensitivity, Specificity per class.\"\"\"\n    rows   = []\n    y_pred = (y_prob >= 0.5).astype(int)\n    for i, c in enumerate(classes):\n        yt = y_true[:, i].astype(int)\n        yp = y_pred[:, i].astype(int)\n        auc = np.nan if len(np.unique(yt)) < 2 else roc_auc_score(yt, y_prob[:, i])\n        tp  = int(((yt==1)&(yp==1)).sum())\n        tn  = int(((yt==0)&(yp==0)).sum())\n        fp  = int(((yt==0)&(yp==1)).sum())\n        fn  = int(((yt==1)&(yp==0)).sum())\n        rows.append({\n            'class': c,\n            'auc':         auc,\n            'f1':          f1_score(yt, yp, zero_division=0),\n            'sensitivity': tp / max(tp+fn, 1),\n            'specificity': tn / max(tn+fp, 1),\n            'support_pos': int(yt.sum()),\n        })\n    return pd.DataFrame(rows)\n\n\ndef plot_auc(metrics_df, out_path, title):\n    plot_df = metrics_df.sort_values('auc')\n    fig, ax = plt.subplots(figsize=(10, 6))\n    bars = ax.barh(plot_df['class'], plot_df['auc'], color='steelblue')\n    ax.axvline(x=0.5, color='red', linestyle='--', alpha=0.5, label='Random (0.5)')\n    ax.set_xlim(0, 1)\n    ax.set_xlabel('AUROC')\n    ax.set_title(title)\n    # Thêm giá trị trên bar\n    for bar, val in zip(bars, plot_df['auc']):\n        if not np.isnan(val):\n            ax.text(val + 0.01, bar.get_y() + bar.get_height()/2,\n                    f'{val:.3f}', va='center', fontsize=8)\n    ax.legend()\n    plt.tight_layout()\n    plt.savefig(out_path, dpi=160)\n    plt.show()\n\n\ndef plot_history(history, out_path, model_name):\n    hist_df = pd.DataFrame(history)\n    fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n    # Loss\n    axes[0].plot(hist_df['epoch'], hist_df['train_loss'], label='Train loss')\n    axes[0].plot(hist_df['epoch'], hist_df['valid_loss'], label='Valid loss')\n    axes[0].set_xlabel('Epoch'); axes[0].set_ylabel('Loss')\n    axes[0].set_title(f'{model_name} — Loss')\n    axes[0].legend()\n    # AUC\n    axes[1].plot(hist_df['epoch'], hist_df['macro_auc'], label='Macro AUC', color='green')\n    axes[1].plot(hist_df['epoch'], hist_df['macro_f1'],  label='Macro F1',  color='orange')\n    axes[1].set_xlabel('Epoch'); axes[1].set_ylabel('Score')\n    axes[1].set_title(f'{model_name} — AUC & F1')\n    axes[1].set_ylim(0, 1); axes[1].legend()\n    plt.tight_layout()\n    plt.savefig(out_path, dpi=160)\n    plt.show()\n\nprint('Helpers defined ✓')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T20:24:02.725824Z","iopub.execute_input":"2026-05-12T20:24:02.726583Z","iopub.status.idle":"2026-05-12T20:24:02.743958Z","shell.execute_reply.started":"2026-05-12T20:24:02.726557Z","shell.execute_reply":"2026-05-12T20:24:02.743035Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9. Huấn luyện các mô hình","metadata":{}},{"cell_type":"code","source":"summaries   = []\nsaved_probs = []\nsaved_targets = None\n\nfor model_name in MODELS:\n    model_dir = OUT_DIR / model_name\n    model_dir.mkdir(parents=True, exist_ok=True)\n    print('\\n' + '='*80)\n    print(f'  Training: {model_name}')\n    print('='*80)\n\n    # ✅ Tạo DataLoader MỚI cho từng model\n    # → Sau khi del loader, worker processes bị kill → giải phóng RAM\n    train_loader = make_loader(\n        VinBigDataset(train_df, CLASSES, train_tfms),\n        BATCH_SIZE, shuffle=True, num_workers=NUM_WORKERS\n    )\n    valid_loader = make_loader(\n        VinBigDataset(valid_df, CLASSES, valid_tfms),\n        BATCH_SIZE * 2, shuffle=False, num_workers=NUM_WORKERS\n    )\n\n    # ── Model ──────────────────────────────────────────────────────────────\n    model = timm.create_model(model_name, pretrained=True,\n                              num_classes=len(CLASSES)).to(device)\n    if torch.cuda.device_count() > 1:\n        print('DataParallel:', torch.cuda.device_count(), 'GPUs')\n        model = nn.DataParallel(model)\n\n    # ── Loss, optimizer, scheduler ─────────────────────────────────────────\n    pos_weight = compute_pos_weight(train_df, CLASSES).to(device)\n    criterion  = nn.BCEWithLogitsLoss(pos_weight=pos_weight)\n    optimizer  = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\n    scheduler  = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)\n\n    use_amp = (device.type == 'cuda')\n    scaler  = torch.amp.GradScaler('cuda', enabled=use_amp)\n\n    # ── Training loop ───────────────────────────────────────────────────────\n    best_auc = -1\n    best_epoch = -1\n    bad_epochs = 0\n    history    = []\n    t0 = time.time()\n\n    for epoch in range(1, EPOCHS + 1):\n        model.train()\n        train_losses = []\n        pbar = tqdm(train_loader, desc=f'[{model_name}] Epoch {epoch}/{EPOCHS}')\n\n        for images, y in pbar:\n            images = images.to(device, non_blocking=True)\n            y      = y.to(device, non_blocking=True)\n            optimizer.zero_grad(set_to_none=True)\n\n            with torch.amp.autocast('cuda', enabled=use_amp):\n                logits = model(images)\n                loss   = criterion(logits, y)\n\n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n            train_losses.append(loss.item())\n            pbar.set_postfix(loss=f'{np.mean(train_losses[-20:]):.4f}')\n\n        scheduler.step()\n\n        valid_loss, y_prob, y_true = evaluate(model, valid_loader, criterion)\n        metrics_df  = per_class_metrics(y_true, y_prob, CLASSES)\n        macro_auc   = float(metrics_df['auc'].mean(skipna=True))\n        macro_f1    = float(metrics_df['f1'].mean())\n\n        row = {\n            'model': model_name, 'epoch': epoch,\n            'train_loss': float(np.mean(train_losses)),\n            'valid_loss': valid_loss,\n            'macro_auc':  macro_auc,\n            'macro_f1':   macro_f1,\n            'lr': optimizer.param_groups[0]['lr'],\n        }\n        history.append(row)\n        pd.DataFrame(history).to_csv(model_dir / 'history.csv', index=False)\n        print(f'  Epoch {epoch}: train_loss={row[\"train_loss\"]:.4f} '\n              f'valid_loss={valid_loss:.4f} macro_AUC={macro_auc:.4f} macro_F1={macro_f1:.4f}')\n\n        if macro_auc > best_auc:\n            best_auc   = macro_auc\n            best_epoch = epoch\n            bad_epochs = 0\n            state = model.module if isinstance(model, nn.DataParallel) else model\n            torch.save({\n                'model_name': model_name,\n                'state_dict': state.state_dict(),\n                'classes':    CLASSES,\n                'image_size': IMAGE_SIZE,\n                'macro_auc':  macro_auc,\n                'epoch':      epoch,\n            }, model_dir / 'best_model.pth')\n            metrics_df.to_csv(model_dir / 'per_class_metrics.csv', index=False)\n            np.save(model_dir / 'valid_probs.npy',   y_prob)\n            np.save(model_dir / 'valid_targets.npy', y_true)\n            plot_auc(metrics_df, model_dir / 'auc_per_class.png',\n                     f'{model_name} — AUROC per class (Epoch {epoch})')\n            print(f'  ✅ Best saved: AUC={best_auc:.4f}')\n        else:\n            bad_epochs += 1\n            print(f'  No improvement ({bad_epochs}/{PATIENCE})')\n\n        if bad_epochs >= PATIENCE:\n            print('  ⏹ Early stopping')\n            break\n\n    elapsed = (time.time() - t0) / 60\n    plot_history(history, model_dir / 'history.png', model_name)\n\n    summaries.append({\n        'model': model_name,\n        'best_epoch':   best_epoch,\n        'best_macro_auc': best_auc,\n        'elapsed_min':  elapsed,\n        'image_size':   IMAGE_SIZE,\n        'batch_size':   BATCH_SIZE,\n    })\n    pd.DataFrame(summaries).to_csv(OUT_DIR / 'model_comparison.csv', index=False)\n\n    if (model_dir / 'valid_probs.npy').exists():\n        saved_probs.append(np.load(model_dir / 'valid_probs.npy'))\n        saved_targets = np.load(model_dir / 'valid_targets.npy')\n\n    # ✅ DỌN RAM KỸ sau mỗi model:\n    # 1. Xoá DataLoader → kill worker processes → giải phóng shared memory\n    del train_loader, valid_loader\n    # 2. Xoá model, optimizer, scheduler, scaler\n    del model, optimizer, scheduler, scaler, pos_weight, criterion\n    # 3. Xoá tensors trung gian\n    del y_prob, y_true, metrics_df\n    # 4. Gọi gc + cuda empty cache\n    gc.collect()\n    if torch.cuda.is_available():\n        torch.cuda.empty_cache()\n        torch.cuda.synchronize()\n    print(f'  🧹 RAM dọn xong sau {model_name} | Elapsed: {elapsed:.1f} min')\n\ncomparison = pd.DataFrame(summaries).sort_values('best_macro_auc', ascending=False)\ncomparison.to_csv(OUT_DIR / 'model_comparison.csv', index=False)\ndisplay(comparison)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T20:24:02.7452Z","iopub.execute_input":"2026-05-12T20:24:02.745613Z","iopub.status.idle":"2026-05-12T22:28:21.165387Z","shell.execute_reply.started":"2026-05-12T20:24:02.745578Z","shell.execute_reply":"2026-05-12T22:28:21.158691Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n    gc.collect()\n    if torch.cuda.is_available():\n        torch.cuda.empty_cache()\n        torch.cuda.synchronize()\n    print(f'  🧹 RAM dọn xong sau {model_name} | Elapsed: {elapsed:.1f} min')\n\ncomparison = pd.DataFrame(summaries).sort_values('best_macro_auc', ascending=False)\ncomparison.to_csv(OUT_DIR / 'model_comparison.csv', index=False)\ndisplay(comparison)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T22:29:41.020043Z","iopub.execute_input":"2026-05-12T22:29:41.020552Z","iopub.status.idle":"2026-05-12T22:29:41.355591Z","shell.execute_reply.started":"2026-05-12T22:29:41.020518Z","shell.execute_reply":"2026-05-12T22:29:41.354577Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 10. Ensemble (Average Probabilities)","metadata":{}},{"cell_type":"code","source":"if len(saved_probs) >= 2 and saved_targets is not None:\n    ens_prob    = np.mean(saved_probs, axis=0)\n    ens_metrics = per_class_metrics(saved_targets, ens_prob, CLASSES)\n    ens_auc     = float(ens_metrics['auc'].mean(skipna=True))\n\n    ens_dir = OUT_DIR / 'ensemble_average'\n    ens_dir.mkdir(parents=True, exist_ok=True)\n    ens_metrics.to_csv(ens_dir / 'per_class_metrics.csv', index=False)\n    plot_auc(ens_metrics, ens_dir / 'auc_per_class.png',\n             f'Ensemble Average ({len(saved_probs)} models) — AUROC')\n\n    ens_row = pd.DataFrame([{\n        'model': f'ensemble_avg_{len(saved_probs)}models',\n        'best_epoch':     None,\n        'best_macro_auc': ens_auc,\n        'elapsed_min':    0,\n        'image_size':     IMAGE_SIZE,\n        'batch_size':     BATCH_SIZE,\n    }])\n    comparison = pd.concat([comparison, ens_row], ignore_index=True)\n    comparison = comparison.sort_values('best_macro_auc', ascending=False)\n    comparison.to_csv(OUT_DIR / 'model_comparison.csv', index=False)\n\n    print(f'Ensemble AUC: {ens_auc:.4f}')\n    display(comparison)\nelse:\n    print('Cần ít nhất 2 model để ensemble.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T22:30:05.213539Z","iopub.execute_input":"2026-05-12T22:30:05.214339Z","iopub.status.idle":"2026-05-12T22:30:05.222424Z","shell.execute_reply.started":"2026-05-12T22:30:05.214294Z","shell.execute_reply":"2026-05-12T22:30:05.221266Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 11. Grad-CAM Visualization\nHiển thị mô hình đang \"nhìn vào đâu\" khi đưa ra dự đoán.","metadata":{}},{"cell_type":"code","source":"try:\n    from pytorch_grad_cam import GradCAM\n    from pytorch_grad_cam.utils.image import show_cam_on_image\n    from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget\n    GRADCAM_OK = True\nexcept ImportError:\n    print('grad-cam chưa cài. Chạy: pip install grad-cam')\n    GRADCAM_OK = False\n\nif GRADCAM_OK:\n    # Load best model (lấy mô hình đầu tiên)\n    best_model_name = comparison.iloc[0]['model']\n    if best_model_name.startswith('ensemble'):\n        best_model_name = comparison.iloc[1]['model']  # lấy model đơn tốt nhất\n\n    ckpt = torch.load(OUT_DIR / best_model_name / 'best_model.pth',\n                      map_location=device, weights_only=True)\n    cam_model = timm.create_model(best_model_name, pretrained=False,\n                                  num_classes=len(CLASSES)).to(device)\n    cam_model.load_state_dict(ckpt['state_dict'])\n    cam_model.eval()\n\n    # Xác định target layer phù hợp với từng architecture\n    def get_target_layer(m, name):\n        if 'densenet' in name:\n            return [m.features.denseblock4.denselayer16.conv2]\n        elif 'resnet' in name:\n            return [m.layer4[-1]]\n        elif 'efficientnet' in name:\n            # timm>=0.9: dùng blocks[-1] thay conv_head (không còn tồn tại)\n            return [m.blocks[-1]]\n        return [list(m.children())[-3]]  # fallback\n\n    target_layers = get_target_layer(cam_model, best_model_name)\n\n    # Lấy vài ảnh positive từ valid set\n    pos_rows = valid_df[valid_df['any_positive'] == 1].head(6)\n    fig, axes = plt.subplots(2, 6, figsize=(20, 7))\n\n    with GradCAM(model=cam_model, target_layers=target_layers) as cam:\n        for ax_idx, (_, row) in enumerate(pos_rows.iterrows()):\n            img_pil = read_xray(row['path'])\n            img_pil = img_pil.resize((IMAGE_SIZE, IMAGE_SIZE))\n            img_np  = np.array(img_pil) / 255.0\n            img_tensor = valid_tfms(img_pil).unsqueeze(0).to(device)\n\n            # Tìm class có nhãn = 1\n            pos_class_idx = int(np.where(row[CLASSES].values == 1)[0][0])\n            targets = [ClassifierOutputTarget(pos_class_idx)]\n            mask    = cam(input_tensor=img_tensor, targets=targets)[0]\n            cam_img = show_cam_on_image(img_np.astype(np.float32), mask, use_rgb=True)\n\n            col = ax_idx\n            axes[0][col].imshow(img_np)\n            axes[0][col].set_title(CLASSES[pos_class_idx], fontsize=7)\n            axes[0][col].axis('off')\n\n            axes[1][col].imshow(cam_img)\n            axes[1][col].set_title('Grad-CAM', fontsize=7)\n            axes[1][col].axis('off')\n\n    plt.suptitle(f'Grad-CAM — {best_model_name}', fontsize=13, fontweight='bold')\n    plt.tight_layout()\n    plt.savefig(OUT_DIR / 'gradcam_visualization.png', dpi=160)\n    plt.show()\n    print('Saved: gradcam_visualization.png')\n\n    del cam_model\n    gc.collect()\n    torch.cuda.empty_cache()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T22:30:11.059641Z","iopub.execute_input":"2026-05-12T22:30:11.060508Z","iopub.status.idle":"2026-05-12T22:30:19.429523Z","shell.execute_reply.started":"2026-05-12T22:30:11.060473Z","shell.execute_reply":"2026-05-12T22:30:19.428561Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 12. So sánh với các nghiên cứu trước\nBảng so sánh macro AUC của bài này với các paper / baseline nổi tiếng.","metadata":{}},{"cell_type":"code","source":"# ── Kết quả từ literature (macro AUC trên các dataset tương tự) ──────────\nprior_work = [\n    {'Model':  'CheXNet (DenseNet-121)',\n     'Dataset': 'CheXNet14',\n     'Macro AUC': 0.841,\n     'Reference': 'Rajpurkar et al., 2017'},\n    {'Model':  'VinDr-CXR Baseline (ResNet)',\n     'Dataset': 'VinDr-CXR',\n     'Macro AUC': 0.769,\n     'Reference': 'Nguyen et al., 2020'},\n    {'Model':  'EfficientNet-B7 (Kaggle top-10)',\n     'Dataset': 'VinBigData',\n     'Macro AUC': 0.812,\n     'Reference': 'Kaggle VinBigData 2021'},\n    {'Model':  'Swin-Transformer (SOTA)',\n     'Dataset': 'VinBigData',\n     'Macro AUC': 0.876,\n     'Reference': 'Liu et al., 2021'},\n    {'Model':  'Ensemble (top-3 solutions)',\n     'Dataset': 'VinBigData',\n     'Macro AUC': 0.899,\n     'Reference': 'Kaggle leaderboard'},\n]\n\n# Thêm kết quả của bài này\nfor _, row in comparison.iterrows():\n    if pd.notna(row['best_macro_auc']):\n        prior_work.append({\n            'Model':    f\"Ours — {row['model']}\",\n            'Dataset':  'VinBigData',\n            'Macro AUC': round(float(row['best_macro_auc']), 4),\n            'Reference': 'This work',\n        })\n\nlit_df = pd.DataFrame(prior_work).sort_values('Macro AUC', ascending=False)\ndisplay(lit_df)\n\n# Visualize so sánh\ncolors = ['#e74c3c' if r=='This work' else '#3498db'\n          for r in lit_df['Reference']]\nplt.figure(figsize=(12, 6))\nbars = plt.barh(lit_df['Model'], lit_df['Macro AUC'], color=colors)\nplt.axvline(x=0.5, color='gray', linestyle='--', alpha=0.5)\nplt.xlim(0.5, 1.0)\nplt.xlabel('Macro AUROC')\nplt.title('So sánh với các nghiên cứu trước (Prior Work Comparison)')\n# Legend\nfrom matplotlib.patches import Patch\nlegend_elements = [\n    Patch(facecolor='#e74c3c', label='Kết quả bài này (This work)'),\n    Patch(facecolor='#3498db', label='Nghiên cứu trước (Prior work)'),\n]\nplt.legend(handles=legend_elements, loc='lower right')\nfor bar, val in zip(bars, lit_df['Macro AUC']):\n    plt.text(val + 0.002, bar.get_y() + bar.get_height()/2,\n             f'{val:.4f}', va='center', fontsize=8)\nplt.tight_layout()\nplt.savefig(OUT_DIR / 'comparison_prior_work.png', dpi=160)\nplt.show()\n\nlit_df.to_csv(OUT_DIR / 'comparison_prior_work.csv', index=False)\nprint('Saved: comparison_prior_work.png & .csv')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T22:30:39.633912Z","iopub.execute_input":"2026-05-12T22:30:39.635126Z","iopub.status.idle":"2026-05-12T22:30:40.175209Z","shell.execute_reply.started":"2026-05-12T22:30:39.635091Z","shell.execute_reply":"2026-05-12T22:30:40.174157Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 13. Tóm tắt kết quả cuối & Export","metadata":{}},{"cell_type":"code","source":"# Tải per-class metrics của từng model để so sánh từng bệnh\nall_per_class = []\nfor row in summaries:\n    m = row['model']\n    csv_path = OUT_DIR / m / 'per_class_metrics.csv'\n    if csv_path.exists():\n        tmp = pd.read_csv(csv_path)\n        tmp.insert(0, 'model', m)\n        all_per_class.append(tmp)\n# Thêm ensemble\nens_path = OUT_DIR / 'ensemble_average' / 'per_class_metrics.csv'\nif ens_path.exists():\n    tmp = pd.read_csv(ens_path)\n    tmp.insert(0, 'model', 'ensemble_average')\n    all_per_class.append(tmp)\n\nif all_per_class:\n    all_metrics = pd.concat(all_per_class, ignore_index=True)\n\n    # Pivot AUC per class\n    pivot_auc = all_metrics.pivot(index='class', columns='model', values='auc')\n    print('\\n=== AUC per class (tất cả model) ===')\n    display(pivot_auc.round(4))\n    pivot_auc.to_csv(OUT_DIR / 'pivot_auc_per_class.csv')\n\n    # Heatmap\n    plt.figure(figsize=(10, 8))\n    sns.heatmap(pivot_auc.astype(float), annot=True, fmt='.3f',\n                cmap='RdYlGn', vmin=0.5, vmax=1.0,\n                linewidths=0.5, linecolor='gray')\n    plt.title('AUC Heatmap: model × class')\n    plt.tight_layout()\n    plt.savefig(OUT_DIR / 'auc_heatmap.png', dpi=160)\n    plt.show()\n\n# ── Export tất cả output ─────────────────────────────────────────────────\nfinal_results = {\n    'models_summary': comparison.to_dict(orient='records'),\n    'best_model':     comparison.iloc[0]['model'],\n    'best_macro_auc': comparison.iloc[0]['best_macro_auc'],\n    'hyperparameters': {\n        'epochs': EPOCHS, 'image_size': IMAGE_SIZE,\n        'batch_size': BATCH_SIZE, 'lr': LR,\n        'weight_decay': WEIGHT_DECAY, 'patience': PATIENCE,\n    }\n}\nwith open(OUT_DIR / 'final_results.json', 'w') as f:\n    json.dump(final_results, f, indent=2, ensure_ascii=False, default=str)\n\nprint('\\n✅ Tất cả file đã được lưu tại:', OUT_DIR)\nimport subprocess\nresult = subprocess.run(['find', str(OUT_DIR), '-maxdepth', '3', '-type', 'f'],\n                        capture_output=True, text=True)\nfor line in sorted(result.stdout.strip().split('\\n')):\n    print(' ', line)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T22:30:56.341924Z","iopub.execute_input":"2026-05-12T22:30:56.342601Z","iopub.status.idle":"2026-05-12T22:30:57.173401Z","shell.execute_reply.started":"2026-05-12T22:30:56.342564Z","shell.execute_reply":"2026-05-12T22:30:57.172395Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 14. Đóng gói kết quả để download","metadata":{}},{"cell_type":"code","source":"import subprocess\nsubprocess.run(['zip', '-qr', '/kaggle/working/results.zip',\n                str(OUT_DIR.name)], cwd='/kaggle/working')\nprint('📦 Download: /kaggle/working/results.zip')\nprint('\\nDone! 🎉')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T22:31:08.625764Z","iopub.execute_input":"2026-05-12T22:31:08.62651Z","iopub.status.idle":"2026-05-12T22:31:16.034413Z","shell.execute_reply.started":"2026-05-12T22:31:08.62648Z","shell.execute_reply":"2026-05-12T22:31:16.033389Z"}},"outputs":[],"execution_count":null}]}