{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","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"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"7a10c323","cell_type":"markdown","source":"# Diabetic Retinopathy Severity Classification\n\nA cross-validated retinal-image ensemble for predicting five diabetic-retinopathy severity levels.\n\n**Data:** [Diabetic Retinopathy Classification #3](https://www.kaggle.com/competitions/diabetic-retinopathy-classification-3)","metadata":{}},{"id":"b2be6bf9","cell_type":"markdown","source":"## 1. Setup","metadata":{}},{"id":"e81adc08","cell_type":"code","source":"import os\nos.environ[\"TF_CPP_MIN_LOG_LEVEL\"] = \"2\"\n\nfrom pathlib import Path\nfrom concurrent.futures import ThreadPoolExecutor\nimport gc\nimport json\nimport random\nimport re\nimport warnings\nimport zipfile\n\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom PIL import Image\nfrom tqdm.auto import tqdm\n\nfrom sklearn.metrics import (\n    accuracy_score,\n    classification_report,\n    cohen_kappa_score,\n    confusion_matrix,\n    f1_score,\n)\nfrom sklearn.model_selection import StratifiedGroupKFold, StratifiedKFold\nfrom sklearn.utils.class_weight import compute_class_weight\n\nimport tensorflow as tf\nfrom tensorflow import keras\nfrom tensorflow.keras import layers, mixed_precision\n\nwarnings.filterwarnings(\"ignore\", category=FutureWarning)\ncv2.setNumThreads(0)\n\nSEED = 42\nNUM_CLASSES = 5\nN_FOLDS = 3\nIMAGE_SIZE = 384\nBATCH_SIZE_PER_REPLICA = 10\nHEAD_EPOCHS = 6\nFINE_TUNE_EPOCHS = 10\nAUTOTUNE = tf.data.AUTOTUNE\n\nOUTPUT_ROOT = Path(\"/kaggle/working/retina_cv_outputs\")\nPROCESSED_TRAIN_DIR = OUTPUT_ROOT / \"processed_train\"\nPROCESSED_TEST_DIR = OUTPUT_ROOT / \"processed_test\"\nWEIGHTS_DIR = OUTPUT_ROOT / \"weights\"\n\nfor directory in [\n    OUTPUT_ROOT,\n    PROCESSED_TRAIN_DIR,\n    PROCESSED_TEST_DIR,\n    WEIGHTS_DIR,\n]:\n    directory.mkdir(parents=True, exist_ok=True)\n\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntf.random.set_seed(SEED)\n\nphysical_gpus = tf.config.list_physical_devices(\"GPU\")\nif physical_gpus:\n    mixed_precision.set_global_policy(\"mixed_float16\")\n\nif len(physical_gpus) > 1:\n    strategy = tf.distribute.MirroredStrategy()\nelse:\n    strategy = tf.distribute.get_strategy()\n\nBATCH_SIZE = BATCH_SIZE_PER_REPLICA * strategy.num_replicas_in_sync\n\nprint(\"TensorFlow:\", tf.__version__)\nprint(\"GPUs:\", len(physical_gpus))\nprint(\"Replicas:\", strategy.num_replicas_in_sync)\nprint(\"Global batch size:\", BATCH_SIZE)\nprint(\"Precision policy:\", mixed_precision.global_policy())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:34:22.109418Z","iopub.execute_input":"2026-07-28T10:34:22.109873Z","iopub.status.idle":"2026-07-28T10:34:51.927265Z","shell.execute_reply.started":"2026-07-28T10:34:22.109842Z","shell.execute_reply":"2026-07-28T10:34:51.926425Z"}},"outputs":[],"execution_count":null},{"id":"2215f6a0","cell_type":"markdown","source":"## 2. Locate the data","metadata":{}},{"id":"de0cda7f","cell_type":"code","source":"IMAGE_SUFFIXES = {\".png\", \".jpg\", \".jpeg\"}\n\nif Path(\"/kaggle/input\").exists():\n    SEARCH_ROOTS = [Path(\"/kaggle/input\")]\nelse:\n    SEARCH_ROOTS = [Path(\".\")]\n\nEXTRACTION_ROOT = Path(\"/kaggle/working/retina_input\")\nEXTRACTION_ROOT.mkdir(parents=True, exist_ok=True)\n\n\ndef normalize_metadata(frame):\n    frame = frame.copy()\n    aliases = {\n        \"image\": \"id_code\",\n        \"image_id\": \"id_code\",\n        \"filename\": \"id_code\",\n        \"level\": \"diagnosis\",\n        \"label\": \"diagnosis\",\n        \"target\": \"diagnosis\",\n    }\n    frame = frame.rename(\n        columns={column: aliases.get(column.lower(), column) for column in frame.columns}\n    )\n    return frame\n\n\ndef inspect_csv(path):\n    try:\n        frame = normalize_metadata(pd.read_csv(path, nrows=20))\n        return set(frame.columns), frame\n    except Exception:\n        return set(), None\n\n\ndef find_metadata_files(search_roots):\n    csv_paths = []\n    for root in search_roots:\n        if root.exists():\n            csv_paths.extend(root.rglob(\"*.csv\"))\n\n    train_candidates = []\n    submission_candidates = []\n\n    for path in sorted(set(csv_paths)):\n        columns, preview = inspect_csv(path)\n        if not {\"id_code\", \"diagnosis\"}.issubset(columns):\n            continue\n\n        name = path.name.lower()\n        if \"submission\" in name or \"sample\" in name:\n            submission_candidates.append(path)\n        else:\n            train_candidates.append(path)\n\n    if not train_candidates:\n        return None, None\n\n    train_path = sorted(\n        train_candidates,\n        key=lambda path: (\n            \"train\" not in path.name.lower(),\n            len(str(path)),\n        ),\n    )[0]\n\n    submission_path = None\n    if submission_candidates:\n        submission_path = sorted(\n            submission_candidates,\n            key=lambda path: (\"submission\" not in path.name.lower(), len(str(path))),\n        )[0]\n\n    return train_path, submission_path\n\n\ndef build_image_index(search_roots):\n    index = {}\n    for root in search_roots:\n        if not root.exists():\n            continue\n        for path in root.rglob(\"*\"):\n            if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES:\n                index.setdefault(path.stem, []).append(path)\n    return index\n\n\ndef extract_archives(search_roots):\n    archives = []\n    for root in search_roots:\n        if root.exists():\n            archives.extend(root.rglob(\"*.zip\"))\n\n    for archive_path in sorted(set(archives)):\n        destination = EXTRACTION_ROOT / archive_path.stem\n        marker = destination / \".complete\"\n        if marker.exists():\n            continue\n\n        destination.mkdir(parents=True, exist_ok=True)\n        try:\n            with zipfile.ZipFile(archive_path) as archive:\n                archive.extractall(destination)\n            marker.touch()\n            print(\"Extracted:\", archive_path.name)\n        except zipfile.BadZipFile:\n            continue\n\n\ntrain_csv_path, submission_csv_path = find_metadata_files(SEARCH_ROOTS)\n\nif train_csv_path is None:\n    extract_archives(SEARCH_ROOTS)\n    SEARCH_ROOTS.append(EXTRACTION_ROOT)\n    train_csv_path, submission_csv_path = find_metadata_files(SEARCH_ROOTS)\n\nif train_csv_path is None:\n    raise FileNotFoundError(\n        \"Could not find the training metadata. Add the competition data through Kaggle Add Input.\"\n    )\n\nlabels_df = normalize_metadata(pd.read_csv(train_csv_path))\nlabels_df = labels_df[[\"id_code\", \"diagnosis\"]].copy()\nlabels_df[\"id_code\"] = labels_df[\"id_code\"].astype(str).map(lambda value: Path(value).stem)\nlabels_df[\"diagnosis\"] = pd.to_numeric(labels_df[\"diagnosis\"], errors=\"raise\").astype(int)\nlabels_df = labels_df.drop_duplicates(subset=\"id_code\").reset_index(drop=True)\n\nunexpected_labels = sorted(set(labels_df[\"diagnosis\"]) - set(range(NUM_CLASSES)))\nif unexpected_labels:\n    raise ValueError(f\"Unexpected severity labels: {unexpected_labels}\")\n\nif submission_csv_path is not None:\n    submission_template = normalize_metadata(pd.read_csv(submission_csv_path))\n    if \"id_code\" not in submission_template.columns:\n        submission_template = submission_template.rename(\n            columns={submission_template.columns[0]: \"id_code\"}\n        )\n    submission_template[\"id_code\"] = (\n        submission_template[\"id_code\"].astype(str).map(lambda value: Path(value).stem)\n    )\nelse:\n    submission_template = None\n\nimage_index = build_image_index(SEARCH_ROOTS)\nrequired_count = len(labels_df) + (0 if submission_template is None else len(submission_template))\n\nif len(image_index) < required_count:\n    extract_archives(SEARCH_ROOTS)\n    if EXTRACTION_ROOT not in SEARCH_ROOTS:\n        SEARCH_ROOTS.append(EXTRACTION_ROOT)\n    image_index = build_image_index(SEARCH_ROOTS)\n\n\ndef choose_image(image_id, preferred_word=None):\n    candidates = image_index.get(image_id, [])\n    if not candidates:\n        return None\n\n    if preferred_word:\n        preferred = [\n            path for path in candidates\n            if preferred_word.lower() in str(path.parent).lower()\n        ]\n        if preferred:\n            return str(sorted(preferred)[0])\n\n    return str(sorted(candidates)[0])\n\n\nlabels_df[\"source_path\"] = labels_df[\"id_code\"].map(\n    lambda image_id: choose_image(image_id, \"train\")\n)\n\nmissing_train = int(labels_df[\"source_path\"].isna().sum())\nif missing_train:\n    examples = labels_df.loc[labels_df[\"source_path\"].isna(), \"id_code\"].head().tolist()\n    raise FileNotFoundError(\n        f\"Could not match {missing_train} training images. Examples: {examples}\"\n    )\n\nif submission_template is None:\n    train_ids = set(labels_df[\"id_code\"])\n    test_ids = sorted(set(image_index).difference(train_ids))\n    submission_template = pd.DataFrame({\"id_code\": test_ids, \"diagnosis\": 0})\n\nsubmission_template[\"source_path\"] = submission_template[\"id_code\"].map(\n    lambda image_id: choose_image(image_id, \"test\")\n)\n\nmissing_test = int(submission_template[\"source_path\"].isna().sum())\nif missing_test:\n    examples = submission_template.loc[\n        submission_template[\"source_path\"].isna(), \"id_code\"\n    ].head().tolist()\n    raise FileNotFoundError(\n        f\"Could not match {missing_test} test images. Examples: {examples}\"\n    )\n\nprint(\"Training metadata:\", train_csv_path)\nprint(\"Submission template:\", submission_csv_path if submission_csv_path else \"Generated\")\nprint(f\"Training images: {len(labels_df):,}\")\nprint(f\"Test images: {len(submission_template):,}\")\ndisplay(labels_df.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:34:51.928794Z","iopub.execute_input":"2026-07-28T10:34:51.929275Z","iopub.status.idle":"2026-07-28T10:35:03.620564Z","shell.execute_reply.started":"2026-07-28T10:34:51.929247Z","shell.execute_reply":"2026-07-28T10:35:03.619839Z"}},"outputs":[],"execution_count":null},{"id":"adfc97ac","cell_type":"markdown","source":"## 3. Data overview","metadata":{}},{"id":"699ffe38","cell_type":"code","source":"severity_names = {\n    0: \"No DR\",\n    1: \"Mild\",\n    2: \"Moderate\",\n    3: \"Severe\",\n    4: \"Proliferative DR\",\n}\n\nclass_counts = labels_df[\"diagnosis\"].value_counts().sort_index()\nclass_percent = class_counts.div(class_counts.sum()).mul(100)\n\nfig, ax = plt.subplots(figsize=(9, 4.8))\nbars = ax.bar(\n    [f\"{index}: {severity_names[index]}\" for index in class_counts.index],\n    class_counts.values,\n)\nax.bar_label(\n    bars,\n    labels=[\n        f\"{count}\\n({percent:.1f}%)\"\n        for count, percent in zip(class_counts.values, class_percent.values)\n    ],\n    padding=3,\n)\nax.set_title(\"Severity Distribution\")\nax.set_xlabel(\"Severity\")\nax.set_ylabel(\"Images\")\nax.set_ylim(0, class_counts.max() * 1.20)\nplt.tight_layout()\nplt.show()\n\ndisplay(\n    pd.DataFrame({\n        \"Severity\": [severity_names[index] for index in class_counts.index],\n        \"Images\": class_counts.values,\n        \"Percent\": class_percent.values,\n    }).style.format({\"Percent\": \"{:.2f}%\"})\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:35:03.621968Z","iopub.execute_input":"2026-07-28T10:35:03.622399Z","iopub.status.idle":"2026-07-28T10:35:03.983862Z","shell.execute_reply.started":"2026-07-28T10:35:03.622370Z","shell.execute_reply":"2026-07-28T10:35:03.982674Z"}},"outputs":[],"execution_count":null},{"id":"c35e8797","cell_type":"code","source":"sample_rows = (\n    labels_df.groupby(\"diagnosis\", group_keys=False)\n    .sample(n=1, random_state=SEED)\n    .sort_values(\"diagnosis\")\n)\n\nfig, axes = plt.subplots(1, NUM_CLASSES, figsize=(16, 3.5))\nfor ax, (_, row) in zip(axes, sample_rows.iterrows()):\n    image = np.asarray(Image.open(row[\"source_path\"]).convert(\"RGB\"))\n    ax.imshow(image)\n    ax.set_title(f\"{row['diagnosis']}: {severity_names[row['diagnosis']]}\")\n    ax.axis(\"off\")\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:35:03.986099Z","iopub.execute_input":"2026-07-28T10:35:03.986731Z","iopub.status.idle":"2026-07-28T10:35:04.662142Z","shell.execute_reply.started":"2026-07-28T10:35:03.986667Z","shell.execute_reply":"2026-07-28T10:35:04.661202Z"}},"outputs":[],"execution_count":null},{"id":"11fc32bb","cell_type":"markdown","source":"## 4. Retina preprocessing","metadata":{}},{"id":"c4d03406","cell_type":"code","source":"def crop_dark_border(image, tolerance=7):\n    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n    mask = gray > tolerance\n    coordinates = cv2.findNonZero(mask.astype(np.uint8))\n\n    if coordinates is None:\n        return image\n\n    x, y, width, height = cv2.boundingRect(coordinates)\n    cropped = image[y:y + height, x:x + width]\n    return cropped if cropped.size else image\n\n\ndef square_resize(image, size):\n    height, width = image.shape[:2]\n    scale = size / max(height, width)\n    resized_width = max(1, int(round(width * scale)))\n    resized_height = max(1, int(round(height * scale)))\n\n    resized = cv2.resize(\n        image,\n        (resized_width, resized_height),\n        interpolation=cv2.INTER_AREA if scale < 1 else cv2.INTER_CUBIC,\n    )\n\n    canvas = np.zeros((size, size, 3), dtype=np.uint8)\n    x_offset = (size - resized_width) // 2\n    y_offset = (size - resized_height) // 2\n    canvas[\n        y_offset:y_offset + resized_height,\n        x_offset:x_offset + resized_width,\n    ] = resized\n    return canvas\n\n\ndef preprocess_fundus(source_path, destination_path):\n    destination_path = Path(destination_path)\n    if destination_path.exists():\n        return str(destination_path)\n\n    image = cv2.imread(str(source_path), cv2.IMREAD_COLOR)\n    if image is None:\n        raise ValueError(f\"Could not read image: {source_path}\")\n\n    image = crop_dark_border(image)\n    image = square_resize(image, IMAGE_SIZE)\n\n    blurred = cv2.GaussianBlur(image, (0, 0), sigmaX=10)\n    enhanced = cv2.addWeighted(image, 4.0, blurred, -4.0, 128)\n\n    mask = np.zeros((IMAGE_SIZE, IMAGE_SIZE), dtype=np.uint8)\n    cv2.circle(\n        mask,\n        (IMAGE_SIZE // 2, IMAGE_SIZE // 2),\n        int(IMAGE_SIZE * 0.49),\n        255,\n        thickness=-1,\n    )\n    enhanced = cv2.bitwise_and(enhanced, enhanced, mask=mask)\n\n    destination_path.parent.mkdir(parents=True, exist_ok=True)\n    success = cv2.imwrite(\n        str(destination_path),\n        enhanced,\n        [cv2.IMWRITE_JPEG_QUALITY, 95],\n    )\n    if not success:\n        raise IOError(f\"Could not write image: {destination_path}\")\n\n    return str(destination_path)\n\n\ndef preprocess_split(frame, output_directory):\n    output_paths = [\n        output_directory / f\"{image_id}.jpg\"\n        for image_id in frame[\"id_code\"]\n    ]\n    jobs = list(zip(frame[\"source_path\"], output_paths))\n    worker_count = min(4, os.cpu_count() or 4)\n\n    with ThreadPoolExecutor(max_workers=worker_count) as executor:\n        processed_paths = list(\n            tqdm(\n                executor.map(lambda pair: preprocess_fundus(*pair), jobs),\n                total=len(jobs),\n                desc=output_directory.name,\n            )\n        )\n\n    return processed_paths\n\n\nlabels_df[\"image_path\"] = preprocess_split(labels_df, PROCESSED_TRAIN_DIR)\nsubmission_template[\"image_path\"] = preprocess_split(\n    submission_template,\n    PROCESSED_TEST_DIR,\n)\n\nprint(\"Preprocessed images are ready.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:35:04.663178Z","iopub.execute_input":"2026-07-28T10:35:04.663509Z","iopub.status.idle":"2026-07-28T10:35:46.996827Z","shell.execute_reply.started":"2026-07-28T10:35:04.663478Z","shell.execute_reply":"2026-07-28T10:35:46.995704Z"}},"outputs":[],"execution_count":null},{"id":"2bf08331","cell_type":"code","source":"preview_rows = labels_df.sample(n=min(4, len(labels_df)), random_state=SEED)\n\nfig, axes = plt.subplots(len(preview_rows), 2, figsize=(8, 4 * len(preview_rows)))\naxes = np.atleast_2d(axes)\n\nfor row_axes, (_, row) in zip(axes, preview_rows.iterrows()):\n    original = np.asarray(Image.open(row[\"source_path\"]).convert(\"RGB\"))\n    processed = np.asarray(Image.open(row[\"image_path\"]).convert(\"RGB\"))\n\n    row_axes[0].imshow(original)\n    row_axes[0].set_title(\"Original\")\n    row_axes[0].axis(\"off\")\n\n    row_axes[1].imshow(processed)\n    row_axes[1].set_title(\"Processed\")\n    row_axes[1].axis(\"off\")\n\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:35:46.998078Z","iopub.execute_input":"2026-07-28T10:35:46.998478Z","iopub.status.idle":"2026-07-28T10:35:48.079416Z","shell.execute_reply.started":"2026-07-28T10:35:46.998449Z","shell.execute_reply":"2026-07-28T10:35:48.078204Z"}},"outputs":[],"execution_count":null},{"id":"2d35ca53","cell_type":"markdown","source":"## 5. Cross-validation folds","metadata":{}},{"id":"21ea8fb9","cell_type":"code","source":"def patient_group(image_id):\n    match = re.match(r\"(.+?)_(left|right)$\", image_id, flags=re.IGNORECASE)\n    return match.group(1) if match else image_id\n\n\nlabels_df[\"group_id\"] = labels_df[\"id_code\"].map(patient_group)\nlabels_df[\"fold\"] = -1\n\nhas_paired_groups = labels_df[\"group_id\"].duplicated(keep=False).any()\n\nif has_paired_groups:\n    splitter = StratifiedGroupKFold(\n        n_splits=N_FOLDS,\n        shuffle=True,\n        random_state=SEED,\n    )\n    split_iterator = splitter.split(\n        labels_df,\n        labels_df[\"diagnosis\"],\n        groups=labels_df[\"group_id\"],\n    )\n    split_method = \"group-aware stratification\"\nelse:\n    splitter = StratifiedKFold(\n        n_splits=N_FOLDS,\n        shuffle=True,\n        random_state=SEED,\n    )\n    split_iterator = splitter.split(labels_df, labels_df[\"diagnosis\"])\n    split_method = \"stratification\"\n\nfor fold_index, (_, validation_indices) in enumerate(split_iterator):\n    labels_df.loc[validation_indices, \"fold\"] = fold_index\n\nif (labels_df[\"fold\"] < 0).any():\n    raise RuntimeError(\"Fold allocation failed.\")\n\nprint(\"Split method:\", split_method)\n\nfold_distribution = pd.crosstab(\n    labels_df[\"fold\"],\n    labels_df[\"diagnosis\"],\n    normalize=\"index\",\n).mul(100)\nfold_distribution.columns = [severity_names[column] for column in fold_distribution.columns]\n\ndisplay(fold_distribution.style.format(\"{:.2f}%\"))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:35:48.080528Z","iopub.execute_input":"2026-07-28T10:35:48.080877Z","iopub.status.idle":"2026-07-28T10:35:48.126953Z","shell.execute_reply.started":"2026-07-28T10:35:48.080830Z","shell.execute_reply":"2026-07-28T10:35:48.126227Z"}},"outputs":[],"execution_count":null},{"id":"e2ae9d19","cell_type":"markdown","source":"## 6. TensorFlow pipeline","metadata":{}},{"id":"c47b1120","cell_type":"code","source":"def decode_image(path, label=None, flip=False):\n    image_bytes = tf.io.read_file(path)\n    image = tf.io.decode_jpeg(image_bytes, channels=3)\n    image = tf.ensure_shape(image, [IMAGE_SIZE, IMAGE_SIZE, 3])\n    image = tf.cast(image, tf.float32)\n\n    if flip:\n        image = tf.image.flip_left_right(image)\n\n    if label is None:\n        return image\n    return image, tf.cast(label, tf.int32)\n\n\ndef make_labeled_dataset(frame, training=False):\n    paths = frame[\"image_path\"].astype(str).to_numpy()\n    labels = frame[\"diagnosis\"].astype(np.int32).to_numpy()\n\n    dataset = tf.data.Dataset.from_tensor_slices((paths, labels))\n\n    if training:\n        dataset = dataset.shuffle(\n            len(frame),\n            seed=SEED,\n            reshuffle_each_iteration=True,\n        )\n\n    dataset = dataset.map(\n        lambda path, label: decode_image(path, label),\n        num_parallel_calls=AUTOTUNE,\n    )\n    dataset = dataset.batch(BATCH_SIZE)\n    dataset = dataset.prefetch(AUTOTUNE)\n    return dataset\n\n\ndef make_prediction_dataset(paths, flip=False):\n    dataset = tf.data.Dataset.from_tensor_slices(np.asarray(paths, dtype=str))\n    dataset = dataset.map(\n        lambda path: decode_image(path, flip=flip),\n        num_parallel_calls=AUTOTUNE,\n    )\n    dataset = dataset.batch(BATCH_SIZE)\n    dataset = dataset.prefetch(AUTOTUNE)\n    return dataset\n\n\ndef compute_tempered_class_weights(labels, exponent=0.40):\n    balanced = compute_class_weight(\n        class_weight=\"balanced\",\n        classes=np.arange(NUM_CLASSES),\n        y=np.asarray(labels),\n    )\n    tempered = np.power(balanced, exponent)\n    return {\n        class_index: float(weight)\n        for class_index, weight in enumerate(tempered)\n    }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:35:48.128021Z","iopub.execute_input":"2026-07-28T10:35:48.128839Z","iopub.status.idle":"2026-07-28T10:35:48.139426Z","shell.execute_reply.started":"2026-07-28T10:35:48.128799Z","shell.execute_reply":"2026-07-28T10:35:48.138665Z"}},"outputs":[],"execution_count":null},{"id":"1fa66981","cell_type":"markdown","source":"## 7. Model","metadata":{}},{"id":"444093a2","cell_type":"code","source":"@keras.utils.register_keras_serializable(package=\"Retina\")\nclass OrdinalHybridLoss(keras.losses.Loss):\n    def __init__(self, ordinal_weight=0.20, name=\"ordinal_hybrid_loss\"):\n        super().__init__(name=name)\n        self.ordinal_weight = float(ordinal_weight)\n\n    def call(self, y_true, y_pred):\n        y_true = tf.cast(tf.reshape(y_true, [-1]), tf.int32)\n        y_pred = tf.cast(y_pred, tf.float32)\n\n        categorical_loss = keras.losses.sparse_categorical_crossentropy(\n            y_true,\n            y_pred,\n        )\n\n        severity_values = tf.cast(tf.range(NUM_CLASSES), tf.float32)\n        expected_severity = tf.reduce_sum(y_pred * severity_values, axis=-1)\n        true_severity = tf.cast(y_true, tf.float32)\n        ordinal_error = tf.square(expected_severity - true_severity) / float(\n            (NUM_CLASSES - 1) ** 2\n        )\n\n        return categorical_loss + self.ordinal_weight * ordinal_error\n\n    def get_config(self):\n        config = super().get_config()\n        config.update({\"ordinal_weight\": self.ordinal_weight})\n        return config\n\n\ndef make_augmentation():\n    return keras.Sequential(\n        [\n            layers.RandomFlip(\"horizontal\"),\n            layers.RandomRotation(0.04, fill_mode=\"reflect\"),\n            layers.RandomTranslation(0.03, 0.03, fill_mode=\"reflect\"),\n            layers.RandomZoom(0.08, fill_mode=\"reflect\"),\n            layers.RandomContrast(0.12),\n        ],\n        name=\"retina_augmentation\",\n    )\n\n\ndef build_model():\n    inputs = layers.Input(\n        shape=(IMAGE_SIZE, IMAGE_SIZE, 3),\n        dtype=tf.float32,\n        name=\"retina_image\",\n    )\n\n    x = make_augmentation()(inputs)\n    backbone = keras.applications.MobileNetV3Large(\n        include_top=False,\n        weights=\"imagenet\",\n        input_shape=(IMAGE_SIZE, IMAGE_SIZE, 3),\n        pooling=\"avg\",\n        include_preprocessing=True,\n    )\n    backbone.trainable = False\n\n    x = backbone(x, training=False)\n    x = layers.BatchNormalization()(x)\n    x = layers.Dropout(0.35)(x)\n    x = layers.Dense(\n        320,\n        activation=\"swish\",\n        kernel_regularizer=keras.regularizers.l2(1e-5),\n    )(x)\n    x = layers.Dropout(0.25)(x)\n    outputs = layers.Dense(\n        NUM_CLASSES,\n        activation=\"softmax\",\n        dtype=\"float32\",\n        name=\"severity\",\n    )(x)\n\n    model = keras.Model(inputs, outputs, name=\"retina_mobilenetv3\")\n    return model, backbone.name\n\n\ndef compile_model(model, learning_rate):\n    model.compile(\n        optimizer=keras.optimizers.AdamW(\n            learning_rate=learning_rate,\n            weight_decay=1e-5,\n            clipnorm=1.0,\n        ),\n        loss=OrdinalHybridLoss(ordinal_weight=0.20),\n        metrics=[keras.metrics.SparseCategoricalAccuracy(name=\"accuracy\")],\n    )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:35:48.140510Z","iopub.execute_input":"2026-07-28T10:35:48.141353Z","iopub.status.idle":"2026-07-28T10:35:48.160850Z","shell.execute_reply.started":"2026-07-28T10:35:48.141323Z","shell.execute_reply":"2026-07-28T10:35:48.159916Z"}},"outputs":[],"execution_count":null},{"id":"e14ef9ae","cell_type":"code","source":"class QWKCheckpoint(keras.callbacks.Callback):\n    def __init__(\n        self,\n        validation_dataset,\n        y_true,\n        weights_path,\n        patience,\n        initial_best=-np.inf,\n        phase=\"training\",\n    ):\n        super().__init__()\n        self.validation_dataset = validation_dataset\n        self.y_true = np.asarray(y_true)\n        self.weights_path = Path(weights_path)\n        self.patience = int(patience)\n        self.best_qwk = float(initial_best)\n        self.wait = 0\n        self.phase = phase\n        self.history_rows = []\n\n    def on_epoch_end(self, epoch, logs=None):\n        logs = logs if logs is not None else {}\n        probabilities = self.model.predict(self.validation_dataset, verbose=0)\n        predictions = probabilities.argmax(axis=1)\n\n        qwk = cohen_kappa_score(\n            self.y_true,\n            predictions,\n            weights=\"quadratic\",\n        )\n        macro_f1 = f1_score(\n            self.y_true,\n            predictions,\n            average=\"macro\",\n            zero_division=0,\n        )\n\n        logs[\"val_qwk\"] = float(qwk)\n        logs[\"val_macro_f1\"] = float(macro_f1)\n        self.history_rows.append({\n            \"phase\": self.phase,\n            \"epoch\": int(epoch + 1),\n            \"qwk\": float(qwk),\n            \"macro_f1\": float(macro_f1),\n        })\n\n        print(f\" — val_qwk: {qwk:.4f} — val_macro_f1: {macro_f1:.4f}\")\n\n        if qwk > self.best_qwk + 1e-5:\n            self.best_qwk = float(qwk)\n            self.wait = 0\n            self.model.save_weights(self.weights_path)\n        else:\n            self.wait += 1\n            if self.wait >= self.patience:\n                self.model.stop_training = True\n                print(\"Early stopping on validation QWK.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:35:48.163608Z","iopub.execute_input":"2026-07-28T10:35:48.163995Z","iopub.status.idle":"2026-07-28T10:35:48.181569Z","shell.execute_reply.started":"2026-07-28T10:35:48.163969Z","shell.execute_reply":"2026-07-28T10:35:48.180442Z"}},"outputs":[],"execution_count":null},{"id":"ad9ac0c1","cell_type":"code","source":"def predict_with_tta(model, paths, verbose=0):\n    original = model.predict(\n        make_prediction_dataset(paths, flip=False),\n        verbose=verbose,\n    )\n    flipped = model.predict(\n        make_prediction_dataset(paths, flip=True),\n        verbose=verbose,\n    )\n    return (original + flipped) / 2.0\n\n\ndef unfreeze_upper_backbone(model, backbone_name, fraction=0.30):\n    backbone = model.get_layer(backbone_name)\n    backbone.trainable = True\n\n    first_trainable = int(len(backbone.layers) * (1.0 - fraction))\n    for layer in backbone.layers[:first_trainable]:\n        layer.trainable = False\n\n    for layer in backbone.layers[first_trainable:]:\n        if isinstance(layer, layers.BatchNormalization):\n            layer.trainable = False\n        else:\n            layer.trainable = True\n\n    return sum(layer.trainable for layer in backbone.layers), len(backbone.layers)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:35:48.183146Z","iopub.execute_input":"2026-07-28T10:35:48.184244Z","iopub.status.idle":"2026-07-28T10:35:48.206240Z","shell.execute_reply.started":"2026-07-28T10:35:48.184180Z","shell.execute_reply":"2026-07-28T10:35:48.205441Z"}},"outputs":[],"execution_count":null},{"id":"383b0a56","cell_type":"markdown","source":"## 8. Three-fold ensemble","metadata":{}},{"id":"2e3b8f9f","cell_type":"code","source":"oof_probabilities = np.zeros((len(labels_df), NUM_CLASSES), dtype=np.float32)\ntest_probability_sum = np.zeros((len(submission_template), NUM_CLASSES), dtype=np.float32)\n\nfold_metrics = []\ntraining_history_rows = []\ntest_paths = submission_template[\"image_path\"].astype(str).tolist()\n\nfor fold_index in range(N_FOLDS):\n    print(f\"\\n{'=' * 78}\\nFold {fold_index + 1}/{N_FOLDS}\\n{'=' * 78}\")\n\n    train_frame = labels_df[labels_df[\"fold\"] != fold_index].reset_index(drop=True)\n    validation_frame = labels_df[labels_df[\"fold\"] == fold_index].reset_index(drop=True)\n\n    train_dataset = make_labeled_dataset(train_frame, training=True)\n    validation_dataset = make_labeled_dataset(validation_frame, training=False)\n    validation_paths = validation_frame[\"image_path\"].astype(str).tolist()\n    y_fold = validation_frame[\"diagnosis\"].to_numpy(dtype=np.int32)\n    class_weight = compute_tempered_class_weights(train_frame[\"diagnosis\"])\n\n    weights_path = WEIGHTS_DIR / f\"fold_{fold_index + 1}.weights.h5\"\n\n    tf.keras.backend.clear_session()\n    gc.collect()\n\n    with strategy.scope():\n        model, backbone_name = build_model()\n        compile_model(model, learning_rate=3e-4)\n\n    head_callback = QWKCheckpoint(\n        validation_dataset,\n        y_fold,\n        weights_path,\n        patience=2,\n        phase=\"frozen\",\n    )\n\n    model.fit(\n        train_dataset,\n        validation_data=validation_dataset,\n        epochs=HEAD_EPOCHS,\n        class_weight=class_weight,\n        callbacks=[\n            head_callback,\n            keras.callbacks.ReduceLROnPlateau(\n                monitor=\"val_loss\",\n                factor=0.40,\n                patience=2,\n                min_lr=2e-6,\n                verbose=1,\n            ),\n        ],\n        verbose=1,\n    )\n\n    model.load_weights(weights_path)\n\n    with strategy.scope():\n        trainable_layers, total_layers = unfreeze_upper_backbone(\n            model,\n            backbone_name,\n            fraction=0.30,\n        )\n        compile_model(model, learning_rate=8e-6)\n\n    print(f\"Trainable backbone layers: {trainable_layers}/{total_layers}\")\n\n    fine_tune_callback = QWKCheckpoint(\n        validation_dataset,\n        y_fold,\n        weights_path,\n        patience=3,\n        initial_best=head_callback.best_qwk,\n        phase=\"fine_tuning\",\n    )\n\n    model.fit(\n        train_dataset,\n        validation_data=validation_dataset,\n        epochs=FINE_TUNE_EPOCHS,\n        class_weight=class_weight,\n        callbacks=[\n            fine_tune_callback,\n            keras.callbacks.ReduceLROnPlateau(\n                monitor=\"val_loss\",\n                factor=0.35,\n                patience=2,\n                min_lr=5e-7,\n                verbose=1,\n            ),\n        ],\n        verbose=1,\n    )\n\n    model.load_weights(weights_path)\n\n    fold_probabilities = predict_with_tta(model, validation_paths, verbose=1)\n    fold_predictions = fold_probabilities.argmax(axis=1)\n\n    original_indices = labels_df.index[labels_df[\"fold\"] == fold_index].to_numpy()\n    oof_probabilities[original_indices] = fold_probabilities\n\n    test_probability_sum += predict_with_tta(model, test_paths, verbose=1)\n\n    fold_qwk = cohen_kappa_score(y_fold, fold_predictions, weights=\"quadratic\")\n    fold_macro_f1 = f1_score(\n        y_fold,\n        fold_predictions,\n        average=\"macro\",\n        zero_division=0,\n    )\n    fold_accuracy = accuracy_score(y_fold, fold_predictions)\n\n    fold_metrics.append({\n        \"Fold\": fold_index + 1,\n        \"Accuracy\": fold_accuracy,\n        \"Macro F1\": fold_macro_f1,\n        \"QWK\": fold_qwk,\n        \"Best Training QWK\": max(head_callback.best_qwk, fine_tune_callback.best_qwk),\n    })\n\n    for row in head_callback.history_rows + fine_tune_callback.history_rows:\n        row[\"fold\"] = fold_index + 1\n        training_history_rows.append(row)\n\n    print(\n        f\"Fold {fold_index + 1}: accuracy={fold_accuracy:.4f}, \"\n        f\"macro_f1={fold_macro_f1:.4f}, qwk={fold_qwk:.4f}\"\n    )\n\n    del model, train_dataset, validation_dataset, fold_probabilities\n    tf.keras.backend.clear_session()\n    gc.collect()\n\n\ntest_probabilities = test_probability_sum / N_FOLDS\nfold_metrics_df = pd.DataFrame(fold_metrics)\ndisplay(fold_metrics_df.style.format({\n    \"Accuracy\": \"{:.4f}\",\n    \"Macro F1\": \"{:.4f}\",\n    \"QWK\": \"{:.4f}\",\n    \"Best Training QWK\": \"{:.4f}\",\n}))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:35:48.207396Z","iopub.execute_input":"2026-07-28T10:35:48.208113Z","iopub.status.idle":"2026-07-28T10:45:39.267798Z","shell.execute_reply.started":"2026-07-28T10:35:48.208069Z","shell.execute_reply":"2026-07-28T10:45:39.266534Z"}},"outputs":[],"execution_count":null},{"id":"993d8155","cell_type":"code","source":"history_df = pd.DataFrame(training_history_rows)\n\nfig, ax = plt.subplots(figsize=(10, 5))\nfor (fold, phase), group in history_df.groupby([\"fold\", \"phase\"]):\n    label = f\"Fold {fold} — {phase.replace('_', ' ')}\"\n    ax.plot(np.arange(1, len(group) + 1), group[\"qwk\"], marker=\"o\", label=label)\n\nax.set_title(\"Validation QWK During Training\")\nax.set_xlabel(\"Epoch within phase\")\nax.set_ylabel(\"Quadratic weighted kappa\")\nax.legend(ncol=2, fontsize=9)\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:45:39.269035Z","iopub.execute_input":"2026-07-28T10:45:39.269984Z","iopub.status.idle":"2026-07-28T10:45:39.530598Z","shell.execute_reply.started":"2026-07-28T10:45:39.269959Z","shell.execute_reply":"2026-07-28T10:45:39.529657Z"}},"outputs":[],"execution_count":null},{"id":"64077407","cell_type":"markdown","source":"## 9. Ordinal calibration","metadata":{}},{"id":"0fd0a997","cell_type":"code","source":"def apply_thresholds(scores, thresholds):\n    return np.digitize(scores, thresholds).astype(np.int32)\n\n\ndef optimize_thresholds(y_true, scores, rounds=5, candidates_per_step=100):\n    thresholds = np.array([0.5, 1.5, 2.5, 3.5], dtype=float)\n    best_score = cohen_kappa_score(\n        y_true,\n        apply_thresholds(scores, thresholds),\n        weights=\"quadratic\",\n    )\n\n    for _ in range(rounds):\n        improved = False\n\n        for threshold_index in range(len(thresholds)):\n            lower = (\n                0.0\n                if threshold_index == 0\n                else thresholds[threshold_index - 1] + 0.03\n            )\n            upper = (\n                4.0\n                if threshold_index == len(thresholds) - 1\n                else thresholds[threshold_index + 1] - 0.03\n            )\n\n            if lower >= upper:\n                continue\n\n            local_best = thresholds[threshold_index]\n            for candidate in np.linspace(lower, upper, candidates_per_step):\n                trial = thresholds.copy()\n                trial[threshold_index] = candidate\n                score = cohen_kappa_score(\n                    y_true,\n                    apply_thresholds(scores, trial),\n                    weights=\"quadratic\",\n                )\n                if score > best_score + 1e-7:\n                    best_score = float(score)\n                    local_best = float(candidate)\n                    improved = True\n\n            thresholds[threshold_index] = local_best\n\n        if not improved:\n            break\n\n    return thresholds, float(best_score)\n\n\ny_true = labels_df[\"diagnosis\"].to_numpy(dtype=np.int32)\nseverity_values = np.arange(NUM_CLASSES, dtype=np.float32)\noof_scores = oof_probabilities @ severity_values\n\nargmax_oof_predictions = oof_probabilities.argmax(axis=1)\nargmax_qwk = cohen_kappa_score(\n    y_true,\n    argmax_oof_predictions,\n    weights=\"quadratic\",\n)\n\ncross_fitted_predictions = np.zeros(len(labels_df), dtype=np.int32)\nthreshold_sets = []\n\nfor fold_index in range(N_FOLDS):\n    calibration_mask = labels_df[\"fold\"].to_numpy() != fold_index\n    evaluation_mask = ~calibration_mask\n\n    thresholds, _ = optimize_thresholds(\n        y_true[calibration_mask],\n        oof_scores[calibration_mask],\n    )\n    threshold_sets.append(thresholds)\n    cross_fitted_predictions[evaluation_mask] = apply_thresholds(\n        oof_scores[evaluation_mask],\n        thresholds,\n    )\n\ncross_fitted_qwk = cohen_kappa_score(\n    y_true,\n    cross_fitted_predictions,\n    weights=\"quadratic\",\n)\n\nfinal_thresholds = np.median(np.vstack(threshold_sets), axis=0)\n\nif cross_fitted_qwk > argmax_qwk + 0.002:\n    oof_predictions = cross_fitted_predictions\n    decoding_method = \"cross-fitted ordinal thresholds\"\nelse:\n    oof_predictions = argmax_oof_predictions\n    decoding_method = \"probability argmax\"\n\nprint(f\"Argmax OOF QWK: {argmax_qwk:.4f}\")\nprint(f\"Cross-fitted threshold OOF QWK: {cross_fitted_qwk:.4f}\")\nprint(\"Selected decoding:\", decoding_method)\nprint(\"Final thresholds:\", np.round(final_thresholds, 4))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:45:39.531897Z","iopub.execute_input":"2026-07-28T10:45:39.532548Z","iopub.status.idle":"2026-07-28T10:45:42.376453Z","shell.execute_reply.started":"2026-07-28T10:45:39.532516Z","shell.execute_reply":"2026-07-28T10:45:42.375597Z"}},"outputs":[],"execution_count":null},{"id":"71c27b9b","cell_type":"markdown","source":"## 10. Ensemble evaluation","metadata":{}},{"id":"fcded65b","cell_type":"code","source":"oof_metrics = pd.Series({\n    \"Accuracy\": accuracy_score(y_true, oof_predictions),\n    \"Macro F1\": f1_score(\n        y_true,\n        oof_predictions,\n        average=\"macro\",\n        zero_division=0,\n    ),\n    \"Weighted F1\": f1_score(\n        y_true,\n        oof_predictions,\n        average=\"weighted\",\n        zero_division=0,\n    ),\n    \"Quadratic Weighted Kappa\": cohen_kappa_score(\n        y_true,\n        oof_predictions,\n        weights=\"quadratic\",\n    ),\n}, name=\"Cross-validated ensemble\")\n\ndisplay(oof_metrics.to_frame().style.format(\"{:.4f}\"))\n\nreport = classification_report(\n    y_true,\n    oof_predictions,\n    labels=np.arange(NUM_CLASSES),\n    target_names=[severity_names[index] for index in range(NUM_CLASSES)],\n    output_dict=True,\n    zero_division=0,\n)\n\ndisplay(\n    pd.DataFrame(report).T.style.format({\n        \"precision\": \"{:.4f}\",\n        \"recall\": \"{:.4f}\",\n        \"f1-score\": \"{:.4f}\",\n        \"support\": \"{:.0f}\",\n    })\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:45:42.377523Z","iopub.execute_input":"2026-07-28T10:45:42.377925Z","iopub.status.idle":"2026-07-28T10:45:42.414701Z","shell.execute_reply.started":"2026-07-28T10:45:42.377896Z","shell.execute_reply":"2026-07-28T10:45:42.413917Z"}},"outputs":[],"execution_count":null},{"id":"d4701f7e","cell_type":"code","source":"matrix = confusion_matrix(\n    y_true,\n    oof_predictions,\n    labels=np.arange(NUM_CLASSES),\n)\n\nfig, ax = plt.subplots(figsize=(7.5, 6.5))\nsns.heatmap(\n    matrix,\n    annot=True,\n    fmt=\"d\",\n    cmap=\"Blues\",\n    cbar=False,\n    xticklabels=[severity_names[index] for index in range(NUM_CLASSES)],\n    yticklabels=[severity_names[index] for index in range(NUM_CLASSES)],\n    ax=ax,\n)\nax.set_title(\"Out-of-Fold Confusion Matrix\")\nax.set_xlabel(\"Predicted\")\nax.set_ylabel(\"Actual\")\nplt.xticks(rotation=30, ha=\"right\")\nplt.yticks(rotation=0)\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:45:42.415685Z","iopub.execute_input":"2026-07-28T10:45:42.416000Z","iopub.status.idle":"2026-07-28T10:45:42.597823Z","shell.execute_reply.started":"2026-07-28T10:45:42.415949Z","shell.execute_reply":"2026-07-28T10:45:42.596941Z"}},"outputs":[],"execution_count":null},{"id":"9ddd847e","cell_type":"code","source":"errors = labels_df[[\"id_code\", \"diagnosis\", \"image_path\"]].copy()\nerrors[\"predicted\"] = oof_predictions\nerrors[\"confidence\"] = oof_probabilities.max(axis=1)\nerrors = errors[errors[\"diagnosis\"] != errors[\"predicted\"]]\nerrors = errors.sort_values(\"confidence\", ascending=False).head(12)\n\nif len(errors):\n    columns = 4\n    rows = int(np.ceil(len(errors) / columns))\n    fig, axes = plt.subplots(rows, columns, figsize=(14, 3.5 * rows))\n    axes = np.atleast_1d(axes).ravel()\n\n    for ax, (_, row) in zip(axes, errors.iterrows()):\n        image = np.asarray(Image.open(row[\"image_path\"]).convert(\"RGB\"))\n        ax.imshow(image)\n        ax.set_title(\n            f\"True: {row['diagnosis']}  Pred: {row['predicted']}\\n\"\n            f\"Confidence: {row['confidence']:.2f}\"\n        )\n        ax.axis(\"off\")\n\n    for ax in axes[len(errors):]:\n        ax.axis(\"off\")\n\n    plt.suptitle(\"Most Confident Out-of-Fold Errors\", y=1.01)\n    plt.tight_layout()\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:45:42.599040Z","iopub.execute_input":"2026-07-28T10:45:42.599408Z","iopub.status.idle":"2026-07-28T10:45:43.847936Z","shell.execute_reply.started":"2026-07-28T10:45:42.599382Z","shell.execute_reply":"2026-07-28T10:45:43.846836Z"}},"outputs":[],"execution_count":null},{"id":"0dcd1b35","cell_type":"markdown","source":"## 11. Submission","metadata":{}},{"id":"57b32605","cell_type":"code","source":"if decoding_method == \"cross-fitted ordinal thresholds\":\n    test_scores = test_probabilities @ severity_values\n    test_predictions = apply_thresholds(test_scores, final_thresholds)\nelse:\n    test_predictions = test_probabilities.argmax(axis=1)\n\nsubmission = submission_template.drop(\n    columns=[\"source_path\", \"image_path\"],\n    errors=\"ignore\",\n).copy()\n\nif \"diagnosis\" not in submission.columns:\n    submission[\"diagnosis\"] = 0\n\nsubmission[\"diagnosis\"] = test_predictions.astype(int)\nsubmission_path = Path(\"/kaggle/working/submission.csv\")\nsubmission.to_csv(submission_path, index=False)\n\ntrain_distribution = (\n    labels_df[\"diagnosis\"].value_counts(normalize=True).sort_index().rename(\"Train\")\n)\ntest_distribution = (\n    submission[\"diagnosis\"].value_counts(normalize=True).sort_index().rename(\"Predicted test\")\n)\ndistribution_comparison = pd.concat(\n    [train_distribution, test_distribution],\n    axis=1,\n).fillna(0).mul(100)\ndistribution_comparison.index = [\n    severity_names[index] for index in distribution_comparison.index\n]\n\ndisplay(submission.head())\ndisplay(distribution_comparison.style.format(\"{:.2f}%\"))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:45:43.849399Z","iopub.execute_input":"2026-07-28T10:45:43.849878Z","iopub.status.idle":"2026-07-28T10:45:43.885810Z","shell.execute_reply.started":"2026-07-28T10:45:43.849810Z","shell.execute_reply":"2026-07-28T10:45:43.885024Z"}},"outputs":[],"execution_count":null},{"id":"09219327","cell_type":"markdown","source":"## 12. Save outputs","metadata":{}},{"id":"3b6a3e7d","cell_type":"code","source":"oof_frame = labels_df[[\"id_code\", \"diagnosis\", \"fold\"]].copy()\noof_frame[\"prediction\"] = oof_predictions\nfor class_index in range(NUM_CLASSES):\n    oof_frame[f\"probability_{class_index}\"] = oof_probabilities[:, class_index]\noof_frame.to_csv(OUTPUT_ROOT / \"oof_predictions.csv\", index=False)\n\ntest_probability_frame = pd.DataFrame(\n    test_probabilities,\n    columns=[f\"probability_{index}\" for index in range(NUM_CLASSES)],\n)\ntest_probability_frame.insert(0, \"id_code\", submission[\"id_code\"].values)\ntest_probability_frame.to_csv(\n    OUTPUT_ROOT / \"test_probabilities.csv\",\n    index=False,\n)\n\nfold_metrics_df.to_csv(OUTPUT_ROOT / \"fold_metrics.csv\", index=False)\nhistory_df.to_csv(OUTPUT_ROOT / \"training_history.csv\", index=False)\noof_metrics.rename_axis(\"Metric\").reset_index().to_csv(\n    OUTPUT_ROOT / \"ensemble_metrics.csv\",\n    index=False,\n)\n\nmetadata = {\n    \"backbone\": \"MobileNetV3Large\",\n    \"folds\": N_FOLDS,\n    \"image_size\": IMAGE_SIZE,\n    \"decoding_method\": decoding_method,\n    \"argmax_oof_qwk\": float(argmax_qwk),\n    \"cross_fitted_threshold_oof_qwk\": float(cross_fitted_qwk),\n    \"final_thresholds\": final_thresholds.tolist(),\n    \"ensemble_metrics\": {\n        key: float(value)\n        for key, value in oof_metrics.items()\n    },\n}\n\n(OUTPUT_ROOT / \"run_metadata.json\").write_text(\n    json.dumps(metadata, indent=2),\n    encoding=\"utf-8\",\n)\n\nprint(\"Saved:\")\nprint(f\"- {submission_path}\")\nprint(f\"- {OUTPUT_ROOT / 'oof_predictions.csv'}\")\nprint(f\"- {OUTPUT_ROOT / 'test_probabilities.csv'}\")\nprint(f\"- {OUTPUT_ROOT / 'fold_metrics.csv'}\")\nprint(f\"- {OUTPUT_ROOT / 'training_history.csv'}\")\nprint(f\"- {OUTPUT_ROOT / 'ensemble_metrics.csv'}\")\nprint(f\"- {OUTPUT_ROOT / 'run_metadata.json'}\")\nprint(f\"- {WEIGHTS_DIR}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T10:45:43.886729Z","iopub.execute_input":"2026-07-28T10:45:43.887061Z","iopub.status.idle":"2026-07-28T10:45:43.938924Z","shell.execute_reply.started":"2026-07-28T10:45:43.887038Z","shell.execute_reply":"2026-07-28T10:45:43.938180Z"}},"outputs":[],"execution_count":null}]}