{"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":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":4104}],"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import subprocess\nimport os\nimport io\nimport time\nimport random\nimport cv2\nimport json\nimport math\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport cv2\n\nfrom torchvision.transforms import ToTensor\nfrom torchvision.models import efficientnet_b0, EfficientNet_B0_Weights\nfrom torch.utils.data import Dataset, DataLoader, Subset\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import accuracy_score, balanced_accuracy_score, confusion_matrix, classification_report\nfrom pathlib import Path\nfrom PIL import Image\nfrom io import BytesIO","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-02T13:09:01.95363Z","iopub.execute_input":"2026-10-02T13:09:01.954449Z","iopub.status.idle":"2026-10-02T13:09:01.960327Z","shell.execute_reply.started":"2026-10-02T13:09:01.954411Z","shell.execute_reply":"2026-10-02T13:09:01.95961Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"OpenCV:\", cv2.__version__)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-02T13:09:02.402262Z","iopub.execute_input":"2026-10-02T13:09:02.402847Z","iopub.status.idle":"2026-10-02T13:09:02.407472Z","shell.execute_reply.started":"2026-10-02T13:09:02.402812Z","shell.execute_reply":"2026-10-02T13:09:02.406627Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"DATA_DIR = Path(\"/kaggle/input/competitions/diabetic-retinopathy-detection\")\nWORK_DIR = Path(\"/kaggle/working\")\n\nARCHIVE_PATH = DATA_DIR / \"train.zip.001\"\nLABELS_PATH = Path(\"/kaggle/tmp/trainLabels.csv\")\n\nTRAIN_LABELS_OUT = WORK_DIR / \"trainLabels.csv\"\nTEST_LABELS_OUT = WORK_DIR / \"testLabels.csv\"\n\nCACHE_DIR = WORK_DIR / \"retinopathy_cache\"\n\nBATCH_SIZE = 1\nRANDOM_SEED = 42","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-02T13:09:02.779641Z","iopub.execute_input":"2026-10-02T13:09:02.780375Z","iopub.status.idle":"2026-10-02T13:09:02.784861Z","shell.execute_reply.started":"2026-10-02T13:09:02.780339Z","shell.execute_reply":"2026-10-02T13:09:02.78405Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!unzip -o \\\n    \"{DATA_DIR}/trainLabels.csv.zip\" \\\n    -d \"/kaggle/tmp/\"","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-02T13:09:05.247309Z","iopub.execute_input":"2026-10-02T13:09:05.247952Z","iopub.status.idle":"2026-10-02T13:09:05.392565Z","shell.execute_reply.started":"2026-10-02T13:09:05.247906Z","shell.execute_reply":"2026-10-02T13:09:05.391564Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**ROI / PREPROCESSING EXPERIMENTS:**","metadata":{}},{"cell_type":"code","source":"# Split na poziomie pacjenta, a nie obrazu aby zapobiec data leakage ze zbioru treningowego do testowego\n\n# --------------------------------------------------\n# Load labels\n# --------------------------------------------------\n\nlabels = pd.read_csv(LABELS_PATH)\n\nprint(\"Liczba obrazów:\", len(labels))\nprint(\"\\nRozkład klas przed splitem:\")\nprint(labels[\"level\"].value_counts().sort_index())\n\n# --------------------------------------------------\n# Extract patient_id\n# Example:\n# 10003_left  -> 10003\n# 10003_right -> 10003\n# --------------------------------------------------\n\nlabels[\"patient_id\"] = (\n    labels[\"image\"]\n    .str.extract(r\"^(\\d+)_\")[0]\n    .astype(int)\n)\n\nprint(\"\\nLiczba unikalnych pacjentów:\", labels[\"patient_id\"].nunique())\n\n# --------------------------------------------------\n# Check number of images per patient\n# --------------------------------------------------\n\nimages_per_patient = labels.groupby(\"patient_id\").size()\n\nprint(\"\\nLiczba zdjęć na pacjenta:\")\nprint(images_per_patient.value_counts().sort_index())\n\n# --------------------------------------------------\n# Create patient-level profile\n#\n# For each patient we keep the two eye labels.\n# Sorting means:\n#   left=0, right=2 -> \"0_2\"\n#   left=2, right=0 -> \"0_2\"\n#\n# This allows us to stratify patients while keeping\n# both eyes together.\n# --------------------------------------------------\n\npatient_profiles = (\n    labels\n    .groupby(\"patient_id\")[\"level\"]\n    .apply(lambda x: \"_\".join(map(str, sorted(x.tolist()))))\n    .reset_index(name=\"eye_profile\")\n)\n\nprint(\"\\nProfile pacjentów:\")\nprint(patient_profiles[\"eye_profile\"].value_counts().sort_index())\n\n# --------------------------------------------------\n# 80/20 patient-level stratified split\n# --------------------------------------------------\n\ntrain_patients, test_patients = train_test_split(\n    patient_profiles,\n    test_size=0.20,\n    random_state=42,\n    stratify=patient_profiles[\"eye_profile\"],\n)\n\ntrain_patient_ids = set(train_patients[\"patient_id\"])\ntest_patient_ids = set(test_patients[\"patient_id\"])\n\n# --------------------------------------------------\n# Create image-level train/test labels\n# --------------------------------------------------\n\ntrain_labels = labels[\n    labels[\"patient_id\"].isin(train_patient_ids)\n].copy()\n\ntest_labels = labels[\n    labels[\"patient_id\"].isin(test_patient_ids)\n].copy()\n\n# --------------------------------------------------\n# Remove helper column\n# --------------------------------------------------\n\ntrain_labels = train_labels.drop(columns=[\"patient_id\"])\ntest_labels = test_labels.drop(columns=[\"patient_id\"])\n\n# --------------------------------------------------\n# Save\n# --------------------------------------------------\n\ntrain_labels.to_csv(TRAIN_LABELS_OUT, index=False)\ntest_labels.to_csv(TEST_LABELS_OUT, index=False)\n\n# --------------------------------------------------\n# Results\n# --------------------------------------------------\n\nprint(\"\\n\" + \"=\" * 50)\nprint(\"WYNIK SPLITU\")\nprint(\"=\" * 50)\n\nprint(f\"\\nTRAIN:\")\nprint(f\"  Obrazy:    {len(train_labels)}\")\nprint(f\"  Pacjenci:  {len(train_patient_ids)}\")\n\nprint(\"\\nTEST:\")\nprint(f\"  Obrazy:    {len(test_labels)}\")\nprint(f\"  Pacjenci:  {len(test_patient_ids)}\")\n\nprint(\"\\nRozkład klas - TRAIN:\")\nprint(train_labels[\"level\"].value_counts().sort_index())\n\nprint(\"\\nRozkład klas - TEST:\")\nprint(test_labels[\"level\"].value_counts().sort_index())\n\nprint(\"\\nProcentowy rozkład klas - TRAIN:\")\nprint(\n    (train_labels[\"level\"].value_counts(normalize=True).sort_index() * 100)\n    .round(2)\n)\n\nprint(\"\\nProcentowy rozkład klas - TEST:\")\nprint(\n    (test_labels[\"level\"].value_counts(normalize=True).sort_index() * 100)\n    .round(2)\n)\n\nprint(\"\\nPliki:\")\nprint(TRAIN_LABELS_OUT)\nprint(TEST_LABELS_OUT)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-02T13:09:16.758022Z","iopub.execute_input":"2026-10-02T13:09:16.75885Z","iopub.status.idle":"2026-10-02T13:09:17.384309Z","shell.execute_reply.started":"2026-10-02T13:09:16.75881Z","shell.execute_reply":"2026-10-02T13:09:17.383544Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 100 pierwszych nazw obrazów z naszego trainLabels.csv\nlabels = pd.read_csv(TRAIN_LABELS_OUT)\n\nimage_names = labels[\"image\"].head(100).tolist()\n\nstart = time.time()\n\nfor image_name in image_names:\n    archive_path = f\"train/{image_name}.jpeg\"\n\n    result = subprocess.run([\"7z\", \"e\", \"-so\", ARCHIVE, archive_path],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.DEVNULL,\n        check=True,\n    )\n\n    img = Image.open(BytesIO(result.stdout))\n    img.load()\n\nelapsed = time.time() - start\n\nprint(f\"Images: {len(image_names)}\")\nprint(f\"Time: {elapsed:.2f} s\")\nprint(f\"Average: {elapsed / len(image_names):.3f} s/image\")\nprint(f\"Estimated for 28,100 train images: {elapsed / 100 * 28100 / 60:.1f} min\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:27:26.357195Z","iopub.execute_input":"2026-09-25T09:27:26.357754Z","iopub.status.idle":"2026-09-25T09:27:40.527243Z","shell.execute_reply.started":"2026-09-25T09:27:26.357731Z","shell.execute_reply":"2026-09-25T09:27:40.526463Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"labels = pd.read_csv(TRAIN_LABELS)\n\nprint(labels.head())\nprint()\nprint(f\"Number of training images: {len(labels):,}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:07:16.082034Z","iopub.execute_input":"2026-09-25T10:07:16.08283Z","iopub.status.idle":"2026-09-25T10:07:16.102347Z","shell.execute_reply.started":"2026-09-25T10:07:16.082798Z","shell.execute_reply":"2026-09-25T10:07:16.10176Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def load_image_from_archive(image_name, archive_path=ARCHIVE):\n    \"\"\"\n    Extract one JPEG from the multi-part 7z archive directly into RAM.\n\n    image_name:\n        e.g. '10003_left'\n\n    Returns:\n        PIL.Image in RGB format\n    \"\"\"\n    \n    archive_member = f\"train/{image_name}.jpeg\"\n\n    result = subprocess.run([\"7z\", \"e\", \"-so\", archive_path, archive_member],\n        stdout=subprocess.PIPE,\n        check=True,\n    )\n\n    image = Image.open(io.BytesIO(result.stdout))\n    image.load()\n\n    return image.convert(\"RGB\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:07:47.531356Z","iopub.execute_input":"2026-09-25T10:07:47.532114Z","iopub.status.idle":"2026-09-25T10:07:47.536447Z","shell.execute_reply.started":"2026-09-25T10:07:47.532083Z","shell.execute_reply":"2026-09-25T10:07:47.535832Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"img = load_image_from_archive(\"10003_left\")\n\nprint(type(img))\nprint(img.size)\nprint(img.mode)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:07:58.386091Z","iopub.execute_input":"2026-09-25T10:07:58.386488Z","iopub.status.idle":"2026-09-25T10:07:58.599102Z","shell.execute_reply.started":"2026-09-25T10:07:58.386461Z","shell.execute_reply":"2026-09-25T10:07:58.598411Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"plt.figure(figsize=(14, 9))\n\nplt.imshow(img)\nplt.title(\"Original fundus image\")\nplt.axis(\"off\")\n\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:27:40.788713Z","iopub.execute_input":"2026-09-25T09:27:40.789322Z","iopub.status.idle":"2026-09-25T09:27:41.862046Z","shell.execute_reply.started":"2026-09-25T09:27:40.789295Z","shell.execute_reply":"2026-09-25T09:27:41.86115Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def detect_fundus_bbox(image, threshold=10, min_area_ratio=0.10, padding=0):\n    \"\"\"\n    Detect the bounding box of the main fundus region.\n\n    Returns\n    -------\n    bbox : tuple[int, int, int, int] | None\n        Bounding box in PIL format: (left, top, right, bottom).\n    fundus_mask : np.ndarray | None\n        Binary mask containing only the selected fundus component.\n    \"\"\"\n    if image.mode != \"RGB\":\n        image = image.convert(\"RGB\")\n\n    img_np = np.asarray(image)\n    gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)\n    foreground_mask = (gray > threshold).astype(np.uint8)\n\n    num_labels, labels_img, stats, _ = cv2.connectedComponentsWithStats(\n        foreground_mask, connectivity=8\n    )\n\n    min_area = gray.size * min_area_ratio\n    best_component = None\n    best_area = 0\n\n    for label_id in range(1, num_labels):\n        area = stats[label_id, cv2.CC_STAT_AREA]\n        if area >= min_area and area > best_area:\n            best_component = label_id\n            best_area = area\n\n    if best_component is None:\n        return None, None\n\n    x = int(stats[best_component, cv2.CC_STAT_LEFT])\n    y = int(stats[best_component, cv2.CC_STAT_TOP])\n    w = int(stats[best_component, cv2.CC_STAT_WIDTH])\n    h = int(stats[best_component, cv2.CC_STAT_HEIGHT])\n\n    left = max(0, x - padding)\n    top = max(0, y - padding)\n    right = min(image.width, x + w + padding)\n    bottom = min(image.height, y + h + padding)\n\n    bbox = (left, top, right, bottom)\n    fundus_mask = (labels_img == best_component).astype(np.uint8)\n\n    return bbox, fundus_mask","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:14:59.139607Z","iopub.execute_input":"2026-09-25T10:14:59.140263Z","iopub.status.idle":"2026-09-25T10:14:59.148222Z","shell.execute_reply.started":"2026-09-25T10:14:59.14023Z","shell.execute_reply":"2026-09-25T10:14:59.147665Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Sanity check: fundus ROI detection\nbbox, fundus_mask = detect_fundus_bbox(img)\n\nif bbox is None:\n    print(\"Fundus ROI could not be detected.\")\nelse:\n    cropped = img.crop(bbox)\n    left, top, right, bottom = bbox\n\n    print(f\"Original size: {img.size}\")\n    print(f\"Bounding box : {bbox}\")\n    print(f\"Cropped size : {cropped.size}\")\n\n    fig, axes = plt.subplots(1, 3, figsize=(20, 7))\n\n    axes[0].imshow(img)\n    axes[0].set_title(\"Original\")\n    axes[0].axis(\"off\")\n\n    axes[1].imshow(img)\n    rect = plt.Rectangle(\n        (left, top), right - left, bottom - top,\n        fill=False, linewidth=3\n    )\n    axes[1].add_patch(rect)\n    axes[1].set_title(\"Detected fundus ROI\")\n    axes[1].axis(\"off\")\n\n    axes[2].imshow(cropped)\n    axes[2].set_title(\"Cropped fundus\")\n    axes[2].axis(\"off\")\n\n    plt.tight_layout()\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:15:39.057598Z","iopub.execute_input":"2026-09-25T10:15:39.058063Z","iopub.status.idle":"2026-09-25T10:15:41.6486Z","shell.execute_reply.started":"2026-09-25T10:15:39.058035Z","shell.execute_reply":"2026-09-25T10:15:41.647671Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def split_into_tiles(image, rows=4, cols=4):\n    \"\"\"Split a PIL image into a regular grid without discarding pixels.\"\"\"\n    if rows <= 0 or cols <= 0:\n        raise ValueError(\"rows and cols must be positive integers.\")\n\n    width, height = image.size\n\n    if rows > height or cols > width:\n        raise ValueError(\"Tile grid is larger than the image dimensions.\")\n\n    tile_width = width // cols\n    tile_height = height // rows\n    tiles = []\n\n    for row in range(rows):\n        for col in range(cols):\n            left = col * tile_width\n            top = row * tile_height\n            right = width if col == cols - 1 else (col + 1) * tile_width\n            bottom = height if row == rows - 1 else (row + 1) * tile_height\n\n            tiles.append({\n                \"row\": row,\n                \"col\": col,\n                \"image\": image.crop((left, top, right, bottom)),\n            })\n\n    return tiles","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:16:02.72227Z","iopub.execute_input":"2026-09-25T10:16:02.72272Z","iopub.status.idle":"2026-09-25T10:16:02.729221Z","shell.execute_reply.started":"2026-09-25T10:16:02.722689Z","shell.execute_reply":"2026-09-25T10:16:02.728336Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Sanity check: 4x4 tiling\ntiles = split_into_tiles(cropped, rows=4, cols=4)\n\nprint(f\"Number of tiles: {len(tiles)}\")\n\nfig, axes = plt.subplots(4, 4, figsize=(16, 12))\n\nfor tile, ax in zip(tiles, axes.flat):\n    ax.imshow(tile[\"image\"])\n    ax.set_title(\n        f\"Tile ({tile['row']}, {tile['col']})\\n\"\n        f\"{tile['image'].width}×{tile['image'].height}\"\n    )\n    ax.axis(\"off\")\n\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:16:18.075167Z","iopub.execute_input":"2026-09-25T10:16:18.075906Z","iopub.status.idle":"2026-09-25T10:16:19.680106Z","shell.execute_reply.started":"2026-09-25T10:16:18.075841Z","shell.execute_reply":"2026-09-25T10:16:19.67906Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def calculate_dark_ratio(image, threshold=10):\n    \"\"\"\n    Fraction of very dark pixels in an image.\n\n    Used as a heuristic for estimating how much of a tile consists\n    of the black background surrounding the fundus.\n    \"\"\"\n    if image.mode != \"RGB\":\n        image = image.convert(\"RGB\")\n\n    arr = np.asarray(image)\n    gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)\n\n    return float((gray <= threshold).mean())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:16:49.605652Z","iopub.execute_input":"2026-09-25T10:16:49.6061Z","iopub.status.idle":"2026-09-25T10:16:49.611565Z","shell.execute_reply.started":"2026-09-25T10:16:49.606067Z","shell.execute_reply":"2026-09-25T10:16:49.610614Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Sanity check: dark-pixel ratio per tile\nfor i, tile in enumerate(tiles):\n    dark_ratio = calculate_dark_ratio(tile[\"image\"])\n\n    print(\n        f\"Tile {i:2d} ({tile['row']}, {tile['col']}): \"\n        f\"{dark_ratio:6.2%} dark pixels\"\n    )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:17:00.00108Z","iopub.execute_input":"2026-09-25T10:17:00.001891Z","iopub.status.idle":"2026-09-25T10:17:00.032199Z","shell.execute_reply.started":"2026-09-25T10:17:00.001822Z","shell.execute_reply":"2026-09-25T10:17:00.031599Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Diagnostic sample: preprocessing statistics\nN = 500\n\nsample_labels = labels.sample(\n    n=min(N, len(labels)),\n    random_state=RANDOM_SEED,\n).reset_index(drop=True)\n\nresults = []\nfailed_images = []\nstart_time = time.time()\n\nfor i, image_name in enumerate(sample_labels[\"image\"]):\n    try:\n        image = load_image_from_archive(image_name)\n        original_width, original_height = image.size\n\n        bbox, fundus_mask = detect_fundus_bbox(image)\n        if bbox is None:\n            failed_images.append(image_name)\n            continue\n\n        cropped_image = image.crop(bbox)\n        cropped_width, cropped_height = cropped_image.size\n        image_tiles = split_into_tiles(cropped_image, rows=4, cols=4)\n\n        for tile in image_tiles:\n            dark_ratio = calculate_dark_ratio(tile[\"image\"])\n\n            results.append({\n                \"image\": image_name,\n                \"original_width\": original_width,\n                \"original_height\": original_height,\n                \"crop_width\": cropped_width,\n                \"crop_height\": cropped_height,\n                \"row\": tile[\"row\"],\n                \"col\": tile[\"col\"],\n                \"dark_ratio\": dark_ratio,\n                \"dark_percent\": dark_ratio * 100,\n            })\n\n    except Exception as exc:\n        failed_images.append(image_name)\n        print(f\"Failed: {image_name} | {type(exc).__name__}: {exc}\")\n\n    if (i + 1) % 50 == 0:\n        elapsed = time.time() - start_time\n        print(\n            f\"{i + 1}/{len(sample_labels)} images | \"\n            f\"{elapsed:.1f} s | {elapsed / (i + 1):.3f} s/image\"\n        )\n\nelapsed = time.time() - start_time\nresults_df = pd.DataFrame(results)\n\nprint(\"\\n\" + \"=\" * 60)\nprint(f\"Images requested : {len(sample_labels)}\")\nprint(f\"Images failed    : {len(failed_images)}\")\nprint(f\"Images processed : {results_df['image'].nunique()}\")\nprint(f\"Total time       : {elapsed:.2f} s\")\nprint(f\"Average/image    : {elapsed / len(sample_labels):.3f} s\")\nprint(\"=\" * 60)\n\nif failed_images:\n    print(\"\\nFailed images:\")\n    print(failed_images[:20])\n\n    if len(failed_images) > 20:\n        print(f\"... and {len(failed_images) - 20} more\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:17:35.872628Z","iopub.execute_input":"2026-09-25T10:17:35.873409Z","iopub.status.idle":"2026-09-25T10:19:17.663717Z","shell.execute_reply.started":"2026-09-25T10:17:35.873377Z","shell.execute_reply":"2026-09-25T10:19:17.66303Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"original_sizes = (\n    results_df[\n        [\"original_width\", \"original_height\"]\n    ]\n    .drop_duplicates()\n    .sort_values(\n        [\"original_width\", \"original_height\"]\n    )\n)\n\nprint(original_sizes.to_string(index=False))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:29:49.552235Z","iopub.execute_input":"2026-09-25T09:29:49.552517Z","iopub.status.idle":"2026-09-25T09:29:49.562984Z","shell.execute_reply.started":"2026-09-25T09:29:49.552495Z","shell.execute_reply":"2026-09-25T09:29:49.562219Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\n    results_df[\n        [\"original_width\", \"original_height\"]\n    ]\n    .value_counts()\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:29:49.563926Z","iopub.execute_input":"2026-09-25T09:29:49.564234Z","iopub.status.idle":"2026-09-25T09:29:49.578433Z","shell.execute_reply.started":"2026-09-25T09:29:49.564201Z","shell.execute_reply":"2026-09-25T09:29:49.577911Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"tile_stats = (\n    results_df\n    .groupby([\"row\", \"col\"])[\"dark_percent\"]\n    .agg([\n        \"count\",\n        \"mean\",\n        \"median\",\n        \"min\",\n        \"max\",\n        lambda x: x.quantile(0.25),\n        lambda x: x.quantile(0.75),\n        lambda x: x.quantile(0.95),\n    ])\n    .reset_index()\n)\n\ntile_stats.columns = [\n    \"row\",\n    \"col\",\n    \"count\",\n    \"mean\",\n    \"median\",\n    \"min\",\n    \"max\",\n    \"q25\",\n    \"q75\",\n    \"q95\",\n]\n\nprint(\n    tile_stats.to_string(\n        index=False,\n        float_format=lambda x: f\"{x:.2f}\"\n    )\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:29:49.579151Z","iopub.execute_input":"2026-09-25T09:29:49.579716Z","iopub.status.idle":"2026-09-25T09:29:49.623669Z","shell.execute_reply.started":"2026-09-25T09:29:49.579693Z","shell.execute_reply":"2026-09-25T09:29:49.622936Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"median_matrix = (\n    tile_stats\n    .pivot(\n        index=\"row\",\n        columns=\"col\",\n        values=\"median\",\n    )\n)\n\nplt.figure(figsize=(8, 7))\n\nplt.imshow(\n    median_matrix.values,\n    vmin=0,\n    vmax=100,\n)\n\nplt.colorbar(label=\"Median dark pixels (%)\")\n\nplt.Xticks(range(4), range(4))\nplt.yticks(range(4), range(4))\n\nplt.xlabel(\"Tile column\")\nplt.ylabel(\"Tile row\")\nplt.title(\"Median dark-pixel ratio — 4×4 tiles\")\n\nfor row in range(4):\n    for col in range(4):\n        value = median_matrix.iloc[row, col]\n        plt.text(\n            col,\n            row,\n            f\"{value:.1f}%\",\n            ha=\"center\",\n            va=\"center\",\n        )\n\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:29:49.624668Z","iopub.execute_input":"2026-09-25T09:29:49.625418Z","iopub.status.idle":"2026-09-25T09:29:49.840712Z","shell.execute_reply.started":"2026-09-25T09:29:49.625383Z","shell.execute_reply":"2026-09-25T09:29:49.840116Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"corners = results_df[\n    ((results_df[\"row\"] == 0) & (results_df[\"col\"] == 0)) |\n    ((results_df[\"row\"] == 0) & (results_df[\"col\"] == 3)) |\n    ((results_df[\"row\"] == 3) & (results_df[\"col\"] == 0)) |\n    ((results_df[\"row\"] == 3) & (results_df[\"col\"] == 3))\n]\n\nprint(\n    corners\n    .groupby([\"row\", \"col\"])[\"dark_percent\"]\n    .agg([\n        \"mean\",\n        \"median\",\n        \"min\",\n        \"max\",\n        lambda x: x.quantile(0.25),\n        lambda x: x.quantile(0.75),\n        lambda x: x.quantile(0.95),\n    ])\n    .to_string(\n        float_format=lambda x: f\"{x:.2f}\"\n    )\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:29:49.841545Z","iopub.execute_input":"2026-09-25T09:29:49.842131Z","iopub.status.idle":"2026-09-25T09:29:49.863502Z","shell.execute_reply.started":"2026-09-25T09:29:49.842105Z","shell.execute_reply":"2026-09-25T09:29:49.862936Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"thresholds = [50, 60, 70, 80]\n\nfor threshold in thresholds:\n    print()\n    print(f\"=== Threshold: {threshold}% ===\")\n\n    discard_rates = (\n        results_df\n        .assign(\n            discard=(\n                results_df[\"dark_percent\"]\n                >= threshold\n            )\n        )\n        .groupby([\"row\", \"col\"])[\"discard\"]\n        .mean()\n        * 100\n    )\n\n    matrix = discard_rates.unstack()\n\n    print(matrix.to_string(float_format=lambda x: f\"{x:.1f}%\"))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:29:49.866132Z","iopub.execute_input":"2026-09-25T09:29:49.866419Z","iopub.status.idle":"2026-09-25T09:29:49.887633Z","shell.execute_reply.started":"2026-09-25T09:29:49.866396Z","shell.execute_reply":"2026-09-25T09:29:49.887089Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Improved preprocessing functions (v2)\ndef detect_fundus_bbox_v2(image, threshold=10, min_area_ratio=0.10, padding=0):\n    \"\"\"Detect the main fundus region and return its bbox and cropped mask.\"\"\"\n    if image.mode != \"RGB\":\n        image = image.convert(\"RGB\")\n\n    gray = cv2.cvtColor(np.asarray(image), cv2.COLOR_RGB2GRAY)\n    foreground_mask = (gray > threshold).astype(np.uint8)\n\n    num_labels, labels_img, stats, _ = cv2.connectedComponentsWithStats(\n        foreground_mask, connectivity=8\n    )\n\n    min_area = gray.size * min_area_ratio\n    best_component = None\n    best_area = 0\n\n    for label_id in range(1, num_labels):\n        area = stats[label_id, cv2.CC_STAT_AREA]\n        if area >= min_area and area > best_area:\n            best_component = label_id\n            best_area = area\n\n    if best_component is None:\n        return None, None\n\n    x = int(stats[best_component, cv2.CC_STAT_LEFT])\n    y = int(stats[best_component, cv2.CC_STAT_TOP])\n    w = int(stats[best_component, cv2.CC_STAT_WIDTH])\n    h = int(stats[best_component, cv2.CC_STAT_HEIGHT])\n\n    left = max(0, x - padding)\n    top = max(0, y - padding)\n    right = min(image.width, x + w + padding)\n    bottom = min(image.height, y + h + padding)\n\n    bbox = (left, top, right, bottom)\n\n    fundus_mask = (labels_img == best_component).astype(np.uint8)\n    cropped_mask = fundus_mask[top:bottom, left:right]\n\n    return bbox, cropped_mask\n\n\ndef split_image_and_mask_into_tiles_v2(image, mask, rows=4, cols=4):\n    \"\"\"Split an image and its aligned mask into the same grid.\"\"\"\n    width, height = image.size\n    tile_width = width // cols\n    tile_height = height // rows\n    tiles = []\n\n    for row in range(rows):\n        for col in range(cols):\n            left = col * tile_width\n            top = row * tile_height\n            right = width if col == cols - 1 else (col + 1) * tile_width\n            bottom = height if row == rows - 1 else (row + 1) * tile_height\n\n            tiles.append({\n                \"row\": row,\n                \"col\": col,\n                \"image\": image.crop((left, top, right, bottom)),\n                \"mask\": mask[top:bottom, left:right],\n            })\n\n    return tiles\n\n\ndef calculate_tile_metrics_v2(tile_image, tile_mask, dark_threshold=10):\n    \"\"\"Calculate dark-pixel ratio and actual fundus coverage.\"\"\"\n    gray = cv2.cvtColor(np.asarray(tile_image), cv2.COLOR_RGB2GRAY)\n\n    return {\n        \"dark_ratio\": float((gray <= dark_threshold).mean()),\n        \"roi_ratio\": float((tile_mask > 0).mean()),\n    }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:30:07.574229Z","iopub.execute_input":"2026-09-25T10:30:07.574838Z","iopub.status.idle":"2026-09-25T10:30:07.585495Z","shell.execute_reply.started":"2026-09-25T10:30:07.574808Z","shell.execute_reply":"2026-09-25T10:30:07.584851Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"ARCHIVE = \"/kaggle/input/competitions/diabetic-retinopathy-detection/train.zip.001\"\nN_IMAGES = 12\nRANDOM_STATE = 42\n\nDARK_THRESHOLD = 10\nMIN_AREA_RATIO = 0.10\n\nROWS = 4\nCOLS = 4\n\n# ============================================================\n# LOAD IMAGE FROM 7z ARCHIVE\n# ============================================================\n\ndef load_image_from_archive(image_name, archive_path=ARCHIVE):\n    archive_member = f\"train/{image_name}.jpeg\"\n\n    result = subprocess.run(\n        [\n            \"7z\",\n            \"e\",\n            \"-so\",\n            archive_path,\n            archive_member,\n        ],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.DEVNULL,\n        check=True,\n    )\n\n    image = Image.open(io.BytesIO(result.stdout))\n    image.load()\n\n    return image.convert(\"RGB\")\n\n\n# ============================================================\n# TILE METRICS\n# ============================================================\n\ndef calculate_tile_metrics(\n    tile_image,\n    tile_mask,\n    dark_threshold=DARK_THRESHOLD,\n):\n    arr = np.asarray(tile_image)\n\n    gray = cv2.cvtColor(\n        arr,\n        cv2.COLOR_RGB2GRAY,\n    )\n\n    dark_ratio = np.mean(\n        gray <= dark_threshold\n    )\n\n    roi_ratio = np.mean(\n        tile_mask > 0\n    )\n\n    return {\n        \"dark_ratio\": dark_ratio,\n        \"roi_ratio\": roi_ratio,\n    }\n\n\n# ============================================================\n# SELECT FIXED RANDOM SAMPLE\n# ============================================================\n\nlabels = pd.read_csv(TRAIN_LABELS)\n\nsample = (\n    labels\n    .sample(\n        n=N_IMAGES,\n        random_state=RANDOM_STATE,\n    )\n    .reset_index(drop=True)\n)\n\nprint(\"Selected images:\")\nprint(sample[[\"image\", \"level\"]])\n\n\n# ============================================================\n# PROCESS IMAGES\n# ============================================================\n\nexperiments = []\n\nfor idx, row in sample.iterrows():\n    image_name = row[\"image\"]\n    level = row[\"level\"]\n\n    print(\n        f\"[{idx + 1:02d}/{N_IMAGES}] \"\n        f\"{image_name} | DR level={level}\"\n    )\n\n    image = load_image_from_archive(image_name)\n    bbox, mask = detect_fundus_bbox_v2(image)\n    cropped = image.crop(bbox)\n\n    tiles = split_image_and_mask_into_tiles_v2(\n        cropped,\n        mask,\n        rows=ROWS,\n        cols=COLS,\n    )\n\n    tile_results = []\n\n    for tile in tiles:\n        metrics = calculate_tile_metrics(\n            tile[\"image\"],\n            tile[\"mask\"],\n        )\n        tile_results.append(\n            {\n                \"row\": tile[\"row\"],\n                \"col\": tile[\"col\"],\n                \"dark_ratio\": metrics[\"dark_ratio\"],\n                \"roi_ratio\": metrics[\"roi_ratio\"],\n            }\n        )\n    experiments.append(\n        {\n            \"image_name\": image_name,\n            \"level\": level,\n            \"original\": image,\n            \"cropped\": cropped,\n            \"mask\": mask,\n            \"tiles\": tiles,\n            \"metrics\": pd.DataFrame(tile_results),\n        }\n    )\n\nprint(\"\\nExperiment finished.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:33:29.04862Z","iopub.execute_input":"2026-09-25T10:33:29.048915Z","iopub.status.idle":"2026-09-25T10:33:31.402694Z","shell.execute_reply.started":"2026-09-25T10:33:29.04889Z","shell.execute_reply":"2026-09-25T10:33:31.40208Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"for exp in experiments:\n\n    image_name = exp[\"image_name\"]\n    level = exp[\"level\"]\n\n    original = exp[\"original\"]\n    cropped = exp[\"cropped\"]\n    mask = exp[\"mask\"]\n    tiles = exp[\"tiles\"]\n    metrics = exp[\"metrics\"]\n\n    fig = plt.figure(figsize=(18, 12))\n\n    # --------------------------------------------------------\n    # ORIGINAL\n    # --------------------------------------------------------\n\n    ax = plt.subplot(2, 3, 1)\n\n    ax.imshow(original)\n    ax.set_title(\n        f\"Original\\n{image_name} | DR={level}\"\n    )\n    ax.axis(\"off\")\n\n    # --------------------------------------------------------\n    # CROPPED\n    # --------------------------------------------------------\n\n    ax = plt.subplot(2, 3, 2)\n\n    ax.imshow(cropped)\n    ax.set_title(\n        f\"ROI crop\\n{cropped.size[0]} × {cropped.size[1]}\"\n    )\n    ax.axis(\"off\")\n\n    # --------------------------------------------------------\n    # MASK\n    # --------------------------------------------------------\n\n    ax = plt.subplot(2, 3, 3)\n\n    ax.imshow(mask, cmap=\"gray\")\n    ax.set_title(\"Detected fundus mask\")\n    ax.axis(\"off\")\n\n    # --------------------------------------------------------\n    # 4x4 TILES\n    # --------------------------------------------------------\n\n    ax = plt.subplot(2, 3, (4, 6))\n\n    ax.imshow(cropped)\n\n    width, height = cropped.size\n\n    tile_width = width / COLS\n    tile_height = height / ROWS\n\n    for i in range(ROWS + 1):\n\n        y = i * tile_height\n\n        ax.axhline(\n            y,\n            linewidth=1,\n        )\n\n    for j in range(COLS + 1):\n\n        x = j * tile_width\n\n        ax.axvline(\n            x,\n            linewidth=1,\n        )\n\n    for _, metric in metrics.iterrows():\n\n        r = int(metric[\"row\"])\n        c = int(metric[\"col\"])\n\n        x = c * tile_width + tile_width / 2\n        y = r * tile_height + tile_height / 2\n\n        dark_pct = metric[\"dark_ratio\"] * 100\n        roi_pct = metric[\"roi_ratio\"] * 100\n\n        text = (\n            f\"{r},{c}\\n\"\n            f\"D: {dark_pct:.1f}%\\n\"\n            f\"R: {roi_pct:.1f}%\"\n        )\n\n        ax.text(\n            x,\n            y,\n            text,\n            ha=\"center\",\n            va=\"center\",\n            fontsize=9,\n            bbox=dict(\n                boxstyle=\"round,pad=0.3\",\n                facecolor=\"white\",\n                alpha=0.75,\n            ),\n        )\n\n    ax.set_title(\n        \"4×4 tiles\\n\"\n        \"D = dark pixels | R = detected ROI\"\n    )\n\n    ax.axis(\"off\")\n\n    plt.tight_layout()\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:29:53.059197Z","iopub.execute_input":"2026-09-25T09:29:53.059539Z","iopub.status.idle":"2026-09-25T09:30:25.526308Z","shell.execute_reply.started":"2026-09-25T09:29:53.059515Z","shell.execute_reply":"2026-09-25T09:30:25.525188Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# SHOW TILES WITH LOW ROI\n# ============================================================\n\nROI_THRESHOLD = 50.0\n\nlow_roi = metrics_df[\n    metrics_df[\"roi_pct\"] < ROI_THRESHOLD\n].copy()\n\nlow_roi = low_roi.sort_values(\n    \"roi_pct\"\n).reset_index(drop=True)\n\nprint(\n    f\"Tiles with ROI < {ROI_THRESHOLD}%: \"\n    f\"{len(low_roi)} / {len(metrics_df)} \"\n    f\"({100 * len(low_roi) / len(metrics_df):.2f}%)\"\n)\n\ndisplay(\n    low_roi[\n        [\n            \"image\",\n            \"level\",\n            \"row\",\n            \"col\",\n            \"dark_pct\",\n            \"roi_pct\",\n        ]\n    ]\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:34:47.178704Z","iopub.execute_input":"2026-09-25T10:34:47.179221Z","iopub.status.idle":"2026-09-25T10:34:47.19415Z","shell.execute_reply.started":"2026-09-25T10:34:47.179193Z","shell.execute_reply":"2026-09-25T10:34:47.193345Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# VISUALIZE LOW-ROI TILES\n# ============================================================\n\nMAX_TILES = 20\n\nfor _, row in low_roi.head(MAX_TILES).iterrows():\n\n    image_name = row[\"image\"]\n    tile_row = int(row[\"row\"])\n    tile_col = int(row[\"col\"])\n\n    # Find corresponding experiment\n    exp = next(\n        e for e in experiments\n        if e[\"image_name\"] == image_name\n    )\n\n    tile = next(\n        t for t in exp[\"tiles\"]\n        if t[\"row\"] == tile_row\n        and t[\"col\"] == tile_col\n    )\n\n    tile_image = tile[\"image\"]\n\n    fig, axes = plt.subplots(\n        1,\n        2,\n        figsize=(12, 5),\n    )\n\n    # --------------------------------------------------------\n    # WHOLE CROPPED IMAGE\n    # --------------------------------------------------------\n\n    axes[0].imshow(exp[\"cropped\"])\n\n    width, height = exp[\"cropped\"].size\n\n    tile_width = width / COLS\n    tile_height = height / ROWS\n\n    # Grid\n    for i in range(ROWS + 1):\n        axes[0].axhline(\n            i * tile_height,\n            linewidth=1,\n        )\n\n    for j in range(COLS + 1):\n        axes[0].axvline(\n            j * tile_width,\n            linewidth=1,\n        )\n\n    # Highlight selected tile\n    x = tile_col * tile_width\n    y = tile_row * tile_height\n\n    rect = plt.Rectangle(\n        (x, y),\n        tile_width,\n        tile_height,\n        fill=False,\n        linewidth=3,\n    )\n\n    axes[0].add_patch(rect)\n\n    axes[0].set_title(\n        f\"{image_name} | DR={exp['level']}\\n\"\n        f\"tile=({tile_row},{tile_col})\"\n    )\n\n    axes[0].axis(\"off\")\n\n    # --------------------------------------------------------\n    # TILE ITSELF\n    # --------------------------------------------------------\n\n    axes[1].imshow(tile_image)\n\n    axes[1].set_title(\n        f\"ROI: {row['roi_pct']:.1f}% | \"\n        f\"dark: {row['dark_pct']:.1f}%\"\n    )\n\n    axes[1].axis(\"off\")\n\n    plt.tight_layout()\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:34:47.777617Z","iopub.execute_input":"2026-09-25T10:34:47.778389Z","iopub.status.idle":"2026-09-25T10:34:58.821663Z","shell.execute_reply.started":"2026-09-25T10:34:47.778359Z","shell.execute_reply":"2026-09-25T10:34:58.820885Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ROI distribution experiment - V2\n\nN_IMAGES_V2 = 500\nRANDOM_STATE_V2 = 42\n\nsample_500_v2 = labels.sample(\n    n=min(N_IMAGES_V2, len(labels)),\n    random_state=RANDOM_STATE_V2,\n).reset_index(drop=True)\n\nresults_500_v2 = []\nfailed_images_v2 = []\nstart_time = time.time()\n\nfor idx, row in sample_500_v2.iterrows():\n    image_name = row[\"image\"]\n\n    try:\n        image = load_image_from_archive(image_name)\n        bbox, mask = detect_fundus_bbox_v2(image)\n\n        if bbox is None:\n            failed_images_v2.append(image_name)\n            continue\n\n        cropped = image.crop(bbox)\n        tiles = split_image_and_mask_into_tiles_v2(cropped, mask, rows=4, cols=4)\n\n        for tile in tiles:\n            metrics = calculate_tile_metrics_v2(tile[\"image\"], tile[\"mask\"])\n\n            results_500_v2.append({\n                \"image\": image_name,\n                \"level\": row[\"level\"],\n                \"row\": tile[\"row\"],\n                \"col\": tile[\"col\"],\n                **metrics,\n            })\n\n    except Exception as exc:\n        failed_images_v2.append(image_name)\n        print(f\"Failed: {image_name} | {exc}\")\n\n    if (idx + 1) % 50 == 0:\n        print(f\"{idx + 1}/{len(sample_500_v2)} | {time.time() - start_time:.1f}s\")\n\nresults_500_v2_df = pd.DataFrame(results_500_v2)\nresults_500_v2_df[\"dark_pct\"] = results_500_v2_df[\"dark_ratio\"] * 100\nresults_500_v2_df[\"roi_pct\"] = results_500_v2_df[\"roi_ratio\"] * 100\n\nelapsed = time.time() - start_time\n\nprint(f\"\\nProcessed images: {results_500_v2_df['image'].nunique()}\")\nprint(f\"Failed images:    {len(failed_images_v2)}\")\nprint(f\"Tiles:            {len(results_500_v2_df)}\")\nprint(f\"Time:             {elapsed:.1f}s\")\n\nif failed_images_v2:\n    print(\"Failed:\", failed_images_v2)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:35:53.412627Z","iopub.execute_input":"2026-09-25T10:35:53.413366Z","iopub.status.idle":"2026-09-25T10:37:37.149963Z","shell.execute_reply.started":"2026-09-25T10:35:53.413334Z","shell.execute_reply":"2026-09-25T10:37:37.149246Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# ROI BY TILE POSITION\n# ============================================================\n\nposition_stats = (\n    results_500_df\n    .groupby([\"row\", \"col\"])[\"roi_pct\"]\n    .agg(\n        [\n            \"count\",\n            \"mean\",\n            \"median\",\n            \"min\",\n            \"max\",\n            lambda x: x.quantile(0.25),\n            lambda x: x.quantile(0.75),\n            lambda x: x.quantile(0.95),\n        ]\n    )\n)\n\nposition_stats.columns = [\n    \"count\",\n    \"mean\",\n    \"median\",\n    \"min\",\n    \"max\",\n    \"q25\",\n    \"q75\",\n    \"q95\",\n]\n\ndisplay(\n    position_stats.round(2)\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:32:33.608152Z","iopub.execute_input":"2026-09-25T09:32:33.608568Z","iopub.status.idle":"2026-09-25T09:32:33.650773Z","shell.execute_reply.started":"2026-09-25T09:32:33.608539Z","shell.execute_reply":"2026-09-25T09:32:33.650262Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# MEDIAN ROI HEATMAP\n# ============================================================\n\nmedian_matrix = (\n    results_500_df\n    .pivot_table(\n        index=\"row\",\n        columns=\"col\",\n        values=\"roi_pct\",\n        aggfunc=\"median\",\n    )\n)\n\nplt.figure(figsize=(7, 6))\n\nplt.imshow(\n    median_matrix.values,\n    vmin=0,\n    vmax=100,\n)\n\nplt.colorbar(\n    label=\"Median ROI [%]\"\n)\n\nplt.xticks(\n    range(4),\n    [\"0\", \"1\", \"2\", \"3\"]\n)\n\nplt.yticks(\n    range(4),\n    [\"0\", \"1\", \"2\", \"3\"]\n)\n\nplt.xlabel(\"Tile column\")\nplt.ylabel(\"Tile row\")\n\nplt.title(\n    \"Median fundus ROI coverage by tile position\"\n)\n\nfor r in range(4):\n    for c in range(4):\n\n        value = median_matrix.iloc[r, c]\n\n        plt.text(\n            c,\n            r,\n            f\"{value:.1f}%\",\n            ha=\"center\",\n            va=\"center\",\n        )\n\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:32:33.651638Z","iopub.execute_input":"2026-09-25T09:32:33.652085Z","iopub.status.idle":"2026-09-25T09:32:33.812991Z","shell.execute_reply.started":"2026-09-25T09:32:33.652064Z","shell.execute_reply":"2026-09-25T09:32:33.812271Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# TILE REMOVAL THRESHOLDS\n# ============================================================\n\nthresholds = [\n    10,\n    20,\n    30,\n    40,\n    50,\n    60,\n    70,\n    80,\n    90,\n]\n\nthreshold_results = []\n\nfor threshold in thresholds:\n    removed = (\n        results_500_df[\"roi_pct\"] < threshold\n    )\n\n    threshold_results.append(\n        {\n            \"ROI threshold [%]\": threshold,\n            \"removed tiles\": int(removed.sum()),\n            \"total tiles\": len(removed),\n            \"removed [%]\": 100 * removed.mean(),\n        }\n    )\n\nthreshold_df = pd.DataFrame(\n    threshold_results\n)\n\ndisplay(\n    threshold_df.round(2)\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:32:33.813862Z","iopub.execute_input":"2026-09-25T09:32:33.814233Z","iopub.status.idle":"2026-09-25T09:32:33.826763Z","shell.execute_reply.started":"2026-09-25T09:32:33.814207Z","shell.execute_reply":"2026-09-25T09:32:33.825993Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# LOW ROI BY POSITION\n# ============================================================\n\nfor threshold in [20, 30, 40, 50, 60]:\n    low = results_500_df[\n        results_500_df[\"roi_pct\"] < threshold\n    ]\n\n    counts = (\n        low\n        .groupby([\"row\", \"col\"])\n        .size()\n        .reindex(\n            pd.MultiIndex.from_product(\n                [range(4), range(4)],\n                names=[\"row\", \"col\"],\n            ),\n            fill_value=0,\n        )\n        .unstack()\n    )\n\n    print(\n        f\"\\n===== ROI < {threshold}% =====\"\n    )\n\n    display(counts)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:32:33.827828Z","iopub.execute_input":"2026-09-25T09:32:33.828441Z","iopub.status.idle":"2026-09-25T09:32:33.88383Z","shell.execute_reply.started":"2026-09-25T09:32:33.828402Z","shell.execute_reply":"2026-09-25T09:32:33.883298Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"bad_image = \"21720_left\"\n\nimage = load_image_from_archive(bad_image)\n\nprint(\"Original size:\", image.size)\n\nplt.figure(figsize=(12, 8))\nplt.imshow(image)\nplt.title(f\"{bad_image} - original\")\nplt.axis(\"off\")\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:32:33.884653Z","iopub.execute_input":"2026-09-25T09:32:33.88498Z","iopub.status.idle":"2026-09-25T09:32:34.522235Z","shell.execute_reply.started":"2026-09-25T09:32:33.884956Z","shell.execute_reply":"2026-09-25T09:32:34.521266Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# HISTORICAL PIPELINE - ROI FILTERING EXPERIMENT\n# ============================================================\n#\n# oryginalny fundus\n#        │\n#        ▼\n# wykrycie bounding box fundus\n#        │\n#        ▼\n# crop czarnego tła\n#        │\n#        ▼\n# podział cropa na 4 × 4\n#        │\n#        ▼\n# obliczenie ROI ratio dla każdego tile'a\n#        │\n#        ├── ROI >= 40% ──► zachowujemy\n#        └── ROI < 40% ──► odrzucamy\n#\n# UWAGA:\n# To jest wariant eksperymentalny.\n# V2 nie stosuje hard ROI filtering.\ndef filter_tiles_by_roi(tiles, roi_threshold=ROI_THRESHOLD):\n    kept_tiles = []\n\n    for tile in tiles:\n        roi_ratio = np.mean(tile[\"mask\"] > 0)\n\n        if roi_ratio >= roi_threshold:\n            tile[\"roi_ratio\"] = roi_ratio\n            kept_tiles.append(tile)\n\n    return kept_tiles","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:38:07.929032Z","iopub.execute_input":"2026-09-25T10:38:07.929445Z","iopub.status.idle":"2026-09-25T10:38:07.934028Z","shell.execute_reply.started":"2026-09-25T10:38:07.929417Z","shell.execute_reply":"2026-09-25T10:38:07.933325Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# STARE EKSPERYMENTY\n# dark_ratio\n#     ↓\n# ROI mask\n#     ↓\n# ROI threshold analysis\n#     ↓\n# ROI_THRESHOLD = 0.40\n#     ↓\n# filter_tiles_by_roi()\n#     ↓\n# zachowane do analizy\n\n\n# AKTUALNY KIERUNEK V2\n# detect_fundus_bbox_v2()\n#     ↓\n# crop + mask\n#     ↓\n# split_image_and_mask_into_tiles_v2()\n#     ↓\n# calculate_tile_metrics_v2()\n#     ↓\n# wszystkie 16 tiles\n#     ↓\n# ROI zapisujemy jako informację\n#     ↓\n# NIE filtrujemy jeszcze","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**DATASET + DATALOADER**","metadata":{}},{"cell_type":"code","source":"to_tensor = ToTensor()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:42:13.569809Z","iopub.execute_input":"2026-09-25T10:42:13.570162Z","iopub.status.idle":"2026-09-25T10:42:13.574646Z","shell.execute_reply.started":"2026-09-25T10:42:13.570136Z","shell.execute_reply":"2026-09-25T10:42:13.573927Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class RetinopathyDataset(Dataset):\n    def __init__(self, labels_csv, archive_path, rows=4, cols=4):\n        self.labels = pd.read_csv(labels_csv)\n        self.archive_path = archive_path\n        self.rows = rows\n        self.cols = cols\n        self.to_tensor = ToTensor()\n\n    def __len__(self):\n        return len(self.labels)\n\n    def __getitem__(self, idx):\n        row = self.labels.iloc[idx]\n        image_name = row[\"image\"]\n        label = int(row[\"level\"])\n\n        try:\n            image = load_image_from_archive(\n                image_name,\n                archive_path=self.archive_path,\n            )\n\n            bbox, mask = detect_fundus_bbox_v2(image)\n\n            if bbox is None:\n                return {\n                    \"image_name\": image_name,\n                    \"label\": label,\n                    \"tiles\": [],\n                    \"valid\": False,\n                }\n\n            cropped = image.crop(bbox)\n\n            tiles = split_image_and_mask_into_tiles_v2(\n                cropped, mask,\n                rows=self.rows,\n                cols=self.cols,\n            )\n\n            for tile in tiles:\n                metrics = calculate_tile_metrics_v2(\n                    tile[\"image\"],\n                    tile[\"mask\"],\n                )\n\n                tile[\"dark_ratio\"] = metrics[\"dark_ratio\"]\n                tile[\"roi_ratio\"] = metrics[\"roi_ratio\"]\n                tile[\"image\"] = self.to_tensor(tile[\"image\"])\n\n            return {\n                \"image_name\": image_name,\n                \"label\": label,\n                \"tiles\": tiles,\n                \"valid\": True,\n            }\n\n        except Exception as exc:\n            print(f\"Failed to process {image_name}: {exc}\")\n\n            return {\n                \"image_name\": image_name,\n                \"label\": label,\n                \"tiles\": [],\n                \"valid\": False,\n            }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:53:25.696271Z","iopub.execute_input":"2026-09-25T10:53:25.696545Z","iopub.status.idle":"2026-09-25T10:53:25.704626Z","shell.execute_reply.started":"2026-09-25T10:53:25.696522Z","shell.execute_reply":"2026-09-25T10:53:25.703833Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_dataset = RetinopathyDataset(\n    labels_csv=TRAIN_LABELS,\n    archive_path=ARCHIVE,\n    rows=4,\n    cols=4,\n)\n\ntest_dataset = RetinopathyDataset(\n    labels_csv=TEST_LABELS,\n    archive_path=ARCHIVE,\n    rows=4,\n    cols=4,\n)\n\nprint(f\"Train: {len(train_dataset)}\")\nprint(f\"Test:  {len(test_dataset)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:42:34.776062Z","iopub.execute_input":"2026-09-25T10:42:34.776323Z","iopub.status.idle":"2026-09-25T10:42:34.801231Z","shell.execute_reply.started":"2026-09-25T10:42:34.7763Z","shell.execute_reply":"2026-09-25T10:42:34.80041Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sample = train_dataset[0]\n\nprint(\"Image:\", sample[\"image_name\"])\nprint(\"Label:\", sample[\"label\"])\nprint(\"Valid:\", sample[\"valid\"])\nprint(\"Number of tiles:\", len(sample[\"tiles\"]))\n\nfor tile in sample[\"tiles\"]:\n    print(\n        f\"({tile['row']}, {tile['col']}) | \"\n        f\"shape={tuple(tile['image'].shape)} | \"\n        f\"ROI={tile['roi_ratio']:.3f} | \"\n        f\"dark={tile['dark_ratio']:.3f}\"\n    )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:42:52.158106Z","iopub.execute_input":"2026-09-25T10:42:52.158755Z","iopub.status.idle":"2026-09-25T10:42:52.493957Z","shell.execute_reply.started":"2026-09-25T10:42:52.158724Z","shell.execute_reply":"2026-09-25T10:42:52.493147Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# BATCH STRUCTURE\n# ============================================================\n#\n# batch\n#  │\n#  ├── image_names\n#  ├── labels\n#  │\n#  └── tiles\n#       ├── image 1 → 16 tensors [C, H1, W1]\n#       ├── image 2 → 16 tensors [C, H2, W2]\n#       └── ...\n#\n# Every valid fundus produces exactly 16 tiles.\n# Tile spatial dimensions may differ between images.","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:32:34.929702Z","iopub.execute_input":"2026-09-25T09:32:34.930037Z","iopub.status.idle":"2026-09-25T09:32:34.93459Z","shell.execute_reply.started":"2026-09-25T09:32:34.930002Z","shell.execute_reply":"2026-09-25T09:32:34.93378Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# aby nie stracić informacji, które tile'e należą do którego zdjęcia\ndef retinopathy_collate_fn(batch):\n    valid_samples = [sample for sample in batch if sample[\"valid\"]]\n\n    if not valid_samples:\n        return None\n\n    return {\n        \"image_names\": [sample[\"image_name\"] for sample in valid_samples],\n        \"labels\": torch.tensor(\n            [sample[\"label\"] for sample in valid_samples],\n            dtype=torch.long,\n        ),\n        \"tiles\": [sample[\"tiles\"] for sample in valid_samples],\n    }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:53:34.879472Z","iopub.execute_input":"2026-09-25T10:53:34.880252Z","iopub.status.idle":"2026-09-25T10:53:34.885547Z","shell.execute_reply.started":"2026-09-25T10:53:34.880205Z","shell.execute_reply":"2026-09-25T10:53:34.884929Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_loader = DataLoader(\n    train_dataset,\n    batch_size=BATCH_SIZE,\n    shuffle=True,\n    num_workers=0,\n    collate_fn=retinopathy_collate_fn,\n)\n\ntest_loader = DataLoader(\n    test_dataset,\n    batch_size=BATCH_SIZE,\n    shuffle=False,\n    num_workers=0,\n    collate_fn=retinopathy_collate_fn,\n)\n\nprint(f\"Train batches: {len(train_loader)}\")\nprint(f\"Test batches:  {len(test_loader)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:44:30.80653Z","iopub.execute_input":"2026-09-25T10:44:30.807249Z","iopub.status.idle":"2026-09-25T10:44:30.813143Z","shell.execute_reply.started":"2026-09-25T10:44:30.807206Z","shell.execute_reply":"2026-09-25T10:44:30.812532Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"batch = next(iter(train_loader))\n\nassert batch is not None\n\nprint(\"Images:\", batch[\"image_names\"])\nprint(\"Labels:\", batch[\"labels\"])\nprint(\"Batch size:\", len(batch[\"image_names\"]))\n\nfor image_name, tiles in zip(batch[\"image_names\"], batch[\"tiles\"]):\n    print(f\"\\n{image_name}: {len(tiles)} tiles\")\n\n    assert len(tiles) == 16\n\n    for tile in tiles:\n        print(\n            f\"  ({tile['row']}, {tile['col']}) \"\n            f\"{tuple(tile['image'].shape)} \"\n            f\"ROI={tile['roi_ratio']:.3f}\"\n        )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:44:44.203588Z","iopub.execute_input":"2026-09-25T10:44:44.204067Z","iopub.status.idle":"2026-09-25T10:44:44.392968Z","shell.execute_reply.started":"2026-09-25T10:44:44.204035Z","shell.execute_reply":"2026-09-25T10:44:44.392137Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Kaggle train.zip\n#        │\n#        ▼\n# oryginalny fundus\n#        │\n#        ▼\n# detekcja fundus ROI\n#        │\n#        ▼\n# crop czarnego tła\n#        │\n#        ▼\n# 4 × 4 tiles\n#        │\n#        ▼\n# ROI ratio\n#        │\n#        ├── < 40% ──► odrzuć\n#        │\n#        └── ≥ 40% ──► zachowaj\n#                          │\n#                          ▼\n#                     PIL → Tensor\n#                          │\n#                          ▼\n#                  jedno zdjęcie\n#                  = N tile'i\n#                          │\n#                          ▼\n#                     DataLoader","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:32:35.17854Z","iopub.execute_input":"2026-09-25T09:32:35.178747Z","iopub.status.idle":"2026-09-25T09:32:35.182837Z","shell.execute_reply.started":"2026-09-25T09:32:35.178728Z","shell.execute_reply":"2026-09-25T09:32:35.182281Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# tile 1 ──► CNN ──► feature 1 ─┐\n# tile 2 ──► CNN ──► feature 2 ─┤\n# tile 3 ──► CNN ──► feature 3 ─┤\n# ...                            ├──► aggregation ──► classifier\n# tile N ──► CNN ──► feature N ─┘                         │\n#                                                         ▼\n#                                                  level 0–4","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:32:35.183685Z","iopub.execute_input":"2026-09-25T09:32:35.184002Z","iopub.status.idle":"2026-09-25T09:32:35.198545Z","shell.execute_reply.started":"2026-09-25T09:32:35.183968Z","shell.execute_reply":"2026-09-25T09:32:35.197893Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#             Fundus image\n#                  │\n#        ┌─────────┴─────────┐\n#        │                   │\n#    tile 1              tile N\n#        │                   │\n#        ▼                   ▼\n# EfficientNet-B0     EfficientNet-B0\n# pretrained          pretrained\n#        │                   │\n#        ▼                   ▼\n#    feature 1           feature N\n#        │                   │\n#        └─────────┬─────────┘\n#                  │\n#             aggregation\n#                  │\n#                  ▼\n#             classifier\n#                  │\n#                  ▼\n#             5 classes\n#             0 1 2 3 4","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:32:35.199501Z","iopub.execute_input":"2026-09-25T09:32:35.200179Z","iopub.status.idle":"2026-09-25T09:32:35.210837Z","shell.execute_reply.started":"2026-09-25T09:32:35.200145Z","shell.execute_reply":"2026-09-25T09:32:35.210085Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"IMAGENET_MEAN = torch.tensor(\n    [0.485, 0.456, 0.406]\n).view(3, 1, 1)\n\nIMAGENET_STD = torch.tensor(\n    [0.229, 0.224, 0.225]\n).view(3, 1, 1)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:45:20.03737Z","iopub.execute_input":"2026-09-25T10:45:20.038066Z","iopub.status.idle":"2026-09-25T10:45:20.042876Z","shell.execute_reply.started":"2026-09-25T10:45:20.038033Z","shell.execute_reply":"2026-09-25T10:45:20.042115Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class EfficientNetB0Encoder(nn.Module):\n    def __init__(self, pretrained=True, freeze_backbone=True):\n        super().__init__()\n\n        weights = EfficientNet_B0_Weights.DEFAULT if pretrained else None\n        self.model = efficientnet_b0(weights=weights)\n\n        self.feature_dim = self.model.classifier[1].in_features\n        self.model.classifier = nn.Identity()\n\n        if freeze_backbone:\n            for parameter in self.model.parameters():\n                parameter.requires_grad = False\n\n    def forward(self, x):\n        return self.model(x)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:53:41.360474Z","iopub.execute_input":"2026-09-25T10:53:41.361185Z","iopub.status.idle":"2026-09-25T10:53:41.366153Z","shell.execute_reply.started":"2026-09-25T10:53:41.361154Z","shell.execute_reply":"2026-09-25T10:53:41.365424Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def normalize_tile(tile):\n    mean = IMAGENET_MEAN.to(device=tile.device, dtype=tile.dtype)\n    std = IMAGENET_STD.to(device=tile.device, dtype=tile.dtype)\n    return (tile - mean) / std","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:53:42.886763Z","iopub.execute_input":"2026-09-25T10:53:42.887366Z","iopub.status.idle":"2026-09-25T10:53:42.891542Z","shell.execute_reply.started":"2026-09-25T10:53:42.887336Z","shell.execute_reply":"2026-09-25T10:53:42.890815Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def pad_tiles_to_max_size(tile_tensors):\n    \"\"\"Pad tiles to a common size without resizing or cropping.\"\"\"\n    max_height = max(tile.shape[-2] for tile in tile_tensors)\n    max_width = max(tile.shape[-1] for tile in tile_tensors)\n\n    padded_tiles = []\n\n    for tile in tile_tensors:\n        height, width = tile.shape[-2:]\n\n        pad_h = max_height - height\n        pad_w = max_width - width\n\n        padded = F.pad(\n            tile,\n            (\n                pad_w // 2,\n                pad_w - pad_w // 2,\n                pad_h // 2,\n                pad_h - pad_h // 2,\n            ),\n            mode=\"constant\",\n            value=0.0,\n        )\n\n        padded_tiles.append(padded)\n\n    return torch.stack(padded_tiles)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:53:43.149257Z","iopub.execute_input":"2026-09-25T10:53:43.149795Z","iopub.status.idle":"2026-09-25T10:53:43.156674Z","shell.execute_reply.started":"2026-09-25T10:53:43.149756Z","shell.execute_reply":"2026-09-25T10:53:43.156025Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**MODEL**","metadata":{}},{"cell_type":"code","source":"class RetinopathyModel(nn.Module):\n    def __init__(self, pretrained=True, freeze_backbone=True, num_classes=5):\n        super().__init__()\n\n        self.freeze_backbone = freeze_backbone\n\n        self.tile_encoder = EfficientNetB0Encoder(\n            pretrained=pretrained,\n            freeze_backbone=freeze_backbone,\n        )\n\n        self.classifier = nn.Linear(\n            self.tile_encoder.feature_dim,\n            num_classes,\n        )\n\n    def train(self, mode=True):\n        super().train(mode)\n\n        if self.freeze_backbone:\n            self.tile_encoder.eval()\n\n        return self\n\n    def forward(self, tiles):\n        device = next(self.parameters()).device\n\n        tile_tensors = [tile[\"image\"] for tile in tiles]\n        tile_batch = pad_tiles_to_max_size(tile_tensors)\n        tile_batch = tile_batch.to(device)\n        tile_batch = normalize_tile(tile_batch)\n\n        if self.freeze_backbone:\n            with torch.no_grad():\n                features = self.tile_encoder(tile_batch)\n        else:\n            features = self.tile_encoder(tile_batch)\n\n        image_feature = features.mean(dim=0)\n        logits = self.classifier(image_feature.unsqueeze(0))\n\n        return logits","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:55:27.31793Z","iopub.execute_input":"2026-09-25T10:55:27.31871Z","iopub.status.idle":"2026-09-25T10:55:27.326425Z","shell.execute_reply.started":"2026-09-25T10:55:27.318673Z","shell.execute_reply":"2026-09-25T10:55:27.325611Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\nmodel = RetinopathyModel(\n    pretrained=True,\n    freeze_backbone=True,\n    num_classes=5,\n).to(device)\n\ntotal_params = sum(p.numel() for p in model.parameters())\ntrainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)\n\nprint(f\"Device: {device}\")\n\nif device.type == \"cuda\":\n    print(f\"GPU: {torch.cuda.get_device_name(0)}\")\n    print(f\"GPU memory: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.2f} GB\")\n\nprint(f\"Total parameters:     {total_params:,}\")\nprint(f\"Trainable parameters: {trainable_params:,}\")\nprint(f\"Feature dimension:    {model.tile_encoder.feature_dim}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:55:29.586386Z","iopub.execute_input":"2026-09-25T10:55:29.587147Z","iopub.status.idle":"2026-09-25T10:55:29.981561Z","shell.execute_reply.started":"2026-09-25T10:55:29.587114Z","shell.execute_reply":"2026-09-25T10:55:29.980939Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sample = train_dataset[0]\nassert sample[\"valid\"]\nassert len(sample[\"tiles\"]) == 16\n\ntile_tensors = [tile[\"image\"] for tile in sample[\"tiles\"]]\n\nprint(\"Original tile shapes:\")\nfor i, tile in enumerate(tile_tensors):\n    print(f\"{i:2d}: {tuple(tile.shape)}\")\n\ntile_batch = pad_tiles_to_max_size(tile_tensors)\n\nprint(\"\\nAfter padding:\")\nprint(\"Shape:\", tuple(tile_batch.shape))\nprint(\"Dtype:\", tile_batch.dtype)\nprint(f\"Memory: {tile_batch.numel() * tile_batch.element_size() / 1024**2:.2f} MB\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:55:30.848812Z","iopub.execute_input":"2026-09-25T10:55:30.849626Z","iopub.status.idle":"2026-09-25T10:55:31.24539Z","shell.execute_reply.started":"2026-09-25T10:55:30.849596Z","shell.execute_reply":"2026-09-25T10:55:31.244499Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# GPU benchmark: full-resolution 16-tile forward pass\n# ============================================================\n\nassert device.type == \"cuda\", \"CUDA is not available.\"\n\nsample = train_dataset[0]\nassert sample[\"valid\"]\nassert len(sample[\"tiles\"]) == 16\n\ntorch.cuda.empty_cache()\ntorch.cuda.reset_peak_memory_stats()\n\nmodel.eval()\n\ntry:\n    with torch.inference_mode():\n        torch.cuda.synchronize()\n        start = time.perf_counter()\n\n        logits = model(sample[\"tiles\"])\n\n        torch.cuda.synchronize()\n        elapsed = time.perf_counter() - start\n\n    allocated = torch.cuda.memory_allocated() / 1024**3\n    reserved = torch.cuda.memory_reserved() / 1024**3\n    peak = torch.cuda.max_memory_allocated() / 1024**3\n\n    print(\"Forward pass: SUCCESS\")\n    print(f\"Logits shape: {tuple(logits.shape)}\")\n    print(f\"Time:         {elapsed:.3f} s\")\n    print(f\"Allocated:    {allocated:.2f} GB\")\n    print(f\"Reserved:     {reserved:.2f} GB\")\n    print(f\"Peak VRAM:    {peak:.2f} GB\")\n\nexcept torch.OutOfMemoryError:\n    print(\"Forward pass: CUDA OUT OF MEMORY\")\n\n    torch.cuda.empty_cache()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:55:33.248101Z","iopub.execute_input":"2026-09-25T10:55:33.24895Z","iopub.status.idle":"2026-09-25T10:55:33.964465Z","shell.execute_reply.started":"2026-09-25T10:55:33.248909Z","shell.execute_reply":"2026-09-25T10:55:33.963796Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"model.train()\n\nprint(\"Whole model training:\", model.training)\nprint(\"Encoder training:    \", model.tile_encoder.training)\nprint(\"Classifier training: \", model.classifier.training)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:56:12.625915Z","iopub.execute_input":"2026-09-25T10:56:12.626608Z","iopub.status.idle":"2026-09-25T10:56:12.63291Z","shell.execute_reply.started":"2026-09-25T10:56:12.626579Z","shell.execute_reply":"2026-09-25T10:56:12.632137Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"total_params = sum(p.numel() for p in model.parameters())\ntrainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)\n\nprint(f\"Total parameters:     {total_params:,}\")\nprint(f\"Trainable parameters: {trainable_params:,}\")\nprint(f\"Frozen parameters:    {total_params - trainable_params:,}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:57:27.64934Z","iopub.execute_input":"2026-09-25T10:57:27.649912Z","iopub.status.idle":"2026-09-25T10:57:27.657185Z","shell.execute_reply.started":"2026-09-25T10:57:27.649843Z","shell.execute_reply":"2026-09-25T10:57:27.656278Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Sanity check: single-image forward pass\nmodel.eval()\n\ntiles = batch[\"tiles\"][0]\ntrue_label = batch[\"labels\"][0].item()\n\nwith torch.inference_mode():\n    logits = model(tiles)\n    prediction = logits.argmax(dim=1).item()\n\nprint(\"Logits shape:\", tuple(logits.shape))\nprint(\"Logits:\", logits)\nprint(\"Prediction:\", prediction)\nprint(\"True label:\", true_label)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:58:21.072575Z","iopub.execute_input":"2026-09-25T10:58:21.072895Z","iopub.status.idle":"2026-09-25T10:58:21.229368Z","shell.execute_reply.started":"2026-09-25T10:58:21.072834Z","shell.execute_reply":"2026-09-25T10:58:21.228771Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def benchmark_image(idx):\n    model.train()\n\n    sample = train_dataset[idx]\n\n    print(\"=\" * 60)\n    print(f\"Image: {sample['image_name']}\")\n    print(f\"Label: {sample['label']}\")\n    print(f\"Valid: {sample['valid']}\")\n    print(f\"Number of tiles: {len(sample['tiles'])}\")\n\n    if not sample[\"valid\"] or not sample[\"tiles\"]:\n        print(\"Skipping invalid sample.\")\n        return\n\n    tile_tensors = [tile[\"image\"] for tile in sample[\"tiles\"]]\n    heights = [tile.shape[-2] for tile in tile_tensors]\n    widths = [tile.shape[-1] for tile in tile_tensors]\n\n    print(f\"Tile H: {min(heights)} - {max(heights)}\")\n    print(f\"Tile W: {min(widths)} - {max(widths)}\")\n\n    tile_batch = pad_tiles_to_max_size(tile_tensors)\n\n    print(f\"Padded batch: {tuple(tile_batch.shape)}\")\n\n    if device.type == \"cuda\":\n        torch.cuda.empty_cache()\n        torch.cuda.reset_peak_memory_stats()\n        torch.cuda.synchronize()\n\n    model.zero_grad(set_to_none=True)\n\n    target = torch.tensor(\n        [sample[\"label\"]],\n        dtype=torch.long,\n        device=device,\n    )\n\n    start = time.perf_counter()\n\n    tile_batch = tile_batch.to(device)\n    tile_batch = normalize_tile(tile_batch)\n\n    if model.freeze_backbone:\n        with torch.no_grad():\n            features = model.tile_encoder(tile_batch)\n    else:\n        features = model.tile_encoder(tile_batch)\n\n    image_feature = features.mean(dim=0)\n    logits = model.classifier(image_feature.unsqueeze(0))\n\n    loss = F.cross_entropy(logits, target)\n    loss.backward()\n\n    if device.type == \"cuda\":\n        torch.cuda.synchronize()\n\n    elapsed = time.perf_counter() - start\n\n    print(f\"Loss: {loss.item():.4f}\")\n    print(f\"Logits shape: {tuple(logits.shape)}\")\n    print(f\"Time: {elapsed:.3f} s\")\n\n    if device.type == \"cuda\":\n        peak_allocated = torch.cuda.max_memory_allocated() / 1024**3\n        peak_reserved = torch.cuda.max_memory_reserved() / 1024**3\n\n        print(f\"Peak allocated VRAM: {peak_allocated:.2f} GB\")\n        print(f\"Peak reserved VRAM:  {peak_reserved:.2f} GB\")\n\n    del tile_batch, features, image_feature, logits, loss, target\n\n    if device.type == \"cuda\":\n        torch.cuda.empty_cache()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T10:59:43.328227Z","iopub.execute_input":"2026-09-25T10:59:43.328638Z","iopub.status.idle":"2026-09-25T10:59:43.338471Z","shell.execute_reply.started":"2026-09-25T10:59:43.328608Z","shell.execute_reply":"2026-09-25T10:59:43.337929Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Find small, medium and large samples for benchmarking\nN_SCAN = min(200, len(train_dataset))\nsample_sizes = []\n\nfor idx in range(N_SCAN):\n    sample = train_dataset[idx]\n\n    if not sample[\"valid\"] or len(sample[\"tiles\"]) != 16:\n        continue\n\n    max_h = max(tile[\"image\"].shape[-2] for tile in sample[\"tiles\"])\n    max_w = max(tile[\"image\"].shape[-1] for tile in sample[\"tiles\"])\n\n    sample_sizes.append({\n        \"idx\": idx,\n        \"image\": sample[\"image_name\"],\n        \"height\": max_h,\n        \"width\": max_w,\n        \"pixels\": max_h * max_w,\n    })\n\nsample_sizes.sort(key=lambda x: x[\"pixels\"])\n\nsmall_idx = sample_sizes[0][\"idx\"]\nmedium_idx = sample_sizes[len(sample_sizes) // 2][\"idx\"]\nlarge_idx = sample_sizes[-1][\"idx\"]\n\nfor name, idx in [\n    (\"Small\", small_idx),\n    (\"Medium\", medium_idx),\n    (\"Large\", large_idx),\n]:\n    info = next(x for x in sample_sizes if x[\"idx\"] == idx)\n\n    print(\n        f\"{name:6s}: idx={idx:<4d} \"\n        f\"image={info['image']:<15s} \"\n        f\"tile={info['height']}x{info['width']}\"\n    )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:01:26.407576Z","iopub.execute_input":"2026-09-25T11:01:26.408012Z","iopub.status.idle":"2026-09-25T11:02:15.142762Z","shell.execute_reply.started":"2026-09-25T11:01:26.407981Z","shell.execute_reply":"2026-09-25T11:02:15.142054Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"benchmark_image(small_idx)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:02:15.144072Z","iopub.execute_input":"2026-09-25T11:02:15.144309Z","iopub.status.idle":"2026-09-25T11:02:15.331538Z","shell.execute_reply.started":"2026-09-25T11:02:15.144287Z","shell.execute_reply":"2026-09-25T11:02:15.330935Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"benchmark_image(medium_idx)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:02:15.332434Z","iopub.execute_input":"2026-09-25T11:02:15.332741Z","iopub.status.idle":"2026-09-25T11:02:15.820619Z","shell.execute_reply.started":"2026-09-25T11:02:15.332712Z","shell.execute_reply":"2026-09-25T11:02:15.819841Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"benchmark_image(large_idx)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:02:15.822091Z","iopub.execute_input":"2026-09-25T11:02:15.822403Z","iopub.status.idle":"2026-09-25T11:02:16.527673Z","shell.execute_reply.started":"2026-09-25T11:02:15.82238Z","shell.execute_reply":"2026-09-25T11:02:16.52714Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# z powyższych danych wynika, że EfficientNet widzi struktury anatomiczne w różnych skalach pikselowych!!!","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# 50-sample training sanity check\n# ============================================================\n\ncriterion = nn.CrossEntropyLoss()\n\noptimizer = torch.optim.Adam(\n    (p for p in model.parameters() if p.requires_grad),\n    lr=1e-3,\n)\n\nmodel.train()\n\nnum_samples = 50\nlosses = []\nstep_times = []\n\nstart_total = time.perf_counter()\n\nfor step, batch in enumerate(train_loader):\n    if step >= num_samples:\n        break\n\n    if batch is None:\n        continue\n\n    step_start = time.perf_counter()\n\n    tiles = batch[\"tiles\"][0]\n    label = batch[\"labels\"][0:1].to(device)\n\n    optimizer.zero_grad(set_to_none=True)\n\n    logits = model(tiles)\n    loss = criterion(logits, label)\n\n    loss.backward()\n    optimizer.step()\n\n    if device.type == \"cuda\":\n        torch.cuda.synchronize()\n\n    step_time = time.perf_counter() - step_start\n\n    losses.append(loss.item())\n    step_times.append(step_time)\n\n    prediction = logits.argmax(dim=1).item()\n\n    print(\n        f\"[{step + 1:02d}/{num_samples}] \"\n        f\"image={batch['image_names'][0]:<15} \"\n        f\"label={label.item()} pred={prediction} \"\n        f\"loss={loss.item():.4f} \"\n        f\"time={step_time:.2f}s\"\n    )\n\ntotal_time = time.perf_counter() - start_total\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"50-SAMPLE TRAINING SANITY CHECK\")\nprint(\"=\" * 60)\n\nprint(f\"Processed:        {len(losses)}\")\nprint(f\"Total time:       {total_time:.2f} s\")\n\nif losses:\n    print(f\"Average step:     {sum(step_times) / len(step_times):.3f} s\")\n    print(f\"Initial loss:     {losses[0]:.4f}\")\n    print(f\"Final loss:       {losses[-1]:.4f}\")\n    print(f\"Mean loss:        {sum(losses) / len(losses):.4f}\")\n    print(f\"Minimum loss:     {min(losses):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:04:30.570341Z","iopub.execute_input":"2026-09-25T11:04:30.571053Z","iopub.status.idle":"2026-09-25T11:04:56.056138Z","shell.execute_reply.started":"2026-09-25T11:04:30.571021Z","shell.execute_reply":"2026-09-25T11:04:56.055337Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_epoch(model, loader, optimizer, criterion, device):\n    model.train()\n\n    total_loss = 0.0\n    all_predictions = []\n    all_labels = []\n    processed_samples = 0\n    skipped_batches = 0\n\n    start_time = time.perf_counter()\n\n    for batch in loader:\n        if batch is None:\n            skipped_batches += 1\n            continue\n\n        optimizer.zero_grad(set_to_none=True)\n        batch_losses = []\n\n        for tiles, label in zip(batch[\"tiles\"], batch[\"labels\"]):\n            label = label.unsqueeze(0).to(device)\n\n            logits = model(tiles)\n            loss = criterion(logits, label)\n\n            batch_losses.append(loss)\n            total_loss += loss.item()\n            processed_samples += 1\n\n            all_predictions.append(logits.argmax(dim=1).item())\n            all_labels.append(label.item())\n\n        torch.stack(batch_losses).mean().backward()\n        optimizer.step()\n\n    if device.type == \"cuda\":\n        torch.cuda.synchronize()\n\n    if processed_samples == 0:\n        raise RuntimeError(\"No valid samples were processed.\")\n\n    return {\n        \"loss\": total_loss / processed_samples,\n        \"accuracy\": accuracy_score(all_labels, all_predictions),\n        \"balanced_accuracy\": balanced_accuracy_score(\n            all_labels,\n            all_predictions,\n        ),\n        \"macro_f1\": f1_score(\n            all_labels,\n            all_predictions,\n            average=\"macro\",\n            zero_division=0,\n        ),\n        \"qwk\": cohen_kappa_score(\n            all_labels,\n            all_predictions,\n            weights=\"quadratic\",\n        ),\n        \"time\": time.perf_counter() - start_time,\n        \"processed_samples\": processed_samples,\n        \"skipped_batches\": skipped_batches,\n    }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:23:19.093582Z","iopub.execute_input":"2026-09-25T11:23:19.094129Z","iopub.status.idle":"2026-09-25T11:23:19.101537Z","shell.execute_reply.started":"2026-09-25T11:23:19.094098Z","shell.execute_reply":"2026-09-25T11:23:19.101013Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def evaluate(model, loader, criterion, device):\n    model.eval()\n\n    total_loss = 0.0\n    all_predictions = []\n    all_labels = []\n    processed_samples = 0\n    skipped_batches = 0\n\n    start_time = time.perf_counter()\n\n    with torch.inference_mode():\n        for batch in loader:\n            if batch is None:\n                skipped_batches += 1\n                continue\n\n            for tiles, label in zip(batch[\"tiles\"], batch[\"labels\"]):\n                label = label.unsqueeze(0).to(device)\n\n                logits = model(tiles)\n                loss = criterion(logits, label)\n                prediction = logits.argmax(dim=1)\n\n                total_loss += loss.item()\n                processed_samples += 1\n\n                all_predictions.append(prediction.item())\n                all_labels.append(label.item())\n\n    if device.type == \"cuda\":\n        torch.cuda.synchronize()\n\n    if processed_samples == 0:\n        raise RuntimeError(\"No valid samples were evaluated.\")\n\n    accuracy = accuracy_score(all_labels, all_predictions)\n    balanced_accuracy = balanced_accuracy_score(\n        all_labels,\n        all_predictions,\n    )\n    macro_f1 = f1_score(\n        all_labels,\n        all_predictions,\n        average=\"macro\",\n        zero_division=0,\n    )\n    qwk = cohen_kappa_score(\n        all_labels,\n        all_predictions,\n        weights=\"quadratic\",\n    )\n\n    cm = confusion_matrix(\n        all_labels,\n        all_predictions,\n        labels=range(5),\n    )\n\n    report = classification_report(\n        all_labels,\n        all_predictions,\n        labels=range(5),\n        target_names=[\n            \"No DR\",\n            \"Mild\",\n            \"Moderate\",\n            \"Severe\",\n            \"Proliferative DR\",\n        ],\n        zero_division=0,\n    )\n\n    return {\n        \"loss\": total_loss / processed_samples,\n        \"accuracy\": accuracy,\n        \"balanced_accuracy\": balanced_accuracy,\n        \"macro_f1\": macro_f1,\n        \"qwk\": qwk,\n        \"confusion_matrix\": cm,\n        \"classification_report\": report,\n        \"time\": time.perf_counter() - start_time,\n        \"labels\": np.asarray(all_labels),\n        \"predictions\": np.asarray(all_predictions),\n        \"processed_samples\": processed_samples,\n        \"skipped_batches\": skipped_batches,\n    }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:23:19.594642Z","iopub.execute_input":"2026-09-25T11:23:19.595055Z","iopub.status.idle":"2026-09-25T11:23:19.604585Z","shell.execute_reply.started":"2026-09-25T11:23:19.595028Z","shell.execute_reply":"2026-09-25T11:23:19.603909Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def print_evaluation_results(results):\n    print(\"=\" * 60)\n    print(\"EVALUATION\")\n    print(\"=\" * 60)\n\n    print(f\"Loss:              {results['loss']:.4f}\")\n    print(f\"Accuracy:          {results['accuracy']:.4f}\")\n    print(f\"Balanced accuracy: {results['balanced_accuracy']:.4f}\")\n    print(f\"Macro F1:          {results['macro_f1']:.4f}\")\n    print(f\"QWK:               {results['qwk']:.4f}\")\n    print(f\"Processed samples: {results['processed_samples']}\")\n    print(f\"Skipped batches:   {results['skipped_batches']}\")\n    print(f\"Time:              {results['time']:.2f} s\")\n\n    print(\"\\nConfusion matrix:\")\n    print(\"Rows = true class, columns = predicted class\")\n    print(results[\"confusion_matrix\"])\n\n    print(\"\\nClassification report:\")\n    print(results[\"classification_report\"])\n\n    print(\"Predicted class distribution:\")\n\n    unique, counts = np.unique(\n        results[\"predictions\"],\n        return_counts=True,\n    )\n    distribution = dict(zip(unique, counts))\n\n    for class_id in range(5):\n        print(f\"Class {class_id}: {distribution.get(class_id, 0)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:23:19.677766Z","iopub.execute_input":"2026-09-25T11:23:19.678363Z","iopub.status.idle":"2026-09-25T11:23:19.683438Z","shell.execute_reply.started":"2026-09-25T11:23:19.678338Z","shell.execute_reply":"2026-09-25T11:23:19.682695Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"SANITY_SAMPLES = 200\nrng = np.random.default_rng(RANDOM_SEED)\n\nsanity_indices = rng.choice(\n    len(train_dataset),\n    size=min(SANITY_SAMPLES, len(train_dataset)),\n    replace=False,\n)\n\nsanity_subset = Subset(train_dataset, sanity_indices)\n\nsanity_loader = DataLoader(\n    sanity_subset,\n    batch_size=1,\n    shuffle=False,\n    num_workers=0,\n    collate_fn=retinopathy_collate_fn,\n)\n\nprint(f\"Sanity samples: {len(sanity_subset)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:13:49.917108Z","iopub.execute_input":"2026-09-25T11:13:49.917702Z","iopub.status.idle":"2026-09-25T11:13:49.922906Z","shell.execute_reply.started":"2026-09-25T11:13:49.917673Z","shell.execute_reply":"2026-09-25T11:13:49.922199Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"model = RetinopathyModel(\n    pretrained=True,\n    freeze_backbone=True,\n    num_classes=5,\n).to(device)\n\ncriterion = nn.CrossEntropyLoss()\n\noptimizer = torch.optim.Adam(\n    (p for p in model.parameters() if p.requires_grad),\n    lr=1e-3,\n)\n\nprint(\n    \"Trainable parameters:\",\n    sum(p.numel() for p in model.parameters() if p.requires_grad),\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:14:07.506213Z","iopub.execute_input":"2026-09-25T11:14:07.50681Z","iopub.status.idle":"2026-09-25T11:14:07.645046Z","shell.execute_reply.started":"2026-09-25T11:14:07.506779Z","shell.execute_reply":"2026-09-25T11:14:07.644367Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sanity_results = train_epoch(\n    model=model,\n    loader=sanity_loader,\n    optimizer=optimizer,\n    criterion=criterion,\n    device=device,\n)\n\nprint(\"Training sanity check:\")\nfor key, value in sanity_results.items():\n    print(f\"{key}: {value}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T11:14:18.011446Z","iopub.execute_input":"2026-09-25T11:14:18.012148Z","iopub.status.idle":"2026-09-25T11:16:01.944287Z","shell.execute_reply.started":"2026-09-25T11:14:18.012118Z","shell.execute_reply":"2026-09-25T11:16:01.943477Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**BASELINE EXPERIMENTS**","metadata":{}},{"cell_type":"code","source":"class FeatureClassifier(nn.Module):\n    def __init__(self, feature_dim=1280, num_classes=5):\n        super().__init__()\n        self.classifier = nn.Linear(feature_dim, num_classes)\n\n    def forward(self, features):\n        # features: [num_tiles, 1280]\n        image_feature = features.mean(dim=0)\n        logits = self.classifier(image_feature.unsqueeze(0))\n        return logits","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:48:53.992977Z","iopub.execute_input":"2026-09-25T09:48:53.993397Z","iopub.status.idle":"2026-09-25T09:48:53.998083Z","shell.execute_reply.started":"2026-09-25T09:48:53.993369Z","shell.execute_reply":"2026-09-25T09:48:53.997452Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# FULL TRAIN + TEST FEATURE CACHE\n# Resumable / checkpoint-based generation\n# ============================================================\n\nTRAIN_FEATURE_CACHE_DIR = \"/kaggle/working/retinopathy_feature_cache_train\"\nTEST_FEATURE_CACHE_DIR = \"/kaggle/working/retinopathy_feature_cache_test\"\n\n# ------------------------------------------------------------\n# COMMON SETTINGS\n# ------------------------------------------------------------\n\nSHARD_SIZE = 500\n\nROWS = 4\nCOLS = 4\n\nROI_THRESHOLD_VALUE = 0.40\n\n\n# ------------------------------------------------------------\n# CREATE DIRECTORIES\n# ------------------------------------------------------------\n\nos.makedirs(\n    TRAIN_FEATURE_CACHE_DIR,\n    exist_ok=True,\n)\n\nos.makedirs(\n    TEST_FEATURE_CACHE_DIR,\n    exist_ok=True,\n)\n\n\n# ============================================================\n# TRAIN\n# ============================================================\n\nprint()\nprint(\"#\" * 80)\nprint(\"# TRAIN FEATURE CACHE\")\nprint(\"#\" * 80)\nprint()\n\ntrain_start = time.perf_counter()\n\ntrain_cache_result = generate_feature_cache_sharded(\n    labels_df=train_labels_df,\n    model=model,\n    archive_path=ARCHIVE,\n    output_dir=TRAIN_FEATURE_CACHE_DIR,\n    shard_size=SHARD_SIZE,\n    roi_threshold=ROI_THRESHOLD_VALUE,\n    rows=ROWS,\n    cols=COLS,\n    device=device,\n)\n\ntrain_elapsed = time.perf_counter() - train_start\n\n\n# ============================================================\n# TEST\n# ============================================================\n\nprint()\nprint(\"#\" * 80)\nprint(\"# TEST FEATURE CACHE\")\nprint(\"#\" * 80)\nprint()\n\ntest_start = time.perf_counter()\n\ntest_cache_result = generate_feature_cache_sharded(\n    labels_df=test_labels_df,\n    model=model,\n    archive_path=ARCHIVE,\n    output_dir=TEST_FEATURE_CACHE_DIR,\n    shard_size=SHARD_SIZE,\n    roi_threshold=ROI_THRESHOLD_VALUE,\n    rows=ROWS,\n    cols=COLS,\n    device=device,\n)\n\ntest_elapsed = time.perf_counter() - test_start\n\n\n# ============================================================\n# FINAL SUMMARY\n# ============================================================\n\nprint()\nprint(\"#\" * 80)\nprint(\"# ALL FEATURE CACHES GENERATED\")\nprint(\"#\" * 80)\nprint()\n\nprint(\"TRAIN\")\nprint(\"-\" * 80)\nprint(\n    f\"Images:          {len(train_labels_df):,}\"\n)\nprint(\n    f\"Shards:          {train_cache_result['num_shards']:,}\"\n)\nprint(\n    f\"Generated:       {train_cache_result['generated_shards']:,}\"\n)\nprint(\n    f\"Already existed: {train_cache_result['completed_shards']:,}\"\n)\nprint(\n    f\"Time:            {train_elapsed / 60:.2f} min\"\n)\n\nprint()\n\nprint(\"TEST\")\nprint(\"-\" * 80)\nprint(\n    f\"Images:          {len(test_labels_df):,}\"\n)\nprint(\n    f\"Shards:          {test_cache_result['num_shards']:,}\"\n)\nprint(\n    f\"Generated:       {test_cache_result['generated_shards']:,}\"\n)\nprint(\n    f\"Already existed: {test_cache_result['completed_shards']:,}\"\n)\nprint(\n    f\"Time:            {test_elapsed / 60:.2f} min\"\n)\n\nprint()\n\nprint(\"TRAIN CACHE:\")\nprint(TRAIN_FEATURE_CACHE_DIR)\n\nprint()\n\nprint(\"TEST CACHE:\")\nprint(TEST_FEATURE_CACHE_DIR)\n\nprint()\nprint(\"#\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T10:01:52.408976Z","iopub.execute_input":"2026-09-24T10:01:52.409812Z","iopub.status.idle":"2026-09-24T14:07:54.256574Z","shell.execute_reply.started":"2026-09-24T10:01:52.409783Z","shell.execute_reply":"2026-09-24T14:07:54.255683Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import glob\n\nCACHE_DIRS = {\n    \"TRAIN\": TRAIN_FEATURE_CACHE_DIR,\n    \"TEST\": TEST_FEATURE_CACHE_DIR,\n}\n\n\nfor name, cache_dir in CACHE_DIRS.items():\n\n    print(\"=\" * 80)\n    print(name)\n    print(\"=\" * 80)\n\n    shard_paths = sorted(\n        glob.glob(\n            os.path.join(\n                cache_dir,\n                \"shard_*.npz\",\n            )\n        )\n    )\n\n    # Nie licz metadata/skipped jako shardów\n    shard_paths = [\n        path\n        for path in shard_paths\n        if \"_metadata\" not in path\n        and \"_skipped\" not in path\n    ]\n\n    print(\"Liczba shardów:\", len(shard_paths))\n\n    total_images = 0\n    total_skipped = 0\n    total_size = 0\n\n    for shard_path in shard_paths:\n\n        data = np.load(\n            shard_path,\n            allow_pickle=True,\n        )\n\n        num_images = len(\n            data[\"image_names\"]\n        )\n\n        total_images += num_images\n\n        total_size += os.path.getsize(\n            shard_path\n        )\n\n        skipped_path = shard_path.replace(\n            \".npz\",\n            \"_skipped.csv\",\n        )\n\n        if os.path.exists(skipped_path):\n            skipped_df = __import__(\"pandas\").read_csv(\n                skipped_path\n            )\n            total_skipped += len(skipped_df)\n\n    print(\"Obrazy w cache:\", total_images)\n    print(\"Pominięte:\", total_skipped)\n    print(\n        \"Rozmiar:\",\n        f\"{total_size / (1024**3):.2f} GB\"\n    )\n\n    print()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T14:10:03.869847Z","iopub.execute_input":"2026-09-24T14:10:03.870729Z","iopub.status.idle":"2026-09-24T14:10:04.071347Z","shell.execute_reply.started":"2026-09-24T14:10:03.870696Z","shell.execute_reply":"2026-09-24T14:10:04.070511Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import math\n\ndef load_skipped_images(cache_dir, dataset_name):\n    skipped_files = sorted(\n        glob.glob(os.path.join(cache_dir, \"shard_*_skipped.csv\"))\n    )\n\n    frames = []\n\n    for path in skipped_files:\n        df = pd.read_csv(path)\n\n        if not df.empty:\n            df[\"dataset\"] = dataset_name\n            frames.append(df)\n\n    if frames:\n        return pd.concat(frames, ignore_index=True)\n\n    return pd.DataFrame(\n        columns=[\"image\", \"label\", \"reason\", \"dataset\"]\n    )\n\n\ndef load_image_from_archive(image_name):\n    archive_member = f\"train/{image_name}.jpeg\"\n\n    result = subprocess.run(\n        [\"7z\", \"e\", \"-so\", ARCHIVE, archive_member],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.DEVNULL,\n        check=True,\n    )\n\n    image = Image.open(io.BytesIO(result.stdout))\n    image.load()\n\n    return image.convert(\"RGB\")\n\n\ntrain_skipped = load_skipped_images(\n    TRAIN_FEATURE_CACHE_DIR,\n    \"TRAIN\"\n)\n\ntest_skipped = load_skipped_images(\n    TEST_FEATURE_CACHE_DIR,\n    \"TEST\"\n)\n\nskipped = pd.concat(\n    [train_skipped, test_skipped],\n    ignore_index=True\n)\n\nprint(f\"Liczba pominiętych obrazów: {len(skipped)}\")\ndisplay(skipped)\n\n\n# ---------------------------------------------------------\n# WYŚWIETLANIE ZDJĘĆ\n# ---------------------------------------------------------\n\nif len(skipped) == 0:\n    print(\"Brak pominiętych obrazów.\")\nelse:\n    n = len(skipped)\n    cols = 3\n    rows = math.ceil(n / cols)\n\n    fig, axes = plt.subplots(\n        rows,\n        cols,\n        figsize=(18, 6 * rows)\n    )\n\n    # Gdy mamy tylko jeden wiersz, ujednolicamy strukturę axes\n    if rows == 1:\n        axes = [axes]\n\n    axes = list(axes)\n    axes_flat = [\n        ax for row_axes in axes for ax in row_axes\n    ]\n\n    for ax, (_, row) in zip(axes_flat, skipped.iterrows()):\n\n        image_name = row[\"image\"]\n        label = int(row[\"label\"])\n        dataset = row[\"dataset\"]\n        reason = row[\"reason\"]\n\n        try:\n            image = load_image_from_archive(image_name)\n\n            ax.imshow(image)\n            ax.set_title(\n                f\"{dataset} | {image_name}.jpeg\\n\"\n                f\"class = {label}\\n\"\n                f\"{reason}\",\n                fontsize=10\n            )\n\n        except Exception as error:\n            ax.text(\n                0.5,\n                0.5,\n                f\"Nie udało się wczytać:\\n{image_name}\\n\\n{error}\",\n                ha=\"center\",\n                va=\"center\",\n                wrap=True\n            )\n\n        ax.axis(\"off\")\n\n    # Wyłączamy niewykorzystane pola\n    for ax in axes_flat[len(skipped):]:\n        ax.axis(\"off\")\n\n    plt.tight_layout()\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T14:11:59.755003Z","iopub.execute_input":"2026-09-24T14:11:59.755453Z","iopub.status.idle":"2026-09-24T14:12:08.111548Z","shell.execute_reply.started":"2026-09-24T14:11:59.755425Z","shell.execute_reply":"2026-09-24T14:12:08.110161Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def inspect_cache(cache_dir, name):\n    shard_files = sorted(\n        glob.glob(os.path.join(cache_dir, \"shard_*.npz\"))\n    )\n\n    print(\"=\" * 80)\n    print(name)\n    print(\"=\" * 80)\n\n    print(f\"Liczba shardów: {len(shard_files)}\")\n\n    total_images = 0\n    total_skipped = 0\n\n    for shard_path in shard_files:\n        data = np.load(shard_path, allow_pickle=True)\n\n        n_images = len(data[\"labels\"])\n        total_images += n_images\n\n        skipped_path = shard_path.replace(\n            \".npz\",\n            \"_skipped.csv\"\n        )\n\n        if os.path.exists(skipped_path):\n            skipped_df = pd.read_csv(skipped_path)\n            total_skipped += len(skipped_df)\n\n    print(f\"Obrazy w cache:       {total_images}\")\n    print(f\"Obrazy pominięte:     {total_skipped}\")\n    print(f\"Łącznie:              {total_images + total_skipped}\")\n\n    print()\n\n    # sprawdzenie pierwszego shardu\n    if shard_files:\n        data = np.load(\n            shard_files[0],\n            allow_pickle=True\n        )\n\n        print(\"Pierwszy shard:\")\n        print(f\"  features:    {data['features'].shape}\")\n        print(f\"  labels:      {data['labels'].shape}\")\n        print(f\"  image_names: {data['image_names'].shape}\")\n        print(f\"  num_tiles:   {data['num_tiles'].shape}\")\n\n        first_features = data[\"features\"][0]\n\n        print()\n        print(\"Pierwszy obraz:\")\n        print(f\"  nazwa:       {data['image_names'][0]}\")\n        print(f\"  label:       {data['labels'][0]}\")\n        print(f\"  num tiles:   {data['num_tiles'][0]}\")\n        print(f\"  feature shape: {first_features.shape}\")\n\n\ninspect_cache(\n    TRAIN_FEATURE_CACHE_DIR,\n    \"TRAIN CACHE\"\n)\n\nprint()\n\ninspect_cache(\n    TEST_FEATURE_CACHE_DIR,\n    \"TEST CACHE\"\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T14:13:27.305907Z","iopub.execute_input":"2026-09-24T14:13:27.306359Z","iopub.status.idle":"2026-09-24T14:13:28.432934Z","shell.execute_reply.started":"2026-09-24T14:13:27.306329Z","shell.execute_reply":"2026-09-24T14:13:28.432165Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"FEATURE_DIM = 1280\nNUM_CLASSES = 5\n\n\ndef load_mean_features_from_cache(cache_dir):\n    \"\"\"\n    Loads all cached tile features and performs mean pooling\n    over tiles for each image.\n\n    Output:\n        features:    [N, 1280]\n        labels:      [N]\n        image_names: [N]\n    \"\"\"\n\n    shard_files = sorted(\n        glob.glob(os.path.join(cache_dir, \"shard_*.npz\"))\n    )\n\n    all_features = []\n    all_labels = []\n    all_image_names = []\n\n    print(f\"Found {len(shard_files)} shards.\")\n\n    for shard_idx, shard_path in enumerate(shard_files, start=1):\n\n        data = np.load(\n            shard_path,\n            allow_pickle=True\n        )\n\n        shard_features = data[\"features\"]\n        shard_labels = data[\"labels\"]\n        shard_image_names = data[\"image_names\"]\n\n        # Mean pooling over tiles\n        for tile_features in shard_features:\n            # tile_features shape: [num_tiles, 1280]\n            image_feature = tile_features.mean(axis=0)\n            # image_feature shape: [1280]\n\n            all_features.append(image_feature)\n\n        all_labels.append(shard_labels)\n        all_image_names.append(shard_image_names)\n\n        print(\n            f\"[{shard_idx:02d}/{len(shard_files)}] \"\n            f\"{os.path.basename(shard_path)} → \"\n            f\"{len(shard_labels)} images\"\n        )\n\n    features = np.stack(\n        all_features\n    ).astype(np.float32)\n\n    labels = np.concatenate(\n        all_labels\n    ).astype(np.int64)\n\n    image_names = np.concatenate(\n        all_image_names\n    )\n\n    print()\n    print(\"Final shapes:\")\n    print(f\"  features:    {features.shape}\")\n    print(f\"  labels:      {labels.shape}\")\n    print(f\"  image_names: {image_names.shape}\")\n\n    return features, labels, image_names\n\n\nprint(\"=\" * 80)\nprint(\"LOADING TRAIN CACHE\")\nprint(\"=\" * 80)\n\ntrain_features, train_labels, train_image_names = (\n    load_mean_features_from_cache(\n        TRAIN_FEATURE_CACHE_DIR\n    )\n)\n\n\nprint()\nprint(\"=\" * 80)\nprint(\"LOADING TEST CACHE\")\nprint(\"=\" * 80)\n\ntest_features, test_labels, test_image_names = (\n    load_mean_features_from_cache(\n        TEST_FEATURE_CACHE_DIR\n    )\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T14:14:53.299942Z","iopub.execute_input":"2026-09-24T14:14:53.300516Z","iopub.status.idle":"2026-09-24T14:15:11.790529Z","shell.execute_reply.started":"2026-09-24T14:14:53.300486Z","shell.execute_reply":"2026-09-24T14:15:11.789624Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from collections import Counter\n\nprint(\"TRAIN class distribution:\")\ntrain_counts = Counter(train_labels)\n\nfor class_id in range(NUM_CLASSES):\n    print(\n        f\"Class {class_id}: \"\n        f\"{train_counts[class_id]}\"\n    )\n\n\nprint()\nprint(\"TEST class distribution:\")\ntest_counts = Counter(test_labels)\n\nfor class_id in range(NUM_CLASSES):\n    print(\n        f\"Class {class_id}: \"\n        f\"{test_counts[class_id]}\"\n    )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T14:15:21.302661Z","iopub.execute_input":"2026-09-24T14:15:21.303069Z","iopub.status.idle":"2026-09-24T14:15:21.314715Z","shell.execute_reply.started":"2026-09-24T14:15:21.303041Z","shell.execute_reply":"2026-09-24T14:15:21.313866Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**trening classifiera na cached features**","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# BUILD VALID TEST DATAFRAME\n# ============================================================\n\nvalid_test_rows = []\nskipped_test_rows = []\n\nprint(\"Checking test images...\\n\")\n\nfor idx, row in test_df.iterrows():\n\n    image_name = row[\"image\"]\n    label = int(row[\"level\"])\n\n    try:\n        image = load_image_from_archive(image_name)\n\n        cropped_image, mask = detect_fundus_bbox_and_mask(\n            image\n        )\n\n        tiles = split_image_and_mask_into_tiles(\n            cropped_image,\n            mask,\n            rows=4,\n            cols=4,\n        )\n\n        if len(tiles) == 0:\n            raise ValueError(\n                \"No valid tiles after ROI filtering.\"\n            )\n\n        valid_test_rows.append({\n            \"image\": image_name,\n            \"level\": label,\n        })\n\n    except Exception as e:\n\n        skipped_test_rows.append({\n            \"image\": image_name,\n            \"level\": label,\n            \"error\": str(e),\n        })\n\n    if (idx + 1) % 100 == 0:\n        print(\n            f\"Checked {idx + 1}/{len(test_df)} \"\n            f\"| valid={len(valid_test_rows)} \"\n            f\"| skipped={len(skipped_test_rows)}\"\n        )\n\n\nvalid_test_df = pd.DataFrame(\n    valid_test_rows\n)\n\nskipped_test_df = pd.DataFrame(\n    skipped_test_rows\n)\n\nprint(\"\\n\" + \"=\" * 70)\nprint(\"RESULT\")\nprint(\"=\" * 70)\n\nprint(\n    \"Original test:\",\n    len(test_df)\n)\n\nprint(\n    \"Valid test:\",\n    len(valid_test_df)\n)\n\nprint(\n    \"Skipped test:\",\n    len(skipped_test_df)\n)\n\nprint(\"\\nSkipped images:\")\n\nprint(\n    skipped_test_df\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T14:56:40.821318Z","iopub.execute_input":"2026-09-24T14:56:40.821718Z","iopub.status.idle":"2026-09-24T15:19:45.966192Z","shell.execute_reply.started":"2026-09-24T14:56:40.821691Z","shell.execute_reply":"2026-09-24T15:19:45.96535Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"@torch.no_grad()\ndef evaluate(\n    model,\n    loader,\n    device,\n):\n\n    model.eval()\n\n    y_true = []\n    y_pred = []\n\n    total_loss = 0.0\n    total = 0\n\n    for batch_idx, batch in enumerate(loader):\n\n        tiles_list, labels, image_names = batch\n\n        tiles = tiles_list[0]\n\n        label = labels[0].to(device)\n\n        logits = model(\n            tiles\n        )\n\n        loss = criterion(\n            logits,\n            label.unsqueeze(0),\n        )\n\n        prediction = logits.argmax(\n            dim=1\n        )\n\n        total_loss += loss.item()\n        total += 1\n\n        y_true.append(\n            label.item()\n        )\n\n        y_pred.append(\n            prediction.item()\n        )\n\n        if (batch_idx + 1) % 100 == 0:\n\n            print(\n                f\"Processed: \"\n                f\"{batch_idx + 1}/{len(loader)}\"\n            )\n\n    y_true = np.array(y_true)\n    y_pred = np.array(y_pred)\n\n    accuracy = accuracy_score(\n        y_true,\n        y_pred,\n    )\n\n    balanced_acc = balanced_accuracy_score(\n        y_true,\n        y_pred,\n    )\n\n    macro_f1 = f1_score(\n        y_true,\n        y_pred,\n        average=\"macro\",\n        zero_division=0,\n    )\n\n    return {\n        \"loss\": total_loss / total,\n        \"accuracy\": accuracy,\n        \"balanced_accuracy\": balanced_acc,\n        \"macro_f1\": macro_f1,\n        \"y_true\": y_true,\n        \"y_pred\": y_pred,\n    }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T15:19:45.974355Z","iopub.execute_input":"2026-09-24T15:19:45.974591Z","iopub.status.idle":"2026-09-24T15:19:45.990479Z","shell.execute_reply.started":"2026-09-24T15:19:45.974564Z","shell.execute_reply":"2026-09-24T15:19:45.989558Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"results = evaluate(\n    model,\n    test_loader,\n    DEVICE,\n)\n\nprint(\"\\n\" + \"=\" * 70)\nprint(\"FINE-TUNING TEST RESULTS\")\nprint(\"=\" * 70)\n\nprint(\n    f\"\\nTest images: \"\n    f\"{len(results['y_true'])}\"\n)\n\nprint(\n    f\"Test loss: \"\n    f\"{results['loss']:.4f}\"\n)\n\nprint(\n    f\"Test accuracy: \"\n    f\"{results['accuracy']:.4f}\"\n)\n\nprint(\n    f\"Test balanced accuracy: \"\n    f\"{results['balanced_accuracy']:.4f}\"\n)\n\nprint(\n    f\"Test macro F1: \"\n    f\"{results['macro_f1']:.4f}\"\n)\n\nprint(\"\\nClassification report:\\n\")\n\nprint(\n    classification_report(\n        results[\"y_true\"],\n        results[\"y_pred\"],\n        labels=list(range(NUM_CLASSES)),\n        target_names=[\n            \"No DR (0)\",\n            \"Mild (1)\",\n            \"Moderate (2)\",\n            \"Severe (3)\",\n            \"PDR (4)\",\n        ],\n        zero_division=0,\n    )\n)\n\ncm = confusion_matrix(\n    results[\"y_true\"],\n    results[\"y_pred\"],\n    labels=list(range(NUM_CLASSES)),\n)\n\nprint(\"\\nConfusion matrix:\")\nprint(cm)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T15:19:45.99204Z","iopub.execute_input":"2026-09-24T15:19:45.992521Z","iopub.status.idle":"2026-09-24T15:48:31.706054Z","shell.execute_reply.started":"2026-09-24T15:19:45.992481Z","shell.execute_reply":"2026-09-24T15:48:31.705126Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#                     ┌── tile 1 ── EfficientNet ── feature ──┐\n#                     ├── tile 2 ── EfficientNet ── feature ──┤\n# image → 4×4 tiles ──┼── tile 3 ── EfficientNet ── feature ──┤\n#                     ├── ...                                  ├─→ Attention\n#                     └── tile N ── EfficientNet ── feature ──┘       ↓\n#                                                                image feature\n#                                                                     ↓\n#                                                                classifier\n#                                                                     ↓\n#                                                                   0–4","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# ATTENTION POOLING — Diabetic Retinopathy\n# ============================================================\n\nfrom sklearn.metrics import (\n    accuracy_score,\n    balanced_accuracy_score,\n    f1_score,\n    classification_report,\n    confusion_matrix,\n)\nfrom collections import Counter\n\n# ------------------------------------------------------------\n# CONFIG\n# ------------------------------------------------------------\n\nTRAIN_CACHE_DIR = \"/kaggle/working/retinopathy_feature_cache_train\"\nTEST_CACHE_DIR = \"/kaggle/working/retinopathy_feature_cache_test\"\n\nNUM_CLASSES = 5\nFEATURE_DIM = 1280\n\nEPOCHS = 20\nBATCH_SIZE = 256\nLR = 1e-3\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\nprint(\"Device:\", DEVICE)\n\n\n# ============================================================\n# 1. LOAD FEATURE CACHE\n# ============================================================\n\ndef load_feature_cache(cache_dir):\n    shard_paths = sorted(\n        glob.glob(os.path.join(cache_dir, \"shard_*.npz\"))\n    )\n\n    if not shard_paths:\n        raise FileNotFoundError(\n            f\"No cache shards found in: {cache_dir}\"\n        )\n\n    all_features = []\n    all_labels = []\n    all_names = []\n    all_num_tiles = []\n\n    print(f\"\\nLoading cache from: {cache_dir}\")\n    print(f\"Found {len(shard_paths)} shards\")\n\n    for shard_path in shard_paths:\n        data = np.load(shard_path, allow_pickle=True)\n\n        all_features.extend(data[\"features\"].tolist())\n        all_labels.extend(data[\"labels\"].tolist())\n        all_names.extend(data[\"image_names\"].tolist())\n        all_num_tiles.extend(data[\"num_tiles\"].tolist())\n\n    print(f\"Loaded images: {len(all_features)}\")\n\n    return (\n        all_features,\n        np.asarray(all_labels, dtype=np.int64),\n        np.asarray(all_names),\n        np.asarray(all_num_tiles, dtype=np.int64),\n    )\n\n\n# Load only if variables don't already exist\nif \"features_train\" not in globals():\n    (\n        features_train,\n        labels_train,\n        names_train,\n        num_tiles_train,\n    ) = load_feature_cache(TRAIN_CACHE_DIR)\n\nif \"features_test\" not in globals():\n    (\n        features_test,\n        labels_test,\n        names_test,\n        num_tiles_test,\n    ) = load_feature_cache(TEST_CACHE_DIR)\n\n\nprint(\"\\nTRAIN:\", len(features_train))\nprint(\"TEST :\", len(features_test))\n\nprint(\"\\nTrain class distribution:\")\nprint(Counter(labels_train))\n\nprint(\"\\nTest class distribution:\")\nprint(Counter(labels_test))\n\n\n# ============================================================\n# 2. DATASET\n# ============================================================\n\nclass TileFeatureDataset(Dataset):\n\n    def __init__(self, features, labels):\n        self.features = features\n        self.labels = labels\n\n    def __len__(self):\n        return len(self.features)\n\n    def __getitem__(self, idx):\n        x = np.asarray(self.features[idx], dtype=np.float32)\n        y = int(self.labels[idx])\n\n        return torch.from_numpy(x), y\n\n\n# ============================================================\n# 3. COLLATE FUNCTION\n#\n# Each image can have a different number of tiles.\n#\n# Output:\n#   x    = [B, max_tiles, 1280]\n#   mask = [B, max_tiles]\n#   y    = [B]\n# ============================================================\n\ndef collate_tile_features(batch):\n\n    tile_features, labels = zip(*batch)\n\n    batch_size = len(tile_features)\n    max_tiles = max(x.shape[0] for x in tile_features)\n\n    x = torch.zeros(\n        batch_size,\n        max_tiles,\n        FEATURE_DIM,\n        dtype=torch.float32,\n    )\n\n    mask = torch.zeros(\n        batch_size,\n        max_tiles,\n        dtype=torch.bool,\n    )\n\n    for i, features in enumerate(tile_features):\n\n        num_tiles = features.shape[0]\n\n        x[i, :num_tiles] = features\n        mask[i, :num_tiles] = True\n\n    y = torch.tensor(labels, dtype=torch.long)\n\n    return x, mask, y\n\n\ntrain_dataset = TileFeatureDataset(\n    features_train,\n    labels_train,\n)\n\ntest_dataset = TileFeatureDataset(\n    features_test,\n    labels_test,\n)\n\ntrain_loader = DataLoader(\n    train_dataset,\n    batch_size=BATCH_SIZE,\n    shuffle=True,\n    collate_fn=collate_tile_features,\n    num_workers=2,\n    pin_memory=True,\n)\n\ntest_loader = DataLoader(\n    test_dataset,\n    batch_size=BATCH_SIZE,\n    shuffle=False,\n    collate_fn=collate_tile_features,\n    num_workers=2,\n    pin_memory=True,\n)\n\n\n# ============================================================\n# 4. ATTENTION POOLING\n# ============================================================\n\nclass AttentionPooling(nn.Module):\n\n    def __init__(\n        self,\n        feature_dim=1280,\n        hidden_dim=256,\n    ):\n        super().__init__()\n\n        self.attention = nn.Sequential(\n            nn.Linear(feature_dim, hidden_dim),\n            nn.Tanh(),\n            nn.Linear(hidden_dim, 1),\n        )\n\n    def forward(self, x, mask):\n        \"\"\"\n        x:\n            [B, T, 1280]\n\n        mask:\n            [B, T]\n\n        Returns:\n            pooled:\n                [B, 1280]\n\n            weights:\n                [B, T]\n        \"\"\"\n\n        scores = self.attention(x).squeeze(-1)\n        # [B, T]\n\n        # Ignore padded tiles\n        scores = scores.masked_fill(\n            ~mask,\n            -1e9,\n        )\n\n        weights = torch.softmax(\n            scores,\n            dim=1,\n        )\n        # [B, T]\n\n        pooled = (\n            weights.unsqueeze(-1) * x\n        ).sum(dim=1)\n\n        return pooled, weights\n\n\n# ============================================================\n# 5. MODEL\n# ============================================================\n\nclass AttentionClassifier(nn.Module):\n\n    def __init__(\n        self,\n        feature_dim=1280,\n        hidden_dim=256,\n        num_classes=5,\n    ):\n        super().__init__()\n\n        self.pooling = AttentionPooling(\n            feature_dim=feature_dim,\n            hidden_dim=hidden_dim,\n        )\n\n        self.classifier = nn.Linear(\n            feature_dim,\n            num_classes,\n        )\n\n    def forward(self, x, mask):\n\n        pooled, weights = self.pooling(\n            x,\n            mask,\n        )\n\n        logits = self.classifier(\n            pooled\n        )\n\n        return logits, weights\n\n\nmodel = AttentionClassifier(\n    feature_dim=FEATURE_DIM,\n    hidden_dim=256,\n    num_classes=NUM_CLASSES,\n).to(DEVICE)\n\n\nprint(\"\\nModel:\")\nprint(model)\n\n\n# ============================================================\n# 6. PARAMETER COUNT\n# ============================================================\n\ntotal_params = sum(\n    p.numel()\n    for p in model.parameters()\n)\n\ntrainable_params = sum(\n    p.numel()\n    for p in model.parameters()\n    if p.requires_grad\n)\n\nprint(\"\\nTotal parameters:    \", total_params)\nprint(\"Trainable parameters:\", trainable_params)\n\n\n# ============================================================\n# 7. CLASS WEIGHTS\n#\n# Same principle as our previous weighted baseline:\n#\n# weight_c = N / (K * N_c)\n# ============================================================\n\nclass_counts = np.bincount(\n    labels_train,\n    minlength=NUM_CLASSES,\n)\n\nN = len(labels_train)\nK = NUM_CLASSES\n\nclass_weights = N / (\n    K * class_counts\n)\n\nclass_weights = torch.tensor(\n    class_weights,\n    dtype=torch.float32,\n    device=DEVICE,\n)\n\nprint(\"\\nClass counts:\")\nprint(class_counts)\n\nprint(\"\\nClass weights:\")\nprint(class_weights)\n\n\n# ============================================================\n# 8. LOSS + OPTIMIZER\n# ============================================================\n\ncriterion = nn.CrossEntropyLoss(\n    weight=class_weights\n)\n\noptimizer = torch.optim.Adam(\n    model.parameters(),\n    lr=LR,\n)\n\n\n# ============================================================\n# 9. TRAINING\n# ============================================================\n\nprint(\"\\n\" + \"=\" * 70)\nprint(\"STARTING TRAINING\")\nprint(\"=\" * 70)\n\nfor epoch in range(1, EPOCHS + 1):\n\n    model.train()\n\n    running_loss = 0.0\n    all_preds = []\n    all_targets = []\n\n    for x, mask, y in train_loader:\n\n        x = x.to(\n            DEVICE,\n            non_blocking=True,\n        )\n\n        mask = mask.to(\n            DEVICE,\n            non_blocking=True,\n        )\n\n        y = y.to(\n            DEVICE,\n            non_blocking=True,\n        )\n\n        optimizer.zero_grad()\n\n        logits, _ = model(\n            x,\n            mask,\n        )\n\n        loss = criterion(\n            logits,\n            y,\n        )\n\n        loss.backward()\n\n        optimizer.step()\n\n        running_loss += (\n            loss.item() * y.size(0)\n        )\n\n        preds = logits.argmax(\n            dim=1\n        )\n\n        all_preds.extend(\n            preds.detach().cpu().numpy()\n        )\n\n        all_targets.extend(\n            y.detach().cpu().numpy()\n        )\n\n    epoch_loss = (\n        running_loss /\n        len(train_dataset)\n    )\n\n    epoch_acc = accuracy_score(\n        all_targets,\n        all_preds,\n    )\n\n    print(\n        f\"Epoch {epoch:02d}/{EPOCHS} | \"\n        f\"loss={epoch_loss:.4f} | \"\n        f\"accuracy={epoch_acc:.4f}\"\n    )\n\n\n# ============================================================\n# 10. TEST EVALUATION\n# ============================================================\n\nmodel.eval()\n\ntest_loss = 0.0\n\nall_preds = []\nall_targets = []\n\nwith torch.no_grad():\n\n    for x, mask, y in test_loader:\n\n        x = x.to(\n            DEVICE,\n            non_blocking=True,\n        )\n\n        mask = mask.to(\n            DEVICE,\n            non_blocking=True,\n        )\n\n        y = y.to(\n            DEVICE,\n            non_blocking=True,\n        )\n\n        logits, _ = model(\n            x,\n            mask,\n        )\n\n        loss = criterion(\n            logits,\n            y,\n        )\n\n        test_loss += (\n            loss.item() * y.size(0)\n        )\n\n        preds = logits.argmax(\n            dim=1\n        )\n\n        all_preds.extend(\n            preds.cpu().numpy()\n        )\n\n        all_targets.extend(\n            y.cpu().numpy()\n        )\n\n\ntest_loss /= len(test_dataset)\n\ntest_accuracy = accuracy_score(\n    all_targets,\n    all_preds,\n)\n\ntest_balanced_accuracy = balanced_accuracy_score(\n    all_targets,\n    all_preds,\n)\n\ntest_macro_f1 = f1_score(\n    all_targets,\n    all_preds,\n    average=\"macro\",\n)\n\nprint(\"\\n\" + \"=\" * 70)\nprint(\"ATTENTION POOLING — TEST RESULTS\")\nprint(\"=\" * 70)\n\nprint(f\"Test loss:              {test_loss:.4f}\")\nprint(f\"Test accuracy:          {test_accuracy:.4f}\")\nprint(f\"Test balanced accuracy: {test_balanced_accuracy:.4f}\")\nprint(f\"Test macro F1:          {test_macro_f1:.4f}\")\n\n\n# ============================================================\n# 11. CLASSIFICATION REPORT\n# ============================================================\n\nprint(\"\\nClassification report:\\n\")\n\nprint(\n    classification_report(\n        all_targets,\n        all_preds,\n        labels=[0, 1, 2, 3, 4],\n        target_names=[\n            \"No DR (0)\",\n            \"Mild (1)\",\n            \"Moderate (2)\",\n            \"Severe (3)\",\n            \"Proliferative (4)\",\n        ],\n        digits=4,\n        zero_division=0,\n    )\n)\n\n\n# ============================================================\n# 12. CONFUSION MATRIX\n# ============================================================\n\ncm = confusion_matrix(\n    all_targets,\n    all_preds,\n    labels=[0, 1, 2, 3, 4],\n)\n\nprint(\"Confusion matrix:\")\nprint(cm)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T16:01:47.255749Z","iopub.execute_input":"2026-09-24T16:01:47.256556Z","iopub.status.idle":"2026-09-24T16:02:58.128158Z","shell.execute_reply.started":"2026-09-24T16:01:47.256526Z","shell.execute_reply":"2026-09-24T16:02:58.127235Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# MAX POOLING — Diabetic Retinopathy\n# ============================================================\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nfrom sklearn.metrics import (\n    accuracy_score,\n    balanced_accuracy_score,\n    f1_score,\n    classification_report,\n    confusion_matrix,\n)\nimport numpy as np\n\n\n# ============================================================\n# CONFIG\n# ============================================================\n\nNUM_CLASSES = 5\nFEATURE_DIM = 1280\n\nEPOCHS = 20\nBATCH_SIZE = 256\nLR = 1e-3\n\nDEVICE = torch.device(\n    \"cuda\" if torch.cuda.is_available() else \"cpu\"\n)\n\nprint(\"Device:\", DEVICE)\n\n\n# ============================================================\n# DATASET\n# ============================================================\n\nclass TileFeatureDataset(Dataset):\n\n    def __init__(self, features, labels):\n        self.features = features\n        self.labels = labels\n\n    def __len__(self):\n        return len(self.features)\n\n    def __getitem__(self, idx):\n\n        x = np.asarray(\n            self.features[idx],\n            dtype=np.float32\n        )\n\n        y = int(self.labels[idx])\n\n        return torch.from_numpy(x), y\n\n\n# ============================================================\n# COLLATE FUNCTION\n#\n# Different images can contain different numbers of tiles.\n#\n# x:\n#   [B, max_tiles, 1280]\n#\n# mask:\n#   [B, max_tiles]\n# ============================================================\n\ndef collate_tile_features(batch):\n\n    tile_features, labels = zip(*batch)\n\n    batch_size = len(tile_features)\n\n    max_tiles = max(\n        x.shape[0]\n        for x in tile_features\n    )\n\n    x = torch.zeros(\n        batch_size,\n        max_tiles,\n        FEATURE_DIM,\n        dtype=torch.float32,\n    )\n\n    mask = torch.zeros(\n        batch_size,\n        max_tiles,\n        dtype=torch.bool,\n    )\n\n    for i, features in enumerate(tile_features):\n\n        num_tiles = features.shape[0]\n\n        x[i, :num_tiles] = features\n\n        mask[i, :num_tiles] = True\n\n    y = torch.tensor(\n        labels,\n        dtype=torch.long\n    )\n\n    return x, mask, y\n\n\ntrain_dataset = TileFeatureDataset(\n    features_train,\n    labels_train,\n)\n\ntest_dataset = TileFeatureDataset(\n    features_test,\n    labels_test,\n)\n\ntrain_loader = DataLoader(\n    train_dataset,\n    batch_size=BATCH_SIZE,\n    shuffle=True,\n    collate_fn=collate_tile_features,\n    num_workers=2,\n    pin_memory=True,\n)\n\ntest_loader = DataLoader(\n    test_dataset,\n    batch_size=BATCH_SIZE,\n    shuffle=False,\n    collate_fn=collate_tile_features,\n    num_workers=2,\n    pin_memory=True,\n)\n\n\n# ============================================================\n# MAX POOLING\n# ============================================================\n\nclass MaxPooling(nn.Module):\n\n    def forward(self, x, mask):\n        \"\"\"\n        x:\n            [B, T, 1280]\n\n        mask:\n            [B, T]\n\n        Returns:\n            pooled:\n                [B, 1280]\n        \"\"\"\n\n        # Padded tiles must not participate in max pooling.\n        x = x.masked_fill(\n            ~mask.unsqueeze(-1),\n            -torch.inf\n        )\n\n        pooled = x.max(\n            dim=1\n        ).values\n\n        return pooled\n\n\n# ============================================================\n# MODEL\n# ============================================================\n\nclass MaxPoolingClassifier(nn.Module):\n\n    def __init__(\n        self,\n        feature_dim=1280,\n        num_classes=5,\n    ):\n        super().__init__()\n\n        self.pooling = MaxPooling()\n\n        self.classifier = nn.Linear(\n            feature_dim,\n            num_classes,\n        )\n\n    def forward(self, x, mask):\n\n        pooled = self.pooling(\n            x,\n            mask\n        )\n\n        logits = self.classifier(\n            pooled\n        )\n\n        return logits\n\n\nmodel = MaxPoolingClassifier(\n    feature_dim=FEATURE_DIM,\n    num_classes=NUM_CLASSES,\n).to(DEVICE)\n\n\n# ============================================================\n# PARAMETERS\n# ============================================================\n\ntotal_params = sum(\n    p.numel()\n    for p in model.parameters()\n)\n\ntrainable_params = sum(\n    p.numel()\n    for p in model.parameters()\n    if p.requires_grad\n)\n\nprint(\"\\nModel:\")\nprint(model)\n\nprint(\"\\nTotal parameters:    \", total_params)\nprint(\"Trainable parameters:\", trainable_params)\n\n\n# ============================================================\n# CLASS WEIGHTS\n#\n# Same formula as previous experiments:\n#\n# weight_c = N / (K * N_c)\n# ============================================================\n\nclass_counts = np.bincount(\n    labels_train,\n    minlength=NUM_CLASSES,\n)\n\nN = len(labels_train)\nK = NUM_CLASSES\n\nclass_weights = N / (\n    K * class_counts\n)\n\nclass_weights = torch.tensor(\n    class_weights,\n    dtype=torch.float32,\n    device=DEVICE,\n)\n\nprint(\"\\nClass counts:\")\nprint(class_counts)\n\nprint(\"\\nClass weights:\")\nprint(class_weights)\n\n\n# ============================================================\n# LOSS + OPTIMIZER\n# ============================================================\n\ncriterion = nn.CrossEntropyLoss(\n    weight=class_weights\n)\n\noptimizer = torch.optim.Adam(\n    model.parameters(),\n    lr=LR,\n)\n\n\n# ============================================================\n# TRAINING\n# ============================================================\n\nprint(\"\\n\" + \"=\" * 70)\nprint(\"STARTING MAX POOLING TRAINING\")\nprint(\"=\" * 70)\n\nfor epoch in range(1, EPOCHS + 1):\n\n    model.train()\n\n    running_loss = 0.0\n\n    all_preds = []\n    all_targets = []\n\n    for x, mask, y in train_loader:\n\n        x = x.to(\n            DEVICE,\n            non_blocking=True\n        )\n\n        mask = mask.to(\n            DEVICE,\n            non_blocking=True\n        )\n\n        y = y.to(\n            DEVICE,\n            non_blocking=True\n        )\n\n        optimizer.zero_grad()\n\n        logits = model(\n            x,\n            mask\n        )\n\n        loss = criterion(\n            logits,\n            y\n        )\n\n        loss.backward()\n\n        optimizer.step()\n\n        running_loss += (\n            loss.item() * y.size(0)\n        )\n\n        preds = logits.argmax(\n            dim=1\n        )\n\n        all_preds.extend(\n            preds.detach().cpu().numpy()\n        )\n\n        all_targets.extend(\n            y.detach().cpu().numpy()\n        )\n\n    epoch_loss = (\n        running_loss /\n        len(train_dataset)\n    )\n\n    epoch_acc = accuracy_score(\n        all_targets,\n        all_preds\n    )\n\n    print(\n        f\"Epoch {epoch:02d}/{EPOCHS} | \"\n        f\"loss={epoch_loss:.4f} | \"\n        f\"accuracy={epoch_acc:.4f}\"\n    )\n\n\n# ============================================================\n# TEST\n# ============================================================\n\nmodel.eval()\n\ntest_loss = 0.0\n\nall_preds = []\nall_targets = []\n\nwith torch.no_grad():\n\n    for x, mask, y in test_loader:\n\n        x = x.to(\n            DEVICE,\n            non_blocking=True\n        )\n\n        mask = mask.to(\n            DEVICE,\n            non_blocking=True\n        )\n\n        y = y.to(\n            DEVICE,\n            non_blocking=True\n        )\n\n        logits = model(\n            x,\n            mask\n        )\n\n        loss = criterion(\n            logits,\n            y\n        )\n\n        test_loss += (\n            loss.item() * y.size(0)\n        )\n\n        preds = logits.argmax(\n            dim=1\n        )\n\n        all_preds.extend(\n            preds.cpu().numpy()\n        )\n\n        all_targets.extend(\n            y.cpu().numpy()\n        )\n\n\ntest_loss /= len(test_dataset)\n\ntest_accuracy = accuracy_score(\n    all_targets,\n    all_preds\n)\n\ntest_balanced_accuracy = balanced_accuracy_score(\n    all_targets,\n    all_preds\n)\n\ntest_macro_f1 = f1_score(\n    all_targets,\n    all_preds,\n    average=\"macro\"\n)\n\n\n# ============================================================\n# RESULTS\n# ============================================================\n\nprint(\"\\n\" + \"=\" * 70)\nprint(\"MAX POOLING — TEST RESULTS\")\nprint(\"=\" * 70)\n\nprint(\n    f\"Test loss:              {test_loss:.4f}\"\n)\n\nprint(\n    f\"Test accuracy:          {test_accuracy:.4f}\"\n)\n\nprint(\n    f\"Test balanced accuracy: \"\n    f\"{test_balanced_accuracy:.4f}\"\n)\n\nprint(\n    f\"Test macro F1:          \"\n    f\"{test_macro_f1:.4f}\"\n)\n\n\n# ============================================================\n# CLASSIFICATION REPORT\n# ============================================================\n\nprint(\"\\nClassification report:\\n\")\n\nprint(\n    classification_report(\n        all_targets,\n        all_preds,\n        labels=[0, 1, 2, 3, 4],\n        target_names=[\n            \"No DR (0)\",\n            \"Mild (1)\",\n            \"Moderate (2)\",\n            \"Severe (3)\",\n            \"Proliferative (4)\",\n        ],\n        digits=4,\n        zero_division=0,\n    )\n)\n\n\n# ============================================================\n# CONFUSION MATRIX\n# ============================================================\n\ncm = confusion_matrix(\n    all_targets,\n    all_preds,\n    labels=[0, 1, 2, 3, 4],\n)\n\nprint(\"Confusion matrix:\")\nprint(cm)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T16:08:32.246924Z","iopub.execute_input":"2026-09-24T16:08:32.247407Z","iopub.status.idle":"2026-09-24T16:09:23.835731Z","shell.execute_reply.started":"2026-09-24T16:08:32.247371Z","shell.execute_reply":"2026-09-24T16:09:23.834685Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Nasze wyniki weighted mean:\n\n# accuracy          53.51%\n# balanced accuracy 47.19%\n# macro F1          31.74%\n\n# są wyraźnie lepsze od unweighted:\n\n# accuracy          74.42%\n# balanced accuracy 26.91%\n# macro F1          28.53%\n\n# Czyli class weighting rzeczywiście pomaga w 5-klasowym problemie, szczególnie pod kątem klas mniejszościowych.","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# FEATURE SPACE DIAGNOSTICS\n# PCA + 2D VISUALIZATION\n# ============================================================\n\nfrom sklearn.decomposition import PCA\nfrom sklearn.metrics import silhouette_score\n\n\n# ============================================================\n# 1. CREATE MEAN-POOLED FEATURES\n#\n# We use the same representation as our best model:\n#\n# EfficientNet tile features\n#        ↓\n# mean pooling\n#        ↓\n# 1280-dimensional image feature\n# ============================================================\n\nif \"X_train\" not in globals():\n\n    print(\"Creating X_train from cached tile features...\")\n\n    X_train = np.stack([\n        np.asarray(f, dtype=np.float32).mean(axis=0)\n        for f in features_train\n    ])\n\n    y_train = np.asarray(\n        labels_train,\n        dtype=np.int64\n    )\n\n\nif \"X_test\" not in globals():\n\n    print(\"Creating X_test from cached tile features...\")\n\n    X_test = np.stack([\n        np.asarray(f, dtype=np.float32).mean(axis=0)\n        for f in features_test\n    ])\n\n    y_test = np.asarray(\n        labels_test,\n        dtype=np.int64\n    )\n\n\nprint(\"\\nFeature shapes:\")\nprint(\"X_train:\", X_train.shape)\nprint(\"y_train:\", y_train.shape)\nprint(\"X_test :\", X_test.shape)\nprint(\"y_test :\", y_test.shape)\n\n\n# ============================================================\n# 2. CHECK FEATURE STATISTICS\n# ============================================================\n\nprint(\"\\nFeature statistics:\")\n\nprint(\n    \"Mean:\",\n    X_train.mean()\n)\n\nprint(\n    \"Std:\",\n    X_train.std()\n)\n\nprint(\n    \"Min:\",\n    X_train.min()\n)\n\nprint(\n    \"Max:\",\n    X_train.max()\n)\n\n\n# ============================================================\n# 3. PCA\n#\n# Reduce 1280 dimensions → 2 dimensions.\n#\n# IMPORTANT:\n# PCA is fitted ONLY on TRAIN.\n# ============================================================\n\nprint(\"\\nRunning PCA...\")\n\npca = PCA(\n    n_components=2,\n    random_state=42\n)\n\nX_train_pca = pca.fit_transform(\n    X_train\n)\n\nexplained_variance = (\n    pca.explained_variance_ratio_\n)\n\nprint(\n    \"\\nExplained variance:\"\n)\n\nprint(\n    f\"PC1: {explained_variance[0]:.4f} \"\n    f\"({explained_variance[0] * 100:.2f}%)\"\n)\n\nprint(\n    f\"PC2: {explained_variance[1]:.4f} \"\n    f\"({explained_variance[1] * 100:.2f}%)\"\n)\n\nprint(\n    f\"Total: {explained_variance.sum():.4f} \"\n    f\"({explained_variance.sum() * 100:.2f}%)\"\n)\n\n\n# ============================================================\n# 4. PCA VISUALIZATION\n# ============================================================\n\nclass_names = {\n    0: \"No DR\",\n    1: \"Mild\",\n    2: \"Moderate\",\n    3: \"Severe\",\n    4: \"Proliferative\",\n}\n\nplt.figure(\n    figsize=(12, 9)\n)\n\nfor class_id in range(5):\n\n    mask = y_train == class_id\n\n    plt.scatter(\n        X_train_pca[mask, 0],\n        X_train_pca[mask, 1],\n        s=5,\n        alpha=0.35,\n        label=class_names[class_id],\n    )\n\nplt.xlabel(\n    f\"PC1 ({explained_variance[0] * 100:.2f}% variance)\"\n)\n\nplt.ylabel(\n    f\"PC2 ({explained_variance[1] * 100:.2f}% variance)\"\n)\n\nplt.title(\n    \"PCA of Frozen EfficientNet-B0 Image Features\"\n)\n\nplt.legend()\n\nplt.grid(\n    alpha=0.2\n)\n\nplt.tight_layout()\n\nplt.show()\n\n\n# ============================================================\n# 5. CLASS CENTROIDS IN PCA SPACE\n# ============================================================\n\nprint(\"\\nClass centroids in PCA space:\")\n\nfor class_id in range(5):\n\n    mask = y_train == class_id\n\n    centroid = X_train_pca[mask].mean(\n        axis=0\n    )\n\n    print(\n        f\"Class {class_id} \"\n        f\"({class_names[class_id]:14s}): \"\n        f\"PC1={centroid[0]:8.3f}, \"\n        f\"PC2={centroid[1]:8.3f}\"\n    )\n\n\n# ============================================================\n# 6. SILHOUETTE SCORE\n#\n# Measures how separated the classes are.\n#\n# Range:\n#   +1 → strong separation\n#    0 → overlapping clusters\n#   <0 → samples often closer to another class\n#\n# We calculate it on a deterministic subset because\n# silhouette_score can be expensive on 28k samples.\n# ============================================================\n\nMAX_SILHOUETTE_SAMPLES = 5000\n\nrng = np.random.RandomState(42)\n\nif len(X_train_pca) > MAX_SILHOUETTE_SAMPLES:\n\n    silhouette_indices = rng.choice(\n        len(X_train_pca),\n        size=MAX_SILHOUETTE_SAMPLES,\n        replace=False,\n    )\n\nelse:\n\n    silhouette_indices = np.arange(\n        len(X_train_pca)\n    )\n\n\nsilhouette = silhouette_score(\n    X_train_pca[silhouette_indices],\n    y_train[silhouette_indices],\n)\n\nprint(\n    \"\\nSilhouette score \"\n    f\"(deterministic {len(silhouette_indices)}-sample subset): \"\n    f\"{silhouette:.4f}\"\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-26T10:04:38.322903Z","iopub.execute_input":"2026-09-26T10:04:38.324298Z","iopub.status.idle":"2026-09-26T10:04:38.344308Z","shell.execute_reply.started":"2026-09-26T10:04:38.32426Z","shell.execute_reply":"2026-09-26T10:04:38.342863Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# MLP CLASSIFIER ON FROZEN EFFICIENTNET-B0 FEATURES\n# ============================================================\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import TensorDataset, DataLoader\nfrom sklearn.metrics import (\n    accuracy_score,\n    balanced_accuracy_score,\n    f1_score,\n    classification_report,\n    confusion_matrix,\n)\nimport numpy as np\n\n\n# ============================================================\n# CONFIG\n# ============================================================\n\nNUM_CLASSES = 5\nFEATURE_DIM = 1280\n\nHIDDEN_DIM_1 = 512\nHIDDEN_DIM_2 = 256\n\nDROPOUT = 0.30\n\nEPOCHS = 20\nBATCH_SIZE = 256\nLR = 1e-3\n\nDEVICE = torch.device(\n    \"cuda\" if torch.cuda.is_available() else \"cpu\"\n)\n\nprint(\"Device:\", DEVICE)\n\n\n# ============================================================\n# MAKE SURE FEATURES ARE TENSORS\n# ============================================================\n\nif not torch.is_tensor(X_train):\n    X_train = torch.tensor(\n        X_train,\n        dtype=torch.float32\n    )\n\nif not torch.is_tensor(y_train):\n    y_train = torch.tensor(\n        y_train,\n        dtype=torch.long\n    )\n\nif not torch.is_tensor(X_test):\n    X_test = torch.tensor(\n        X_test,\n        dtype=torch.float32\n    )\n\nif not torch.is_tensor(y_test):\n    y_test = torch.tensor(\n        y_test,\n        dtype=torch.long\n    )\n\n\nprint(\"\\nFeature shapes:\")\nprint(\"X_train:\", X_train.shape)\nprint(\"y_train:\", y_train.shape)\nprint(\"X_test :\", X_test.shape)\nprint(\"y_test :\", y_test.shape)\n\n\n# ============================================================\n# DATASETS\n# ============================================================\n\ntrain_dataset = TensorDataset(\n    X_train,\n    y_train\n)\n\ntest_dataset = TensorDataset(\n    X_test,\n    y_test\n)\n\ntrain_loader = DataLoader(\n    train_dataset,\n    batch_size=BATCH_SIZE,\n    shuffle=True,\n    num_workers=2,\n    pin_memory=True,\n)\n\ntest_loader = DataLoader(\n    test_dataset,\n    batch_size=BATCH_SIZE,\n    shuffle=False,\n    num_workers=2,\n    pin_memory=True,\n)\n\n\n# ============================================================\n# MLP MODEL\n# ============================================================\n\nclass MLPClassifier(nn.Module):\n\n    def __init__(\n        self,\n        input_dim=1280,\n        hidden_dim_1=512,\n        hidden_dim_2=256,\n        num_classes=5,\n        dropout=0.30,\n    ):\n        super().__init__()\n\n        self.network = nn.Sequential(\n\n            nn.Linear(\n                input_dim,\n                hidden_dim_1\n            ),\n\n            nn.ReLU(),\n\n            nn.Dropout(\n                dropout\n            ),\n\n            nn.Linear(\n                hidden_dim_1,\n                hidden_dim_2\n            ),\n\n            nn.ReLU(),\n\n            nn.Dropout(\n                dropout\n            ),\n\n            nn.Linear(\n                hidden_dim_2,\n                num_classes\n            ),\n        )\n\n    def forward(self, x):\n        return self.network(x)\n\n\nmodel = MLPClassifier(\n    input_dim=FEATURE_DIM,\n    hidden_dim_1=HIDDEN_DIM_1,\n    hidden_dim_2=HIDDEN_DIM_2,\n    num_classes=NUM_CLASSES,\n    dropout=DROPOUT,\n).to(DEVICE)\n\n\n# ============================================================\n# PARAMETER COUNT\n# ============================================================\n\ntotal_params = sum(\n    p.numel()\n    for p in model.parameters()\n)\n\ntrainable_params = sum(\n    p.numel()\n    for p in model.parameters()\n    if p.requires_grad\n)\n\nprint(\"\\nModel:\")\nprint(model)\n\nprint(\n    \"\\nTotal parameters:    \",\n    total_params\n)\n\nprint(\n    \"Trainable parameters:\",\n    trainable_params\n)\n\n\n# ============================================================\n# CLASS WEIGHTS\n# ============================================================\n\nclass_counts = np.bincount(\n    y_train.cpu().numpy(),\n    minlength=NUM_CLASSES,\n)\n\nN = len(y_train)\nK = NUM_CLASSES\n\nclass_weights = N / (\n    K * class_counts\n)\n\nclass_weights = torch.tensor(\n    class_weights,\n    dtype=torch.float32,\n    device=DEVICE,\n)\n\nprint(\"\\nClass counts:\")\nprint(class_counts)\n\nprint(\"\\nClass weights:\")\nprint(class_weights)\n\n\n# ============================================================\n# LOSS + OPTIMIZER\n# ============================================================\n\ncriterion = nn.CrossEntropyLoss(\n    weight=class_weights\n)\n\noptimizer = torch.optim.Adam(\n    model.parameters(),\n    lr=LR,\n)\n\n\n# ============================================================\n# TRAINING\n# ============================================================\n\nprint(\"\\n\" + \"=\" * 70)\nprint(\"STARTING MLP TRAINING\")\nprint(\"=\" * 70)\n\nfor epoch in range(1, EPOCHS + 1):\n\n    model.train()\n\n    running_loss = 0.0\n\n    all_preds = []\n    all_targets = []\n\n    for x, y in train_loader:\n\n        x = x.to(\n            DEVICE,\n            non_blocking=True\n        )\n\n        y = y.to(\n            DEVICE,\n            non_blocking=True\n        )\n\n        optimizer.zero_grad()\n\n        logits = model(x)\n\n        loss = criterion(\n            logits,\n            y\n        )\n\n        loss.backward()\n\n        optimizer.step()\n\n        running_loss += (\n            loss.item() * y.size(0)\n        )\n\n        preds = logits.argmax(\n            dim=1\n        )\n\n        all_preds.extend(\n            preds.detach().cpu().numpy()\n        )\n\n        all_targets.extend(\n            y.detach().cpu().numpy()\n        )\n\n    epoch_loss = (\n        running_loss /\n        len(train_dataset)\n    )\n\n    epoch_acc = accuracy_score(\n        all_targets,\n        all_preds\n    )\n\n    print(\n        f\"Epoch {epoch:02d}/{EPOCHS} | \"\n        f\"loss={epoch_loss:.4f} | \"\n        f\"accuracy={epoch_acc:.4f}\"\n    )\n\n\n# ============================================================\n# TEST EVALUATION\n# ============================================================\n\nmodel.eval()\n\ntest_loss = 0.0\n\nall_preds = []\nall_targets = []\n\nwith torch.no_grad():\n\n    for x, y in test_loader:\n\n        x = x.to(\n            DEVICE,\n            non_blocking=True\n        )\n\n        y = y.to(\n            DEVICE,\n            non_blocking=True\n        )\n\n        logits = model(x)\n\n        loss = criterion(\n            logits,\n            y\n        )\n\n        test_loss += (\n            loss.item() * y.size(0)\n        )\n\n        preds = logits.argmax(\n            dim=1\n        )\n\n        all_preds.extend(\n            preds.cpu().numpy()\n        )\n\n        all_targets.extend(\n            y.cpu().numpy()\n        )\n\n\ntest_loss /= len(test_dataset)\n\ntest_accuracy = accuracy_score(\n    all_targets,\n    all_preds\n)\n\ntest_balanced_accuracy = balanced_accuracy_score(\n    all_targets,\n    all_preds\n)\n\ntest_macro_f1 = f1_score(\n    all_targets,\n    all_preds,\n    average=\"macro\"\n)\n\n\n# ============================================================\n# RESULTS\n# ============================================================\n\nprint(\"\\n\" + \"=\" * 70)\nprint(\"MLP — TEST RESULTS\")\nprint(\"=\" * 70)\n\nprint(\n    f\"Test loss:              {test_loss:.4f}\"\n)\n\nprint(\n    f\"Test accuracy:          {test_accuracy:.4f}\"\n)\n\nprint(\n    f\"Test balanced accuracy: \"\n    f\"{test_balanced_accuracy:.4f}\"\n)\n\nprint(\n    f\"Test macro F1:          \"\n    f\"{test_macro_f1:.4f}\"\n)\n\n\n# ============================================================\n# CLASSIFICATION REPORT\n# ============================================================\n\nprint(\"\\nClassification report:\\n\")\n\nprint(\n    classification_report(\n        all_targets,\n        all_preds,\n        labels=[0, 1, 2, 3, 4],\n        target_names=[\n            \"No DR (0)\",\n            \"Mild (1)\",\n            \"Moderate (2)\",\n            \"Severe (3)\",\n            \"Proliferative (4)\",\n        ],\n        digits=4,\n        zero_division=0,\n    )\n)\n\n\n# ============================================================\n# CONFUSION MATRIX\n# ============================================================\n\ncm = confusion_matrix(\n    all_targets,\n    all_preds,\n    labels=[0, 1, 2, 3, 4],\n)\n\nprint(\"Confusion matrix:\")\nprint(cm)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-24T16:23:36.035156Z","iopub.execute_input":"2026-09-24T16:23:36.035826Z","iopub.status.idle":"2026-09-24T16:23:50.502174Z","shell.execute_reply.started":"2026-09-24T16:23:36.035789Z","shell.execute_reply":"2026-09-24T16:23:50.501317Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# EFFICIENTNET-B0 FULL FINE-TUNING SPEED BENCHMARK\n#\n# Uses YOUR EXISTING:\n#   train_labels_df\n#   RetinopathyDataset\n#\n# Tests:\n#   tile micro-batch = 1\n#   tile micro-batch = 2\n#   tile micro-batch = 4\n#\n# Features:\n#   - full EfficientNet-B0 unfrozen\n#   - ImageNet pretrained weights\n#   - AMP / FP16\n#   - NO gradient checkpointing\n#   - NO feature cache\n#   - NO resizing here\n#   - variable tile sizes supported\n#   - weighted CrossEntropy\n#   - real forward + backward + optimizer.step()\n# ============================================================\n\n\n# ============================================================\n# 1. IMPORTS\n# ============================================================\n\nimport gc\nimport time\nimport math\nimport numpy as np\nimport pandas as pd\n\nimport torch\nimport torch.nn as nn\n\nfrom torch.utils.data import DataLoader\n\nfrom torchvision.models import (\n    efficientnet_b0,\n    EfficientNet_B0_Weights,\n)\n\n\n# ============================================================\n# 2. CONFIG\n# ============================================================\n\nSEED = 42\n\nBENCHMARK_SAMPLES = 100\n\nNUM_CLASSES = 5\nFEATURE_DIM = 1280\n\nLEARNING_RATE = 1e-4\nWEIGHT_DECAY = 1e-4\n\nTARGET_EPOCHS = 20\n\nMICRO_BATCH_SIZES = [\n    1,\n    2,\n    4,\n]\n\n\nDEVICE = torch.device(\n    \"cuda\"\n    if torch.cuda.is_available()\n    else \"cpu\"\n)\n\n\ntorch.manual_seed(SEED)\nnp.random.seed(SEED)\n\n\nprint(\"=\" * 75)\nprint(\"ENVIRONMENT\")\nprint(\"=\" * 75)\n\nprint(\"Device:\", DEVICE)\n\nif torch.cuda.is_available():\n\n    print(\n        \"GPU:\",\n        torch.cuda.get_device_name(0)\n    )\n\n    print(\n        \"GPU total memory:\",\n        f\"{torch.cuda.get_device_properties(0).total_memory / 1024**3:.2f} GB\"\n    )\n\n\n# ============================================================\n# 3. VERIFY REQUIRED OBJECTS\n# ============================================================\n\nif \"train_labels_df\" not in globals():\n\n    raise RuntimeError(\n        \"train_labels_df is not defined. \"\n        \"Run the cell that loads your train CSV first.\"\n    )\n\n\nif \"RetinopathyDataset\" not in globals():\n\n    raise RuntimeError(\n        \"RetinopathyDataset is not defined. \"\n        \"Run the cell containing your working dataset/preprocessing first.\"\n    )\n\n\nprint(\"\\nRequired objects found:\")\nprint(\"train_labels_df:\", len(train_labels_df))\nprint(\"RetinopathyDataset: OK\")\n\n\n# ============================================================\n# 4. DETERMINE ACTUAL LABEL COLUMN\n# ============================================================\n\nif \"level\" not in train_labels_df.columns:\n\n    raise RuntimeError(\n        \"Expected column 'level' in train_labels_df.\"\n    )\n\n\n# ============================================================\n# 5. BENCHMARK SUBSET\n#\n# Speed benchmark only.\n# We don't care about stratification here.\n# ============================================================\n\nbenchmark_df = (\n    train_labels_df\n    .iloc[:BENCHMARK_SAMPLES]\n    .copy()\n    .reset_index(drop=True)\n)\n\n\nprint(\"\\n\" + \"=\" * 75)\nprint(\"BENCHMARK DATA\")\nprint(\"=\" * 75)\n\nprint(\n    \"Requested samples:\",\n    len(benchmark_df)\n)\n\nprint(\"\\nClass distribution:\")\n\nprint(\n    benchmark_df[\"level\"]\n    .value_counts()\n    .sort_index()\n)\n\n\n# ============================================================\n# 6. USE YOUR EXISTING DATASET\n# ============================================================\n\nbenchmark_dataset = RetinopathyDataset(\n    benchmark_df,\n    ARCHIVE\n)\n\n\n# ============================================================\n# 7. COLLATE\n#\n# Dataset returns:\n#\n#   (\n#       list_of_tiles,\n#       label,\n#       image_name\n#   )\n#\n# or None for an invalid image.\n# ============================================================\n\ndef benchmark_collate(batch):\n\n    return batch[0]\n\n\nbenchmark_loader = DataLoader(\n    benchmark_dataset,\n    batch_size=1,\n    shuffle=False,\n    num_workers=0,\n    collate_fn=benchmark_collate,\n)\n\n\n# ============================================================\n# 8. CLASS WEIGHTS FROM FULL TRAINING SET\n# ============================================================\n\nclass_counts = np.bincount(\n    train_labels_df[\n        \"level\"\n    ].to_numpy(),\n    minlength=NUM_CLASSES,\n)\n\n\nclass_weights_np = (\n    len(train_labels_df)\n    /\n    (\n        NUM_CLASSES\n        *\n        class_counts\n    )\n)\n\n\nprint(\"\\n\" + \"=\" * 75)\nprint(\"CLASS WEIGHTS\")\nprint(\"=\" * 75)\n\n\nfor c in range(NUM_CLASSES):\n\n    print(\n        f\"Class {c}: \"\n        f\"count={class_counts[c]:5d} | \"\n        f\"weight={class_weights_np[c]:.4f}\"\n    )\n\n\n# ============================================================\n# 9. MODEL\n#\n# Important:\n#\n# Tiles may have slightly different dimensions:\n#\n#   [3,749,752]\n#   [3,749,755]\n#   ...\n#\n# Therefore a micro-batch can contain ONLY tiles\n# with identical H x W.\n#\n# We group by spatial size and then split each group\n# into micro-batches.\n# ============================================================\n\nclass FullFineTuneModel(nn.Module):\n\n    def __init__(\n        self,\n        backbone,\n        micro_batch_size,\n        feature_dim=1280,\n        num_classes=5,\n    ):\n\n        super().__init__()\n\n        self.backbone = backbone\n\n        self.micro_batch_size = (\n            micro_batch_size\n        )\n\n        self.classifier = nn.Linear(\n            feature_dim,\n            num_classes\n        )\n\n\n    def forward(\n        self,\n        tiles\n    ):\n\n        # ----------------------------------------------------\n        # GROUP TILES BY H x W\n        # ----------------------------------------------------\n\n        groups = {}\n\n        for tile in tiles:\n\n            h = int(\n                tile.shape[-2]\n            )\n\n            w = int(\n                tile.shape[-1]\n            )\n\n            key = (\n                h,\n                w\n            )\n\n            if key not in groups:\n\n                groups[key] = []\n\n            groups[key].append(\n                tile\n            )\n\n\n        # ----------------------------------------------------\n        # ACCUMULATE FEATURE SUM\n        #\n        # We need:\n        #\n        # mean(features)\n        #\n        # so accumulating the sum is enough.\n        # ----------------------------------------------------\n\n        feature_sum = None\n\n        total_tiles = 0\n\n\n        for group_tiles in groups.values():\n\n            # ------------------------------------------------\n            # Divide equal-sized tiles into micro-batches\n            # ------------------------------------------------\n\n            for start in range(\n                0,\n                len(group_tiles),\n                self.micro_batch_size,\n            ):\n\n                chunk = group_tiles[\n                    start:\n                    start + self.micro_batch_size\n                ]\n\n\n                tile_batch = torch.stack(\n                    chunk,\n                    dim=0\n                )\n\n\n                # --------------------------------------------\n                # EfficientNet:\n                #\n                # [B,3,H,W]\n                #      ↓\n                # [B,1280]\n                # --------------------------------------------\n\n                features = self.backbone(\n                    tile_batch\n                )\n\n\n                chunk_sum = features.sum(\n                    dim=0,\n                    keepdim=True\n                )\n\n\n                if feature_sum is None:\n\n                    feature_sum = chunk_sum\n\n                else:\n\n                    feature_sum = (\n                        feature_sum\n                        +\n                        chunk_sum\n                    )\n\n\n                total_tiles += (\n                    features.shape[0]\n                )\n\n\n        if feature_sum is None:\n\n            raise RuntimeError(\n                \"Image has zero valid tiles.\"\n            )\n\n\n        # ----------------------------------------------------\n        # MEAN POOLING\n        # ----------------------------------------------------\n\n        pooled = (\n            feature_sum\n            /\n            total_tiles\n        )\n\n\n        # ----------------------------------------------------\n        # CLASSIFICATION\n        # ----------------------------------------------------\n\n        logits = self.classifier(\n            pooled\n        )\n\n\n        return logits\n\n\n# ============================================================\n# 10. MODEL FACTORY\n#\n# Every benchmark gets a completely fresh model.\n# ============================================================\n\ndef create_model(\n    micro_batch_size\n):\n\n    weights = (\n        EfficientNet_B0_Weights.DEFAULT\n    )\n\n\n    backbone = efficientnet_b0(\n        weights=weights\n    )\n\n\n    # Remove original ImageNet classifier.\n\n    backbone.classifier = nn.Identity()\n\n\n    # --------------------------------------------------------\n    # FULL UNFREEZE\n    # --------------------------------------------------------\n\n    for parameter in backbone.parameters():\n\n        parameter.requires_grad = True\n\n\n    model = FullFineTuneModel(\n        backbone=backbone,\n        micro_batch_size=micro_batch_size,\n        feature_dim=FEATURE_DIM,\n        num_classes=NUM_CLASSES,\n    )\n\n\n    model = model.to(\n        DEVICE\n    )\n\n\n    return model\n\n\n# ============================================================\n# 11. SINGLE MICRO-BATCH BENCHMARK\n# ============================================================\n\ndef run_benchmark(\n    micro_batch_size\n):\n\n    print(\"\\n\")\n    print(\"=\" * 75)\n    print(\n        f\"MICRO-BATCH = {micro_batch_size}\"\n    )\n    print(\"=\" * 75)\n\n\n    # --------------------------------------------------------\n    # CLEAN MEMORY\n    # --------------------------------------------------------\n\n    gc.collect()\n\n\n    if torch.cuda.is_available():\n\n        torch.cuda.empty_cache()\n\n        torch.cuda.reset_peak_memory_stats()\n\n\n    # --------------------------------------------------------\n    # MODEL\n    # --------------------------------------------------------\n\n    model = create_model(\n        micro_batch_size\n    )\n\n\n    total_params = sum(\n        p.numel()\n        for p in model.parameters()\n    )\n\n\n    trainable_params = sum(\n        p.numel()\n        for p in model.parameters()\n        if p.requires_grad\n    )\n\n\n    print(\n        f\"Total parameters:     \"\n        f\"{total_params:,}\"\n    )\n\n    print(\n        f\"Trainable parameters: \"\n        f\"{trainable_params:,}\"\n    )\n\n\n    if (\n        total_params\n        !=\n        trainable_params\n    ):\n\n        raise RuntimeError(\n            \"Backbone is not fully unfrozen.\"\n        )\n\n\n    # --------------------------------------------------------\n    # LOSS\n    # --------------------------------------------------------\n\n    class_weights = torch.tensor(\n        class_weights_np,\n        dtype=torch.float32,\n        device=DEVICE,\n    )\n\n\n    criterion = nn.CrossEntropyLoss(\n        weight=class_weights\n    )\n\n\n    # --------------------------------------------------------\n    # OPTIMIZER\n    # --------------------------------------------------------\n\n    optimizer = torch.optim.AdamW(\n        model.parameters(),\n        lr=LEARNING_RATE,\n        weight_decay=WEIGHT_DECAY,\n    )\n\n\n    # --------------------------------------------------------\n    # AMP\n    # --------------------------------------------------------\n\n    use_amp = (\n        DEVICE.type\n        ==\n        \"cuda\"\n    )\n\n\n    scaler = (\n        torch.amp.GradScaler(\n            \"cuda\"\n        )\n        if use_amp\n        else None\n    )\n\n\n    model.train()\n\n\n    processed = 0\n    skipped = 0\n\n    step_times = []\n\n\n    wall_start = (\n        time.perf_counter()\n    )\n\n\n    # ========================================================\n    # TRAIN\n    # ========================================================\n\n    try:\n\n        for batch_idx, batch in enumerate(\n            benchmark_loader,\n            start=1\n        ):\n\n\n            # ------------------------------------------------\n            # INVALID IMAGE\n            # ------------------------------------------------\n\n            if batch is None:\n\n                skipped += 1\n\n                continue\n\n\n            tiles, label, image_name = batch\n\n\n            # ------------------------------------------------\n            # LABEL\n            # ------------------------------------------------\n\n            if torch.is_tensor(label):\n\n                label = (\n                    label\n                    .long()\n                    .view(-1)\n                    .to(DEVICE)\n                )\n\n            else:\n\n                label = torch.tensor(\n                    [int(label)],\n                    dtype=torch.long,\n                    device=DEVICE,\n                )\n\n\n            # ------------------------------------------------\n            # TILES → GPU\n            #\n            # Still a list because H/W differ.\n            # ------------------------------------------------\n\n            tiles = [\n                tile.to(\n                    DEVICE,\n                    non_blocking=True,\n                )\n                for tile in tiles\n            ]\n\n\n            num_tiles = len(\n                tiles\n            )\n\n\n            optimizer.zero_grad(\n                set_to_none=True\n            )\n\n\n            if torch.cuda.is_available():\n\n                torch.cuda.synchronize()\n\n\n            step_start = (\n                time.perf_counter()\n            )\n\n\n            # =================================================\n            # FORWARD + BACKWARD WITH AMP\n            # =================================================\n\n            if use_amp:\n\n                with torch.autocast(\n                    device_type=\"cuda\",\n                    dtype=torch.float16,\n                ):\n\n                    logits = model(\n                        tiles\n                    )\n\n                    loss = criterion(\n                        logits,\n                        label\n                    )\n\n\n                scaler.scale(\n                    loss\n                ).backward()\n\n\n                scaler.step(\n                    optimizer\n                )\n\n\n                scaler.update()\n\n\n            else:\n\n                logits = model(\n                    tiles\n                )\n\n\n                loss = criterion(\n                    logits,\n                    label\n                )\n\n\n                loss.backward()\n\n\n                optimizer.step()\n\n\n            # ------------------------------------------------\n            # Accurate GPU timing\n            # ------------------------------------------------\n\n            if torch.cuda.is_available():\n\n                torch.cuda.synchronize()\n\n\n            step_time = (\n                time.perf_counter()\n                -\n                step_start\n            )\n\n\n            processed += 1\n\n\n            # Ignore first image because of CUDA warm-up.\n\n            if processed > 1:\n\n                step_times.append(\n                    step_time\n                )\n\n\n            # ------------------------------------------------\n            # REPORT\n            # ------------------------------------------------\n\n            if (\n                processed <= 5\n                or\n                processed % 10 == 0\n            ):\n\n                avg = (\n                    np.mean(step_times)\n                    if step_times\n                    else float(\"nan\")\n                )\n\n\n                if torch.cuda.is_available():\n\n                    allocated = (\n                        torch.cuda.memory_allocated()\n                        /\n                        1024**3\n                    )\n\n                    peak = (\n                        torch.cuda.max_memory_allocated()\n                        /\n                        1024**3\n                    )\n\n                else:\n\n                    allocated = 0\n                    peak = 0\n\n\n                print(\n                    f\"{processed:3d}/{BENCHMARK_SAMPLES} | \"\n                    f\"image={str(image_name):15s} | \"\n                    f\"tiles={num_tiles:2d} | \"\n                    f\"loss={loss.item():.4f} | \"\n                    f\"step={step_time:.3f}s | \"\n                    f\"avg={avg:.3f}s | \"\n                    f\"VRAM={allocated:.2f}GB | \"\n                    f\"peak={peak:.2f}GB\"\n                )\n\n\n    # ========================================================\n    # HANDLE CUDA OOM\n    # ========================================================\n\n    except torch.OutOfMemoryError:\n\n        print(\n            \"\\n*** CUDA OUT OF MEMORY ***\"\n        )\n\n\n        del model\n        del optimizer\n        del criterion\n\n\n        gc.collect()\n\n\n        if torch.cuda.is_available():\n\n            torch.cuda.empty_cache()\n\n\n        return {\n            \"micro_batch\": micro_batch_size,\n            \"status\": \"OOM\",\n            \"processed\": processed,\n        }\n\n\n    # ========================================================\n    # RESULTS\n    # ========================================================\n\n    if torch.cuda.is_available():\n\n        torch.cuda.synchronize()\n\n\n    wall_time = (\n        time.perf_counter()\n        -\n        wall_start\n    )\n\n\n    if len(step_times) == 0:\n\n        result = {\n            \"micro_batch\": micro_batch_size,\n            \"status\": \"NO_DATA\",\n            \"processed\": processed,\n        }\n\n\n    else:\n\n        mean_step = float(\n            np.mean(step_times)\n        )\n\n\n        median_step = float(\n            np.median(step_times)\n        )\n\n\n        p95_step = float(\n            np.percentile(\n                step_times,\n                95\n            )\n        )\n\n\n        peak_vram = (\n            torch.cuda.max_memory_allocated()\n            /\n            1024**3\n            if torch.cuda.is_available()\n            else 0\n        )\n\n\n        # ----------------------------------------------------\n        # IMPORTANT:\n        #\n        # End-to-end wall time must be divided by the number\n        # of actually processed + skipped requested samples.\n        #\n        # For a valid benchmark we expect almost all 100\n        # samples to be processed.\n        # ----------------------------------------------------\n\n        requested_seen = (\n            processed\n            +\n            skipped\n        )\n\n\n        wall_per_sample = (\n            wall_time\n            /\n            requested_seen\n        )\n\n\n        full_train_size = len(\n            train_labels_df\n        )\n\n\n        epoch_seconds = (\n            wall_per_sample\n            *\n            full_train_size\n        )\n\n\n        twenty_epochs_seconds = (\n            epoch_seconds\n            *\n            TARGET_EPOCHS\n        )\n\n\n        result = {\n\n            \"micro_batch\":\n                micro_batch_size,\n\n            \"status\":\n                \"OK\",\n\n            \"processed\":\n                processed,\n\n            \"skipped\":\n                skipped,\n\n            \"mean_step_s\":\n                mean_step,\n\n            \"median_step_s\":\n                median_step,\n\n            \"p95_step_s\":\n                p95_step,\n\n            \"peak_vram_gb\":\n                peak_vram,\n\n            \"benchmark_wall_min\":\n                wall_time / 60,\n\n            \"epoch_hours\":\n                epoch_seconds / 3600,\n\n            \"20_epochs_hours\":\n                twenty_epochs_seconds / 3600,\n        }\n\n\n    # --------------------------------------------------------\n    # DELETE MODEL BEFORE NEXT CONFIGURATION\n    # --------------------------------------------------------\n\n    del model\n    del optimizer\n    del criterion\n\n\n    gc.collect()\n\n\n    if torch.cuda.is_available():\n\n        torch.cuda.empty_cache()\n\n\n    return result\n\n\n# ============================================================\n# 12. RUN BENCHMARKS\n# ============================================================\n\nresults = []\n\n\nfor micro_batch_size in MICRO_BATCH_SIZES:\n\n    result = run_benchmark(\n        micro_batch_size\n    )\n\n    results.append(\n        result\n    )\n\n\n# ============================================================\n# 13. SUMMARY\n# ============================================================\n\nprint(\"\\n\\n\")\nprint(\"=\" * 95)\nprint(\"FINAL BENCHMARK SUMMARY\")\nprint(\"=\" * 95)\n\n\nfor result in results:\n\n    print(\n        \"\\nMicro-batch:\",\n        result[\"micro_batch\"]\n    )\n\n\n    print(\n        \"Status:\",\n        result[\"status\"]\n    )\n\n\n    if result[\"status\"] == \"OK\":\n\n        print(\n            f\"Processed:       \"\n            f\"{result['processed']}\"\n        )\n\n        print(\n            f\"Skipped:         \"\n            f\"{result['skipped']}\"\n        )\n\n        print(\n            f\"Mean step:       \"\n            f\"{result['mean_step_s']:.3f} s/image\"\n        )\n\n        print(\n            f\"Median step:     \"\n            f\"{result['median_step_s']:.3f} s/image\"\n        )\n\n        print(\n            f\"Peak VRAM:       \"\n            f\"{result['peak_vram_gb']:.2f} GB\"\n        )\n\n        print(\n            f\"Benchmark time:  \"\n            f\"{result['benchmark_wall_min']:.2f} min\"\n        )\n\n        print(\n            f\"Estimated epoch: \"\n            f\"{result['epoch_hours']:.2f} h\"\n        )\n\n        print(\n            f\"Estimated 20 ep: \"\n            f\"{result['20_epochs_hours']:.2f} h\"\n        )\n\n\nprint(\"\\n\" + \"=\" * 95)\n\n\n# ============================================================\n# 14. FIND FASTEST SUCCESSFUL CONFIGURATION\n# ============================================================\n\nsuccessful = [\n    result\n    for result in results\n    if result[\"status\"] == \"OK\"\n]\n\n\nif successful:\n\n    fastest = min(\n        successful,\n        key=lambda x: x[\n            \"mean_step_s\"\n        ]\n    )\n\n\n    print(\n        \"\\nFastest successful configuration:\"\n    )\n\n    print(\n        \"Micro-batch:\",\n        fastest[\"micro_batch\"]\n    )\n\n    print(\n        f\"Mean step: \"\n        f\"{fastest['mean_step_s']:.3f} s/image\"\n    )\n\n    print(\n        f\"Peak VRAM: \"\n        f\"{fastest['peak_vram_gb']:.2f} GB\"\n    )\n\n    print(\n        f\"Estimated 1 epoch: \"\n        f\"{fastest['epoch_hours']:.2f} h\"\n    )\n\n    print(\n        f\"Estimated 20 epochs: \"\n        f\"{fastest['20_epochs_hours']:.2f} h\"\n    )\n\n\nprint(\"\\nBenchmark finished.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-25T09:52:20.408069Z","iopub.execute_input":"2026-09-25T09:52:20.408534Z","iopub.status.idle":"2026-09-25T09:52:20.703388Z","shell.execute_reply.started":"2026-09-25T09:52:20.408504Z","shell.execute_reply":"2026-09-25T09:52:20.702426Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# EKSPERYMENTY v9\n\n**Sekcja A** – eksperymenty na istniejącym cache cech (`retinopathy_feature_cache_train/test`, 12–16 kafelków, ROI ≥ 40%). Trwa kilka–kilkanaście minut, nie wymaga dostępu do zdjęć.\n\n**Sekcja B** – nowy cache (wszystkie 16 kafelków + wariant z normalizacją skali) oraz częściowy fine-tuning EfficientNet-B0. Wymaga GPU i trwa kilka godzin; jest wznawialna (shardy).\n\nZmiany w protokole względem wcześniejszych eksperymentów:\n* zbiór walidacyjny wydzielony z treningowego **na poziomie pacjenta** (15%); wybór epoki i progów odbywa się na walidacji, test jest używany tylko do raportowania,\n* każdy wariant jest uruchamiany na **5 ziarnach** – raportujemy średnią ± odchylenie standardowe,\n* liczone są zawsze: accuracy, balanced accuracy, macro F1, **QWK**, macierz pomyłek (dla zadań binarnych: AUROC, czułość, swoistość).\n\nWyniki trafiają do `/kaggle/working/experiments_v9/` (`results.csv`, `results_table.tex`, wykresy PNG).\n\n**Kolejność:** A0 → A8, potem ewentualnie B0 → B4. Sekcja A jest samodzielna (ma własne importy), nie trzeba uruchamiać wcześniejszych komórek notebooka. Sekcja B korzysta z funkcji i podziału walidacyjnego z sekcji A, więc najpierw trzeba uruchomić A0–A5.","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# A0. KONFIGURACJA I WCZYTANIE CACHE CECH\n# ============================================================\n\nimport os\nimport glob\nimport json\nimport time\nimport copy\nimport random\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport torch\nimport torch.nn as nn\n\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import (\n    accuracy_score,\n    balanced_accuracy_score,\n    f1_score,\n    cohen_kappa_score,\n    confusion_matrix,\n    roc_auc_score,\n    roc_curve,\n)\n\nEXP_DIR = \"/kaggle/working/experiments_v9\"\nos.makedirs(EXP_DIR, exist_ok=True)\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nNUM_CLASSES = 5\nFEATURE_DIM = 1280\n\nVAL_SIZE = 0.15      # część pacjentów treningowych przeznaczona na walidację\nSPLIT_SEED = 42\nSEEDS = [0, 1, 2, 3, 4]\n\nprint(\"Device:\", DEVICE)\n\n\ndef load_tile_cache(cache_dir):\n    \"\"\"\n    Wczytuje cache cech kafelków zapisany w shardach .npz.\n\n    Zwraca:\n        feats : lista tablic [num_tiles, 1280] (float32)\n        df    : DataFrame z kolumnami image, level, patient_id, eye, num_tiles\n        extra : słownik z dodatkowymi polami (np. roi_ratios, positions),\n                jeśli cache je zawiera (nowy cache z sekcji B)\n    \"\"\"\n    paths = sorted(\n        p for p in glob.glob(os.path.join(cache_dir, \"shard_*.npz\"))\n        if \"_metadata\" not in p and \"_skipped\" not in p\n    )\n\n    if not paths:\n        raise FileNotFoundError(f\"Brak shardów w {cache_dir}\")\n\n    feats, labels, names = [], [], []\n    extra = {}\n\n    for path in paths:\n        data = np.load(path, allow_pickle=True)\n\n        feats.extend(np.asarray(f, dtype=np.float32) for f in data[\"features\"])\n        labels.extend(int(v) for v in data[\"labels\"])\n        names.extend(str(v) for v in data[\"image_names\"])\n\n        for key in data.files:\n            if key not in (\"features\", \"labels\", \"image_names\", \"num_tiles\"):\n                extra.setdefault(key, []).extend(list(data[key]))\n\n    df = pd.DataFrame({\"image\": names, \"level\": np.asarray(labels, dtype=np.int64)})\n    df[\"patient_id\"] = df[\"image\"].str.extract(r\"^(\\d+)_\")[0].astype(int)\n    df[\"eye\"] = np.where(df[\"image\"].str.endswith(\"left\"), \"left\", \"right\")\n    df[\"num_tiles\"] = [f.shape[0] for f in feats]\n\n    print(f\"{cache_dir}: {len(paths)} shardów, {len(df)} zdjęć\")\n\n    return feats, df, extra\n\n\ndef load_bundle(train_dir, test_dir):\n    tr_feats, tr_df, tr_extra = load_tile_cache(train_dir)\n    te_feats, te_df, te_extra = load_tile_cache(test_dir)\n\n    return dict(\n        tr_feats=tr_feats, tr_df=tr_df, tr_extra=tr_extra,\n        te_feats=te_feats, te_df=te_df, te_extra=te_extra,\n    )\n\n\ndef find_cache_dir(dir_name, roots=(\"/kaggle/working\", \"/kaggle/input\")):\n    \"\"\"\n    Szuka katalogu cache o podanej nazwie: najpierw w /kaggle/working,\n    potem (rekurencyjnie) w /kaggle/input – tam trafia output\n    poprzedniej wersji notebooka lub dataset dodany jako input.\n    \"\"\"\n    for root in roots:\n        direct = os.path.join(root, dir_name)\n\n        if glob.glob(os.path.join(direct, \"shard_*.npz\")):\n            return direct\n\n        for hit in glob.glob(os.path.join(root, \"**\", dir_name), recursive=True):\n            if glob.glob(os.path.join(hit, \"shard_*.npz\")):\n                return hit\n\n    return None\n\n\ndef filter_bundle_by_roi(bundle, roi_min):\n    \"\"\"Zostawia tylko kafelki z pokryciem fundusem >= roi_min (cache z sekcji B).\"\"\"\n    out = dict(bundle)\n\n    for split in (\"tr\", \"te\"):\n        feats, rois = [], []\n\n        for f, r in zip(bundle[f\"{split}_feats\"], bundle[f\"{split}_extra\"][\"roi_ratios\"]):\n            r = np.asarray(r, dtype=np.float32)\n            keep = r >= roi_min\n\n            if not keep.any():\n                keep = r == r.max()\n\n            feats.append(f[keep])\n            rois.append(r[keep])\n\n        out[f\"{split}_feats\"] = feats\n        out[f\"{split}_extra\"] = {**bundle[f\"{split}_extra\"], \"roi_ratios\": rois}\n        out[f\"{split}_df\"] = bundle[f\"{split}_df\"].assign(num_tiles=[f.shape[0] for f in feats])\n\n    return out\n\n\n# DATA przechowuje różne źródła cech (oryginalny cache, nowe cache z sekcji B, ...)\nif \"DATA\" not in globals():\n    DATA = {}\n\nif \"orig12\" not in DATA:\n    t0 = time.time()\n    TRAIN_CACHE_DIR = find_cache_dir(\"retinopathy_feature_cache_train\")\n    TEST_CACHE_DIR = find_cache_dir(\"retinopathy_feature_cache_test\")\n    NATIVE_TRAIN_DIR = find_cache_dir(\"cache_v9_native16_train\")\n    NATIVE_TEST_DIR = find_cache_dir(\"cache_v9_native16_test\")\n\n    if TRAIN_CACHE_DIR and TEST_CACHE_DIR:\n        # oryginalny cache (12–16 kafelków, ROI >= 40%)\n        DATA[\"orig12\"] = load_bundle(TRAIN_CACHE_DIR, TEST_CACHE_DIR)\n\n    elif NATIVE_TRAIN_DIR and NATIVE_TEST_DIR:\n        # brak oryginalnego cache -> odtwarzamy go z nowego cache (B1) filtrem ROI >= 40%\n        print(\"Brak oryginalnego cache – używam cache_v9_native16 z filtrem ROI >= 40%.\")\n        DATA[\"native16\"] = load_bundle(NATIVE_TRAIN_DIR, NATIVE_TEST_DIR)\n        DATA[\"orig12\"] = filter_bundle_by_roi(DATA[\"native16\"], 0.40)\n\n    else:\n        raise FileNotFoundError(\n            \"Nie znaleziono cache cech ani w /kaggle/working, ani w /kaggle/input.\\n\"\n            \"Jeśli cache przepadł razem z sesją: uruchom komórki B0 i B1 (działają po tej komórce, \"\n            \"importy i DEVICE są już zdefiniowane), a potem uruchom tę komórkę ponownie.\"\n        )\n\n    print(f\"Wczytano w {time.time() - t0:.1f} s\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# A1. ZBIÓR WALIDACYJNY (NA POZIOMIE PACJENTA)\n#\n# Pacjenci treningowi są dzieleni 85/15 ze stratyfikacją po\n# profilu obu oczu (tak jak przy podziale train/test).\n# Lista pacjentów walidacyjnych jest zapisywana do CSV, żeby\n# używać tego samego podziału w sekcji B i w uczeniu federacyjnym.\n# ============================================================\n\nVAL_SPLIT_PATH = os.path.join(EXP_DIR, \"val_patients.csv\")\n\n\ndef make_val_patients(train_df, val_size=VAL_SIZE, seed=SPLIT_SEED):\n    prof = (\n        train_df.groupby(\"patient_id\")[\"level\"]\n        .apply(lambda x: \"_\".join(map(str, sorted(x.tolist()))))\n        .rename(\"profile\")\n        .reset_index()\n    )\n\n    # rzadkie profile łączymy w jedną grupę, żeby stratyfikacja była możliwa\n    counts = prof[\"profile\"].value_counts()\n    prof[\"strat\"] = np.where(prof[\"profile\"].map(counts) >= 10, prof[\"profile\"], \"rare\")\n\n    if (prof[\"strat\"] == \"rare\").sum() < 2:\n        prof.loc[prof[\"strat\"] == \"rare\", \"strat\"] = counts.index[0]\n\n    _, val_p = train_test_split(\n        prof[\"patient_id\"],\n        test_size=val_size,\n        random_state=seed,\n        stratify=prof[\"strat\"],\n    )\n\n    return set(int(p) for p in val_p)\n\n\nif os.path.exists(VAL_SPLIT_PATH):\n    VAL_PATIENTS = set(pd.read_csv(VAL_SPLIT_PATH)[\"patient_id\"].astype(int))\n    print(\"Wczytano istniejący podział walidacyjny.\")\nelse:\n    VAL_PATIENTS = make_val_patients(DATA[\"orig12\"][\"tr_df\"])\n    pd.DataFrame({\"patient_id\": sorted(VAL_PATIENTS)}).to_csv(VAL_SPLIT_PATH, index=False)\n    print(\"Zapisano podział walidacyjny:\", VAL_SPLIT_PATH)\n\n_df = DATA[\"orig12\"][\"tr_df\"]\n_is_val = _df[\"patient_id\"].isin(VAL_PATIENTS)\n\nprint(f\"Pacjenci walidacyjni: {len(VAL_PATIENTS)}\")\nprint(f\"Zdjęcia: train={int((~_is_val).sum())}, val={int(_is_val.sum())}\")\nprint(\"Rozkład klas train:\", np.bincount(_df.loc[~_is_val, \"level\"], minlength=NUM_CLASSES))\nprint(\"Rozkład klas val:  \", np.bincount(_df.loc[_is_val, \"level\"], minlength=NUM_CLASSES))","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# A2. METRYKI\n# ============================================================\n\ndef multiclass_metrics(y_true, y_pred):\n    labels = list(range(NUM_CLASSES))\n\n    return {\n        \"acc\": accuracy_score(y_true, y_pred),\n        \"bal_acc\": balanced_accuracy_score(y_true, y_pred),\n        \"macro_f1\": f1_score(y_true, y_pred, labels=labels, average=\"macro\", zero_division=0),\n        \"qwk\": cohen_kappa_score(y_true, y_pred, labels=labels, weights=\"quadratic\"),\n        \"f1_per_class\": f1_score(y_true, y_pred, labels=labels, average=None, zero_division=0).round(4).tolist(),\n        \"recall_per_class\": (\n            np.diag(confusion_matrix(y_true, y_pred, labels=labels))\n            / np.maximum(np.bincount(y_true, minlength=NUM_CLASSES), 1)\n        ).round(4).tolist(),\n        \"cm\": confusion_matrix(y_true, y_pred, labels=labels).tolist(),\n    }\n\n\ndef youden_threshold(y_true, scores):\n    \"\"\"Próg maksymalizujący czułość + swoistość - 1 (wybierany na walidacji).\"\"\"\n    fpr, tpr, thr = roc_curve(y_true, scores)\n    best = np.argmax(tpr - fpr)\n    return float(thr[best])\n\n\ndef binary_metrics(y_true, scores, threshold):\n    y_true = np.asarray(y_true).astype(int)\n    y_pred = (scores >= threshold).astype(int)\n\n    tp = int(((y_pred == 1) & (y_true == 1)).sum())\n    tn = int(((y_pred == 0) & (y_true == 0)).sum())\n    fp = int(((y_pred == 1) & (y_true == 0)).sum())\n    fn = int(((y_pred == 0) & (y_true == 1)).sum())\n\n    sens = tp / max(tp + fn, 1)\n    spec = tn / max(tn + fp, 1)\n\n    return {\n        \"auroc\": roc_auc_score(y_true, scores),\n        \"sens\": sens,\n        \"spec\": spec,\n        \"bal_acc\": (sens + spec) / 2,\n        \"f1\": f1_score(y_true, y_pred, zero_division=0),\n        \"acc\": (tp + tn) / len(y_true),\n        \"threshold\": threshold,\n        \"cm\": [[tn, fp], [fn, tp]],\n    }\n\n\nDEFAULT_THRESHOLDS = np.array([0.5, 1.5, 2.5, 3.5])\n\n\ndef optimize_thresholds(y_true, scores, n_passes=3, n_grid=60):\n    \"\"\"\n    Dobór progów zamieniających wynik regresji na klasy 0–4 tak,\n    aby zmaksymalizować QWK (przeszukiwanie po współrzędnych).\n    \"\"\"\n    thr = DEFAULT_THRESHOLDS.copy()\n\n    def qwk(t):\n        return cohen_kappa_score(y_true, np.digitize(scores, t), weights=\"quadratic\")\n\n    best = qwk(thr)\n\n    for _ in range(n_passes):\n        for i in range(len(thr)):\n            lo = thr[i - 1] if i > 0 else min(scores.min(), thr[0])\n            hi = thr[i + 1] if i < len(thr) - 1 else max(scores.max(), thr[-1])\n\n            if hi - lo < 2e-3:\n                continue\n\n            for cand in np.linspace(lo + 1e-3, hi - 1e-3, n_grid):\n                t = thr.copy()\n                t[i] = cand\n\n                if np.any(np.diff(t) <= 0):\n                    continue\n\n                score = qwk(t)\n\n                if score > best:\n                    best, thr = score, t\n\n    return thr","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# A3. BUDOWA CECH ZDJĘCIA\n#\n# pooling:\n#   \"mean\"    – średnia po kafelkach (jak dotychczas)\n#   \"max\"     – maksimum po kafelkach\n#   \"meanmax\" – konkatenacja średniej i maksimum (2560 cech)\n#\n# fellow_eye=True – do cech zdjęcia doklejane są cechy drugiego\n#   oka tego samego pacjenta (stan obu oczu jest silnie\n#   skorelowany, a w badaniu przesiewowym oba zdjęcia są dostępne).\n#   Podział jest na poziomie pacjenta, więc drugie oko jest zawsze\n#   w tym samym zbiorze.\n#\n# roi_min – filtracja kafelków po pokryciu fundusem (tylko dla\n#   cache zawierającego roi_ratios, czyli z sekcji B).\n# ============================================================\n\ndef pool_tiles(tile_feats, pooling):\n    if pooling == \"mean\":\n        return np.stack([f.mean(axis=0) for f in tile_feats])\n\n    if pooling == \"max\":\n        return np.stack([f.max(axis=0) for f in tile_feats])\n\n    if pooling == \"meanmax\":\n        return np.stack([np.concatenate([f.mean(axis=0), f.max(axis=0)]) for f in tile_feats])\n\n    raise ValueError(pooling)\n\n\ndef build_features(feats, df, extra, pooling=\"mean\", fellow_eye=False, roi_min=None):\n    if roi_min is not None:\n        if \"roi_ratios\" not in extra:\n            raise ValueError(\"Ten cache nie zawiera roi_ratios – filtracja niemożliwa.\")\n\n        selected = []\n\n        for f, r in zip(feats, extra[\"roi_ratios\"]):\n            r = np.asarray(r, dtype=np.float32)\n            keep = r >= roi_min\n\n            if not keep.any():\n                keep = r == r.max()\n\n            selected.append(f[keep])\n\n        feats = selected\n\n    X = pool_tiles(feats, pooling)\n\n    if fellow_eye:\n        key = df[\"patient_id\"].astype(str) + \"_\" + df[\"eye\"]\n        position = dict(zip(key, range(len(df))))\n        other_eye = np.where(df[\"eye\"] == \"left\", \"right\", \"left\")\n        other_key = df[\"patient_id\"].astype(str) + \"_\" + other_eye\n\n        # brak drugiego oka (np. pominięte zdjęcie) -> używamy cech tego samego oka\n        other_idx = np.array([position.get(k, i) for i, k in enumerate(other_key)])\n        X = np.concatenate([X, X[other_idx]], axis=1)\n\n    return X.astype(np.float32)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# A4. TRENING KLASYFIKATORA NA CECHACH\n#\n# task:\n#   \"multiclass\" – 5 klas, (ważona) entropia krzyżowa\n#   \"regression\" – jedno wyjście, MSE, klasy przez progi\n#                  dobrane na walidacji pod QWK\n#   \"binary\"     – klasy >= binary_pos vs reszta, BCE\n#                  (binary_pos=2: 0–1 vs 2–4, binary_pos=1: 0 vs 1–4)\n#\n# Wybór epoki (early stopping) na zbiorze walidacyjnym:\n#   multiclass – macro F1, regression – QWK, binary – AUROC\n# ============================================================\n\nDEFAULT_CFG = dict(\n    pooling=\"mean\",\n    fellow_eye=False,\n    roi_min=None,\n    task=\"multiclass\",\n    binary_pos=2,\n    standardize=True,\n    weighted=True,\n    hidden=None,\n    dropout=0.3,\n    lr=1e-3,\n    weight_decay=1e-4,\n    batch_size=256,\n    max_epochs=60,\n    patience=10,\n)\n\n\ndef set_seed(seed):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n\n\nclass Head(nn.Module):\n    def __init__(self, in_dim, out_dim, hidden=None, dropout=0.3):\n        super().__init__()\n\n        if hidden:\n            self.net = nn.Sequential(\n                nn.Linear(in_dim, hidden),\n                nn.ReLU(),\n                nn.Dropout(dropout),\n                nn.Linear(hidden, out_dim),\n            )\n        else:\n            self.net = nn.Linear(in_dim, out_dim)\n\n    def forward(self, x):\n        return self.net(x)\n\n\ndef class_weights(y, num_classes):\n    counts = np.bincount(y, minlength=num_classes)\n    return len(y) / (num_classes * np.maximum(counts, 1))\n\n\n@torch.no_grad()\ndef predict_scores(model, X, mu, sd, task):\n    model.eval()\n    Xt = torch.tensor((X - mu) / sd, dtype=torch.float32, device=DEVICE)\n    out = model(Xt)\n\n    if task == \"multiclass\":\n        return torch.softmax(out, dim=1).cpu().numpy()\n\n    if task == \"binary\":\n        return torch.sigmoid(out.squeeze(1)).cpu().numpy()\n\n    return out.squeeze(1).cpu().numpy()\n\n\ndef validation_score(task, y_val, scores):\n    if task == \"multiclass\":\n        return f1_score(y_val, scores.argmax(1), average=\"macro\", zero_division=0)\n\n    if task == \"binary\":\n        return roc_auc_score(y_val, scores)\n\n    return cohen_kappa_score(y_val, np.digitize(scores, DEFAULT_THRESHOLDS), weights=\"quadratic\")\n\n\ndef train_head(X_tr, y_tr, X_va, y_va, cfg, seed):\n    set_seed(seed)\n    task = cfg[\"task\"]\n\n    if cfg[\"standardize\"]:\n        mu = X_tr.mean(axis=0, keepdims=True)\n        sd = X_tr.std(axis=0, keepdims=True) + 1e-6\n    else:\n        mu = np.zeros((1, X_tr.shape[1]), dtype=np.float32)\n        sd = np.ones((1, X_tr.shape[1]), dtype=np.float32)\n\n    Xt = torch.tensor((X_tr - mu) / sd, dtype=torch.float32, device=DEVICE)\n\n    if task == \"multiclass\":\n        out_dim = NUM_CLASSES\n        yt = torch.tensor(y_tr, dtype=torch.long, device=DEVICE)\n        w = class_weights(y_tr, NUM_CLASSES) if cfg[\"weighted\"] else np.ones(NUM_CLASSES)\n        criterion = nn.CrossEntropyLoss(weight=torch.tensor(w, dtype=torch.float32, device=DEVICE))\n        loss_fn = lambda out, idx: criterion(out, yt[idx])\n\n    elif task == \"binary\":\n        out_dim = 1\n        yt = torch.tensor(y_tr, dtype=torch.float32, device=DEVICE)\n        pos = max(float(y_tr.sum()), 1.0)\n        pos_weight = (len(y_tr) - pos) / pos if cfg[\"weighted\"] else 1.0\n        criterion = nn.BCEWithLogitsLoss(pos_weight=torch.tensor(pos_weight, device=DEVICE))\n        loss_fn = lambda out, idx: criterion(out.squeeze(1), yt[idx])\n\n    elif task == \"regression\":\n        out_dim = 1\n        yt = torch.tensor(y_tr, dtype=torch.float32, device=DEVICE)\n        w = class_weights(y_tr.astype(int), NUM_CLASSES) if cfg[\"weighted\"] else np.ones(NUM_CLASSES)\n        sample_w = torch.tensor(w[y_tr.astype(int)], dtype=torch.float32, device=DEVICE)\n        loss_fn = lambda out, idx: (sample_w[idx] * (out.squeeze(1) - yt[idx]) ** 2).sum() / sample_w[idx].sum()\n\n    else:\n        raise ValueError(task)\n\n    model = Head(X_tr.shape[1], out_dim, cfg[\"hidden\"], cfg[\"dropout\"]).to(DEVICE)\n    optimizer = torch.optim.Adam(model.parameters(), lr=cfg[\"lr\"], weight_decay=cfg[\"weight_decay\"])\n    generator = torch.Generator(device=\"cpu\").manual_seed(seed)\n\n    best_score, best_state, best_epoch, bad_epochs = -np.inf, None, 0, 0\n    history = []\n\n    for epoch in range(1, cfg[\"max_epochs\"] + 1):\n        model.train()\n        order = torch.randperm(len(Xt), generator=generator).to(DEVICE)\n        epoch_loss = 0.0\n\n        for start in range(0, len(order), cfg[\"batch_size\"]):\n            idx = order[start:start + cfg[\"batch_size\"]]\n            optimizer.zero_grad()\n            loss = loss_fn(model(Xt[idx]), idx)\n            loss.backward()\n            optimizer.step()\n            epoch_loss += loss.item() * len(idx)\n\n        val_scores = predict_scores(model, X_va, mu, sd, task)\n        score = validation_score(task, y_va, val_scores)\n        history.append({\"epoch\": epoch, \"train_loss\": epoch_loss / len(Xt), \"val_score\": score})\n\n        if score > best_score:\n            best_score, best_epoch, bad_epochs = score, epoch, 0\n            best_state = copy.deepcopy(model.state_dict())\n        else:\n            bad_epochs += 1\n\n            if bad_epochs >= cfg[\"patience\"]:\n                break\n\n    model.load_state_dict(best_state)\n\n    return model, mu, sd, best_epoch, best_score, history","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# A5. URUCHAMIANIE EKSPERYMENTU (KILKA ZIAREN)\n# ============================================================\n\nif \"RESULTS\" not in globals():\n    RESULTS = {}\n\n\ndef summarize(per_seed):\n    keys = [k for k, v in per_seed[0].items() if isinstance(v, (int, float, np.floating))]\n    out = {}\n\n    for k in keys:\n        values = np.array([m[k] for m in per_seed], dtype=float)\n        out[f\"{k}_mean\"] = values.mean()\n        out[f\"{k}_std\"] = values.std()\n\n    return out\n\n\ndef run_experiment(name, source=\"orig12\", seeds=None, verbose=True, **overrides):\n    seeds = SEEDS if seeds is None else seeds\n    cfg = {**DEFAULT_CFG, **overrides}\n    task = cfg[\"task\"]\n    bundle = DATA[source]\n    t0 = time.time()\n\n    X_all = build_features(bundle[\"tr_feats\"], bundle[\"tr_df\"], bundle[\"tr_extra\"],\n                           cfg[\"pooling\"], cfg[\"fellow_eye\"], cfg[\"roi_min\"])\n    X_te = build_features(bundle[\"te_feats\"], bundle[\"te_df\"], bundle[\"te_extra\"],\n                          cfg[\"pooling\"], cfg[\"fellow_eye\"], cfg[\"roi_min\"])\n\n    y_all = bundle[\"tr_df\"][\"level\"].to_numpy()\n    y_te = bundle[\"te_df\"][\"level\"].to_numpy()\n    is_val = bundle[\"tr_df\"][\"patient_id\"].isin(VAL_PATIENTS).to_numpy()\n\n    if task == \"binary\":\n        target_all = (y_all >= cfg[\"binary_pos\"]).astype(np.float32)\n        target_te = (y_te >= cfg[\"binary_pos\"]).astype(int)\n    elif task == \"regression\":\n        target_all = y_all.astype(np.float32)\n        target_te = y_te\n    else:\n        target_all = y_all\n        target_te = y_te\n\n    X_tr, X_va = X_all[~is_val], X_all[is_val]\n    t_tr, t_va = target_all[~is_val], target_all[is_val]\n\n    per_seed = []\n    test_scores = []\n\n    for seed in seeds:\n        model, mu, sd, best_epoch, best_val, _ = train_head(X_tr, t_tr, X_va, t_va, cfg, seed)\n        s_va = predict_scores(model, X_va, mu, sd, task)\n        s_te = predict_scores(model, X_te, mu, sd, task)\n\n        if task == \"multiclass\":\n            m = multiclass_metrics(y_te, s_te.argmax(1))\n        elif task == \"regression\":\n            thr = optimize_thresholds(t_va.astype(int), s_va)\n            m = multiclass_metrics(y_te, np.digitize(s_te, thr))\n            m[\"thresholds\"] = np.round(thr, 3).tolist()\n        else:\n            thr = youden_threshold(t_va, s_va)\n            m = binary_metrics(target_te, s_te, thr)\n\n        m[\"best_epoch\"] = best_epoch\n        m[\"val_score\"] = best_val\n        per_seed.append(m)\n        test_scores.append(s_te)\n\n    summary = summarize(per_seed)\n    RESULTS[name] = dict(\n        name=name,\n        source=source,\n        cfg=cfg,\n        n_features=X_all.shape[1],\n        per_seed=per_seed,\n        summary=summary,\n        test_scores_seed0=test_scores[0],\n        test_targets=target_te,\n    )\n\n    with open(os.path.join(EXP_DIR, f\"{name}.json\"), \"w\") as fh:\n        json.dump(\n            {k: v for k, v in RESULTS[name].items() if k not in (\"test_scores_seed0\", \"test_targets\")},\n            fh, indent=2, default=float,\n        )\n\n    if verbose:\n        if task == \"binary\":\n            keys = [\"auroc\", \"sens\", \"spec\", \"bal_acc\"]\n        else:\n            keys = [\"acc\", \"bal_acc\", \"macro_f1\", \"qwk\"]\n\n        line = \" | \".join(f\"{k}={summary[k + '_mean']:.4f}±{summary[k + '_std']:.4f}\" for k in keys)\n        print(f\"{name:<36s} {line}  [{time.time() - t0:.0f} s]\")\n\n    return RESULTS[name]","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### A6. Eksperymenty\n\n* **E0–E3** – odtworzenie wariantów z rozdziału 5 (CE bez wag, ważona CE, max pooling, MLP) w nowym protokole (walidacja + 5 ziaren). Dają brakujące wartości QWK do tabel w pracy.\n* **E4–E8** – próby poprawy: standaryzacja cech, mean‖max pooling, cechy drugiego oka, MLP, regresja z progami dobieranymi pod QWK.\n* **B1–B4** – zadania binarne: 0–1 vs 2–4 (retinopatia wymagająca konsultacji) oraz 0 vs 1–4 (dowolna retinopatia).\n\nAttention pooling nie jest tu powtarzany (pracuje na poziomie kafelków, a nie zagregowanych cech). Żeby uzupełnić dla niego QWK w pracy, wystarczy w komórce z attention pooling dodać `cohen_kappa_score(y_true, y_pred, weights=\"quadratic\")`.","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# A6. EKSPERYMENTY NA ORYGINALNYM CACHE (12–16 KAFELKÓW)\n# ============================================================\n\n# --- odtworzenie wariantów z pracy (bez standaryzacji, jak wcześniej)\nrun_experiment(\"E0_mean_linear_CE\", pooling=\"mean\", weighted=False, standardize=False)\nrun_experiment(\"E1_mean_linear_wCE\", pooling=\"mean\", standardize=False)\nrun_experiment(\"E2_max_linear_wCE\", pooling=\"max\", standardize=False)\nrun_experiment(\"E3_mean_MLP_wCE\", pooling=\"mean\", standardize=False, hidden=512)\n\n# --- próby poprawy\nrun_experiment(\"E4_mean_linear_wCE_std\", pooling=\"mean\")\nrun_experiment(\"E5_meanmax_linear_wCE_std\", pooling=\"meanmax\")\nrun_experiment(\"E6_meanmax_fellow_wCE_std\", pooling=\"meanmax\", fellow_eye=True)\nrun_experiment(\"E7_meanmax_fellow_MLP_wCE\", pooling=\"meanmax\", fellow_eye=True, hidden=512)\nrun_experiment(\"E8_meanmax_fellow_regression\", pooling=\"meanmax\", fellow_eye=True,\n               task=\"regression\", weighted=False)\n\n# --- zadania binarne\nrun_experiment(\"B1_referable_mean\", pooling=\"mean\", task=\"binary\", binary_pos=2)\nrun_experiment(\"B2_referable_meanmax_fellow\", pooling=\"meanmax\", fellow_eye=True, task=\"binary\", binary_pos=2)\nrun_experiment(\"B3_anyDR_mean\", pooling=\"mean\", task=\"binary\", binary_pos=1)\nrun_experiment(\"B4_anyDR_meanmax_fellow\", pooling=\"meanmax\", fellow_eye=True, task=\"binary\", binary_pos=1)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# A7. TABELA ZBIORCZA (CSV + LaTeX)\n# ============================================================\n\ndef fmt(summary, key, scale=100):\n    return f\"{summary[key + '_mean'] * scale:.2f} ± {summary[key + '_std'] * scale:.2f}\"\n\n\ndef results_table():\n    multi_rows, bin_rows = [], []\n\n    for name, r in RESULTS.items():\n        s = r[\"summary\"]\n        base = {\"experiment\": name, \"source\": r[\"source\"], \"n_features\": r[\"n_features\"]}\n\n        if r[\"cfg\"][\"task\"] == \"binary\":\n            bin_rows.append({\n                **base,\n                \"AUROC\": f\"{s['auroc_mean']:.3f} ± {s['auroc_std']:.3f}\",\n                \"Czułość [%]\": fmt(s, \"sens\"),\n                \"Swoistość [%]\": fmt(s, \"spec\"),\n                \"Bal. acc. [%]\": fmt(s, \"bal_acc\"),\n            })\n        else:\n            multi_rows.append({\n                **base,\n                \"Accuracy [%]\": fmt(s, \"acc\"),\n                \"Bal. acc. [%]\": fmt(s, \"bal_acc\"),\n                \"Macro F1 [%]\": fmt(s, \"macro_f1\"),\n                \"QWK\": f\"{s['qwk_mean']:.3f} ± {s['qwk_std']:.3f}\",\n            })\n\n    return pd.DataFrame(multi_rows), pd.DataFrame(bin_rows)\n\n\nmulti_table, binary_table = results_table()\n\npd.set_option(\"display.width\", 200)\npd.set_option(\"display.max_colwidth\", 40)\n\nprint(\"KLASYFIKACJA 5-KLASOWA\")\ndisplay(multi_table)\n\nprint(\"\\nKLASYFIKACJA BINARNA\")\ndisplay(binary_table)\n\nflat = pd.DataFrame([{\"experiment\": n, \"source\": r[\"source\"], **r[\"summary\"]} for n, r in RESULTS.items()])\nflat.to_csv(os.path.join(EXP_DIR, \"results.csv\"), index=False)\n\ndef to_latex_pl(table):\n    \"\"\"Tabela do pracy: przecinek dziesiętny, \\\\pm, escapowanie znaków specjalnych.\"\"\"\n    t = table.copy()\n    t.columns = [c.replace(\"%\", \"\\\\%\").replace(\"_\", \"\\\\_\") for c in t.columns]\n\n    for col in t.columns:\n        t[col] = [\n            str(v).replace(\"_\", \"\\\\_\").replace(\"%\", \"\\\\%\").replace(\".\", \"{,}\").replace(\" ± \", \" $\\\\pm$ \")\n            for v in t[col]\n        ]\n\n    return t.to_latex(index=False, escape=False)\n\n\nwith open(os.path.join(EXP_DIR, \"results_table.tex\"), \"w\") as fh:\n    fh.write(to_latex_pl(multi_table))\n    fh.write(\"\\n\\n\")\n    fh.write(to_latex_pl(binary_table))\n\nprint(\"\\nZapisano:\", os.path.join(EXP_DIR, \"results.csv\"))","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# A8. WYKRESY: MACIERZ POMYŁEK I KRZYWE ROC\n# ============================================================\n\ndef plot_confusion(name, seed_idx=0, normalize=True):\n    cm = np.array(RESULTS[name][\"per_seed\"][seed_idx][\"cm\"], dtype=float)\n    shown = cm / cm.sum(axis=1, keepdims=True) if normalize else cm\n\n    fig, ax = plt.subplots(figsize=(5.5, 4.8))\n    im = ax.imshow(shown, cmap=\"Blues\", vmin=0, vmax=1 if normalize else None)\n\n    for i in range(cm.shape[0]):\n        for j in range(cm.shape[1]):\n            ax.text(j, i, f\"{shown[i, j]:.2f}\\n({int(cm[i, j])})\", ha=\"center\", va=\"center\",\n                    fontsize=8, color=\"white\" if shown[i, j] > 0.5 else \"black\")\n\n    ax.set_xlabel(\"Klasa przewidziana\")\n    ax.set_ylabel(\"Klasa rzeczywista\")\n    ax.set_title(name, fontsize=9)\n    fig.colorbar(im, ax=ax, fraction=0.046)\n    fig.tight_layout()\n    fig.savefig(os.path.join(EXP_DIR, f\"cm_{name}.png\"), dpi=200)\n    plt.show()\n\n\ndef plot_roc(names):\n    fig, ax = plt.subplots(figsize=(5.5, 5.5))\n\n    for name in names:\n        r = RESULTS[name]\n        fpr, tpr, _ = roc_curve(r[\"test_targets\"], r[\"test_scores_seed0\"])\n        auc = roc_auc_score(r[\"test_targets\"], r[\"test_scores_seed0\"])\n        ax.plot(fpr, tpr, label=f\"{name} (AUROC = {auc:.3f})\")\n\n    ax.plot([0, 1], [0, 1], \"k:\", lw=1)\n    ax.set_xlabel(\"FPR = 1 − swoistość\")\n    ax.set_ylabel(\"TPR = czułość\")\n    ax.legend(fontsize=7, loc=\"lower right\")\n    ax.grid(alpha=0.3)\n    fig.tight_layout()\n    fig.savefig(os.path.join(EXP_DIR, \"roc_binary.png\"), dpi=200)\n    plt.show()\n\n\nmulticlass_names = [n for n, r in RESULTS.items() if r[\"cfg\"][\"task\"] != \"binary\"]\nbest_multi = max(multiclass_names, key=lambda n: RESULTS[n][\"summary\"][\"qwk_mean\"])\nprint(\"Najlepszy wariant 5-klasowy wg QWK:\", best_multi)\n\nplot_confusion(\"E1_mean_linear_wCE\")\nplot_confusion(best_multi)\n\nbinary_names = [n for n, r in RESULTS.items() if r[\"cfg\"][\"task\"] == \"binary\"]\nplot_roc(binary_names)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# SEKCJA B – nowy cache cech i fine-tuning (GPU, długie)\n\nJedno przejście po archiwum zdjęć generuje jednocześnie:\n\n1. **`native16`** – cechy wszystkich 16 kafelków w natywnej rozdzielczości, razem z pokryciem fundusem (`roi_ratios`) każdego kafelka. Pozwala porównać 16 vs 12 kafelków bez ponownego liczenia (filtr `roi_min=0.40` w `run_experiment`).\n2. **`scaled1024`** – zdjęcie przeskalowane tak, aby szerokość fundusu wynosiła 1024 px, dopełnione do kwadratu 1024×1024 → 16 kafelków po 256×256 px. Ujednolica skalę zmian między aparatami i zbliża rozmiar kafelka do tego, na którym trenowano EfficientNet (224 px).\n3. **`fundus_1024/`** – przeskalowane zdjęcia zapisane jako JPEG; wykorzystywane do fine-tuningu (B3), dzięki czemu nie trzeba ponownie rozpakowywać archiwum.\n\nGenerowanie jest wznawialne: gotowe shardy są pomijane, więc można je przerwać i uruchomić ponownie w kolejnej sesji. Szacunkowy czas: ok. 1,5–3 h na T4 (głównie rozpakowywanie i dekodowanie JPEG).\n\nMiejsce na dysku: ok. 2 × 2,2 GB (cache) + ok. 4–5 GB (JPEG).","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# B0. KONFIGURACJA I PRZETWARZANIE WSTĘPNE\n# ============================================================\n\nimport io\nimport subprocess\n\nimport cv2\nimport torch.nn.functional as F\n\nfrom PIL import Image\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision.models import efficientnet_b0, EfficientNet_B0_Weights\n\nARCHIVE_V9 = \"/kaggle/input/competitions/diabetic-retinopathy-detection/train.zip.001\"\nTRAIN_CSV_V9 = \"/kaggle/working/trainLabels.csv\"\nTEST_CSV_V9 = \"/kaggle/working/testLabels.csv\"\n\nNEW_CACHES = {\n    \"native16\": dict(\n        train=\"/kaggle/working/cache_v9_native16_train\",\n        test=\"/kaggle/working/cache_v9_native16_test\",\n        scale=None,\n    ),\n    \"scaled1024\": dict(\n        train=\"/kaggle/working/cache_v9_scaled1024_train\",\n        test=\"/kaggle/working/cache_v9_scaled1024_test\",\n        scale=1024,\n    ),\n}\n\nJPEG_DIR = \"/kaggle/working/fundus_1024\"   # przeskalowane zdjęcia do fine-tuningu\nJPEG_SIZE = 1024\nJPEG_QUALITY = 92\n\nSHARD_SIZE_V9 = 500\nNUM_WORKERS_V9 = 4\nUSE_AMP = True                     # FP16 przy ekstrakcji cech (szybciej na T4)\n\nIMAGENET_MEAN_V9 = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)\nIMAGENET_STD_V9 = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)\n\n\ndef load_image_7z(image_name, archive_path=ARCHIVE_V9):\n    result = subprocess.run(\n        [\"7z\", \"e\", \"-so\", archive_path, f\"train/{image_name}.jpeg\"],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.DEVNULL,\n        check=True,\n    )\n    image = Image.open(io.BytesIO(result.stdout))\n    image.load()\n    return image.convert(\"RGB\")\n\n\ndef detect_fundus(image, threshold=10, min_area_ratio=0.10):\n    \"\"\"Największa spójna składowa jaśniejsza niż próg -> bbox i maska (przycięta do bbox).\"\"\"\n    gray = cv2.cvtColor(np.asarray(image), cv2.COLOR_RGB2GRAY)\n    fg = (gray > threshold).astype(np.uint8)\n    n, lab, stats, _ = cv2.connectedComponentsWithStats(fg, connectivity=8)\n\n    best, best_area = None, 0\n\n    for i in range(1, n):\n        area = stats[i, cv2.CC_STAT_AREA]\n\n        if area >= gray.size * min_area_ratio and area > best_area:\n            best, best_area = i, area\n\n    if best is None:\n        return None, None\n\n    x, y, w, h = (int(stats[best, k]) for k in\n                  (cv2.CC_STAT_LEFT, cv2.CC_STAT_TOP, cv2.CC_STAT_WIDTH, cv2.CC_STAT_HEIGHT))\n    mask = (lab[y:y + h, x:x + w] == best).astype(np.uint8)\n\n    return (x, y, x + w, y + h), mask\n\n\ndef scale_to_square(image, mask, size):\n    \"\"\"\n    Skaluje wykadrowany fundus tak, by jego szerokość wynosiła `size`,\n    a następnie dopełnia (lub przycina) symetrycznie do kwadratu size × size.\n    \"\"\"\n    scale = size / image.width\n    new_h = max(1, int(round(image.height * scale)))\n\n    img = np.asarray(image.resize((size, new_h), Image.LANCZOS))\n    msk = cv2.resize(mask, (size, new_h), interpolation=cv2.INTER_NEAREST)\n\n    out_img = np.zeros((size, size, 3), dtype=np.uint8)\n    out_msk = np.zeros((size, size), dtype=np.uint8)\n\n    if new_h <= size:\n        top = (size - new_h) // 2\n        out_img[top:top + new_h] = img\n        out_msk[top:top + new_h] = msk\n    else:\n        top = (new_h - size) // 2\n        out_img[:] = img[top:top + size]\n        out_msk[:] = msk[top:top + size]\n\n    return out_img, out_msk\n\n\ndef split_tiles(img, mask, rows=4, cols=4):\n    \"\"\"Siatka rows × cols (reszta z dzielenia trafia do ostatniego wiersza/kolumny).\"\"\"\n    h, w = img.shape[:2]\n    th, tw = h // rows, w // cols\n    tiles, rois, positions = [], [], []\n\n    for r in range(rows):\n        for c in range(cols):\n            top, left = r * th, c * tw\n            bottom = h if r == rows - 1 else (r + 1) * th\n            right = w if c == cols - 1 else (c + 1) * tw\n\n            tiles.append(torch.from_numpy(np.ascontiguousarray(img[top:bottom, left:right])).permute(2, 0, 1))\n            rois.append(float(mask[top:bottom, left:right].mean()))\n            positions.append((r, c))\n\n    return tiles, rois, positions\n\n\ndef preprocess_variants(image, variants, jpeg_path=None):\n    \"\"\"\n    variants: {nazwa: scale} – scale=None oznacza natywną rozdzielczość.\n    Zwraca {nazwa: (tiles_uint8, rois, positions)} albo None przy nieudanej detekcji.\n    \"\"\"\n    bbox, mask = detect_fundus(image)\n\n    if bbox is None:\n        return None\n\n    cropped = image.crop(bbox)\n    out = {}\n\n    for name, scale in variants.items():\n        if scale is None:\n            img, msk = np.asarray(cropped), mask\n        else:\n            img, msk = scale_to_square(cropped, mask, scale)\n\n            if jpeg_path is not None and scale == JPEG_SIZE and not os.path.exists(jpeg_path):\n                Image.fromarray(img).save(jpeg_path, quality=JPEG_QUALITY)\n\n        out[name] = split_tiles(img, msk)\n\n    return out\n\n\ndef pad_and_normalize(tiles_uint8, device):\n    \"\"\"Symetryczne dopełnienie zerami do wspólnego rozmiaru + normalizacja ImageNet.\"\"\"\n    max_h = max(t.shape[1] for t in tiles_uint8)\n    max_w = max(t.shape[2] for t in tiles_uint8)\n    batch = []\n\n    for t in tiles_uint8:\n        ph, pw = max_h - t.shape[1], max_w - t.shape[2]\n        batch.append(F.pad(t, (pw // 2, pw - pw // 2, ph // 2, ph - ph // 2), value=0))\n\n    x = torch.stack(batch).to(device, non_blocking=True).float() / 255.0\n    return (x - IMAGENET_MEAN_V9.to(device)) / IMAGENET_STD_V9.to(device)\n\n\ndef to_object_array(items):\n    \"\"\"Lista tablic -> 1-wymiarowa tablica obiektów (bezpieczne także przy równych kształtach).\"\"\"\n    arr = np.empty(len(items), dtype=object)\n    for i, item in enumerate(items):\n        arr[i] = item\n    return arr\n\n\ndef make_backbone(weights=EfficientNet_B0_Weights.DEFAULT):\n    model = efficientnet_b0(weights=weights)\n    model.classifier = nn.Identity()\n    return model","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-02T13:11:39.71447Z","iopub.execute_input":"2026-10-02T13:11:39.714941Z","iopub.status.idle":"2026-10-02T13:11:39.789212Z","shell.execute_reply.started":"2026-10-02T13:11:39.714859Z","shell.execute_reply":"2026-10-02T13:11:39.788584Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# B1. GENEROWANIE NOWEGO CACHE (WZNAWIALNE)\n# ============================================================\n\nclass ArchivePreprocDataset(Dataset):\n    def __init__(self, df, variants, jpeg_dir=None):\n        self.df = df.reset_index(drop=True)\n        self.variants = variants\n        self.jpeg_dir = jpeg_dir\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        name, label = row[\"image\"], int(row[\"level\"])\n        jpeg_path = os.path.join(self.jpeg_dir, f\"{name}.jpg\") if self.jpeg_dir else None\n\n        try:\n            out = preprocess_variants(load_image_7z(name), self.variants, jpeg_path)\n            reason = None if out is not None else \"fundus not detected\"\n        except Exception as exc:\n            out, reason = None, f\"error: {exc}\"\n\n        return dict(name=name, label=label, variants=out, reason=reason)\n\n\ndef shard_path(out_dir, shard_idx):\n    return os.path.join(out_dir, f\"shard_{shard_idx:04d}.npz\")\n\n\n@torch.no_grad()\ndef generate_cache_v9(labels_df, caches, split, backbone, jpeg_dir=None):\n    \"\"\"\n    caches: {nazwa: dict(train=..., test=..., scale=...)}\n    split:  \"train\" albo \"test\"\n    \"\"\"\n    variants = {name: c[\"scale\"] for name, c in caches.items()}\n    out_dirs = {name: c[split] for name, c in caches.items()}\n\n    for d in out_dirs.values():\n        os.makedirs(d, exist_ok=True)\n\n    if jpeg_dir:\n        os.makedirs(jpeg_dir, exist_ok=True)\n\n    n_shards = int(np.ceil(len(labels_df) / SHARD_SIZE_V9))\n    backbone.eval()\n    t_start = time.time()\n\n    for shard_idx in range(n_shards):\n        todo = {n: d for n, d in out_dirs.items() if not os.path.exists(shard_path(d, shard_idx))}\n\n        if not todo:\n            continue\n\n        part = labels_df.iloc[shard_idx * SHARD_SIZE_V9:(shard_idx + 1) * SHARD_SIZE_V9]\n        loader = DataLoader(\n            ArchivePreprocDataset(part, {n: variants[n] for n in todo}, jpeg_dir),\n            batch_size=1,\n            shuffle=False,\n            num_workers=NUM_WORKERS_V9,\n            collate_fn=lambda b: b[0],\n        )\n\n        store = {n: dict(features=[], labels=[], names=[], rois=[], positions=[]) for n in todo}\n        skipped = []\n        t_shard = time.time()\n\n        for sample in loader:\n            if sample[\"variants\"] is None:\n                skipped.append({\"image\": sample[\"name\"], \"label\": sample[\"label\"], \"reason\": sample[\"reason\"]})\n                continue\n\n            for n, (tiles, rois, positions) in sample[\"variants\"].items():\n                x = pad_and_normalize(tiles, DEVICE)\n\n                with torch.autocast(device_type=DEVICE.type, dtype=torch.float16,\n                                    enabled=USE_AMP and DEVICE.type == \"cuda\"):\n                    feats = backbone(x)\n\n                store[n][\"features\"].append(feats.float().cpu().numpy().astype(np.float16))\n                store[n][\"labels\"].append(sample[\"label\"])\n                store[n][\"names\"].append(sample[\"name\"])\n                store[n][\"rois\"].append(np.asarray(rois, dtype=np.float32))\n                store[n][\"positions\"].append(np.asarray(positions, dtype=np.int8))\n\n        for n, s in store.items():\n            path = shard_path(todo[n], shard_idx)\n            np.savez(\n                path,\n                features=to_object_array(s[\"features\"]),\n                labels=np.asarray(s[\"labels\"], dtype=np.int64),\n                image_names=np.asarray(s[\"names\"]),\n                num_tiles=np.asarray([f.shape[0] for f in s[\"features\"]], dtype=np.int64),\n                roi_ratios=to_object_array(s[\"rois\"]),\n                positions=to_object_array(s[\"positions\"]),\n            )\n            pd.DataFrame(skipped, columns=[\"image\", \"label\", \"reason\"]).to_csv(\n                path.replace(\".npz\", \"_skipped.csv\"), index=False)\n\n        elapsed = time.time() - t_start\n        print(f\"[{split}] shard {shard_idx + 1}/{n_shards}: {len(part)} zdjęć, \"\n              f\"pominięte {len(skipped)}, {time.time() - t_shard:.0f} s (łącznie {elapsed / 60:.1f} min)\")\n\n\nbackbone_v9 = make_backbone().to(DEVICE)\n\ntrain_csv_v9 = pd.read_csv(TRAIN_CSV_V9)\ntest_csv_v9 = pd.read_csv(TEST_CSV_V9)\n\ngenerate_cache_v9(train_csv_v9, NEW_CACHES, \"train\", backbone_v9, jpeg_dir=JPEG_DIR)\ngenerate_cache_v9(test_csv_v9, NEW_CACHES, \"test\", backbone_v9, jpeg_dir=JPEG_DIR)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-02T13:11:48.208797Z","iopub.execute_input":"2026-10-02T13:11:48.209586Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# B2. EKSPERYMENTY NA NOWYCH CACHE\n#\n# C1 vs C2: wszystkie 16 kafelków vs filtr ROI >= 40%\n#           (C2 odpowiada oryginalnemu cache, ale liczonemu w FP16)\n# C3 vs C1: wpływ normalizacji skali\n# ============================================================\n\nfor name, c in NEW_CACHES.items():\n    if name not in DATA:\n        DATA[name] = load_bundle(c[\"train\"], c[\"test\"])\n\nrun_experiment(\"C1_native16_all_mean\", source=\"native16\", pooling=\"mean\")\nrun_experiment(\"C2_native16_roi40_mean\", source=\"native16\", pooling=\"mean\", roi_min=0.40)\nrun_experiment(\"C3_scaled1024_all_mean\", source=\"scaled1024\", pooling=\"mean\")\nrun_experiment(\"C4_scaled1024_roi40_mean\", source=\"scaled1024\", pooling=\"mean\", roi_min=0.40)\n\nrun_experiment(\"C5_scaled1024_meanmax_fellow\", source=\"scaled1024\", pooling=\"meanmax\", fellow_eye=True)\nrun_experiment(\"C6_scaled1024_meanmax_fellow_regr\", source=\"scaled1024\", pooling=\"meanmax\",\n               fellow_eye=True, task=\"regression\", weighted=False)\nrun_experiment(\"C7_scaled1024_referable_best\", source=\"scaled1024\", pooling=\"meanmax\",\n               fellow_eye=True, task=\"binary\", binary_pos=2)\nrun_experiment(\"C8_scaled1024_anyDR_best\", source=\"scaled1024\", pooling=\"meanmax\",\n               fellow_eye=True, task=\"binary\", binary_pos=1)\n\nmulti_table, binary_table = results_table()\ndisplay(multi_table)\ndisplay(binary_table)","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### B3. Częściowy fine-tuning EfficientNet-B0\n\n* wejście: zapisane zdjęcia 1024×1024 → 16 kafelków 256×256 (wszystkie kafelki, bez filtracji),\n* augmentacja: odbicia poziome/pionowe, obrót o wielokrotność 90°, niewielka zmiana jasności i kontrastu,\n* trenowane są ostatnie `N_UNFROZEN` modułów `features` sieci (domyślnie 3: dwa ostatnie etapy MBConv + końcowa konwolucja 1×1) oraz klasyfikator; reszta zamrożona (BatchNorm zamrożonej części w trybie eval),\n* mean pooling po kafelkach, ważona entropia krzyżowa, AdamW, AMP, harmonogram cosinusowy,\n* wybór epoki na walidacji (macro F1), raport na teście.\n\nUwaga dla FL: jeśli ten wariant okaże się wyraźnie lepszy, w uczeniu federacyjnym trzeba będzie przesyłać również odmrożone wagi sieci (zamiast 6405 parametrów klasyfikatora).","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# B3. CZĘŚCIOWY FINE-TUNING\n# ============================================================\n\nFT_CFG = dict(\n    n_unfrozen=3,         # ile ostatnich modułów model.features trenujemy\n    epochs=8,\n    batch_size=8,         # zdjęć na krok (8 × 16 kafelków = 128 kafelków)\n    lr_backbone=1e-4,\n    lr_head=1e-3,\n    weight_decay=1e-4,\n    num_workers=4,\n    train_subset=None,    # np. 3000 do szybkiego testu\n    seed=42,\n)\n\nFT_DIR = os.path.join(EXP_DIR, \"finetune\")\nos.makedirs(FT_DIR, exist_ok=True)\n\n\nclass FundusJpegDataset(Dataset):\n    def __init__(self, df, jpeg_dir, augment=False, rows=4, cols=4):\n        names = df[\"image\"].tolist()\n        exists = [os.path.exists(os.path.join(jpeg_dir, f\"{n}.jpg\")) for n in names]\n        self.df = df[exists].reset_index(drop=True)\n        self.jpeg_dir, self.augment, self.rows, self.cols = jpeg_dir, augment, rows, cols\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        img = np.asarray(Image.open(os.path.join(self.jpeg_dir, f\"{row['image']}.jpg\")).convert(\"RGB\"))\n\n        if self.augment:\n            if random.random() < 0.5:\n                img = img[:, ::-1]\n            if random.random() < 0.5:\n                img = img[::-1, :]\n            img = np.rot90(img, k=random.randint(0, 3))\n            alpha = random.uniform(0.9, 1.1)    # kontrast\n            beta = random.uniform(-10, 10)      # jasność\n            img = np.clip(img.astype(np.float32) * alpha + beta, 0, 255).astype(np.uint8)\n\n        x = torch.from_numpy(np.ascontiguousarray(img)).permute(2, 0, 1)   # [3, S, S]\n        th, tw = x.shape[1] // self.rows, x.shape[2] // self.cols\n        tiles = x[:, :th * self.rows, :tw * self.cols].unfold(1, th, th).unfold(2, tw, tw)\n        tiles = tiles.permute(1, 2, 0, 3, 4).reshape(self.rows * self.cols, 3, th, tw)\n\n        return tiles, int(row[\"level\"])\n\n\nclass TileMeanNet(nn.Module):\n    def __init__(self, n_unfrozen=3, num_classes=NUM_CLASSES, weights=EfficientNet_B0_Weights.DEFAULT):\n        super().__init__()\n        self.backbone = make_backbone(weights)\n\n        for p in self.backbone.parameters():\n            p.requires_grad = False\n\n        blocks = list(self.backbone.features)\n        self.frozen_blocks = blocks[:-n_unfrozen] if n_unfrozen > 0 else blocks\n\n        for block in blocks[len(blocks) - n_unfrozen:]:\n            for p in block.parameters():\n                p.requires_grad = True\n\n        self.head = nn.Linear(FEATURE_DIM, num_classes)\n\n    def train(self, mode=True):\n        super().train(mode)\n\n        for block in self.frozen_blocks:\n            block.eval()\n\n        return self\n\n    def tile_features(self, x):\n        # x: [B, T, 3, H, W] uint8 -> [B, T, 1280]\n        b, t = x.shape[:2]\n        x = x.flatten(0, 1).float() / 255.0\n        x = (x - IMAGENET_MEAN_V9.to(x.device)) / IMAGENET_STD_V9.to(x.device)\n        return self.backbone(x).view(b, t, -1)\n\n    def forward(self, x):\n        return self.head(self.tile_features(x).mean(dim=1))\n\n\n@torch.no_grad()\ndef ft_predict(model, loader):\n    model.eval()\n    probs, labels = [], []\n\n    for x, y in loader:\n        with torch.autocast(device_type=DEVICE.type, dtype=torch.float16, enabled=DEVICE.type == \"cuda\"):\n            logits = model(x.to(DEVICE, non_blocking=True))\n        probs.append(torch.softmax(logits.float(), dim=1).cpu().numpy())\n        labels.append(y.numpy())\n\n    return np.concatenate(probs), np.concatenate(labels)\n\n\ndef run_finetune(cfg=FT_CFG):\n    set_seed(cfg[\"seed\"])\n    train_df = pd.read_csv(TRAIN_CSV_V9)\n    test_df = pd.read_csv(TEST_CSV_V9)\n    train_df[\"patient_id\"] = train_df[\"image\"].str.extract(r\"^(\\d+)_\")[0].astype(int)\n\n    is_val = train_df[\"patient_id\"].isin(VAL_PATIENTS)\n    tr_df, va_df = train_df[~is_val], train_df[is_val]\n\n    if cfg[\"train_subset\"]:\n        tr_df = tr_df.sample(n=min(cfg[\"train_subset\"], len(tr_df)), random_state=cfg[\"seed\"])\n\n    ds_tr = FundusJpegDataset(tr_df, JPEG_DIR, augment=True)\n    ds_va = FundusJpegDataset(va_df, JPEG_DIR)\n    ds_te = FundusJpegDataset(test_df, JPEG_DIR)\n    print(f\"Train {len(ds_tr)} | val {len(ds_va)} | test {len(ds_te)}\")\n\n    kw = dict(num_workers=cfg[\"num_workers\"], pin_memory=DEVICE.type == \"cuda\")\n    dl_tr = DataLoader(ds_tr, batch_size=cfg[\"batch_size\"], shuffle=True, drop_last=True, **kw)\n    dl_va = DataLoader(ds_va, batch_size=cfg[\"batch_size\"] * 2, **kw)\n    dl_te = DataLoader(ds_te, batch_size=cfg[\"batch_size\"] * 2, **kw)\n\n    model = TileMeanNet(cfg[\"n_unfrozen\"]).to(DEVICE)\n    n_train = sum(p.numel() for p in model.parameters() if p.requires_grad)\n    print(f\"Parametry trenowane: {n_train:,}\")\n\n    w = class_weights(ds_tr.df[\"level\"].to_numpy(), NUM_CLASSES)\n    criterion = nn.CrossEntropyLoss(weight=torch.tensor(w, dtype=torch.float32, device=DEVICE))\n\n    optimizer = torch.optim.AdamW(\n        [\n            {\"params\": [p for p in model.backbone.parameters() if p.requires_grad], \"lr\": cfg[\"lr_backbone\"]},\n            {\"params\": model.head.parameters(), \"lr\": cfg[\"lr_head\"]},\n        ],\n        weight_decay=cfg[\"weight_decay\"],\n    )\n    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=cfg[\"epochs\"] * len(dl_tr))\n    scaler = torch.cuda.amp.GradScaler(enabled=DEVICE.type == \"cuda\")\n\n    best_f1, history = -1.0, []\n    ckpt = os.path.join(FT_DIR, \"best.pt\")\n\n    for epoch in range(1, cfg[\"epochs\"] + 1):\n        model.train()\n        t0, total, n = time.time(), 0.0, 0\n\n        for step, (x, y) in enumerate(dl_tr, start=1):\n            x, y = x.to(DEVICE, non_blocking=True), y.to(DEVICE)\n            optimizer.zero_grad(set_to_none=True)\n\n            with torch.autocast(device_type=DEVICE.type, dtype=torch.float16, enabled=DEVICE.type == \"cuda\"):\n                loss = criterion(model(x).float(), y)\n\n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n            scheduler.step()\n\n            total += loss.item() * len(y)\n            n += len(y)\n\n            if epoch == 1 and step == 20:\n                eta = (time.time() - t0) / step * len(dl_tr) / 60\n                print(f\"  szacowany czas epoki: {eta:.1f} min\")\n\n        p_va, y_va = ft_predict(model, dl_va)\n        m_va = multiclass_metrics(y_va, p_va.argmax(1))\n        history.append({\"epoch\": epoch, \"train_loss\": total / n,\n                        **{f\"val_{k}\": m_va[k] for k in (\"acc\", \"bal_acc\", \"macro_f1\", \"qwk\")}})\n        print(f\"Epoka {epoch}: loss={total / n:.4f} | val macro F1={m_va['macro_f1']:.4f} \"\n              f\"| val QWK={m_va['qwk']:.4f} | bal acc={m_va['bal_acc']:.4f} | {(time.time() - t0) / 60:.1f} min\")\n\n        if m_va[\"macro_f1\"] > best_f1:\n            best_f1 = m_va[\"macro_f1\"]\n            torch.save(model.state_dict(), ckpt)\n\n    model.load_state_dict(torch.load(ckpt, map_location=DEVICE))\n    p_te, y_te = ft_predict(model, dl_te)\n    m_te = multiclass_metrics(y_te, p_te.argmax(1))\n\n    pd.DataFrame(history).to_csv(os.path.join(FT_DIR, \"history.csv\"), index=False)\n    with open(os.path.join(FT_DIR, \"test_metrics.json\"), \"w\") as fh:\n        json.dump({\"cfg\": cfg, \"n_trainable\": n_train, **m_te}, fh, indent=2, default=float)\n\n    print(\"\\nTEST:\", {k: round(m_te[k], 4) for k in (\"acc\", \"bal_acc\", \"macro_f1\", \"qwk\")})\n    print(\"Macierz pomyłek:\\n\", np.array(m_te[\"cm\"]))\n\n    RESULTS[\"FT_partial_mean\"] = dict(\n        name=\"FT_partial_mean\", source=\"finetune\", cfg={**DEFAULT_CFG, \"task\": \"multiclass\"},\n        n_features=FEATURE_DIM, per_seed=[m_te], summary=summarize([m_te]),\n        test_scores_seed0=p_te, test_targets=y_te,\n    )\n\n    return model, history, m_te\n\n\nft_model, ft_history, ft_test = run_finetune()","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# B4. CACHE CECH Z DOSTROJONEGO MODELU\n#\n# Cechy kafelków z dostrojonej sieci zapisywane w tym samym\n# formacie co pozostałe cache – można na nich uruchomić\n# run_experiment (drugie oko, regresja, zadania binarne).\n#\n# Uwaga: sieć widziała zdjęcia treningowe podczas fine-tuningu,\n# więc ich cechy są \"optymistyczne\"; wybór epoki i progów nadal\n# odbywa się na walidacji, a raport na teście jest uczciwy.\n# ============================================================\n\nFT_CACHE = dict(train=os.path.join(FT_DIR, \"cache_train\"), test=os.path.join(FT_DIR, \"cache_test\"))\n\n\n@torch.no_grad()\ndef cache_from_model(model, df, out_dir, batch_size=16):\n    os.makedirs(out_dir, exist_ok=True)\n    ds = FundusJpegDataset(df, JPEG_DIR)\n    dl = DataLoader(ds, batch_size=batch_size, num_workers=FT_CFG[\"num_workers\"])\n    model.eval()\n    feats = []\n\n    for x, _ in dl:\n        with torch.autocast(device_type=DEVICE.type, dtype=torch.float16, enabled=DEVICE.type == \"cuda\"):\n            f = model.tile_features(x.to(DEVICE))\n        feats.extend(f.float().cpu().numpy().astype(np.float16))\n\n    np.savez(\n        os.path.join(out_dir, \"shard_0000.npz\"),\n        features=to_object_array(feats),\n        labels=ds.df[\"level\"].to_numpy(dtype=np.int64),\n        image_names=ds.df[\"image\"].to_numpy(),\n        num_tiles=np.asarray([f.shape[0] for f in feats], dtype=np.int64),\n    )\n\n\ncache_from_model(ft_model, pd.read_csv(TRAIN_CSV_V9), FT_CACHE[\"train\"])\ncache_from_model(ft_model, pd.read_csv(TEST_CSV_V9), FT_CACHE[\"test\"])\n\nDATA[\"finetune\"] = load_bundle(FT_CACHE[\"train\"], FT_CACHE[\"test\"])\n\nrun_experiment(\"F1_ft_mean_linear\", source=\"finetune\", pooling=\"mean\")\nrun_experiment(\"F2_ft_meanmax_fellow\", source=\"finetune\", pooling=\"meanmax\", fellow_eye=True)\nrun_experiment(\"F3_ft_referable_best\", source=\"finetune\", pooling=\"meanmax\", fellow_eye=True,\n               task=\"binary\", binary_pos=2)\n\nmulti_table, binary_table = results_table()\ndisplay(multi_table)\ndisplay(binary_table)","metadata":{},"outputs":[],"execution_count":null}]}