{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"\"\"\"\nRetinaSense — EfficientNet-B6 (regression + OptimizedRounder), trained on\nAPTOS 2019 + IDRiD combined, held out on IDRiD-test + Messidor-2\n=============================================================================\nAdapted from a Kaggle notebook that scored Quadratic Weighted Kappa 0.9174\non an APTOS-only validation split. That run never touched IDRiD or\nMessidor-2, so it couldn't tell you anything about domain generalization —\nexactly what your Research slide's \"India-specific\" (IDRiD) and\ncross-domain (Messidor-2) claims depend on.\n\nThis version:\n  - Combines APTOS 2019 + IDRiD's official train split into one training\n    pool (same as train_dr_model.py's HF version does)\n  - Holds out IDRiD's official test set and Messidor-2 completely —\n    neither is ever used for training or hyperparameter selection\n  - Keeps the same architecture, preprocessing (circle-crop -> resize ->\n    Ben Graham), and ordinal-regression-via-OptimizedRounder approach that\n    produced the strong APTOS-only result\n  - Caches preprocessed images to disk so re-running doesn't reprocess\n    images it already has\n\nEXPECTED FOLDER LAYOUT (same as train_dr_model.py):\n    data/\n      aptos2019/train.csv, train_images/\n      idrid/B_Disease_Grading/{train,test}_labels.csv, {train,test}_images/\n      messidor2/messidor2_grades.csv, images/     (optional)\n\nInstall:\n    pip install timm albumentations opencv-python-headless pandas \\\n                scikit-learn scipy tqdm torch torchvision\n\nRun:\n    python train_effnetb6_combined.py\n\"\"\"\n\nimport os\nimport random\n\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport timm\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom albumentations import Compose, Normalize, HorizontalFlip, Rotate, ColorJitter\nfrom albumentations.pytorch import ToTensorV2\nfrom scipy.optimize import minimize\nfrom sklearn.metrics import classification_report, confusion_matrix, cohen_kappa_score\nfrom sklearn.model_selection import train_test_split\nfrom torch.optim.lr_scheduler import CosineAnnealingLR\nfrom torch.utils.data import DataLoader, Dataset\nfrom tqdm import tqdm\n\n# ---------------------------------------------------------------------------\n# 1. Config\n# ---------------------------------------------------------------------------\nAPTOS_CSV = \"data/aptos2019/train.csv\"\nAPTOS_IMG_DIR = \"data/aptos2019/train_images\"\n\nIDRID_TRAIN_CSV = \"data/idrid/B_Disease_Grading/train_labels.csv\"\nIDRID_TRAIN_IMG_DIR = \"data/idrid/B_Disease_Grading/train_images\"\nIDRID_TEST_CSV = \"data/idrid/B_Disease_Grading/test_labels.csv\"\nIDRID_TEST_IMG_DIR = \"data/idrid/B_Disease_Grading/test_images\"\n\nMESSIDOR2_CSV = \"data/messidor2/messidor2_grades.csv\"          # optional\nMESSIDOR2_IMG_DIR = \"data/messidor2/images\"                     # optional\n\nPREPROCESSED_DIR = \"./preprocessed_512\"   # cached circle-crop+Ben-Graham output\n\nMODEL_NAME = \"efficientnet_b6\"\nIMAGE_SIZE = 512\nBATCH_SIZE = 4\nNUM_EPOCHS = 30\nLEARNING_RATE = 1e-4\nNUM_WORKERS = 2\nVAL_FRACTION = 0.10\nPATIENCE = 5\nSEED = 42\n\nLABEL_NAMES = {0: \"No DR\", 1: \"Mild\", 2: \"Moderate\", 3: \"Severe\", 4: \"Proliferative DR\"}\nMODEL_SAVE_PATH = f\"best_model_kappa_{IMAGE_SIZE}_combined.pth\"\n\n\ndef set_seed(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    if torch.cuda.is_available():\n        torch.cuda.manual_seed_all(seed)\n        torch.backends.cudnn.deterministic = True\n        torch.backends.cudnn.benchmark = False\n\n\nset_seed(SEED)\n\n\n# ---------------------------------------------------------------------------\n# 2. Preprocessing (identical to the original Kaggle script)\n# ---------------------------------------------------------------------------\ndef circle_crop(image):\n    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n    _, thresh = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)\n    contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    if not contours:\n        return image\n    cnt = max(contours, key=cv2.contourArea)\n    x, y, w, h = cv2.boundingRect(cnt)\n    return image[y:y + h, x:x + w]\n\n\ndef apply_ben_graham_preprocessing(image, sigmaX=30):\n    blurred = cv2.GaussianBlur(image, (0, 0), sigmaX)\n    return cv2.addWeighted(image, 4, blurred, -4, 128)\n\n\ndef preprocess_and_cache(df, source_name, output_dir, image_size):\n    \"\"\"Circle-crop + resize + Ben Graham each image once, cache to disk.\n    Re-running skips images that are already cached.\"\"\"\n    os.makedirs(output_dir, exist_ok=True)\n    new_paths = []\n    for row in tqdm(df.itertuples(index=False), total=len(df), desc=f\"Preprocessing {source_name}\"):\n        src_path = row.filepath\n        basename = os.path.splitext(os.path.basename(src_path))[0]\n        out_path = os.path.join(output_dir, f\"{source_name}_{basename}.png\")\n        if not os.path.exists(out_path):\n            image = cv2.imread(src_path)\n            if image is None:\n                new_paths.append(None)\n                continue\n            image = circle_crop(image)\n            image = cv2.resize(image, (image_size, image_size))\n            image = apply_ben_graham_preprocessing(image)\n            cv2.imwrite(out_path, image)\n        new_paths.append(out_path)\n    result = df.copy()\n    result[\"filepath\"] = new_paths\n    result = result[result[\"filepath\"].notnull()].reset_index(drop=True)\n    return result\n\n\ndef get_transforms(data_type=\"train\"):\n    if data_type == \"train\":\n        return Compose([\n            Rotate(limit=40, p=0.5),\n            ColorJitter(brightness=0.1, contrast=0.1, saturation=0.1, hue=0.1, p=0.3),\n            HorizontalFlip(p=0.5),\n            Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n            ToTensorV2(),\n        ])\n    return Compose([\n        Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n        ToTensorV2(),\n    ])\n\n\n# ---------------------------------------------------------------------------\n# 3. Dataset loading — same column-name handling as train_dr_model.py\n# ---------------------------------------------------------------------------\ndef load_aptos():\n    if not os.path.exists(APTOS_CSV):\n        print(f\"  [skip] APTOS not found at {APTOS_CSV}\")\n        return pd.DataFrame(columns=[\"filepath\", \"label\"])\n    df = pd.read_csv(APTOS_CSV)\n    df[\"filepath\"] = df[\"id_code\"].apply(lambda x: os.path.join(APTOS_IMG_DIR, f\"{x}.png\"))\n    df[\"label\"] = df[\"diagnosis\"].astype(int)\n    df = df[df[\"filepath\"].apply(os.path.exists)]\n    print(f\"  [ok] APTOS: {len(df)} images\")\n    return df[[\"filepath\", \"label\"]]\n\n\ndef load_idrid_split(csv_path, img_dir):\n    if not os.path.exists(csv_path):\n        print(f\"  [skip] IDRiD not found at {csv_path}\")\n        return pd.DataFrame(columns=[\"filepath\", \"label\"])\n    df = pd.read_csv(csv_path)\n    name_col = \"Image name\" if \"Image name\" in df.columns else df.columns[0]\n    grade_col = \"Retinopathy grade\" if \"Retinopathy grade\" in df.columns else df.columns[1]\n    df[\"filepath\"] = df[name_col].apply(\n        lambda x: os.path.join(img_dir, x if str(x).lower().endswith((\".jpg\", \".png\")) else f\"{x}.jpg\")\n    )\n    df[\"label\"] = df[grade_col].astype(int)\n    df = df[df[\"filepath\"].apply(os.path.exists)]\n    print(f\"  [ok] IDRiD ({os.path.basename(csv_path)}): {len(df)} images\")\n    return df[[\"filepath\", \"label\"]]\n\n\ndef load_messidor2():\n    if not os.path.exists(MESSIDOR2_CSV):\n        print(f\"  [skip] Messidor-2 not found at {MESSIDOR2_CSV} (optional — cross-domain eval only)\")\n        return pd.DataFrame(columns=[\"filepath\", \"label\"])\n    df = pd.read_csv(MESSIDOR2_CSV)\n    id_col, grade_col = df.columns[0], df.columns[1]\n    df[\"filepath\"] = df[id_col].apply(lambda x: os.path.join(MESSIDOR2_IMG_DIR, str(x)))\n    df[\"label\"] = df[grade_col].astype(int)\n    df = df[df[\"filepath\"].apply(os.path.exists)]\n    print(f\"  [ok] Messidor-2: {len(df)} images\")\n    return df[[\"filepath\", \"label\"]]\n\n\n# ---------------------------------------------------------------------------\n# 4. Model (identical to the original Kaggle script)\n# ---------------------------------------------------------------------------\ndef replace_batchnorm_with_groupnorm(module, num_groups=32):\n    for name, child in module.named_children():\n        if isinstance(child, nn.BatchNorm2d):\n            num_channels = child.num_features\n            if num_channels % num_groups == 0:\n                setattr(module, name, nn.GroupNorm(num_groups=num_groups, num_channels=num_channels))\n        else:\n            replace_batchnorm_with_groupnorm(child, num_groups)\n\n\nclass EfficientNetModel(nn.Module):\n    def __init__(self, model_name):\n        super().__init__()\n        self.model = timm.create_model(model_name, pretrained=True)\n        replace_batchnorm_with_groupnorm(self.model)\n        in_features = self.model.classifier.in_features\n        self.model.classifier = nn.Sequential(nn.Linear(in_features, 1))\n\n    def forward(self, x):\n        return self.model(x)\n\n\nclass PreprocessedImageDataset(Dataset):\n    \"\"\"Reads already-preprocessed (circle-crop + Ben Graham) PNGs straight\n    from disk — preprocessing happens once up front, not per-epoch.\"\"\"\n    def __init__(self, df, transform=None):\n        self.filepaths = df[\"filepath\"].tolist()\n        self.labels = df[\"label\"].tolist()\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.filepaths)\n\n    def __getitem__(self, idx):\n        img = cv2.imread(self.filepaths[idx])\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        if self.transform:\n            img = self.transform(image=img)[\"image\"]\n        return img, float(self.labels[idx])\n\n\nclass OptimizedRounder:\n    def __init__(self):\n        self.coef_ = None\n\n    def _kappa_loss(self, coef, X, y):\n        preds = self.predict(X, coef)\n        return -cohen_kappa_score(y, preds, weights=\"quadratic\")\n\n    def fit(self, X, y):\n        loss_partial = lambda coef: self._kappa_loss(coef, X, y)\n        initial_coef = [0.5, 1.5, 2.5, 3.5]\n        self.coef_ = minimize(loss_partial, initial_coef, method=\"nelder-mead\")[\"x\"]\n        print(f\"Optimized thresholds: {self.coef_}\")\n\n    def predict(self, X, coef):\n        X_p = np.copy(X)\n        for i, pred in enumerate(X_p):\n            if pred < coef[0]:\n                X_p[i] = 0\n            elif pred < coef[1]:\n                X_p[i] = 1\n            elif pred < coef[2]:\n                X_p[i] = 2\n            elif pred < coef[3]:\n                X_p[i] = 3\n            else:\n                X_p[i] = 4\n        return X_p.astype(int)\n\n\n# ---------------------------------------------------------------------------\n# 5. Train\n# ---------------------------------------------------------------------------\ndef train(model, train_loader, valid_loader, device, num_epochs, lr):\n    criterion = nn.MSELoss()\n    optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-5)\n    scheduler = CosineAnnealingLR(optimizer, T_max=num_epochs, eta_min=1e-6)\n\n    best_valid_kappa = -1.0\n    epochs_no_improve = 0\n\n    for epoch in range(num_epochs):\n        model.train()\n        running_loss = 0.0\n        for images, labels in tqdm(train_loader, desc=f\"Epoch {epoch + 1} train\"):\n            images = images.to(device)\n            labels = labels.to(device).float().view(-1, 1)\n            optimizer.zero_grad()\n            outputs = model(images)\n            loss = criterion(outputs, labels)\n            loss.backward()\n            optimizer.step()\n            running_loss += loss.item()\n\n        model.eval()\n        all_labels, all_preds = [], []\n        with torch.no_grad():\n            for images, labels in tqdm(valid_loader, desc=f\"Epoch {epoch + 1} valid\"):\n                images = images.to(device)\n                outputs = model(images).squeeze()\n                all_labels.extend(labels.cpu().numpy())\n                all_preds.extend(np.atleast_1d(outputs.cpu().numpy()))\n\n        rounded = np.round(all_preds).astype(int)\n        valid_kappa = cohen_kappa_score(all_labels, rounded, weights=\"quadratic\")\n\n        print(f\"\\nEpoch {epoch + 1}/{num_epochs} — train_loss={running_loss / len(train_loader):.4f} \"\n              f\"valid_kappa={valid_kappa:.4f} lr={optimizer.param_groups[0]['lr']:.6f}\")\n        scheduler.step()\n\n        if valid_kappa > best_valid_kappa:\n            best_valid_kappa = valid_kappa\n            epochs_no_improve = 0\n            torch.save(model.state_dict(), MODEL_SAVE_PATH)\n            print(f\"  -> new best model saved (kappa={best_valid_kappa:.4f})\")\n        else:\n            epochs_no_improve += 1\n\n        if epochs_no_improve >= PATIENCE:\n            print(f\"\\nEarly stopping — no improvement in {PATIENCE} epochs.\")\n            break\n\n    return best_valid_kappa\n\n\ndef get_predictions(loader, model, device):\n    model.eval()\n    all_labels, all_preds = [], []\n    with torch.no_grad():\n        for images, labels in tqdm(loader, desc=\"Predicting\"):\n            images = images.to(device)\n            outputs = model(images).squeeze()\n            all_labels.extend(labels.cpu().numpy())\n            all_preds.extend(np.atleast_1d(outputs.cpu().numpy()))\n    return np.array(all_preds), np.array(all_labels)\n\n\ndef evaluate_holdout(name, dataset, model, device, coefficients, batch_size=BATCH_SIZE):\n    if dataset is None or len(dataset) == 0:\n        print(f\"\\n[{name}] skipped — not available\")\n        return\n    loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=NUM_WORKERS)\n    raw_preds, labels = get_predictions(loader, model, device)\n    rounder = OptimizedRounder()\n    preds = rounder.predict(raw_preds, coefficients)\n\n    kappa = cohen_kappa_score(labels, preds, weights=\"quadratic\")\n    print(f\"\\n=== {name} ({len(dataset)} images) ===\")\n    print(f\"Quadratic Weighted Kappa: {kappa:.4f}\\n\")\n    print(classification_report(labels, preds, labels=list(LABEL_NAMES.keys()),\n                                 target_names=list(LABEL_NAMES.values()), zero_division=0))\n    print(\"Confusion matrix:\")\n    print(confusion_matrix(labels, preds, labels=list(LABEL_NAMES.keys())))\n\n\n# ---------------------------------------------------------------------------\n# 6. Main\n# ---------------------------------------------------------------------------\nif __name__ == \"__main__\":\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    print(f\"Device: {device}\")\n\n    print(\"\\nLoading datasets...\")\n    aptos_df = load_aptos()\n    idrid_train_df = load_idrid_split(IDRID_TRAIN_CSV, IDRID_TRAIN_IMG_DIR)\n    idrid_test_df = load_idrid_split(IDRID_TEST_CSV, IDRID_TEST_IMG_DIR)   # held out\n    messidor2_df = load_messidor2()                                       # held out\n\n    train_pool = pd.concat([aptos_df, idrid_train_df], ignore_index=True)\n    if len(train_pool) == 0:\n        raise RuntimeError(\"No training images found — check the CONFIG paths at the top of this file.\")\n\n    print(f\"\\nTrain pool: {len(train_pool)} (APTOS {len(aptos_df)} + IDRiD-train {len(idrid_train_df)})\")\n    print(f\"Held-out: IDRiD-test={len(idrid_test_df)}, Messidor-2={len(messidor2_df)}\")\n\n    print(\"\\nPreprocessing (cached — reruns skip already-processed images)...\")\n    train_pool = preprocess_and_cache(train_pool, \"train\", os.path.join(PREPROCESSED_DIR, \"train\"), IMAGE_SIZE)\n    if len(idrid_test_df):\n        idrid_test_df = preprocess_and_cache(idrid_test_df, \"idrid_test\",\n                                              os.path.join(PREPROCESSED_DIR, \"idrid_test\"), IMAGE_SIZE)\n    if len(messidor2_df):\n        messidor2_df = preprocess_and_cache(messidor2_df, \"messidor2\",\n                                             os.path.join(PREPROCESSED_DIR, \"messidor2\"), IMAGE_SIZE)\n\n    train_df, val_df = train_test_split(\n        train_pool, test_size=VAL_FRACTION, stratify=train_pool[\"label\"], random_state=SEED\n    )\n    print(f\"  -> train={len(train_df)}, val={len(val_df)}\")\n\n    train_dataset = PreprocessedImageDataset(train_df, transform=get_transforms(\"train\"))\n    val_dataset = PreprocessedImageDataset(val_df, transform=get_transforms(\"valid\"))\n    idrid_test_dataset = PreprocessedImageDataset(idrid_test_df, transform=get_transforms(\"valid\")) if len(idrid_test_df) else None\n    messidor2_dataset = PreprocessedImageDataset(messidor2_df, transform=get_transforms(\"valid\")) if len(messidor2_df) else None\n\n    train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True,\n                               num_workers=NUM_WORKERS, pin_memory=True)\n    val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False,\n                             num_workers=NUM_WORKERS, pin_memory=True)\n\n    print(\"\\nTraining...\")\n    model = EfficientNetModel(MODEL_NAME).to(device)\n    train(model, train_loader, val_loader, device, NUM_EPOCHS, LEARNING_RATE)\n\n    print(f\"\\nLoading best checkpoint from {MODEL_SAVE_PATH} for threshold fitting + held-out eval...\")\n    model.load_state_dict(torch.load(MODEL_SAVE_PATH, map_location=device))\n\n    val_preds, val_labels = get_predictions(val_loader, model, device)\n    rounder = OptimizedRounder()\n    rounder.fit(val_preds, val_labels)\n    final_val_kappa = cohen_kappa_score(val_labels, rounder.predict(val_preds, rounder.coef_), weights=\"quadratic\")\n    print(f\"Final optimized validation Kappa (in-domain, APTOS+IDRiD mix): {final_val_kappa:.4f}\")\n\n    print(\"\\n\\n########## HELD-OUT EVALUATION (never trained on) ##########\")\n    evaluate_holdout(\"IDRiD official test set (India-specific)\", idrid_test_dataset, model, device, rounder.coef_)\n    evaluate_holdout(\"Messidor-2 (cross-domain check)\", messidor2_dataset, model, device, rounder.coef_)\n\n    print(f\"\\nDone. Model saved to {MODEL_SAVE_PATH}\")\"\"\"\nRetinaSense — EfficientNet-B6 (regression + OptimizedRounder), trained on\nAPTOS 2019 + IDRiD combined, held out on IDRiD-test + Messidor-2\n=============================================================================\nAdapted from a Kaggle notebook that scored Quadratic Weighted Kappa 0.9174\non an APTOS-only validation split. That run never touched IDRiD or\nMessidor-2, so it couldn't tell you anything about domain generalization —\nexactly what your Research slide's \"India-specific\" (IDRiD) and\ncross-domain (Messidor-2) claims depend on.\n\nThis version:\n  - Combines APTOS 2019 + IDRiD's official train split into one training\n    pool (same as train_dr_model.py's HF version does)\n  - Holds out IDRiD's official test set and Messidor-2 completely —\n    neither is ever used for training or hyperparameter selection\n  - Keeps the same architecture, preprocessing (circle-crop -> resize ->\n    Ben Graham), and ordinal-regression-via-OptimizedRounder approach that\n    produced the strong APTOS-only result\n  - Caches preprocessed images to disk so re-running doesn't reprocess\n    images it already has\n\nEXPECTED FOLDER LAYOUT (same as train_dr_model.py):\n    data/\n      aptos2019/train.csv, train_images/\n      idrid/B_Disease_Grading/{train,test}_labels.csv, {train,test}_images/\n      messidor2/messidor2_grades.csv, images/     (optional)\n\nInstall:\n    pip install timm albumentations opencv-python-headless pandas \\\n                scikit-learn scipy tqdm torch torchvision\n\nRun:\n    python train_effnetb6_combined.py\n\"\"\"\n\nimport os\nimport random\n\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport timm\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom albumentations import Compose, Normalize, HorizontalFlip, Rotate, ColorJitter\nfrom albumentations.pytorch import ToTensorV2\nfrom scipy.optimize import minimize\nfrom sklearn.metrics import classification_report, confusion_matrix, cohen_kappa_score\nfrom sklearn.model_selection import train_test_split\nfrom torch.optim.lr_scheduler import CosineAnnealingLR\nfrom torch.utils.data import DataLoader, Dataset\nfrom tqdm import tqdm\n\n# ---------------------------------------------------------------------------\n# 1. Config\n# ---------------------------------------------------------------------------\nAPTOS_CSV = \"data/aptos2019/train.csv\"\nAPTOS_IMG_DIR = \"data/aptos2019/train_images\"\n\nIDRID_TRAIN_CSV = \"data/idrid/B_Disease_Grading/train_labels.csv\"\nIDRID_TRAIN_IMG_DIR = \"data/idrid/B_Disease_Grading/train_images\"\nIDRID_TEST_CSV = \"data/idrid/B_Disease_Grading/test_labels.csv\"\nIDRID_TEST_IMG_DIR = \"data/idrid/B_Disease_Grading/test_images\"\n\nMESSIDOR2_CSV = \"data/messidor2/messidor2_grades.csv\"          # optional\nMESSIDOR2_IMG_DIR = \"data/messidor2/images\"                     # optional\n\nPREPROCESSED_DIR = \"./preprocessed_512\"   # cached circle-crop+Ben-Graham output\n\nMODEL_NAME = \"efficientnet_b6\"\nIMAGE_SIZE = 512\nBATCH_SIZE = 4\nNUM_EPOCHS = 30\nLEARNING_RATE = 1e-4\nNUM_WORKERS = 2\nVAL_FRACTION = 0.10\nPATIENCE = 5\nSEED = 42\n\nLABEL_NAMES = {0: \"No DR\", 1: \"Mild\", 2: \"Moderate\", 3: \"Severe\", 4: \"Proliferative DR\"}\nMODEL_SAVE_PATH = f\"best_model_kappa_{IMAGE_SIZE}_combined.pth\"\n\n\ndef set_seed(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    if torch.cuda.is_available():\n        torch.cuda.manual_seed_all(seed)\n        torch.backends.cudnn.deterministic = True\n        torch.backends.cudnn.benchmark = False\n\n\nset_seed(SEED)\n\n\n# ---------------------------------------------------------------------------\n# 2. Preprocessing (identical to the original Kaggle script)\n# ---------------------------------------------------------------------------\ndef circle_crop(image):\n    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n    _, thresh = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)\n    contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    if not contours:\n        return image\n    cnt = max(contours, key=cv2.contourArea)\n    x, y, w, h = cv2.boundingRect(cnt)\n    return image[y:y + h, x:x + w]\n\n\ndef apply_ben_graham_preprocessing(image, sigmaX=30):\n    blurred = cv2.GaussianBlur(image, (0, 0), sigmaX)\n    return cv2.addWeighted(image, 4, blurred, -4, 128)\n\n\ndef preprocess_and_cache(df, source_name, output_dir, image_size):\n    \"\"\"Circle-crop + resize + Ben Graham each image once, cache to disk.\n    Re-running skips images that are already cached.\"\"\"\n    os.makedirs(output_dir, exist_ok=True)\n    new_paths = []\n    for row in tqdm(df.itertuples(index=False), total=len(df), desc=f\"Preprocessing {source_name}\"):\n        src_path = row.filepath\n        basename = os.path.splitext(os.path.basename(src_path))[0]\n        out_path = os.path.join(output_dir, f\"{source_name}_{basename}.png\")\n        if not os.path.exists(out_path):\n            image = cv2.imread(src_path)\n            if image is None:\n                new_paths.append(None)\n                continue\n            image = circle_crop(image)\n            image = cv2.resize(image, (image_size, image_size))\n            image = apply_ben_graham_preprocessing(image)\n            cv2.imwrite(out_path, image)\n        new_paths.append(out_path)\n    result = df.copy()\n    result[\"filepath\"] = new_paths\n    result = result[result[\"filepath\"].notnull()].reset_index(drop=True)\n    return result\n\n\ndef get_transforms(data_type=\"train\"):\n    if data_type == \"train\":\n        return Compose([\n            Rotate(limit=40, p=0.5),\n            ColorJitter(brightness=0.1, contrast=0.1, saturation=0.1, hue=0.1, p=0.3),\n            HorizontalFlip(p=0.5),\n            Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n            ToTensorV2(),\n        ])\n    return Compose([\n        Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n        ToTensorV2(),\n    ])\n\n\n# ---------------------------------------------------------------------------\n# 3. Dataset loading — same column-name handling as train_dr_model.py\n# ---------------------------------------------------------------------------\ndef load_aptos():\n    if not os.path.exists(APTOS_CSV):\n        print(f\"  [skip] APTOS not found at {APTOS_CSV}\")\n        return pd.DataFrame(columns=[\"filepath\", \"label\"])\n    df = pd.read_csv(APTOS_CSV)\n    df[\"filepath\"] = df[\"id_code\"].apply(lambda x: os.path.join(APTOS_IMG_DIR, f\"{x}.png\"))\n    df[\"label\"] = df[\"diagnosis\"].astype(int)\n    df = df[df[\"filepath\"].apply(os.path.exists)]\n    print(f\"  [ok] APTOS: {len(df)} images\")\n    return df[[\"filepath\", \"label\"]]\n\n\ndef load_idrid_split(csv_path, img_dir):\n    if not os.path.exists(csv_path):\n        print(f\"  [skip] IDRiD not found at {csv_path}\")\n        return pd.DataFrame(columns=[\"filepath\", \"label\"])\n    df = pd.read_csv(csv_path)\n    name_col = \"Image name\" if \"Image name\" in df.columns else df.columns[0]\n    grade_col = \"Retinopathy grade\" if \"Retinopathy grade\" in df.columns else df.columns[1]\n    df[\"filepath\"] = df[name_col].apply(\n        lambda x: os.path.join(img_dir, x if str(x).lower().endswith((\".jpg\", \".png\")) else f\"{x}.jpg\")\n    )\n    df[\"label\"] = df[grade_col].astype(int)\n    df = df[df[\"filepath\"].apply(os.path.exists)]\n    print(f\"  [ok] IDRiD ({os.path.basename(csv_path)}): {len(df)} images\")\n    return df[[\"filepath\", \"label\"]]\n\n\ndef load_messidor2():\n    if not os.path.exists(MESSIDOR2_CSV):\n        print(f\"  [skip] Messidor-2 not found at {MESSIDOR2_CSV} (optional — cross-domain eval only)\")\n        return pd.DataFrame(columns=[\"filepath\", \"label\"])\n    df = pd.read_csv(MESSIDOR2_CSV)\n    id_col, grade_col = df.columns[0], df.columns[1]\n    df[\"filepath\"] = df[id_col].apply(lambda x: os.path.join(MESSIDOR2_IMG_DIR, str(x)))\n    df[\"label\"] = df[grade_col].astype(int)\n    df = df[df[\"filepath\"].apply(os.path.exists)]\n    print(f\"  [ok] Messidor-2: {len(df)} images\")\n    return df[[\"filepath\", \"label\"]]\n\n\n# ---------------------------------------------------------------------------\n# 4. Model (identical to the original Kaggle script)\n# ---------------------------------------------------------------------------\ndef replace_batchnorm_with_groupnorm(module, num_groups=32):\n    for name, child in module.named_children():\n        if isinstance(child, nn.BatchNorm2d):\n            num_channels = child.num_features\n            if num_channels % num_groups == 0:\n                setattr(module, name, nn.GroupNorm(num_groups=num_groups, num_channels=num_channels))\n        else:\n            replace_batchnorm_with_groupnorm(child, num_groups)\n\n\nclass EfficientNetModel(nn.Module):\n    def __init__(self, model_name):\n        super().__init__()\n        self.model = timm.create_model(model_name, pretrained=True)\n        replace_batchnorm_with_groupnorm(self.model)\n        in_features = self.model.classifier.in_features\n        self.model.classifier = nn.Sequential(nn.Linear(in_features, 1))\n\n    def forward(self, x):\n        return self.model(x)\n\n\nclass PreprocessedImageDataset(Dataset):\n    \"\"\"Reads already-preprocessed (circle-crop + Ben Graham) PNGs straight\n    from disk — preprocessing happens once up front, not per-epoch.\"\"\"\n    def __init__(self, df, transform=None):\n        self.filepaths = df[\"filepath\"].tolist()\n        self.labels = df[\"label\"].tolist()\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.filepaths)\n\n    def __getitem__(self, idx):\n        img = cv2.imread(self.filepaths[idx])\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        if self.transform:\n            img = self.transform(image=img)[\"image\"]\n        return img, float(self.labels[idx])\n\n\nclass OptimizedRounder:\n    def __init__(self):\n        self.coef_ = None\n\n    def _kappa_loss(self, coef, X, y):\n        preds = self.predict(X, coef)\n        return -cohen_kappa_score(y, preds, weights=\"quadratic\")\n\n    def fit(self, X, y):\n        loss_partial = lambda coef: self._kappa_loss(coef, X, y)\n        initial_coef = [0.5, 1.5, 2.5, 3.5]\n        self.coef_ = minimize(loss_partial, initial_coef, method=\"nelder-mead\")[\"x\"]\n        print(f\"Optimized thresholds: {self.coef_}\")\n\n    def predict(self, X, coef):\n        X_p = np.copy(X)\n        for i, pred in enumerate(X_p):\n            if pred < coef[0]:\n                X_p[i] = 0\n            elif pred < coef[1]:\n                X_p[i] = 1\n            elif pred < coef[2]:\n                X_p[i] = 2\n            elif pred < coef[3]:\n                X_p[i] = 3\n            else:\n                X_p[i] = 4\n        return X_p.astype(int)\n\n\n# ---------------------------------------------------------------------------\n# 5. Train\n# ---------------------------------------------------------------------------\ndef train(model, train_loader, valid_loader, device, num_epochs, lr):\n    criterion = nn.MSELoss()\n    optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-5)\n    scheduler = CosineAnnealingLR(optimizer, T_max=num_epochs, eta_min=1e-6)\n\n    best_valid_kappa = -1.0\n    epochs_no_improve = 0\n\n    for epoch in range(num_epochs):\n        model.train()\n        running_loss = 0.0\n        for images, labels in tqdm(train_loader, desc=f\"Epoch {epoch + 1} train\"):\n            images = images.to(device)\n            labels = labels.to(device).float().view(-1, 1)\n            optimizer.zero_grad()\n            outputs = model(images)\n            loss = criterion(outputs, labels)\n            loss.backward()\n            optimizer.step()\n            running_loss += loss.item()\n\n        model.eval()\n        all_labels, all_preds = [], []\n        with torch.no_grad():\n            for images, labels in tqdm(valid_loader, desc=f\"Epoch {epoch + 1} valid\"):\n                images = images.to(device)\n                outputs = model(images).squeeze()\n                all_labels.extend(labels.cpu().numpy())\n                all_preds.extend(np.atleast_1d(outputs.cpu().numpy()))\n\n        rounded = np.round(all_preds).astype(int)\n        valid_kappa = cohen_kappa_score(all_labels, rounded, weights=\"quadratic\")\n\n        print(f\"\\nEpoch {epoch + 1}/{num_epochs} — train_loss={running_loss / len(train_loader):.4f} \"\n              f\"valid_kappa={valid_kappa:.4f} lr={optimizer.param_groups[0]['lr']:.6f}\")\n        scheduler.step()\n\n        if valid_kappa > best_valid_kappa:\n            best_valid_kappa = valid_kappa\n            epochs_no_improve = 0\n            torch.save(model.state_dict(), MODEL_SAVE_PATH)\n            print(f\"  -> new best model saved (kappa={best_valid_kappa:.4f})\")\n        else:\n            epochs_no_improve += 1\n\n        if epochs_no_improve >= PATIENCE:\n            print(f\"\\nEarly stopping — no improvement in {PATIENCE} epochs.\")\n            break\n\n    return best_valid_kappa\n\n\ndef get_predictions(loader, model, device):\n    model.eval()\n    all_labels, all_preds = [], []\n    with torch.no_grad():\n        for images, labels in tqdm(loader, desc=\"Predicting\"):\n            images = images.to(device)\n            outputs = model(images).squeeze()\n            all_labels.extend(labels.cpu().numpy())\n            all_preds.extend(np.atleast_1d(outputs.cpu().numpy()))\n    return np.array(all_preds), np.array(all_labels)\n\n\ndef evaluate_holdout(name, dataset, model, device, coefficients, batch_size=BATCH_SIZE):\n    if dataset is None or len(dataset) == 0:\n        print(f\"\\n[{name}] skipped — not available\")\n        return\n    loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=NUM_WORKERS)\n    raw_preds, labels = get_predictions(loader, model, device)\n    rounder = OptimizedRounder()\n    preds = rounder.predict(raw_preds, coefficients)\n\n    kappa = cohen_kappa_score(labels, preds, weights=\"quadratic\")\n    print(f\"\\n=== {name} ({len(dataset)} images) ===\")\n    print(f\"Quadratic Weighted Kappa: {kappa:.4f}\\n\")\n    print(classification_report(labels, preds, labels=list(LABEL_NAMES.keys()),\n                                 target_names=list(LABEL_NAMES.values()), zero_division=0))\n    print(\"Confusion matrix:\")\n    print(confusion_matrix(labels, preds, labels=list(LABEL_NAMES.keys())))\n\n\n# ---------------------------------------------------------------------------\n# 6. Main\n# ---------------------------------------------------------------------------\nif __name__ == \"__main__\":\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    print(f\"Device: {device}\")\n\n    print(\"\\nLoading datasets...\")\n    aptos_df = load_aptos()\n    idrid_train_df = load_idrid_split(IDRID_TRAIN_CSV, IDRID_TRAIN_IMG_DIR)\n    idrid_test_df = load_idrid_split(IDRID_TEST_CSV, IDRID_TEST_IMG_DIR)   # held out\n    messidor2_df = load_messidor2()                                       # held out\n\n    train_pool = pd.concat([aptos_df, idrid_train_df], ignore_index=True)\n    if len(train_pool) == 0:\n        raise RuntimeError(\"No training images found — check the CONFIG paths at the top of this file.\")\n\n    print(f\"\\nTrain pool: {len(train_pool)} (APTOS {len(aptos_df)} + IDRiD-train {len(idrid_train_df)})\")\n    print(f\"Held-out: IDRiD-test={len(idrid_test_df)}, Messidor-2={len(messidor2_df)}\")\n\n    print(\"\\nPreprocessing (cached — reruns skip already-processed images)...\")\n    train_pool = preprocess_and_cache(train_pool, \"train\", os.path.join(PREPROCESSED_DIR, \"train\"), IMAGE_SIZE)\n    if len(idrid_test_df):\n        idrid_test_df = preprocess_and_cache(idrid_test_df, \"idrid_test\",\n                                              os.path.join(PREPROCESSED_DIR, \"idrid_test\"), IMAGE_SIZE)\n    if len(messidor2_df):\n        messidor2_df = preprocess_and_cache(messidor2_df, \"messidor2\",\n                                             os.path.join(PREPROCESSED_DIR, \"messidor2\"), IMAGE_SIZE)\n\n    train_df, val_df = train_test_split(\n        train_pool, test_size=VAL_FRACTION, stratify=train_pool[\"label\"], random_state=SEED\n    )\n    print(f\"  -> train={len(train_df)}, val={len(val_df)}\")\n\n    train_dataset = PreprocessedImageDataset(train_df, transform=get_transforms(\"train\"))\n    val_dataset = PreprocessedImageDataset(val_df, transform=get_transforms(\"valid\"))\n    idrid_test_dataset = PreprocessedImageDataset(idrid_test_df, transform=get_transforms(\"valid\")) if len(idrid_test_df) else None\n    messidor2_dataset = PreprocessedImageDataset(messidor2_df, transform=get_transforms(\"valid\")) if len(messidor2_df) else None\n\n    train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True,\n                               num_workers=NUM_WORKERS, pin_memory=True)\n    val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False,\n                             num_workers=NUM_WORKERS, pin_memory=True)\n\n    print(\"\\nTraining...\")\n    model = EfficientNetModel(MODEL_NAME).to(device)\n    train(model, train_loader, val_loader, device, NUM_EPOCHS, LEARNING_RATE)\n\n    print(f\"\\nLoading best checkpoint from {MODEL_SAVE_PATH} for threshold fitting + held-out eval...\")\n    model.load_state_dict(torch.load(MODEL_SAVE_PATH, map_location=device))\n\n    val_preds, val_labels = get_predictions(val_loader, model, device)\n    rounder = OptimizedRounder()\n    rounder.fit(val_preds, val_labels)\n    final_val_kappa = cohen_kappa_score(val_labels, rounder.predict(val_preds, rounder.coef_), weights=\"quadratic\")\n    print(f\"Final optimized validation Kappa (in-domain, APTOS+IDRiD mix): {final_val_kappa:.4f}\")\n\n    print(\"\\n\\n########## HELD-OUT EVALUATION (never trained on) ##########\")\n    evaluate_holdout(\"IDRiD official test set (India-specific)\", idrid_test_dataset, model, device, rounder.coef_)\n    evaluate_holdout(\"Messidor-2 (cross-domain check)\", messidor2_dataset, model, device, rounder.coef_)\n\n    print(f\"\\nDone. Model saved to {MODEL_SAVE_PATH}\")","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-09-22T14:35:12.601611Z","iopub.execute_input":"2026-09-22T14:35:12.602462Z","iopub.status.idle":"2026-09-22T14:35:12.691828Z","shell.execute_reply.started":"2026-09-22T14:35:12.60243Z","shell.execute_reply":"2026-09-22T14:35:12.690528Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":" !ls /kaggle/input/\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-11T03:16:45.759103Z","iopub.execute_input":"2026-09-11T03:16:45.759616Z","iopub.status.idle":"2026-09-11T03:16:45.878555Z","shell.execute_reply.started":"2026-09-11T03:16:45.759578Z","shell.execute_reply":"2026-09-11T03:16:45.877697Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!cat /kaggle/input/datasets/vishalramcs/messidor/IMAGES.zip.00* > /kaggle/working/IMAGES.zip\n!unzip -q /kaggle/working/IMAGES.zip -d /kaggle/working/messidor_images\n!ls /kaggle/working/messidor_images","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-11T03:28:50.110234Z","iopub.execute_input":"2026-09-11T03:28:50.110756Z","iopub.status.idle":"2026-09-11T03:29:18.72324Z","shell.execute_reply.started":"2026-09-11T03:28:50.110719Z","shell.execute_reply":"2026-09-11T03:29:18.722388Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}