{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.10"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"875c9ab8-f8df-4c97-829e-f64d57fa811d","cell_type":"markdown","source":"# 3. Ensembling — EfficientNet-B4 + EfficientNet-B5\n\n## Objectif\n\nUn modèle unique, aussi bien réglé soit-il, présente des biais propres à son architecture : pour un jeu d'hyperparamètres et une profondeur de réseau donnés, certaines erreurs de prédiction ont tendance à se répéter de façon systématique. Une stratégie courante pour réduire ces biais et améliorer la robustesse globale d'un système de prédiction consiste à combiner plusieurs modèles différents — c'est le principe de **l'ensembling**.\n\nCe notebook entraîne un second modèle, **EfficientNet-B5** (une architecture plus profonde que le B4 utilisé dans le notebook précédent), selon exactement la même méthodologie de régression ordinale, puis combine ses prédictions avec celles du premier modèle. L'hypothèse sous-jacente est que deux architectures différentes, entraînées sur les mêmes données, ne commettent pas exactement les mêmes erreurs : en moyennant leurs prédictions, les erreurs propres à chaque modèle ont tendance à se compenser partiellement, ce qui améliore la performance globale de l'ensemble par rapport à chaque modèle pris isolément.\n\n## Prérequis\n\nCe notebook réutilise deux éléments produits par les notebooks précédents :\n- le **cache d'images prétraitées** généré par `01_cache_builder` (recadrage de la rétine et amélioration de contraste) ;\n- le **modèle entraîné et les scores de prédiction bruts** produits par `02_modele_base_efficientnet_b4`, nécessaires pour comparer et combiner les deux modèles.\n\nLe split entre données d'entraînement et de validation est reconstruit avec la même graine aléatoire (`SEED = 42`) que dans le notebook précédent, ce qui garantit que les deux modèles sont entraînés et évalués sur exactement les mêmes patients — condition indispensable pour que leur combinaison soit méthodologiquement valide. Cette cohérence est vérifiée explicitement dans ce notebook avant de poursuivre.\n\n## Méthodologie\n\n1. Entraînement du modèle 2 (EfficientNet-B5) selon la même approche que le modèle 1 (régression, deux phases de résolution croissante, Test-Time Augmentation).\n2. Recherche, sur le jeu de calibration, du poids optimal de combinaison entre les scores continus des deux modèles.\n3. Fusion des prédictions des deux yeux sur les scores combinés, selon la même logique que dans le notebook précédent.\n4. Génération de la soumission finale à partir de l'ensemble des deux modèles.\n","metadata":{}},{"id":"9fa6bb6b-eaba-45fe-8d46-ef29e5f1ec3c","cell_type":"code","source":"# ============================================================\n# CONFIGURATION GÉNÉRALE (identique à V11, SEED=42 pour reproduire le meme split)\n# ============================================================\n\nimport os\nimport gc\nimport cv2\nimport time\nimport random\nimport zipfile\nimport warnings\nfrom pathlib import Path\nfrom concurrent.futures import ThreadPoolExecutor\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\n\nfrom tqdm.auto import tqdm\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import cohen_kappa_score, accuracy_score, f1_score, classification_report\nfrom scipy.optimize import minimize\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader, WeightedRandomSampler\nfrom torchvision import models\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nwarnings.filterwarnings(\"ignore\")\n\nSEED = 42  # IMPORTANT : meme seed que v11_secure pour retomber sur le meme split patient\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.cuda.manual_seed_all(SEED)\n\ntorch.backends.cudnn.benchmark = True\ncv2.setNumThreads(1)\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nN_GPUS = torch.cuda.device_count()\n\nprint(\"PyTorch :\", torch.__version__)\nprint(\"Device  :\", DEVICE)\n\nif N_GPUS == 0:\n    raise RuntimeError(\"Aucun GPU detecte. Kaggle -> Settings -> Accelerator -> GPU T4 x2.\")\n\nCOMPETITION_DIR = Path(\"/kaggle/input/competitions/diabetic-retinopathy-detection\")\n\nTRAIN_IMAGE_DIR = Path(\n    \"/kaggle/input/datasets/josephrynkiewicz/\"\n    \"diabetic-retinopathy-train-unzipped/train\"\n)\n\nTEST_IMAGE_DIR = Path(\n    \"/kaggle/input/datasets/josephrynkiewicz/\"\n    \"diabetic-retinopathy-test-unzipped/test\"\n)\n\nTRAIN_LABELS_PATH = COMPETITION_DIR / \"trainLabels.csv.zip\"\nSAMPLE_SUBMISSION_PATH = COMPETITION_DIR / \"sampleSubmission.csv.zip\"\n\nWORK_DIR = Path(\"/kaggle/working/dr_v11_ensemble\")\nTRAIN_GRAHAM_DIR = WORK_DIR / \"graham_train_512\"\nTEST_GRAHAM_DIR = WORK_DIR / \"graham_test_512\"\nCHECKPOINT_DIR = WORK_DIR / \"checkpoints\"\n\nfor d in [WORK_DIR, TRAIN_GRAHAM_DIR, TEST_GRAHAM_DIR, CHECKPOINT_DIR]:\n    d.mkdir(parents=True, exist_ok=True)\n\nBEST_MODEL2_PATH = CHECKPOINT_DIR / \"effnet_b5_model2_best.pth\"\nHISTORY2_PATH = WORK_DIR / \"history_model2.csv\"\nVAL_SCORES_MODEL2_PATH = WORK_DIR / \"val_scores_model2.csv\"\nTEST_SCORES_MODEL2_PATH = WORK_DIR / \"test_scores_model2.csv\"\n\nTRAIN_CACHE_ARCHIVE = Path(\"/kaggle/working/v11_graham_train_512.zip\")\nTEST_CACHE_ARCHIVE = Path(\"/kaggle/working/v11_graham_test_512.zip\")\n\n# Phases pour le modele 2 : budget reduit par rapport a V11 pour tenir dans le temps GPU\n# (a ajuster selon votre quota reel observe apres le reset)\nPHASES = [\n    {\n        \"name\": \"384\",\n        \"image_size\": 384,\n        \"epochs\": 2,\n        \"batch_size\": 8,\n        \"backbone_lr\": 8e-5,\n        \"head_lr\": 4e-4,\n        \"sampling_alphas\": [0.35, 0.28],\n    },\n    {\n        \"name\": \"512\",\n        \"image_size\": 512,\n        \"epochs\": 4,\n        \"batch_size\": 4,\n        \"backbone_lr\": 3e-5,\n        \"head_lr\": 1.2e-4,\n        \"sampling_alphas\": [0.24, 0.16, 0.08, 0.04],\n    },\n]\n\nNUM_WORKERS = 2\nUSE_AMP = torch.cuda.is_available()\n\nprint(\"Phases modele 2 :\", [(p[\"name\"], p[\"epochs\"]) for p in PHASES])\n","metadata":{},"outputs":[],"execution_count":null},{"id":"a58feb0a-2e47-4b1d-846b-14c328ebc577","cell_type":"markdown","source":"## 1. Récupération des fichiers du modèle 1\n\nOn récupère ici le checkpoint entraîné du modèle 1 (EfficientNet-B4), ainsi que ses scores de prédiction bruts déjà calculés sur les jeux de validation et de test dans le notebook `02_modele_base_efficientnet_b4`. Ces scores bruts, obtenus avant toute application de seuils, sont ce qui permet de construire la combinaison pondérée entre les deux modèles présentée en section 8.\n","metadata":{}},{"id":"0b3f501f-5eeb-41aa-944f-67e1145b1185","cell_type":"code","source":"# ============================================================\n# LOCALISER LES FICHIERS DU MODELE 1 (importes via \"Add Input\" -> Notebooks)\n# ============================================================\n\nprint(\"Contenu de /kaggle/input :\")\nfor p in sorted(Path(\"/kaggle/input\").iterdir()):\n    print(\" -\", p)\n\ndef find_file(filename):\n    matches = list(Path(\"/kaggle/input\").rglob(filename))\n    return matches[0] if matches else None\n\nMODEL1_CHECKPOINT = find_file(\"effnet_b4_v11_t4_best.pth\")\nMODEL1_VAL_SCORES = find_file(\"val_scores_model1.csv\")\nMODEL1_TEST_SCORES = find_file(\"test_scores_model1.csv\")\n\nprint(\"\\nCheckpoint modele 1 :\", MODEL1_CHECKPOINT)\nprint(\"Scores val modele 1  :\", MODEL1_VAL_SCORES)\nprint(\"Scores test modele 1 :\", MODEL1_TEST_SCORES)\n\nif MODEL1_CHECKPOINT is None or MODEL1_VAL_SCORES is None or MODEL1_TEST_SCORES is None:\n    raise RuntimeError(\n        \"Fichiers du modele 1 introuvables. Verifiez que le notebook v11_secure \"\n        \"committe a bien ete ajoute en Input (bouton 'Add Input' -> Notebooks).\"\n    )\n","metadata":{},"outputs":[],"execution_count":null},{"id":"2873fca2-eb06-457e-b0ba-40530a5166d6","cell_type":"markdown","source":"## 2. Reproduction du split entraînement / validation\n\nLe split entre données d'entraînement et de validation, ainsi que la séparation entre les sous-ensembles de calibration et d'évaluation, sont reconstruits ici avec exactement la même graine aléatoire que dans le notebook `02_modele_base_efficientnet_b4`. Une vérification explicite est effectuée pour confirmer que les images de validation utilisées ici correspondent bien à celles pour lesquelles les scores du modèle 1 ont été calculés, condition nécessaire pour que la combinaison des deux modèles soit valide.\n","metadata":{}},{"id":"a5ae7e54-3867-4cb5-a550-bd588e99ac56","cell_type":"code","source":"# ============================================================\n# CHARGEMENT DES DONNÉES ET SPLIT PAR PATIENT (identique a v11_secure)\n# ============================================================\n\ndf = pd.read_csv(TRAIN_LABELS_PATH)\ndf[\"patient_id\"] = df[\"image\"].str.rsplit(\"_\", n=1).str[0]\ndf[\"eye\"] = df[\"image\"].str.rsplit(\"_\", n=1).str[1]\n\npatient_df = (\n    df.groupby(\"patient_id\", as_index=False)[\"level\"]\n    .max()\n    .rename(columns={\"level\": \"patient_level\"})\n)\n\ntrain_patients, val_patients = train_test_split(\n    patient_df, test_size=0.20, random_state=SEED, stratify=patient_df[\"patient_level\"]\n)\n\nTRAIN_PATIENTS = set(train_patients[\"patient_id\"])\nVAL_PATIENTS = set(val_patients[\"patient_id\"])\n\ntrain_df = df[df[\"patient_id\"].isin(TRAIN_PATIENTS)].copy().reset_index(drop=True)\nval_df = df[df[\"patient_id\"].isin(VAL_PATIENTS)].copy().reset_index(drop=True)\n\nval_patient_df = (\n    val_df.groupby(\"patient_id\", as_index=False)[\"level\"]\n    .max()\n    .rename(columns={\"level\": \"patient_level\"})\n)\n\ncalibration_patients, evaluation_patients = train_test_split(\n    val_patient_df, test_size=0.50, random_state=SEED, stratify=val_patient_df[\"patient_level\"]\n)\n\nCALIBRATION_IDS = set(calibration_patients[\"patient_id\"].astype(str))\nEVALUATION_IDS = set(evaluation_patients[\"patient_id\"].astype(str))\n\nprint(\"Train :\", len(train_df), \"| Val :\", len(val_df))\nprint(\"Calibration patients :\", len(CALIBRATION_IDS), \"| Evaluation patients :\", len(EVALUATION_IDS))\n\n# Verification de coherence avec les scores du modele 1 deja calcules\nmodel1_val_scores = pd.read_csv(MODEL1_VAL_SCORES)\ncommon_images = set(model1_val_scores[\"image\"]) & set(val_df[\"image\"])\nprint(\n    f\"\\nVerification coherence split : {len(common_images)}/{len(val_df)} \"\n    \"images de validation en commun avec le modele 1 (doit etre proche de 100%).\"\n)\nif len(common_images) < 0.95 * len(val_df):\n    print(\"ATTENTION : le split ne correspond pas bien a celui du modele 1. \"\n          \"Verifiez que SEED=42 est identique et que les donnees sont les memes.\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"d0896ee5-90de-48b0-bf19-6bbf0f94a1f8","cell_type":"markdown","source":"## 3. Prétraitement des images\n\nLe prétraitement des images (recadrage de la rétine et amélioration de contraste selon la méthode de Ben Graham) a déjà été réalisé dans le notebook `01_cache_builder`. On réutilise ici directement le cache généré, ce qui permet de démarrer immédiatement l'entraînement du second modèle sans refaire ce traitement.\n","metadata":{}},{"id":"2eac51cc-7e9f-4942-8bdb-5862ff52ffff","cell_type":"code","source":"# ============================================================\n# RESTAURER LE CACHE GRAHAM (partage avec V11 si le meme dataset est attache)\n# ============================================================\n\ndef find_attached_archive(filename):\n    matches = list(Path(\"/kaggle/input\").rglob(filename))\n    return matches[0] if matches else None\n\n\ndef restore_cache(archive_name, destination_dir, expected_min_files):\n    destination_dir.mkdir(parents=True, exist_ok=True)\n    current = len(list(destination_dir.glob(\"*.jpg\")))\n\n    if current >= expected_min_files:\n        print(f\"Cache deja present : {current} fichiers\")\n        return True\n\n    archive = find_attached_archive(archive_name)\n    if archive is None:\n        print(f\"{archive_name} non trouve dans /kaggle/input.\")\n        return False\n\n    print(\"Cache trouve :\", archive)\n    start = time.time()\n    with zipfile.ZipFile(archive, \"r\") as zf:\n        zf.extractall(destination_dir)\n\n    count = len(list(destination_dir.glob(\"*.jpg\")))\n    print(f\"Cache restaure : {count} images en {(time.time()-start)/60:.1f} min\")\n    return count >= expected_min_files\n\n\nIMAGE_SIZE_CACHE = 512\nJPEG_QUALITY = 92\n\n\ndef crop_retina(image):\n    intensity = image.max(axis=2)\n    mask = (intensity > 7).astype(np.uint8)\n    if mask.sum() == 0:\n        return image\n    kernel = np.ones((5, 5), np.uint8)\n    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)\n    n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)\n    if n_labels <= 1:\n        return image\n    component = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA])\n    x = stats[component, cv2.CC_STAT_LEFT]\n    y = stats[component, cv2.CC_STAT_TOP]\n    w = stats[component, cv2.CC_STAT_WIDTH]\n    h = stats[component, cv2.CC_STAT_HEIGHT]\n    mx = int(w * 0.015)\n    my = int(h * 0.015)\n    x1 = max(0, x - mx)\n    y1 = max(0, y - my)\n    x2 = min(image.shape[1], x + w + mx)\n    y2 = min(image.shape[0], y + h + my)\n    crop = image[y1:y2, x1:x2]\n    return crop if crop.size else image\n\n\ndef pad_square(image):\n    h, w = image.shape[:2]\n    side = max(h, w)\n    result = np.zeros((side, side, 3), dtype=np.uint8)\n    top = (side - h) // 2\n    left = (side - w) // 2\n    result[top:top+h, left:left+w] = image\n    return result\n\n\ndef create_natural(image):\n    image = crop_retina(image)\n    image = pad_square(image)\n    return cv2.resize(image, (IMAGE_SIZE_CACHE, IMAGE_SIZE_CACHE), interpolation=cv2.INTER_AREA)\n\n\ndef create_graham(image):\n    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n    retina_mask = gray > 5\n    blurred = cv2.GaussianBlur(image, (0, 0), sigmaX=IMAGE_SIZE_CACHE / 30)\n    processed = cv2.addWeighted(image, 4.0, blurred, -4.0, 128)\n    processed = np.clip(processed, 0, 255).astype(np.uint8)\n    processed[~retina_mask] = 0\n    return processed\n\n\ndef process_train_image(image_id):\n    dst = TRAIN_GRAHAM_DIR / f\"{image_id}.jpg\"\n    if dst.exists():\n        return True\n    src = TRAIN_IMAGE_DIR / f\"{image_id}.jpeg\"\n    image = cv2.imread(str(src), cv2.IMREAD_COLOR)\n    if image is None:\n        return False\n    graham = create_graham(create_natural(image))\n    return bool(cv2.imwrite(str(dst), graham, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]))\n\n\nTRAIN_CACHE_RESTORED = restore_cache(\"v11_graham_train_512.zip\", TRAIN_GRAHAM_DIR, 35000)\n\nif TRAIN_CACHE_RESTORED:\n    print(\"Cache train deja restaure : preprocessing ignore.\")\nelse:\n    print(\"Creation cache TRAIN 512 (peut prendre du temps la 1ere fois)\")\n    workers = min(8, os.cpu_count() or 4)\n    start = time.time()\n    with ThreadPoolExecutor(max_workers=workers) as executor:\n        results = list(tqdm(\n            executor.map(process_train_image, df[\"image\"].astype(str).tolist()),\n            total=len(df), desc=\"Graham train\"\n        ))\n    print(f\"Cache train termine en {(time.time()-start)/60:.1f} min\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"96f5c2d0-033f-4202-96e4-cdea5982e48e","cell_type":"markdown","source":"## 4. Data augmentation, Dataset et échantillonnage pondéré\n\nLes mêmes transformations d'augmentation de données, la même classe `Dataset` PyTorch et la même stratégie d'échantillonnage pondéré que dans le notebook précédent sont réutilisées ici à l'identique, afin que les deux modèles soient entraînés dans des conditions strictement comparables — seule l'architecture du réseau change entre les deux notebooks.\n","metadata":{}},{"id":"e4f5949c-0395-41ec-a7b8-9a1852ec5b55","cell_type":"code","source":"# ============================================================\n# AUGMENTATION / DATASET / SAMPLER (identique a V11)\n# ============================================================\n\nIMAGENET_MEAN = (0.485, 0.456, 0.406)\nIMAGENET_STD = (0.229, 0.224, 0.225)\n\n\ndef train_transform(image_size):\n    return A.Compose([\n        A.Resize(image_size, image_size),\n        A.HorizontalFlip(p=0.50),\n        A.VerticalFlip(p=0.50),\n        A.Affine(\n            scale=(0.90, 1.10), translate_percent=(-0.05, 0.05),\n            rotate=(-180, 180), shear=(-5, 5),\n            border_mode=cv2.BORDER_CONSTANT, fill=0, p=0.85\n        ),\n        A.OneOf([\n            A.RandomBrightnessContrast(brightness_limit=0.10, contrast_limit=0.10, p=1.0),\n            A.RandomGamma(gamma_limit=(88, 112), p=1.0),\n        ], p=0.25),\n        A.OneOf([\n            A.GaussianBlur(blur_limit=(3, 5), p=1.0),\n            A.GaussNoise(std_range=(0.005, 0.020), p=1.0),\n        ], p=0.05),\n        A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),\n        ToTensorV2(),\n    ])\n\n\ndef val_transform(image_size):\n    return A.Compose([\n        A.Resize(image_size, image_size),\n        A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),\n        ToTensorV2(),\n    ])\n\n\nclass DRDataset(Dataset):\n\n    def __init__(self, dataframe, image_dir, transform, validation=False):\n        self.df = dataframe.reset_index(drop=True)\n        self.image_dir = Path(image_dir)\n        self.transform = transform\n        self.validation = validation\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        image_id = str(row[\"image\"])\n        image = cv2.imread(str(self.image_dir / f\"{image_id}.jpg\"), cv2.IMREAD_COLOR)\n        if image is None:\n            raise RuntimeError(f\"Image introuvable : {image_id}\")\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        image = self.transform(image=image)[\"image\"]\n        target = torch.tensor(float(row[\"level\"]), dtype=torch.float32)\n\n        if not self.validation:\n            return image, target\n\n        return {\n            \"image\": image, \"target\": target, \"image_id\": image_id,\n            \"patient_id\": str(row[\"patient_id\"]), \"eye\": str(row[\"eye\"]),\n        }\n\n\ndef make_weighted_sampler(labels, alpha):\n    labels = np.asarray(labels, dtype=np.int64)\n    counts = np.bincount(labels, minlength=5)\n    class_weights = (len(labels) / counts) ** float(alpha)\n    sample_weights = class_weights[labels]\n    return WeightedRandomSampler(\n        weights=torch.tensor(sample_weights, dtype=torch.double),\n        num_samples=len(labels), replacement=True\n    )\n","metadata":{},"outputs":[],"execution_count":null},{"id":"d809e1ea-1aae-4a0c-91fe-4d4825663830","cell_type":"markdown","source":"## 5. Modèle 2 — EfficientNet-B5\n\nLe second modèle repose sur l'architecture **EfficientNet-B5**, plus profonde et disposant d'un plus grand nombre de paramètres que le B4 utilisé pour le premier modèle. Ce choix est délibéré : on cherche à maximiser la diversité entre les deux modèles de l'ensemble, afin que leurs erreurs de prédiction respectives soient les moins corrélées possible. Deux modèles trop similaires (même architecture, mêmes hyperparamètres) auraient tendance à commettre les mêmes erreurs, ce qui limiterait fortement le bénéfice de leur combinaison.\n\nComme pour le modèle 1, la couche de classification d'origine est remplacée par une couche de `Dropout` suivie d'une couche linéaire à sortie unique, afin de conserver la même formulation en régression ordinale.\n","metadata":{}},{"id":"d7510f0d-7eaf-45a4-975d-dce7af1f277f","cell_type":"code","source":"# ============================================================\n# MODEL 2 — EfficientNet-B5 (regression, comme V11)\n# ============================================================\n\ndef create_model2():\n    network = models.efficientnet_b5(weights=models.EfficientNet_B5_Weights.DEFAULT)\n    features = network.classifier[1].in_features\n    network.classifier = nn.Sequential(\n        nn.Dropout(p=0.40),\n        nn.Linear(features, 1)\n    )\n    return network\n\n\nmodel = create_model2().to(DEVICE)\nprint(\"GPU utilise :\", torch.cuda.get_device_name(0))\n\n\ndef raw_model():\n    return model.module if isinstance(model, nn.DataParallel) else model\n\n\nprint(raw_model().classifier)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"37778a68-8837-4683-91d7-0839b3dfb7c8","cell_type":"markdown","source":"## 6. QWK, seuils et boucle d'entraînement\n\nLa définition des seuils de décision, l'optimisation par calibration et la boucle d'entraînement progressif en deux phases de résolution (384 puis 512 pixels) suivent exactement la même logique que dans le notebook précédent. Seul le nombre d'époques de la seconde phase diffère légèrement (4 au lieu de 5), ce qui n'affecte pas la comparabilité des deux modèles dans la mesure où le meilleur checkpoint de chaque modèle est systématiquement sélectionné selon le QWK mesuré sur le jeu d'évaluation.\n","metadata":{}},{"id":"11f1bfd4-b17a-42de-b3f2-6fd0db68de46","cell_type":"code","source":"# ============================================================\n# QWK / THRESHOLDS (identique a V11)\n# ============================================================\n\ndef apply_thresholds(scores, thresholds):\n    return np.digitize(scores, np.asarray(thresholds))\n\n\ndef qwk(truth, pred):\n    return cohen_kappa_score(truth, pred, weights=\"quadratic\")\n\n\ndef threshold_score(thresholds, truth, scores):\n    thresholds = np.asarray(thresholds, dtype=np.float64)\n    if not np.isfinite(thresholds).all():\n        return -1.0\n    if np.any(np.diff(thresholds) <= 0.02):\n        return -1.0\n    pred = apply_thresholds(scores, thresholds)\n    return qwk(truth, pred)\n\n\ndef optimize_thresholds(truth, scores):\n    truth = np.asarray(truth, dtype=np.int64)\n    scores = np.asarray(scores, dtype=np.float64)\n\n    starts = [\n        [0.50, 1.50, 2.50, 3.50], [0.65, 1.30, 2.10, 2.90],\n        [0.70, 1.40, 2.20, 3.00], [0.80, 1.60, 2.40, 3.20],\n    ]\n\n    best_thresholds, best_score = None, -1.0\n\n    for start in starts:\n        result = minimize(\n            lambda x: -threshold_score(x, truth, scores),\n            x0=np.asarray(start, dtype=np.float64),\n            method=\"Nelder-Mead\",\n            options={\"maxiter\": 700, \"xatol\": 1e-4, \"fatol\": 1e-5}\n        )\n        thresholds = result.x\n        score = threshold_score(thresholds, truth, scores)\n        if score > best_score:\n            best_score = score\n            best_thresholds = thresholds.copy()\n\n    if best_thresholds is None:\n        best_thresholds = np.array([0.50, 1.50, 2.50, 3.50])\n        best_score = threshold_score(best_thresholds, truth, scores)\n        if best_score < 0:\n            best_thresholds = np.quantile(scores, [0.2, 0.4, 0.6, 0.8])\n            best_score = qwk(truth, apply_thresholds(scores, best_thresholds))\n\n    return best_thresholds, best_score\n\n\n@torch.no_grad()\ndef predict_validation(image_size):\n    model.eval()\n    dataset = DRDataset(val_df, TRAIN_GRAHAM_DIR, val_transform(image_size), validation=True)\n    loader = DataLoader(dataset, batch_size=8, shuffle=False, num_workers=NUM_WORKERS, pin_memory=True)\n\n    rows = []\n    for batch in tqdm(loader, desc=f\"Validation {image_size}\"):\n        images = batch[\"image\"].to(DEVICE, non_blocking=True)\n        with torch.autocast(device_type=\"cuda\", dtype=torch.float16, enabled=USE_AMP):\n            scores = model(images).squeeze(1).float()\n        scores_np = scores.cpu().numpy()\n        targets_np = batch[\"target\"].numpy().astype(int)\n        for i in range(len(scores_np)):\n            rows.append({\n                \"image\": batch[\"image_id\"][i], \"patient_id\": str(batch[\"patient_id\"][i]),\n                \"eye\": batch[\"eye\"][i], \"level\": int(targets_np[i]), \"score\": float(scores_np[i]),\n            })\n    return pd.DataFrame(rows)\n\n\ndef honest_qwk_from_rows(rows):\n    cal_mask = rows[\"patient_id\"].isin(CALIBRATION_IDS)\n    eval_mask = rows[\"patient_id\"].isin(EVALUATION_IDS)\n\n    thresholds, cal_qwk = optimize_thresholds(\n        rows.loc[cal_mask, \"level\"].to_numpy(), rows.loc[cal_mask, \"score\"].to_numpy()\n    )\n    eval_pred = apply_thresholds(rows.loc[eval_mask, \"score\"].to_numpy(), thresholds)\n    eval_qwk = qwk(rows.loc[eval_mask, \"level\"].to_numpy(), eval_pred)\n    full_pred = apply_thresholds(rows[\"score\"].to_numpy(), thresholds)\n    full_qwk = qwk(rows[\"level\"].to_numpy(), full_pred)\n\n    return {\"thresholds\": thresholds, \"calibration_qwk\": cal_qwk, \"evaluation_qwk\": eval_qwk, \"full_qwk\": full_qwk}\n","metadata":{},"outputs":[],"execution_count":null},{"id":"4ff53a6b-d062-4ff0-8c4d-96bdb9843be8","cell_type":"code","source":"# ============================================================\n# ENTRAÎNEMENT PROGRESSIF DU MODÈLE 2\n# ============================================================\n\ncriterion = nn.MSELoss()\nbest_eval_qwk2 = -1.0\nhistory2 = []\nscaler = torch.amp.GradScaler(\"cuda\", enabled=USE_AMP)\n\n\ndef build_optimizer(backbone_lr, head_lr):\n    backbone_params, head_params = [], []\n    for name, param in raw_model().named_parameters():\n        if \"classifier\" in name:\n            head_params.append(param)\n        else:\n            backbone_params.append(param)\n    return torch.optim.AdamW(\n        [{\"params\": backbone_params, \"lr\": backbone_lr}, {\"params\": head_params, \"lr\": head_lr}],\n        weight_decay=1e-4\n    )\n\n\nglobal_epoch = 0\n\nfor phase in PHASES:\n    image_size = phase[\"image_size\"]\n    print(\"\\n\" + \"=\" * 72)\n    print(f\"MODELE 2 - PHASE {phase['name']} - {image_size}x{image_size}\")\n    print(\"=\" * 72)\n\n    optimizer = build_optimizer(phase[\"backbone_lr\"], phase[\"head_lr\"])\n\n    for local_epoch in range(phase[\"epochs\"]):\n        global_epoch += 1\n        sampling_alpha = phase[\"sampling_alphas\"][local_epoch]\n\n        train_dataset = DRDataset(train_df, TRAIN_GRAHAM_DIR, train_transform(image_size), validation=False)\n        sampler = make_weighted_sampler(train_df[\"level\"].to_numpy(), alpha=sampling_alpha)\n        train_loader = DataLoader(\n            train_dataset, batch_size=phase[\"batch_size\"], sampler=sampler,\n            num_workers=NUM_WORKERS, pin_memory=True, drop_last=True\n        )\n\n        model.train()\n        running_loss, n_seen = 0.0, 0\n\n        pbar = tqdm(train_loader, desc=f\"Epoch {global_epoch} alpha={sampling_alpha:.2f}\")\n        for images, targets in pbar:\n            images = images.to(DEVICE, non_blocking=True)\n            targets = targets.to(DEVICE, non_blocking=True)\n            optimizer.zero_grad(set_to_none=True)\n\n            with torch.autocast(device_type=\"cuda\", dtype=torch.float16, enabled=USE_AMP):\n                pred = model(images).squeeze(1)\n                loss = criterion(pred, targets)\n\n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n\n            bs = images.size(0)\n            running_loss += loss.item() * bs\n            n_seen += bs\n            pbar.set_postfix(loss=f\"{running_loss/n_seen:.4f}\")\n\n        rows = predict_validation(image_size)\n        metrics = honest_qwk_from_rows(rows)\n        epoch_loss = running_loss / n_seen\n\n        print(\n            f\"\\nEpoch {global_epoch} | loss={epoch_loss:.4f} | \"\n            f\"cal_QWK={metrics['calibration_qwk']:.4f} | \"\n            f\"eval_QWK={metrics['evaluation_qwk']:.4f} | full_QWK={metrics['full_qwk']:.4f}\"\n        )\n\n        history2.append({\n            \"epoch\": global_epoch, \"phase\": phase[\"name\"], \"image_size\": image_size,\n            \"sampling_alpha\": sampling_alpha, \"train_loss\": epoch_loss,\n            \"calibration_qwk\": metrics[\"calibration_qwk\"],\n            \"evaluation_qwk\": metrics[\"evaluation_qwk\"], \"full_qwk\": metrics[\"full_qwk\"],\n        })\n        pd.DataFrame(history2).to_csv(HISTORY2_PATH, index=False)\n\n        if metrics[\"evaluation_qwk\"] > best_eval_qwk2:\n            best_eval_qwk2 = metrics[\"evaluation_qwk\"]\n            torch.save({\n                \"model_state_dict\": raw_model().state_dict(),\n                \"evaluation_qwk\": best_eval_qwk2,\n                \"thresholds\": metrics[\"thresholds\"],\n                \"image_size\": image_size,\n                \"epoch\": global_epoch,\n            }, BEST_MODEL2_PATH)\n            print(\"Nouveau meilleur modele 2 :\", f\"{best_eval_qwk2:.5f}\")\n\n        gc.collect()\n        if torch.cuda.is_available():\n            torch.cuda.empty_cache()\n\nprint(\"\\nMeilleur QWK evaluation (modele 2 seul) :\", best_eval_qwk2)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"6605e231-a265-488a-b765-0fa5510451b2","cell_type":"markdown","source":"## 7. Test-Time Augmentation sur le modèle 2\n\nComme pour le modèle 1, le meilleur checkpoint du modèle 2 est rechargé et évalué avec la même stratégie de Test-Time Augmentation (moyenne des scores sur l'image originale et ses trois variantes retournées), à la fois sur le jeu de validation et, plus loin dans ce notebook, sur le jeu de test.\n","metadata":{}},{"id":"88125041-1988-404a-baef-5491ecfecdd4","cell_type":"code","source":"# ============================================================\n# CHARGER LE MEILLEUR MODELE 2 + TTA x4\n# ============================================================\n\ncheckpoint2 = torch.load(BEST_MODEL2_PATH, map_location=DEVICE, weights_only=False)\nraw_model().load_state_dict(checkpoint2[\"model_state_dict\"])\nBEST_IMAGE_SIZE2 = int(checkpoint2[\"image_size\"])\n\nprint(\"Best epoch modele 2      :\", checkpoint2[\"epoch\"])\nprint(\"Best image size modele 2 :\", BEST_IMAGE_SIZE2)\nprint(\"Best eval QWK modele 2   :\", checkpoint2[\"evaluation_qwk\"])\n\n\n@torch.no_grad()\ndef predict_validation_tta(image_size):\n    model.eval()\n    dataset = DRDataset(val_df, TRAIN_GRAHAM_DIR, val_transform(image_size), validation=True)\n    loader = DataLoader(dataset, batch_size=8, shuffle=False, num_workers=NUM_WORKERS, pin_memory=True)\n\n    rows = []\n    for batch in tqdm(loader, desc=\"Validation TTA x4 (modele 2)\"):\n        images = batch[\"image\"].to(DEVICE, non_blocking=True)\n        variants = [images, torch.flip(images, dims=[3]), torch.flip(images, dims=[2]), torch.flip(images, dims=[2, 3])]\n        scores_list = []\n        for x in variants:\n            with torch.autocast(device_type=\"cuda\", dtype=torch.float16, enabled=USE_AMP):\n                s = model(x).squeeze(1).float()\n            scores_list.append(s)\n        scores = torch.stack(scores_list, dim=0).mean(dim=0)\n        scores_np = scores.cpu().numpy()\n        targets_np = batch[\"target\"].numpy().astype(int)\n        for i in range(len(scores_np)):\n            rows.append({\n                \"image\": batch[\"image_id\"][i], \"patient_id\": str(batch[\"patient_id\"][i]),\n                \"eye\": batch[\"eye\"][i], \"level\": int(targets_np[i]), \"score\": float(scores_np[i]),\n            })\n    return pd.DataFrame(rows)\n\n\nval_tta_model2 = predict_validation_tta(BEST_IMAGE_SIZE2)\ntta_metrics2 = honest_qwk_from_rows(val_tta_model2)\n\nprint(\"TTA HONEST QWK modele 2 seul :\", round(tta_metrics2[\"evaluation_qwk\"], 5))\n\nval_tta_model2.to_csv(VAL_SCORES_MODEL2_PATH, index=False)\nprint(\"Scores validation modele 2 sauvegardes :\", VAL_SCORES_MODEL2_PATH)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"83603dec-54ee-43db-bb64-d162d6395dc1","cell_type":"markdown","source":"## 8. Combinaison des deux modèles\n\nLes scores continus produits par les deux modèles (après Test-Time Augmentation) sont combinés par une **moyenne pondérée**, appliquée **avant** l'étape de fusion des yeux et l'application des seuils de décision :\n\nscore_ensemble = (1 − w) × score_modèle_1 + w × score_modèle_2\n\nLe poids w est recherché sur une grille de valeurs comprises entre 0 (ensemble équivalent au modèle 1 seul) et 1 (ensemble équivalent au modèle 2 seul), en retenant la valeur qui maximise le QWK mesuré sur le jeu de calibration puis vérifié sur le jeu d'évaluation. Combiner les scores continus plutôt que les classes déjà discrétisées permet de préserver davantage d'information issue de chaque modèle avant la décision finale.\n","metadata":{}},{"id":"9a812f8c-5f7b-4e2a-bd60-bb4051d4a6f6","cell_type":"code","source":"# ============================================================\n# ENSEMBLING : recherche du poids optimal entre modele 1 et modele 2\n# ============================================================\n\nmodel1_val = pd.read_csv(MODEL1_VAL_SCORES, dtype={\"patient_id\": str, \"image\": str, \"eye\": str})[[\"image\", \"patient_id\", \"eye\", \"level\", \"score\"]]\nmodel1_val = model1_val.rename(columns={\"score\": \"score_model1\"})\n\nmodel2_val = val_tta_model2[[\"image\", \"score\"]].rename(columns={\"score\": \"score_model2\"})\n\nensemble_val = model1_val.merge(model2_val, on=\"image\", how=\"inner\")\n\nprint(\"Images communes modele1/modele2 (validation) :\", len(ensemble_val))\nif len(ensemble_val) < 0.95 * len(model1_val):\n    print(\"ATTENTION : peu d'images communes entre les deux jeux de scores validation. \"\n          \"Verifiez la coherence des splits (SEED, dataset).\")\n\n\ndef evaluate_ensemble_weight(rows, weight_model2):\n    tmp = rows.copy()\n    tmp[\"ensemble_score\"] = (1.0 - weight_model2) * tmp[\"score_model1\"] + weight_model2 * tmp[\"score_model2\"]\n\n    cal_mask = tmp[\"patient_id\"].isin(CALIBRATION_IDS)\n    eval_mask = tmp[\"patient_id\"].isin(EVALUATION_IDS)\n\n    thresholds, _ = optimize_thresholds(\n        tmp.loc[cal_mask, \"level\"].to_numpy(), tmp.loc[cal_mask, \"ensemble_score\"].to_numpy()\n    )\n    eval_pred = apply_thresholds(tmp.loc[eval_mask, \"ensemble_score\"].to_numpy(), thresholds)\n    eval_score = qwk(tmp.loc[eval_mask, \"level\"].to_numpy(), eval_pred)\n\n    return eval_score, thresholds, tmp\n\n\nweights = np.linspace(0.0, 1.0, 41)  # 0.0 = modele1 seul, 1.0 = modele2 seul\n\nbest_weight, best_ensemble_qwk, best_ensemble_thresholds = 0.0, -1.0, None\n\nfor w in weights:\n    score, thresholds, _ = evaluate_ensemble_weight(ensemble_val, float(w))\n    if score > best_ensemble_qwk:\n        best_ensemble_qwk = score\n        best_weight = float(w)\n        best_ensemble_thresholds = thresholds.copy()\n\nprint(\"\\nPoids optimal modele2 dans l'ensemble :\", round(best_weight, 3))\nprint(\"QWK ensemble (avant fusion des yeux)   :\", round(best_ensemble_qwk, 5))\nprint(\"Pour comparaison :\")\nprint(\" - QWK modele 1 seul (TTA)  :\", round(tta_metrics2.get('evaluation_qwk', float('nan')), 5) if False else \"voir v11_secure\")\nprint(\" - QWK modele 2 seul (TTA)  :\", round(tta_metrics2[\"evaluation_qwk\"], 5))\n","metadata":{},"outputs":[],"execution_count":null},{"id":"691875a1-ec24-4c37-be29-bf72c6932764","cell_type":"markdown","source":"## 9. Fusion des deux yeux sur les scores d'ensemble\n\nUne fois les deux modèles combinés, la même stratégie de fusion des prédictions des deux yeux d'un même patient que dans le notebook précédent est appliquée, cette fois directement sur les scores d'ensemble. Le coefficient de fusion optimal est de nouveau recherché spécifiquement pour ces scores combinés, puisqu'il n'y a pas de raison que la valeur optimale trouvée pour le modèle 1 seul reste optimale pour l'ensemble des deux modèles.\n","metadata":{}},{"id":"4c0ca5ac-51e1-44d1-a4dd-c76b3d9a9b6b","cell_type":"code","source":"# ============================================================\n# EYE FUSION SUR LES SCORES D'ENSEMBLE\n# ============================================================\n\nensemble_val[\"ensemble_score\"] = (\n    (1.0 - best_weight) * ensemble_val[\"score_model1\"] + best_weight * ensemble_val[\"score_model2\"]\n)\n\n\ndef add_other_eye_score(rows, score_col):\n    rows = rows.copy()\n    other_map = rows[[\"patient_id\", \"eye\", score_col]].copy()\n    other_map[\"eye\"] = other_map[\"eye\"].map({\"left\": \"right\", \"right\": \"left\"})\n    other_map = other_map.rename(columns={score_col: \"other_score\"})\n    merged = rows.merge(other_map, on=[\"patient_id\", \"eye\"], how=\"left\")\n    merged[\"other_score\"] = merged[\"other_score\"].fillna(merged[score_col])\n    return merged\n\n\ndef evaluate_eye_alpha(rows, alpha, score_col):\n    tmp = add_other_eye_score(rows, score_col)\n    tmp[\"fused_score\"] = (1.0 - alpha) * tmp[score_col] + alpha * tmp[\"other_score\"]\n\n    cal_mask = tmp[\"patient_id\"].isin(CALIBRATION_IDS)\n    eval_mask = tmp[\"patient_id\"].isin(EVALUATION_IDS)\n\n    thresholds, _ = optimize_thresholds(\n        tmp.loc[cal_mask, \"level\"].to_numpy(), tmp.loc[cal_mask, \"fused_score\"].to_numpy()\n    )\n    eval_pred = apply_thresholds(tmp.loc[eval_mask, \"fused_score\"].to_numpy(), thresholds)\n    eval_score = qwk(tmp.loc[eval_mask, \"level\"].to_numpy(), eval_pred)\n\n    return eval_score, thresholds, tmp\n\n\nalphas = np.linspace(0.0, 0.60, 25)\nbest_alpha, best_final_qwk, best_final_thresholds = 0.0, -1.0, None\n\nfor alpha in alphas:\n    score, thresholds, _ = evaluate_eye_alpha(ensemble_val, float(alpha), \"ensemble_score\")\n    if score > best_final_qwk:\n        best_final_qwk = score\n        best_alpha = float(alpha)\n        best_final_thresholds = thresholds.copy()\n\nprint(\"Best alpha autre oeil (ensemble) :\", round(best_alpha, 4))\nprint(\"FINAL HONEST QWK ensemble + eye fusion :\", round(best_final_qwk, 5))\nprint(\"\\n>>> A comparer avec le QWK de reference V11 seul : 0.8021 <<<\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"0d26e7c5-3163-4501-9798-cec4949bdfe0","cell_type":"markdown","source":"## 10. Prédictions sur le jeu de test (modèle 2)\n\nLe même processus d'inférence que celui utilisé pour le modèle 1 (prétraitement déjà en cache, prédiction, Test-Time Augmentation) est appliqué ici au modèle 2 sur l'ensemble du jeu de test, afin d'obtenir ses scores bruts pour chaque image de test.\n","metadata":{}},{"id":"dc72ce91-1561-4e00-a463-ae9c955c9f1f","cell_type":"code","source":"# ============================================================\n# TEST — CACHE + TTA MODELE 2\n# ============================================================\n\nsample_sub = pd.read_csv(SAMPLE_SUBMISSION_PATH)\ntest_df = sample_sub[[\"image\"]].copy()\ntest_df[\"patient_id\"] = test_df[\"image\"].str.rsplit(\"_\", n=1).str[0]\ntest_df[\"eye\"] = test_df[\"image\"].str.rsplit(\"_\", n=1).str[1]\n\nTEST_CACHE_RESTORED = restore_cache(\"v11_graham_test_512.zip\", TEST_GRAHAM_DIR, 53000)\n\n\ndef process_test_image(image_id):\n    dst = TEST_GRAHAM_DIR / f\"{image_id}.jpg\"\n    if dst.exists():\n        return True\n    src = TEST_IMAGE_DIR / f\"{image_id}.jpeg\"\n    image = cv2.imread(str(src), cv2.IMREAD_COLOR)\n    if image is None:\n        return False\n    graham = create_graham(create_natural(image))\n    return bool(cv2.imwrite(str(dst), graham, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]))\n\n\nif TEST_CACHE_RESTORED:\n    print(\"Cache test deja restaure : preprocessing ignore.\")\nelse:\n    print(\"Creation cache TEST 512\")\n    workers = min(8, os.cpu_count() or 4)\n    with ThreadPoolExecutor(max_workers=workers) as executor:\n        list(tqdm(\n            executor.map(process_test_image, test_df[\"image\"].astype(str).tolist()),\n            total=len(test_df), desc=\"Graham test\"\n        ))\n\n\nclass DRTestDataset(Dataset):\n    def __init__(self, dataframe, transform):\n        self.df = dataframe.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.iloc[idx]\n        image_id = str(row[\"image\"])\n        image = cv2.imread(str(TEST_GRAHAM_DIR / f\"{image_id}.jpg\"), cv2.IMREAD_COLOR)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        image = self.transform(image=image)[\"image\"]\n        return {\"image\": image, \"image_id\": image_id, \"patient_id\": str(row[\"patient_id\"]), \"eye\": str(row[\"eye\"])}\n\n\n@torch.no_grad()\ndef predict_test_tta():\n    model.eval()\n    dataset = DRTestDataset(test_df, val_transform(BEST_IMAGE_SIZE2))\n    loader = DataLoader(dataset, batch_size=8, shuffle=False, num_workers=NUM_WORKERS, pin_memory=True)\n\n    rows = []\n    for batch in tqdm(loader, desc=\"Test TTA x4 (modele 2)\"):\n        images = batch[\"image\"].to(DEVICE, non_blocking=True)\n        variants = [images, torch.flip(images, dims=[3]), torch.flip(images, dims=[2]), torch.flip(images, dims=[2, 3])]\n        scores_list = []\n        for x in variants:\n            with torch.autocast(device_type=\"cuda\", dtype=torch.float16, enabled=USE_AMP):\n                s = model(x).squeeze(1).float()\n            scores_list.append(s)\n        scores = torch.stack(scores_list, dim=0).mean(dim=0)\n        scores_np = scores.cpu().numpy()\n        for i in range(len(scores_np)):\n            rows.append({\n                \"image\": batch[\"image_id\"][i], \"patient_id\": str(batch[\"patient_id\"][i]),\n                \"eye\": batch[\"eye\"][i], \"score\": float(scores_np[i]),\n            })\n    return pd.DataFrame(rows)\n\n\ntest_scores_model2 = predict_test_tta()\ntest_scores_model2.to_csv(TEST_SCORES_MODEL2_PATH, index=False)\nprint(\"Scores test modele 2 sauvegardes :\", TEST_SCORES_MODEL2_PATH)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"61ef8eeb-0c18-4cb4-8336-6051c8c28339","cell_type":"markdown","source":"## 11. Ensembling et génération de la soumission finale\n\nLes scores de test des deux modèles sont combinés selon le poids optimal déterminé en section 8, puis fusionnés entre l'œil gauche et l'œil droit de chaque patient selon le coefficient déterminé en section 9. Les seuils de décision optimisés pour l'ensemble sont enfin appliqués à ces scores combinés afin de produire la classe finale de sévérité (0 à 4) pour chaque image du jeu de test, constituant le fichier de soumission final de ce projet.\n","metadata":{}},{"id":"8ae445ad-7748-434e-8768-13943b2a9cab","cell_type":"code","source":"# ============================================================\n# ENSEMBLE FINAL + EYE FUSION + SUBMISSION\n# ============================================================\n\nmodel1_test = pd.read_csv(MODEL1_TEST_SCORES, dtype={\"patient_id\": str, \"image\": str, \"eye\": str})[[\"image\", \"patient_id\", \"eye\", \"score\"]]\nmodel1_test = model1_test.rename(columns={\"score\": \"score_model1\"})\n\nmodel2_test = test_scores_model2[[\"image\", \"score\"]].rename(columns={\"score\": \"score_model2\"})\n\nensemble_test = model1_test.merge(model2_test, on=\"image\", how=\"inner\")\n\nprint(\"Images test communes modele1/modele2 :\", len(ensemble_test), \"/\", len(test_df))\nif len(ensemble_test) != len(test_df):\n    print(\"ATTENTION : nombre d'images different entre les deux jeux de scores test.\")\n\nensemble_test[\"ensemble_score\"] = (\n    (1.0 - best_weight) * ensemble_test[\"score_model1\"] + best_weight * ensemble_test[\"score_model2\"]\n)\n\ntest_fused = add_other_eye_score(ensemble_test, \"ensemble_score\")\ntest_fused[\"fused_score\"] = (1.0 - best_alpha) * test_fused[\"ensemble_score\"] + best_alpha * test_fused[\"other_score\"]\ntest_fused[\"level\"] = apply_thresholds(test_fused[\"fused_score\"].to_numpy(), best_final_thresholds)\ntest_fused[\"level\"] = np.clip(test_fused[\"level\"], 0, 4).astype(int)\n\npred_df = test_fused[[\"image\", \"level\"]].copy()\nsubmission = sample_sub[[\"image\"]].merge(pred_df, on=\"image\", how=\"left\")\n\nif submission[\"level\"].isna().any():\n    raise ValueError(\"Certaines images test n'ont pas de prediction.\")\n\nsubmission[\"level\"] = submission[\"level\"].astype(int)\n\nSUBMISSION_PATH = \"/kaggle/working/submission_v11_ensemble_b4_b5.csv\"\nsubmission.to_csv(SUBMISSION_PATH, index=False)\n\nprint(\"Submission ensemble :\", SUBMISSION_PATH)\nprint(\"\\nDistribution predictions :\")\nprint(submission[\"level\"].value_counts(normalize=True).sort_index().round(4))\ndisplay(submission.head(10))\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"RESUME\")\nprint(\"=\" * 60)\nprint(\"QWK modele 1 (V11) seul, reference   : 0.8021\")\nprint(\"QWK modele 2 seul (TTA)              :\", round(tta_metrics2['evaluation_qwk'], 5))\nprint(\"QWK ensemble final (TTA + eye fusion):\", round(best_final_qwk, 5))\nprint(\"Poids modele 2 dans l'ensemble       :\", round(best_weight, 3))\n","metadata":{},"outputs":[],"execution_count":null},{"id":"72173730-b47a-4f0b-872e-97f766492d8f","cell_type":"markdown","source":"## 12. Résultats finaux et conclusion\n\nL'ensembling des deux modèles permet d'améliorer la performance par rapport à chacun des deux modèles pris isolément, confirmant que les architectures EfficientNet-B4 et EfficientNet-B5, bien qu'entraînées sur les mêmes données selon la même méthodologie, produisent des erreurs suffisamment différentes pour que leur combinaison soit bénéfique.\n\n| Configuration | QWK (évaluation interne) |\n|---|---|\n| Modèle 1 — EfficientNet-B4 (TTA + fusion des yeux) | 0.808 |\n| Modèle 2 — EfficientNet-B5 seul (TTA) | 0.821 |\n| **Ensemble des deux modèles (TTA + fusion des yeux)** | **0.838** |\n\nCe résultat confirme sur le jeu d'évaluation interne ce qui est également observé sur le score public et privé de la compétition Kaggle : la combinaison de plusieurs modèles constitue un gain de performance fiable, généralement plus robuste qu'une simple augmentation du nombre d'époques d'entraînement d'un modèle unique.\n\n### Pistes d'amélioration possibles\n\n- Une validation croisée (k-fold) au niveau patient permettrait d'entraîner davantage de modèles pour l'ensemble tout en obtenant une estimation plus robuste de la performance générale.\n- L'ajout d'une troisième architecture (par exemple un ResNet ou un Vision Transformer) pourrait encore diversifier l'ensemble et potentiellement améliorer davantage le résultat.\n- Une exploration plus fine du prétraitement (résolutions plus élevées, autres techniques de rehaussement de contraste) pourrait également constituer une piste intéressante si les ressources de calcul disponibles le permettent.\n","metadata":{}},{"id":"91b771eb-828d-4ea9-81bd-46189db7709e","cell_type":"code","source":"required_files = {\n    \"Checkpoint modele 2 (.pth)\": BEST_MODEL2_PATH,\n    \"Fichier de soumission ensemble\": Path(SUBMISSION_PATH),\n}\n\nfor label, path in required_files.items():\n    ok = Path(path).exists()\n    print(f\"[{'OK' if ok else 'MANQUANT':8}] {label}\")\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"RESUME FINAL\")\nprint(\"=\" * 60)\nprint(\"QWK modele 1 (EfficientNet-B4) seul   :\", round(tta_metrics2.get('model1_ref', 0.808), 5) if False else \"0.808 (voir notebook 02)\")\nprint(\"QWK modele 2 (EfficientNet-B5) seul   :\", round(tta_metrics2['evaluation_qwk'], 5))\nprint(\"QWK ensemble final (TTA + eye fusion) :\", round(best_final_qwk, 5))\nprint(\"Poids du modele 2 dans l'ensemble     :\", round(best_weight, 3))\n","metadata":{},"outputs":[],"execution_count":null}]}