{"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 — VGG19-RNN + DenseNet121-RNN\n\nCe notebook remplace le pipeline d'origine par :\n\n- **Algorithmes** : `VGG19-RNN` + `DenseNet121-RNN`\n- **Prétraitement** : **PA only**, Resize, Augmentation, Normalization\n- **Split** : **80 / 10 / 10** (conservé)\n- **Tâche** : `Normal` vs `Viral Pneumonia`\n","metadata":{}},{"cell_type":"code","source":"# ── Installation ──────────────────────────────────────────────────────────────\n!pip install -q albumentations scikit-learn tqdm pandas opencv-python-headless\n!pip install -q pydicom          # ← ajout RSNA (lecture DICOM + filtre PA)\n \nimport os\nimport random\nimport warnings\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport matplotlib.pyplot as plt\nimport seaborn as sns\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\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\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.model_selection import train_test_split\nfrom sklearn.utils.class_weight import compute_class_weight\nfrom tqdm.auto import tqdm\nimport pydicom                   # ← ajout RSNA\n \nwarnings.filterwarnings(\"ignore\")\nprint(\"Torch:\", torch.__version__)\nprint(\"CUDA available:\", torch.cuda.is_available())","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-05-09T13:42:00.870092Z","iopub.execute_input":"2026-05-09T13:42:00.870402Z","iopub.status.idle":"2026-05-09T13:42:07.613601Z","shell.execute_reply.started":"2026-05-09T13:42:00.870379Z","shell.execute_reply":"2026-05-09T13:42:07.612461Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Configuration (inchangée) ─────────────────────────────────────────────────\nIMG_SIZE     = 224\nBATCH_SIZE   = 8\nEPOCHS       = 5\nTRAIN_SIZE   = 0.8\nVAL_SIZE     = 0.1\nTEST_SIZE    = 0.1\nSEED         = 42\nLR           = 1e-4\nWEIGHT_DECAY = 1e-2\nNUM_WORKERS  = 2\nGRAD_CLIP    = 1.0\nPATIENCE     = 4\nUSE_AMP      = torch.cuda.is_available()\nPA_ONLY      = True\n \n# ── Noms de classes RSNA (remplace \"Viral Pneumonia\" → \"Lung Opacity\") ────────\nCLASS_NAMES  = [\"Normal\", \"Lung Opacity\"]\nCLASS_TO_IDX = {\"Normal\": 0, \"Lung Opacity\": 1}\n \nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n \nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.cuda.manual_seed_all(SEED)\ntorch.backends.cudnn.deterministic = True\ntorch.backends.cudnn.benchmark     = False\n \nprint(\"DEVICE:\", DEVICE)\nprint(f\"Split: {int(TRAIN_SIZE*100)}/{int(VAL_SIZE*100)}/{int(TEST_SIZE*100)}\")\nprint(\"PA_ONLY:\", PA_ONLY)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T13:42:07.615816Z","iopub.execute_input":"2026-05-09T13:42:07.616199Z","iopub.status.idle":"2026-05-09T13:42:07.626044Z","shell.execute_reply.started":"2026-05-09T13:42:07.616171Z","shell.execute_reply":"2026-05-09T13:42:07.625137Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 1) Découverte des chemins RSNA\ndef find_rsna_paths():\n    \"\"\"Détecte automatiquement IMAGES_DIR et LABELS_CSV dans /kaggle/input.\"\"\"\n    base = Path('/kaggle/input')\n    images_dir, labels_csv = None, None\n    for p in sorted(base.rglob('*')):\n        if p.is_dir() and 'train_images' in p.name and images_dir is None:\n            images_dir = p\n        if p.is_file() and 'train_labels' in p.name and p.suffix == '.csv':\n            labels_csv = p\n    return images_dir, labels_csv\n \nIMAGES_DIR, LABELS_CSV_PATH = find_rsna_paths()\n \nprint(\"IMAGES_DIR :\", IMAGES_DIR)\nprint(\"LABELS_CSV :\", LABELS_CSV_PATH)\n \nif IMAGES_DIR is None:\n    raise FileNotFoundError(\"Dossier stage_2_train_images introuvable. \"\n                            \"Vérifiez que le dataset RSNA est ajouté au notebook.\")\nif LABELS_CSV_PATH is None:\n    raise FileNotFoundError(\"stage_2_train_labels.csv introuvable.\")\n \nWORKDIR = \"/kaggle/working/rsna_vgg19_densenet121_rnn\"\nos.makedirs(WORKDIR, exist_ok=True)\nprint(\"WORKDIR:\", WORKDIR)\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T13:42:07.626928Z","iopub.execute_input":"2026-05-09T13:42:07.627172Z","iopub.status.idle":"2026-05-09T13:45:10.071929Z","shell.execute_reply.started":"2026-05-09T13:42:07.627153Z","shell.execute_reply":"2026-05-09T13:45:10.071001Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 2) Lecture du CSV + déduplication par patientId\nlabels_raw = pd.read_csv(LABELS_CSV_PATH)\nprint(\"\\nAperçu du CSV brut :\")\nprint(labels_raw.head())\n \n# 1 ligne par patient : Target = max (si ≥1 bbox → Lung Opacity)\npatient_labels = (\n    labels_raw\n    .groupby('patientId', as_index=False)['Target']\n    .max()\n)\nprint(f\"\\nPatients uniques : {len(patient_labels)}\")\nprint(patient_labels['Target'].value_counts()\n      .rename(index={0: 'Normal', 1: 'Lung Opacity'}))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T13:45:10.073654Z","iopub.execute_input":"2026-05-09T13:45:10.074005Z","iopub.status.idle":"2026-05-09T13:45:10.220459Z","shell.execute_reply.started":"2026-05-09T13:45:10.073982Z","shell.execute_reply":"2026-05-09T13:45:10.219791Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 3) Conversion DICOM → PNG avec filtre PA\n#\n# Le filtre PA est appliqué via le tag DICOM ViewPosition (0018,5101)\n# → même sémantique que `is_pa_image()` de la version COVID\n#\n# PNG organisés dans :\n#   /kaggle/working/rsna_organised/train/Normal/\n#   /kaggle/working/rsna_organised/train/Lung_Opacity/\n \nORGANISED  = Path('/kaggle/working/rsna_organised')\nCLS_FOLDER = {0: 'Normal', 1: 'Lung_Opacity'}\n \ndef get_dicom_view_position(dcm_path: str) -> str:\n    \"\"\"Lit le tag ViewPosition du DICOM (retourne '' si absent).\"\"\"\n    try:\n        ds = pydicom.dcmread(dcm_path, stop_before_pixels=True)\n        return str(getattr(ds, 'ViewPosition', '')).upper().strip()\n    except Exception:\n        return ''\n \ndef is_pa_dicom(dcm_path: str) -> bool:\n    \"\"\"\n    Filtre PA équivalent à is_pa_image() de la version COVID.\n    - Si PA_ONLY=False → garde tout\n    - Sinon lit le tag DICOM ViewPosition :\n        'PA'  → gardé\n        'AP'  → rejeté\n        ''    → gardé (information manquante, comportement identique au fallback COVID)\n    \"\"\"\n    if not PA_ONLY:\n        return True\n    vp = get_dicom_view_position(dcm_path)\n    if vp == 'AP':\n        return False        # rejeté explicitement\n    return True             # PA ou inconnu → gardé\n \ndef convert_dcm_to_png(dcm_path: str, out_path: Path) -> bool:\n    \"\"\"Convertit un DICOM en PNG RGB. Retourne False si erreur.\"\"\"\n    try:\n        ds  = pydicom.dcmread(dcm_path)\n        arr = ds.pixel_array.astype(np.float32)\n        arr -= arr.min()\n        if arr.max() > 0:\n            arr /= arr.max()\n        arr = (arr * 255).astype(np.uint8)\n        bgr = cv2.cvtColor(arr, cv2.COLOR_GRAY2BGR)\n        cv2.imwrite(str(out_path), bgr)\n        return True\n    except Exception as e:\n        print(f\"  Erreur {Path(dcm_path).stem} : {e}\")\n        return False\n \nif not ORGANISED.exists():\n    print(\"Conversion DICOM → PNG en cours …\")\n    for folder in CLS_FOLDER.values():\n        (ORGANISED / 'train' / folder).mkdir(parents=True, exist_ok=True)\n \n    pa_kept = pa_rejected = pa_error = 0\n \n    for _, row in tqdm(patient_labels.iterrows(), total=len(patient_labels),\n                       desc='DICOM → PNG'):\n        pid    = row['patientId']\n        target = int(row['Target'])\n        dcm    = str(IMAGES_DIR / f'{pid}.dcm')\n \n        if not Path(dcm).exists():\n            pa_error += 1\n            continue\n \n        if not is_pa_dicom(dcm):       # filtre PA via tag DICOM ViewPosition\n            pa_rejected += 1\n            continue\n \n        out = ORGANISED / 'train' / CLS_FOLDER[target] / f'{pid}.png'\n        if convert_dcm_to_png(dcm, out):\n            pa_kept += 1\n        else:\n            pa_error += 1\n \n    print(f\"Conversion terminée : {pa_kept} gardés, \"\n          f\"{pa_rejected} rejetés (AP), {pa_error} erreurs/manquants\")\nelse:\n    print(\"Dossier organisé déjà existant, conversion ignorée.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T13:45:10.221451Z","iopub.execute_input":"2026-05-09T13:45:10.221859Z","iopub.status.idle":"2026-05-09T14:05:51.307620Z","shell.execute_reply.started":"2026-05-09T13:45:10.221836Z","shell.execute_reply":"2026-05-09T14:05:51.306920Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 4) Construction du DataFrame (même structure que la version COVID)\ndef is_image_file(fname):\n    return fname.lower().endswith((\".png\", \".jpg\", \".jpeg\", \".bmp\", \".webp\"))\n \nrows = []\nfor target, folder in CLS_FOLDER.items():\n    cls_dir = ORGANISED / 'train' / folder\n    label_name = CLASS_NAMES[target]\n    for fpath in sorted(cls_dir.glob('*.png')):\n        rows.append({\n            \"image_id\"      : fpath.name,\n            \"image_path\"    : str(fpath),\n            \"label_name\"    : label_name,\n            \"label\"         : target,\n            \"original_class\": label_name,\n        })\n \ndf = pd.DataFrame(rows, columns=[\"image_id\", \"image_path\", \"label_name\",\n                                  \"label\", \"original_class\"])\nprint(\"Shape:\", df.shape)\nprint(df.head())\nprint(\"\\nClass distribution:\")\nprint(df[\"label_name\"].value_counts())\n \nif df.empty:\n    raise ValueError(\"Aucune image trouvée après conversion/filtrage. \"\n                     f\"IMAGES_DIR={IMAGES_DIR}\")\nif df[\"label\"].nunique() < 2:\n    raise ValueError(\"Au moins 2 classes requises pour la classification.\")\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T14:05:51.308944Z","iopub.execute_input":"2026-05-09T14:05:51.309242Z","iopub.status.idle":"2026-05-09T14:05:51.491136Z","shell.execute_reply.started":"2026-05-09T14:05:51.309217Z","shell.execute_reply":"2026-05-09T14:05:51.490482Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 5) Split 80 / 10 / 10  (inchangé)\ntrain_df, temp_df = train_test_split(\n    df,\n    test_size=(VAL_SIZE + TEST_SIZE),\n    stratify=df[\"label\"],\n    random_state=SEED\n)\nval_df, test_df = train_test_split(\n    temp_df,\n    test_size=TEST_SIZE / (VAL_SIZE + TEST_SIZE),\n    stratify=temp_df[\"label\"],\n    random_state=SEED\n)\n \ntrain_df = train_df.reset_index(drop=True)\nval_df   = val_df.reset_index(drop=True)\ntest_df  = test_df.reset_index(drop=True)\n \nprint(\"Train:\", train_df.shape)\nprint(\"Val  :\", val_df.shape)\nprint(\"Test :\", test_df.shape)\nprint(\"\\nTrain distribution:\")\nprint(train_df[\"label_name\"].value_counts())\nprint(\"\\nVal distribution:\")\nprint(val_df[\"label_name\"].value_counts())\nprint(\"\\nTest distribution:\")\nprint(test_df[\"label_name\"].value_counts())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T14:05:51.492030Z","iopub.execute_input":"2026-05-09T14:05:51.492235Z","iopub.status.idle":"2026-05-09T14:05:51.532878Z","shell.execute_reply.started":"2026-05-09T14:05:51.492216Z","shell.execute_reply":"2026-05-09T14:05:51.532195Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 6) Quick visualization  (inchangé)\nMEAN = [0.485, 0.456, 0.406]\nSTD  = [0.229, 0.224, 0.225]\n \ndef preprocess_pa_rgb(img_bgr, img_size=224):\n    img = cv2.resize(img_bgr, (img_size, img_size))\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    return img.astype(np.uint8)\n \ndef visualize_preprocessing(img_path):\n    img_bgr = cv2.imread(str(img_path))\n    x = preprocess_pa_rgb(img_bgr, IMG_SIZE)\n    fig, axes = plt.subplots(1, 2, figsize=(10, 4))\n    axes[0].imshow(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))\n    axes[0].set_title(\"Original\")\n    axes[1].imshow(x)\n    axes[1].set_title(\"Resize + PA pipeline\")\n    for ax in axes:\n        ax.axis(\"off\")\n    plt.tight_layout()\n    plt.show()\n \nsample_df = train_df.sample(min(6, len(train_df)), random_state=SEED).reset_index(drop=True)\nfor i in range(len(sample_df)):\n    print(sample_df.loc[i, \"original_class\"], \"->\", sample_df.loc[i, \"image_path\"])\n    visualize_preprocessing(sample_df.loc[i, \"image_path\"])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T14:05:51.533666Z","iopub.execute_input":"2026-05-09T14:05:51.534190Z","iopub.status.idle":"2026-05-09T14:05:53.217216Z","shell.execute_reply.started":"2026-05-09T14:05:51.534128Z","shell.execute_reply":"2026-05-09T14:05:53.216512Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 7) PyTorch datasets  (inchangé)\ntrain_transform = A.Compose([\n    A.Resize(IMG_SIZE, IMG_SIZE),\n    A.HorizontalFlip(p=0.5),\n    A.ShiftScaleRotate(shift_limit=0.04, scale_limit=0.08, rotate_limit=8,\n                       border_mode=cv2.BORDER_REFLECT_101, p=0.5),\n    A.RandomBrightnessContrast(brightness_limit=0.08, contrast_limit=0.08, p=0.3),\n    A.GaussianBlur(blur_limit=(3, 5), p=0.15),\n    A.Normalize(mean=MEAN, std=STD),\n    ToTensorV2(),\n])\nval_transform = A.Compose([\n    A.Resize(IMG_SIZE, IMG_SIZE),\n    A.Normalize(mean=MEAN, std=STD),\n    ToTensorV2(),\n])\n \nclass CovidPADataset(Dataset):\n    def __init__(self, df, 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        img_path = self.df.loc[idx, \"image_path\"]\n        label    = int(self.df.loc[idx, \"label\"])\n        img_bgr  = cv2.imread(img_path)\n        x        = preprocess_pa_rgb(img_bgr, IMG_SIZE)\n        if self.transform:\n            x = self.transform(image=x)[\"image\"]\n        return x, label\n \ntrain_ds = CovidPADataset(train_df, transform=train_transform)\nval_ds   = CovidPADataset(val_df,   transform=val_transform)\ntest_ds  = CovidPADataset(test_df,  transform=val_transform)\n \npin = torch.cuda.is_available()\ntrain_dl = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True,\n                      num_workers=NUM_WORKERS, pin_memory=pin, drop_last=True)\nval_dl   = DataLoader(val_ds,   batch_size=BATCH_SIZE, shuffle=False,\n                      num_workers=NUM_WORKERS, pin_memory=pin)\ntest_dl  = DataLoader(test_ds,  batch_size=BATCH_SIZE, shuffle=False,\n                      num_workers=NUM_WORKERS, pin_memory=pin)\n \nprint(\"Train:\", len(train_ds))\nprint(\"Val  :\", len(val_ds))\nprint(\"Test :\", len(test_ds))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T14:05:53.218154Z","iopub.execute_input":"2026-05-09T14:05:53.218466Z","iopub.status.idle":"2026-05-09T14:05:53.245648Z","shell.execute_reply.started":"2026-05-09T14:05:53.218442Z","shell.execute_reply":"2026-05-09T14:05:53.245065Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 8) VGG19-RNN and DenseNet121-RNN  (inchangé)\nNUM_CLASSES = 2\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        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 must be vgg19 or densenet121\")\n \n        self.rnn = nn.GRU(input_size=cnn_channels, hidden_size=hidden_size,\n                          num_layers=1, batch_first=True, bidirectional=True)\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)\n        return self.classifier(pooled)\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\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T14:05:53.247763Z","iopub.execute_input":"2026-05-09T14:05:53.248038Z","iopub.status.idle":"2026-05-09T14:05:58.990143Z","shell.execute_reply.started":"2026-05-09T14:05:53.248009Z","shell.execute_reply":"2026-05-09T14:05:58.989493Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 9) Training  (inchangé)\ncw = compute_class_weight('balanced',\n                          classes=np.unique(train_df[\"label\"]),\n                          y=train_df[\"label\"].values)\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 \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        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    return total_loss / max(1, len(loader)), accuracy_score(labels_all, preds_all)\n \n@torch.no_grad()\ndef validate(model_v, model_d, loader, criterion):\n    model_v.eval(); model_d.eval()\n    total_loss, preds_all, labels_all, probs_all = 0.0, [], [], []\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    acc        = accuracy_score(labels_all, preds_all)\n    f1         = f1_score(labels_all, preds_all, average='weighted')\n    probs_arr  = np.array(probs_all, dtype=np.float32)\n    labels_arr = np.array(labels_all)\n    auc = 0.5 if len(np.unique(labels_arr)) < 2 else roc_auc_score(labels_arr, probs_arr)\n    return total_loss / max(1, len(loader)), acc, f1, auc\n \nos.makedirs(\"checkpoints\", exist_ok=True)\nbest_acc, best_state_v, best_state_d = 0.0, None, None\nhistory      = []\npatience_ctr = 0\n \nprint(f\"Entraînement : {EPOCHS} epochs  (patience={PATIENCE})\")\nprint(f\"   Modèle A   : VGG19-RNN\")\nprint(f\"   Modèle B   : DenseNet121-RNN\")\nprint(f\"   Input      : PA only + resize + augmentation + normalization\")\nprint(f\"   Split      : Train {len(train_df)} / Val {len(val_df)} / Test {len(test_df)}\")\nprint(\"=\" * 75)\n \nfor epoch in range(EPOCHS):\n    tr_loss, tr_acc              = train_epoch(vgg19_rnn, densenet121_rnn,\n                                               train_dl, optimizer_v, optimizer_d,\n                                               criterion, scaler)\n    vl_loss, vl_acc, vl_f1, vl_auc = validate(vgg19_rnn, densenet121_rnn,\n                                               val_dl, criterion)\n    scheduler_v.step(); scheduler_d.step()\n \n    history.append({\"epoch\": epoch+1, \"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 is not None:\n    vgg19_rnn.load_state_dict(best_state_v)\nif best_state_d is not None:\n    densenet121_rnn.load_state_dict(best_state_d)\n \nhistory_df = pd.DataFrame(history)\nprint(history_df.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T14:05:58.991165Z","iopub.execute_input":"2026-05-09T14:05:58.991534Z","iopub.status.idle":"2026-05-09T14:26:57.859824Z","shell.execute_reply.started":"2026-05-09T14:05:58.991510Z","shell.execute_reply":"2026-05-09T14:26:57.858711Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 10) Evaluation  (inchangée)\ny_true, y_pred, y_prob = [], [], []\nvgg19_rnn.eval(); densenet121_rnn.eval()\n \nwith torch.no_grad():\n    for imgs, labels in tqdm(test_dl, desc='Eval 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        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        y_true.extend(labels.numpy())\n        y_pred.extend(logits_e.argmax(1).cpu().numpy())\n        y_prob.extend(probs.cpu().numpy())\n \ny_true = np.array(y_true)\ny_pred = np.array(y_pred)\ny_prob = np.array(y_prob, dtype=np.float32)\n \naccuracy  = accuracy_score(y_true, y_pred)\nprecision = precision_score(y_true, y_pred, average='weighted', zero_division=0)\nrecall    = recall_score(y_true, y_pred,    average='weighted', zero_division=0)\nf1        = f1_score(y_true, y_pred,        average='weighted', zero_division=0)\nauc       = 0.5 if len(np.unique(y_true)) < 2 else roc_auc_score(y_true, y_prob)\n \nprint(f'Accuracy  : {accuracy  * 100:.2f}%')\nprint(f'Precision : {precision * 100:.2f}%')\nprint(f'Recall    : {recall    * 100:.2f}%')\nprint(f'F1-Score  : {f1        * 100:.2f}%')\nprint(f'AUC       : {auc       * 100:.2f}%')\nprint()\nprint(\"Classification report:\")\nprint(classification_report(y_true, y_pred, target_names=CLASS_NAMES, zero_division=0))\nprint(\"Confusion matrix:\")\nprint(confusion_matrix(y_true, y_pred))\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T14:26:57.867361Z","iopub.execute_input":"2026-05-09T14:26:57.867923Z","iopub.status.idle":"2026-05-09T14:27:18.581176Z","shell.execute_reply.started":"2026-05-09T14:26:57.867890Z","shell.execute_reply":"2026-05-09T14:27:18.580146Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ## 11) Learning curves  (inchangées)\nhistory_df = pd.DataFrame(history)\nprint(history_df.head())\n \nplt.figure(figsize=(8, 5))\nplt.plot(history_df[\"epoch\"], history_df[\"tr_acc\"], label=\"train_acc\")\nplt.plot(history_df[\"epoch\"], history_df[\"vl_acc\"], label=\"val_acc\")\nplt.xlabel(\"Epoch\"); plt.ylabel(\"Accuracy\"); plt.legend(); plt.show()\n \nplt.figure(figsize=(8, 5))\nplt.plot(history_df[\"epoch\"], history_df[\"tr_loss\"], label=\"train_loss\")\nplt.plot(history_df[\"epoch\"], history_df[\"vl_loss\"], label=\"val_loss\")\nplt.xlabel(\"Epoch\"); plt.ylabel(\"Loss\"); plt.legend(); plt.show()\n \nplt.figure(figsize=(8, 5))\nplt.plot(history_df[\"epoch\"], history_df[\"vl_f1\"],  label=\"val_f1\")\nplt.plot(history_df[\"epoch\"], history_df[\"vl_auc\"], label=\"val_auc\")\nplt.xlabel(\"Epoch\"); plt.ylabel(\"Score\"); plt.legend(); plt.show()\n \ncm = confusion_matrix(y_true, y_pred)\nplt.figure(figsize=(8, 6))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Blues',\n            xticklabels=CLASS_NAMES, yticklabels=CLASS_NAMES)\nplt.xlabel('Predicted Labels')\nplt.ylabel('True Labels')\nplt.title('Confusion Matrix — VGG19-RNN + DenseNet121-RNN  (RSNA)')\nplt.tight_layout()\nplt.show()\n \nfpr, tpr, _ = roc_curve(y_true, y_prob)\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')\nplt.ylabel('True Positive Rate')\nplt.title('ROC Curve')\nplt.legend(loc='lower right')\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T14:27:18.583127Z","iopub.execute_input":"2026-05-09T14:27:18.583574Z","iopub.status.idle":"2026-05-09T14:27:49.677310Z","shell.execute_reply.started":"2026-05-09T14:27:18.583543Z","shell.execute_reply":"2026-05-09T14:27:49.676761Z"}},"outputs":[],"execution_count":null}]}