{"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":10338,"databundleVersionId":862042,"isSourceIdPinned":false}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"**RSNA Pneumonia Detection Challenge — VGG19-RNN + DenseNet121-RNN (80/10/10)**\n","metadata":{}},{"cell_type":"code","source":"# ── 0 · Dépendances ────────────────────────────────────────\nimport os, pathlib, shutil, random, time, copy, warnings\nwarnings.filterwarnings('ignore')\n \nimport cv2\nimport pydicom\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport seaborn as sns\n \nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.optim import AdamW\nfrom torch.optim.lr_scheduler import CosineAnnealingLR\nfrom torchvision import models\n \nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n \nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import (\n    accuracy_score, precision_score, recall_score,\n    f1_score, roc_auc_score, confusion_matrix,\n    classification_report, roc_curve\n)\nfrom sklearn.utils.class_weight import compute_class_weight\nfrom tqdm.auto import tqdm\n \nprint('Torch  :', torch.__version__)\nprint('CUDA   :', torch.cuda.is_available())\n \nIMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.bmp', '.webp'}\n \n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:00:19.854558Z","iopub.execute_input":"2026-05-10T11:00:19.854876Z","iopub.status.idle":"2026-05-10T11:00:33.904918Z","shell.execute_reply.started":"2026-05-10T11:00:19.854850Z","shell.execute_reply":"2026-05-10T11:00:33.904120Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"BATCH_SIZE   = 8\nIMG_SIZE     = 224\nEPOCHS       = 5\nLR           = 1e-4\nWEIGHT_DECAY = 1e-2\nNUM_WORKERS  = 2\nGRAD_CLIP    = 1.0\nPATIENCE     = 4\nUSE_AMP      = torch.cuda.is_available()\nDEVICE       = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nSEED         = 42\n \nTRAIN_RATIO = 0.80\nVAL_RATIO   = 0.10\nTEST_RATIO  = 0.10\n \nCLASS_NAMES  = ['NORMAL', 'PNEUMONIA']\nCLASS_TO_IDX = {'NORMAL': 0, 'PNEUMONIA': 1}\nNUM_CLASSES  = 2\n \nMEAN = [0.485, 0.456, 0.406]\nSTD  = [0.229, 0.224, 0.225]\n \nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.cuda.manual_seed_all(SEED)\n \nprint('Device :', DEVICE)\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:00:33.906573Z","iopub.execute_input":"2026-05-10T11:00:33.907346Z","iopub.status.idle":"2026-05-10T11:00:33.920872Z","shell.execute_reply.started":"2026-05-10T11:00:33.907317Z","shell.execute_reply":"2026-05-10T11:00:33.920197Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 2 · Détection du dataset RSNA Pneumonia ─────────────────\n \ndef find_dataset_root():\n    \"\"\"Détecte la racine du dataset RSNA Pneumonia Detection Challenge.\"\"\"\n    candidates = [\n        pathlib.Path('/kaggle/input/competitions/rsna-pneumonia-detection-challenge'),\n        pathlib.Path('/kaggle/input/rsna-pneumonia-detection-challenge'),\n    ]\n    for p in candidates:\n        if (p / 'stage_2_train_labels.csv').exists() and (p / 'stage_2_train_images').exists():\n            return p\n \n    for root, dirs, files in os.walk('/kaggle/input'):\n        root = pathlib.Path(root)\n        if (root / 'stage_2_train_labels.csv').exists() and (root / 'stage_2_train_images').exists():\n            return root\n \n    raise FileNotFoundError(\n        \"Dataset RSNA introuvable. \"\n        \"Vérifie que la compétition 'rsna-pneumonia-detection-challenge' est ajoutée au notebook.\"\n    )\n \nDATA_ROOT = find_dataset_root()\nprint('DATA_ROOT :', DATA_ROOT)\nprint('Contenu   :', sorted([p.name for p in DATA_ROOT.iterdir()]))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:00:33.921655Z","iopub.execute_input":"2026-05-10T11:00:33.921982Z","iopub.status.idle":"2026-05-10T11:00:33.937757Z","shell.execute_reply.started":"2026-05-10T11:00:33.921950Z","shell.execute_reply":"2026-05-10T11:00:33.936805Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 3 · Construction du DataFrame + Split 80/10/10 ─────────\n#\n#  RSNA fournit stage_2_train_labels.csv avec un patientId et Target.\n#  Une image positive peut apparaître plusieurs fois si plusieurs bounding boxes\n#  existent. On garde un seul label par patientId avec max(Target).\n# ───────────────────────────────────────────────────────────\n \nlabels_csv = DATA_ROOT / 'stage_2_train_labels.csv'\nimages_dir = DATA_ROOT / 'stage_2_train_images'\n \nlabels_df = pd.read_csv(labels_csv)\nlabels_df = labels_df.groupby('patientId', as_index=False)['Target'].max()\n \nrecords = []\nfor _, row in labels_df.iterrows():\n    patient_id = row['patientId']\n    label = int(row['Target'])\n    cls = 'PNEUMONIA' if label == 1 else 'NORMAL'\n    p = images_dir / f'{patient_id}.dcm'\n    if p.exists():\n        records.append({\n            'path'      : str(p),\n            'filename'  : p.name,\n            'class_name': cls,\n            'label'     : label,\n            'study_id'  : patient_id,\n        })\n \nct_df = pd.DataFrame(records)\n \nprint(f'\\nTotal images  : {len(ct_df)}')\nprint(ct_df['class_name'].value_counts().to_string())\nprint(f'Total studies : {ct_df[\"study_id\"].nunique()}')\n \n# Split par study_id → aucun patient dans deux splits\nstudy_df = ct_df[['study_id', 'label']].drop_duplicates()\n \ntrain_studies, temp_studies = train_test_split(\n    study_df,\n    test_size=(VAL_RATIO + TEST_RATIO),\n    random_state=SEED,\n    stratify=study_df['label'],\n)\nval_studies, test_studies = train_test_split(\n    temp_studies,\n    test_size=TEST_RATIO / (VAL_RATIO + TEST_RATIO),\n    random_state=SEED,\n    stratify=temp_studies['label'],\n)\n \ntrain_df = ct_df[ct_df['study_id'].isin(train_studies['study_id'])].reset_index(drop=True)\nval_df   = ct_df[ct_df['study_id'].isin(val_studies['study_id'])].reset_index(drop=True)\ntest_df  = ct_df[ct_df['study_id'].isin(test_studies['study_id'])].reset_index(drop=True)\n \nprint(f'\\nTrain : {len(train_df)} | Val : {len(val_df)} | Test : {len(test_df)}')\nfor df_, name in [(train_df,'Train'),(val_df,'Val'),(test_df,'Test')]:\n    print(f'  {name} → ' + str(df_['class_name'].value_counts().to_dict()))\n \n# Vérification anti data-leakage\nfor a, b, lbl in [(train_df, val_df, 'train∩val'),\n                  (train_df, test_df,'train∩test'),\n                  (val_df,   test_df,'val∩test')]:\n    print(f'Studies communs {lbl} : {len(set(a[\"study_id\"]) & set(b[\"study_id\"]))}')\n \n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:00:33.938872Z","iopub.execute_input":"2026-05-10T11:00:33.939151Z","iopub.status.idle":"2026-05-10T11:02:43.172281Z","shell.execute_reply.started":"2026-05-10T11:00:33.939126Z","shell.execute_reply":"2026-05-10T11:02:43.171471Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 4 · Multi-channel Preprocessing ───────────────────────\n#\n#  Pipeline par image :\n#  1. Charger en niveaux de gris (IMREAD_GRAYSCALE)\n#  2. Redimensionner → IMG_SIZE × IMG_SIZE\n#  3. Bilateral Filter  → débruite, préserve les bords\n#  4. Canal 0 — CLAHE   → contraste local adaptatif\n#  5. Canal 1 — Sobel   → gradient (bords / textures)\n#  6. Canal 2 — Gamma   → γ=0.5, amplifie zones sombres\n#  7. Stack → ndarray (H, W, 3) uint8 [0, 255]\n#             compatible avec Albumentations\n# ───────────────────────────────────────────────────────────\n \ndef gamma_correction_lut(img_uint8: np.ndarray, gamma: float = 0.5) -> np.ndarray:\n    \"\"\"Correction gamma via LUT (O(1) sur GPU-like). γ<1 → éclaircit.\"\"\"\n    inv_gamma = 1.0 / gamma\n    lut = np.array([((i / 255.0) ** inv_gamma) * 255 for i in range(256)], dtype=np.uint8)\n    return cv2.LUT(img_uint8, lut)\n \n \ndef sobel_magnitude_uint8(img_uint8: np.ndarray) -> np.ndarray:\n    \"\"\"\n    Gradient de Sobel normalisé → uint8 [0, 255].\n    Percentile 99 pour robustesse aux valeurs extrêmes.\n    \"\"\"\n    sx  = cv2.Sobel(img_uint8, cv2.CV_32F, 1, 0, ksize=3)\n    sy  = cv2.Sobel(img_uint8, cv2.CV_32F, 0, 1, ksize=3)\n    mag = np.sqrt(sx ** 2 + sy ** 2)\n    p99 = np.percentile(mag, 99)\n    mag = np.clip(mag / (p99 + 1e-7), 0.0, 1.0)\n    return (mag * 255).astype(np.uint8)\n \n \ndef build_multichannel(path_str: str, img_size: int = IMG_SIZE) -> np.ndarray:\n    \"\"\"\n    Retourne un ndarray (img_size, img_size, 3) uint8 [0, 255].\n \n    Les 3 canaux encodent des informations complémentaires :\n      Ch0 — CLAHE      : révèle les opacités et les différences\n                         de densité dans les champs pulmonaires\n      Ch1 — Sobel      : contours, structures, infiltrats, consolidations\n      Ch2 — Gamma(0.5) : amplifie les régions sombres sous-exposées\n                         (lobes inférieurs postérieurs souvent masqués)\n    \"\"\"\n    if str(path_str).lower().endswith('.dcm'):\n        ds = pydicom.dcmread(path_str)\n        img = ds.pixel_array.astype(np.float32)\n        img = img - img.min()\n        if img.max() > 0:\n            img = img / img.max()\n        img = (img * 255).astype(np.uint8)\n    else:\n        img = cv2.imread(path_str, cv2.IMREAD_GRAYSCALE)\n        if img is None:\n            return np.zeros((img_size, img_size, 3), dtype=np.uint8)\n \n    img = cv2.resize(img, (img_size, img_size), interpolation=cv2.INTER_AREA)\n \n    # Débruitage — Bilateral Filter préserve les contours anatomiques\n    denoised = cv2.bilateralFilter(img, d=9, sigmaColor=75, sigmaSpace=75)\n \n    # Canal 0 : CLAHE\n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n    ch0   = clahe.apply(denoised)                   # uint8\n \n    # Canal 1 : Sobel magnitude\n    ch1   = sobel_magnitude_uint8(denoised)          # uint8\n \n    # Canal 2 : Gamma γ = 0.5\n    ch2   = gamma_correction_lut(denoised, gamma=0.5)  # uint8\n \n    return np.stack([ch0, ch1, ch2], axis=-1)        # (H, W, 3) uint8","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:02:43.173392Z","iopub.execute_input":"2026-05-10T11:02:43.173742Z","iopub.status.idle":"2026-05-10T11:02:43.183892Z","shell.execute_reply.started":"2026-05-10T11:02:43.173698Z","shell.execute_reply":"2026-05-10T11:02:43.182987Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 5 · Albumentations Transforms ─────────────────────────\n#\n#  Les canaux sont déjà dans [0,255] uint8 → Albumentations\n#  les normalise ensuite vers les stats ImageNet.\n#\n#  Augmentation train — médicalement cohérente :\n#    ✓ HorizontalFlip   : symétrie L/R réelle en radiologie\n#    ✓ ShiftScaleRotate : légère variation de cadrage\n#    ✓ BrightnessContrast : simule différences d'exposition\n#    ✗ Pas de flip vertical (orientation anatomique fixe)\n#    ✗ Pas de zoom fort   (coupe les bords pulmonaires)\n# ───────────────────────────────────────────────────────────\n \ntrain_transform = A.Compose([\n    A.HorizontalFlip(p=0.5),\n    A.ShiftScaleRotate(\n        shift_limit=0.04, scale_limit=0.08, rotate_limit=8,\n        border_mode=cv2.BORDER_REFLECT_101, p=0.5\n    ),\n    A.RandomBrightnessContrast(brightness_limit=0.08, contrast_limit=0.08, p=0.3),\n    A.Normalize(mean=MEAN, std=STD),\n    ToTensorV2(),\n])\n \nval_transform = A.Compose([\n    A.Normalize(mean=MEAN, std=STD),\n    ToTensorV2(),\n])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:02:43.185147Z","iopub.execute_input":"2026-05-10T11:02:43.185622Z","iopub.status.idle":"2026-05-10T11:02:43.206245Z","shell.execute_reply.started":"2026-05-10T11:02:43.185593Z","shell.execute_reply":"2026-05-10T11:02:43.205357Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 6 · Dataset PyTorch ────────────────────────────────────\n \nclass RSNADataset(Dataset):\n    \"\"\"\n    Dataset RSNA avec preprocessing multi-channel.\n \n    __getitem__ :\n      1. build_multichannel() → (H, W, 3) uint8\n         (CLAHE | Sobel | Gamma — Bilateral Filter en amont)\n      2. Transform Albumentations (augmentation train, normalisation)\n      3. Retourne (tensor[3,H,W] float32, label int)\n    \"\"\"\n    def __init__(self, df: pd.DataFrame, transform=None):\n        self.df        = df.reset_index(drop=True)\n        self.transform = transform\n \n    def __len__(self):\n        return len(self.df)\n \n    def __getitem__(self, idx):\n        row   = self.df.loc[idx]\n        img   = build_multichannel(row['path'])      # (H,W,3) uint8\n        label = int(row['label'])\n \n        if self.transform:\n            img = self.transform(image=img)['image']  # → tensor float32\n \n        return img, label\n \n    def get_filename(self, idx):\n        return self.df.loc[idx, 'filename']\n \n    def get_filepath(self, idx):\n        return self.df.loc[idx, 'path']\n \n \ntrain_ds = RSNADataset(train_df, transform=train_transform)\nval_ds   = RSNADataset(val_df,   transform=val_transform)\ntest_ds  = RSNADataset(test_df,  transform=val_transform)\n \ntrain_dl = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True,\n                      num_workers=NUM_WORKERS, pin_memory=True, drop_last=True)\nval_dl   = DataLoader(val_ds,   batch_size=BATCH_SIZE, shuffle=False,\n                      num_workers=NUM_WORKERS, pin_memory=True)\ntest_dl  = DataLoader(test_ds,  batch_size=BATCH_SIZE, shuffle=False,\n                      num_workers=NUM_WORKERS, pin_memory=True)\n \nprint(f'\\nTrain DS : {len(train_ds)} | Val DS : {len(val_ds)} | Test DS : {len(test_ds)}')\n \n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:02:43.208431Z","iopub.execute_input":"2026-05-10T11:02:43.208988Z","iopub.status.idle":"2026-05-10T11:02:43.221075Z","shell.execute_reply.started":"2026-05-10T11:02:43.208960Z","shell.execute_reply":"2026-05-10T11:02:43.220132Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 7 · Visualisation des 3 canaux ─────────────────────────\n \ndef visualize_channels(df: pd.DataFrame, idx: int = 0):\n    row = df.loc[idx]\n    if str(row['path']).lower().endswith('.dcm'):\n        ds = pydicom.dcmread(row['path'])\n        raw = ds.pixel_array.astype(np.float32)\n        raw = raw - raw.min()\n        if raw.max() > 0:\n            raw = raw / raw.max()\n        raw = (raw * 255).astype(np.uint8)\n    else:\n        raw = cv2.imread(row['path'], cv2.IMREAD_GRAYSCALE)\n    raw = cv2.resize(raw, (IMG_SIZE, IMG_SIZE))\n    den = cv2.bilateralFilter(raw, 9, 75, 75)\n \n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n    imgs  = [raw,\n             clahe.apply(den),\n             sobel_magnitude_uint8(den),\n             gamma_correction_lut(den, 0.5)]\n    titles = ['Original', 'Ch0 — CLAHE', 'Ch1 — Sobel Edges', 'Ch2 — Gamma (γ=0.5)']\n \n    fig, axes = plt.subplots(1, 4, figsize=(16, 4))\n    for ax, im, t in zip(axes, imgs, titles):\n        ax.imshow(im, cmap='gray'); ax.set_title(t); ax.axis('off')\n    plt.suptitle(f\"Preprocessing — {row['class_name']} ({row['filename']})\")\n    plt.tight_layout(); plt.show()\n \nvisualize_channels(train_df, idx=0)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:02:43.222161Z","iopub.execute_input":"2026-05-10T11:02:43.222484Z","iopub.status.idle":"2026-05-10T11:02:43.839443Z","shell.execute_reply.started":"2026-05-10T11:02:43.222457Z","shell.execute_reply":"2026-05-10T11:02:43.838581Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 8 · Modèle : CNN-RNN Backbone ─────────────────────────\n#\n#  Architecture :\n#    CNN (VGG19 ou DenseNet121) → feature map (B, C, H', W')\n#    Aplatissement spatial       → séquence  (B, H'×W', C)\n#    GRU bidirectionnel          → (B, H'×W', 2×hidden)\n#    Mean pooling temporel       → (B, 2×hidden)\n#    MLP classifieur             → (B, num_classes)\n#\n#  La séquence spatiale permet au GRU de capturer des\n#  dépendances longue distance entre régions pulmonaires.\n# ───────────────────────────────────────────────────────────\n \nclass CNNRNNBackbone(nn.Module):\n    def __init__(self, cnn_type='vgg19', num_classes=2, hidden_size=256, dropout=0.3):\n        super().__init__()\n        self.cnn_type = cnn_type\n \n        if cnn_type == 'vgg19':\n            base = models.vgg19(weights=models.VGG19_Weights.IMAGENET1K_V1)\n            self.features = base.features\n            cnn_channels  = 512\n        elif cnn_type == 'densenet121':\n            base = models.densenet121(weights=models.DenseNet121_Weights.IMAGENET1K_V1)\n            self.features = base.features\n            cnn_channels  = 1024\n        else:\n            raise ValueError('cnn_type doit être \"vgg19\" ou \"densenet121\"')\n \n        self.rnn = nn.GRU(\n            input_size   = cnn_channels,\n            hidden_size  = hidden_size,\n            num_layers   = 1,\n            batch_first  = True,\n            bidirectional= True,\n        )\n        self.classifier = nn.Sequential(\n            nn.Dropout(dropout),\n            nn.Linear(hidden_size * 2, 256),\n            nn.ReLU(inplace=True),\n            nn.Dropout(dropout),\n            nn.Linear(256, num_classes),\n        )\n \n    def forward(self, x):\n        feat = self.features(x)\n        if self.cnn_type == 'densenet121':\n            feat = F.relu(feat, inplace=False)\n        b, c, h, w = feat.shape\n        seq        = feat.flatten(2).transpose(1, 2)   # [B, H*W, C]\n        rnn_out, _ = self.rnn(seq)\n        pooled     = rnn_out.mean(dim=1)               # mean pooling temporel\n        return self.classifier(pooled)\n \n \nvgg19_rnn       = CNNRNNBackbone('vgg19',       num_classes=NUM_CLASSES).to(DEVICE)\ndensenet121_rnn = CNNRNNBackbone('densenet121', num_classes=NUM_CLASSES).to(DEVICE)\n \nn_vgg = sum(p.numel() for p in vgg19_rnn.parameters()       if p.requires_grad)\nn_den = sum(p.numel() for p in densenet121_rnn.parameters() if p.requires_grad)\nprint(f'VGG19-RNN params       : {n_vgg/1e6:.2f}M')\nprint(f'DenseNet121-RNN params : {n_den/1e6:.2f}M')\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:02:43.840514Z","iopub.execute_input":"2026-05-10T11:02:43.840893Z","iopub.status.idle":"2026-05-10T11:02:48.775201Z","shell.execute_reply.started":"2026-05-10T11:02:43.840866Z","shell.execute_reply":"2026-05-10T11:02:48.774516Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 9 · Loss + Optimiseurs ─────────────────────────────────\n \ncw = compute_class_weight(\n    'balanced',\n    classes=np.unique(train_df['label'].values),\n    y=train_df['label'].values\n)\nclass_weights = torch.tensor(cw, dtype=torch.float32).to(DEVICE)\nprint(f'Class weights : {cw}')\n \ncriterion   = nn.CrossEntropyLoss(weight=class_weights, label_smoothing=0.03)\noptimizer_v = AdamW(vgg19_rnn.parameters(),       lr=LR, weight_decay=WEIGHT_DECAY)\noptimizer_d = AdamW(densenet121_rnn.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\nscheduler_v = CosineAnnealingLR(optimizer_v, T_max=EPOCHS, eta_min=1e-6)\nscheduler_d = CosineAnnealingLR(optimizer_d, T_max=EPOCHS, eta_min=1e-6)\nscaler      = torch.cuda.amp.GradScaler(enabled=USE_AMP)\n \n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:02:48.776107Z","iopub.execute_input":"2026-05-10T11:02:48.776389Z","iopub.status.idle":"2026-05-10T11:02:48.794212Z","shell.execute_reply.started":"2026-05-10T11:02:48.776352Z","shell.execute_reply":"2026-05-10T11:02:48.793145Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 10 · Boucles d'entraînement et validation ──────────────\n \ndef train_epoch(model_v, model_d, loader, opt_v, opt_d, criterion, scaler):\n    model_v.train(); model_d.train()\n    total_loss = 0.0\n    preds_all, labels_all = [], []\n \n    for imgs, labels in tqdm(loader, desc='[TRAIN]', leave=False):\n        imgs   = imgs.float().to(DEVICE, non_blocking=True)\n        labels = labels.long().to(DEVICE, non_blocking=True)\n \n        opt_v.zero_grad(set_to_none=True)\n        opt_d.zero_grad(set_to_none=True)\n \n        with torch.autocast(device_type='cuda', dtype=torch.float16, enabled=USE_AMP):\n            logits_e = (model_v(imgs) + model_d(imgs)) / 2.0\n            logits_e = torch.nan_to_num(logits_e, nan=0.0, posinf=1e4, neginf=-1e4)\n            loss     = criterion(logits_e, labels)\n \n        if torch.isnan(loss) or torch.isinf(loss):\n            continue\n \n        scaler.scale(loss).backward()\n        scaler.unscale_(opt_v); scaler.unscale_(opt_d)\n        nn.utils.clip_grad_norm_(model_v.parameters(), GRAD_CLIP)\n        nn.utils.clip_grad_norm_(model_d.parameters(), GRAD_CLIP)\n        scaler.step(opt_v); scaler.step(opt_d)\n        scaler.update()\n \n        total_loss  += loss.item()\n        preds_all.extend(logits_e.argmax(1).detach().cpu().numpy())\n        labels_all.extend(labels.detach().cpu().numpy())\n \n    acc = accuracy_score(labels_all, preds_all)\n    return total_loss / max(1, len(loader)), acc\n \n \n@torch.no_grad()\ndef validate(model_v, model_d, loader, criterion):\n    model_v.eval(); model_d.eval()\n    total_loss = 0.0\n    preds_all, labels_all, probs_all = [], [], []\n \n    for imgs, labels in loader:\n        imgs   = imgs.float().to(DEVICE)\n        labels = labels.long().to(DEVICE)\n \n        with torch.autocast(device_type='cuda', dtype=torch.float16, enabled=USE_AMP):\n            logits_e = (model_v(imgs) + model_d(imgs)) / 2.0\n            logits_e = torch.nan_to_num(logits_e, nan=0.0, posinf=1e4, neginf=-1e4)\n            loss     = criterion(logits_e, labels)\n \n        probs = F.softmax(logits_e, dim=1)[:, 1]\n        probs = torch.clamp(torch.nan_to_num(probs, nan=0.5), 1e-7, 1 - 1e-7)\n \n        total_loss += loss.item()\n        preds_all.extend(logits_e.argmax(1).cpu().numpy())\n        labels_all.extend(labels.cpu().numpy())\n        probs_all.extend(probs.cpu().numpy())\n \n    labels_np = np.array(labels_all)\n    probs_np  = np.array(probs_all, dtype=np.float32)\n    acc  = accuracy_score(labels_np, preds_all)\n    f1   = f1_score(labels_np, preds_all, average='weighted')\n    auc  = 0.5 if len(np.unique(labels_np)) < 2 else roc_auc_score(labels_np, probs_np)\n    return total_loss / max(1, len(loader)), acc, f1, auc\n \n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:02:48.795666Z","iopub.execute_input":"2026-05-10T11:02:48.796141Z","iopub.status.idle":"2026-05-10T11:02:48.811578Z","shell.execute_reply.started":"2026-05-10T11:02:48.796102Z","shell.execute_reply":"2026-05-10T11:02:48.810809Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 11 · Entraînement ──────────────────────────────────────\n \nos.makedirs('checkpoints', exist_ok=True)\nbest_acc, best_state_v, best_state_d = 0.0, None, None\nhistory, patience_ctr = [], 0\n \nprint(f'\\nEntraînement : {EPOCHS} epochs  (patience={PATIENCE})')\nprint(f'  Modèle A   : VGG19-RNN')\nprint(f'  Modèle B   : DenseNet121-RNN')\nprint(f'  Preprocessing : Multi-channel (CLAHE | Sobel | Gamma) + Bilateral Filter')\nprint(f'  Split      : Train {len(train_ds)} / Val {len(val_ds)} / Test {len(test_ds)}')\nprint('=' * 75)\n \nfor epoch in range(EPOCHS):\n    tr_loss, tr_acc = train_epoch(\n        vgg19_rnn, densenet121_rnn, train_dl,\n        optimizer_v, optimizer_d, criterion, scaler\n    )\n    vl_loss, vl_acc, vl_f1, vl_auc = validate(\n        vgg19_rnn, densenet121_rnn, val_dl, criterion\n    )\n    scheduler_v.step(); scheduler_d.step()\n \n    history.append({\n        'epoch'  : epoch + 1,\n        'tr_loss': tr_loss, 'tr_acc': tr_acc,\n        'vl_loss': vl_loss, 'vl_acc': vl_acc,\n        'vl_f1'  : vl_f1,   'vl_auc': vl_auc,\n    })\n    print(f'Epoch {epoch+1:02d}/{EPOCHS} | '\n          f'Tr={tr_acc:.4f}  Val={vl_acc:.4f}  F1={vl_f1:.4f}  AUC={vl_auc:.4f}')\n \n    if vl_acc > best_acc:\n        best_acc      = vl_acc\n        best_state_v  = {k: v.detach().cpu().clone() for k,v in vgg19_rnn.state_dict().items()}\n        best_state_d  = {k: v.detach().cpu().clone() for k,v in densenet121_rnn.state_dict().items()}\n        torch.save(best_state_v, 'checkpoints/vgg19_rnn_best.pth')\n        torch.save(best_state_d, 'checkpoints/densenet121_rnn_best.pth')\n        patience_ctr  = 0\n        print(f'   ★ Nouveau record  Acc={best_acc*100:.2f}%')\n    else:\n        patience_ctr += 1\n        if patience_ctr >= PATIENCE:\n            print(f'Early stopping (patience={PATIENCE})')\n            break\n \nif best_state_v: vgg19_rnn.load_state_dict(best_state_v)\nif best_state_d: densenet121_rnn.load_state_dict(best_state_d)\nprint(f'\\nEntraînement terminé. Meilleure Val Acc = {best_acc*100:.2f}%')\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:02:48.812823Z","iopub.execute_input":"2026-05-10T11:02:48.813154Z","iopub.status.idle":"2026-05-10T11:39:50.945170Z","shell.execute_reply.started":"2026-05-10T11:02:48.813127Z","shell.execute_reply":"2026-05-10T11:39:50.943828Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 12 · Courbes d'apprentissage ───────────────────────────\n \nhist_df = pd.DataFrame(history)\nfig, axes = plt.subplots(1, 3, figsize=(20, 5))\n \naxes[0].plot(hist_df.epoch, hist_df.tr_loss, label='Train', color='steelblue')\naxes[0].plot(hist_df.epoch, hist_df.vl_loss, label='Val',   color='tomato')\naxes[0].set_title('Loss'); axes[0].legend(); axes[0].set_xlabel('Epoch')\n \naxes[1].plot(hist_df.epoch, hist_df.tr_acc * 100, label='Train', color='steelblue')\naxes[1].plot(hist_df.epoch, hist_df.vl_acc * 100, label='Val',   color='tomato')\naxes[1].set_title('Accuracy (%)'); axes[1].legend(); axes[1].set_xlabel('Epoch')\n \naxes[2].plot(hist_df.epoch, hist_df.vl_f1,  label='F1',  color='purple')\naxes[2].plot(hist_df.epoch, hist_df.vl_auc, label='AUC', color='orange')\naxes[2].set_title('F1 & AUC'); axes[2].legend(); axes[2].set_xlabel('Epoch')\n \nplt.suptitle('Ensemble(VGG19-RNN + DenseNet121-RNN) — RSNA | Multi-channel Preprocessing',\n             fontsize=13, fontweight='bold')\nplt.tight_layout(); plt.show()\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:39:50.947056Z","iopub.execute_input":"2026-05-10T11:39:50.947488Z","iopub.status.idle":"2026-05-10T11:39:51.482796Z","shell.execute_reply.started":"2026-05-10T11:39:50.947447Z","shell.execute_reply":"2026-05-10T11:39:51.481654Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 13 · Évaluation finale sur le Test Set ─────────────────\n \nvgg19_rnn.eval(); densenet121_rnn.eval()\nall_preds, all_probs, all_true = [], [], []\n \nwith torch.no_grad():\n    for imgs, labels in tqdm(test_dl, desc='Évaluation finale'):\n        imgs = imgs.float().to(DEVICE)\n        with torch.autocast(device_type='cuda', dtype=torch.float16, enabled=USE_AMP):\n            logits_e = (vgg19_rnn(imgs) + densenet121_rnn(imgs)) / 2.0\n            logits_e = torch.nan_to_num(logits_e, nan=0.0, posinf=1e4, neginf=-1e4)\n \n        probs = F.softmax(logits_e, dim=1)[:, 1]\n        probs = torch.clamp(torch.nan_to_num(probs, nan=0.5), 1e-7, 1 - 1e-7)\n \n        all_preds.extend(logits_e.argmax(1).cpu().numpy())\n        all_probs.extend(probs.cpu().numpy())\n        all_true.extend(labels.numpy())\n \nall_true  = np.array(all_true)\nall_preds = np.array(all_preds)\nall_probs = np.array(all_probs, dtype=np.float32)\n \nacc  = accuracy_score(all_true, all_preds)\nprec = precision_score(all_true, all_preds, average='weighted', zero_division=0)\nrec  = recall_score(all_true, all_preds,    average='weighted', zero_division=0)\nf1   = f1_score(all_true, all_preds,        average='weighted', zero_division=0)\nauc  = 0.5 if len(np.unique(all_true)) < 2 else roc_auc_score(all_true, all_probs)\n \nprint('\\n─── RÉSULTATS FINAUX ──────────────────────────────────────────')\nprint('  Ensemble   : VGG19-RNN + DenseNet121-RNN (mean logits)')\nprint('  Dataset    : Kermany Chest X-Ray Pneumonia')\nprint('  Classes    : NORMAL vs PNEUMONIA')\nprint('  Preprocessing : Multi-channel (CLAHE | Sobel | Gamma + Bilateral Filter)')\nprint(f'  Split      : {int(TRAIN_RATIO*100)}% Train / {int(VAL_RATIO*100)}% Val / {int(TEST_RATIO*100)}% Test')\nprint('──────────────────────────────────────────────────────────────')\nprint(f'  Accuracy  : {acc*100:.2f}%')\nprint(f'  Precision : {prec*100:.2f}%')\nprint(f'  Recall    : {rec*100:.2f}%')\nprint(f'  F1-Score  : {f1*100:.2f}%')\nprint(f'  AUC-ROC   : {auc*100:.2f}%')\nprint('──────────────────────────────────────────────────────────────')\nprint()\nprint(classification_report(all_true, all_preds, target_names=CLASS_NAMES, zero_division=0))\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:39:51.484710Z","iopub.execute_input":"2026-05-10T11:39:51.485523Z","iopub.status.idle":"2026-05-10T11:40:33.039284Z","shell.execute_reply.started":"2026-05-10T11:39:51.485493Z","shell.execute_reply":"2026-05-10T11:40:33.038300Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 14 · Visualisations ────────────────────────────────────\n \n# Confusion Matrix\ncm = confusion_matrix(all_true, all_preds)\nplt.figure(figsize=(7, 5))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Blues',\n            xticklabels=CLASS_NAMES, yticklabels=CLASS_NAMES)\nplt.xlabel('Predicted'); plt.ylabel('True')\nplt.title('Confusion Matrix — VGG19-RNN + DenseNet121-RNN | RSNA')\nplt.tight_layout(); plt.show()\n \n# ROC Curve\nfpr, tpr, _ = roc_curve(all_true, all_probs)\nplt.figure(figsize=(7, 5))\nplt.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC (AUC={auc:.4f})')\nplt.plot([0,1],[0,1], linestyle='--', color='navy', lw=1)\nplt.xlabel('False Positive Rate'); plt.ylabel('True Positive Rate')\nplt.title('Courbe ROC — RSNA Multi-channel')\nplt.legend(loc='lower right'); plt.tight_layout(); plt.show()\n \n# Bar chart métriques\nmetrics_dict = {'Accuracy': acc, 'Precision': prec,\n                'Recall': rec, 'F1-Score': f1, 'AUC': auc}\nfig, ax = plt.subplots(figsize=(7, 4))\nbars = ax.bar(metrics_dict.keys(), [v*100 for v in metrics_dict.values()], color='#4C72B0')\nax.bar_label(bars, fmt='%.1f%%', padding=3)\nax.set_ylim(0, 115); ax.set_ylabel('Score (%)')\nax.set_title('VGG19-RNN + DenseNet121-RNN — RSNA Multi-channel')\nplt.tight_layout(); plt.show()\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:40:33.041231Z","iopub.execute_input":"2026-05-10T11:40:33.041503Z","iopub.status.idle":"2026-05-10T11:40:43.547362Z","shell.execute_reply.started":"2026-05-10T11:40:33.041464Z","shell.execute_reply":"2026-05-10T11:40:43.546649Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 15 · Sauvegarde finale ─────────────────────────────────\n \ntorch.save({\n    'vgg19_rnn_state'       : vgg19_rnn.state_dict(),\n    'densenet121_rnn_state' : densenet121_rnn.state_dict(),\n    'accuracy' : acc,  'precision': prec,\n    'recall'   : rec,  'f1'       : f1,   'auc': auc,\n    'img_size'     : IMG_SIZE,\n    'preprocessing': 'Multi-channel: CLAHE | Sobel | Gamma + Bilateral Filter',\n    'split'        : '80/10/10 train/val/test par study_id',\n    'fusion'       : 'mean logits',\n    'classes'      : CLASS_NAMES,\n}, 'checkpoints/vgg19_densenet121_rnn_rsna_final.pth')\n \nprint('Sauvegardé → checkpoints/vgg19_densenet121_rnn_rsna_final.pth')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-10T11:40:43.548354Z","iopub.execute_input":"2026-05-10T11:40:43.548768Z","iopub.status.idle":"2026-05-10T11:40:43.776023Z","shell.execute_reply.started":"2026-05-10T11:40:43.548740Z","shell.execute_reply":"2026-05-10T11:40:43.775234Z"}},"outputs":[],"execution_count":null}]}