{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nimport matplotlib.pyplot as plt\nfrom PIL import Image","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:42:10.356492Z","iopub.execute_input":"2026-09-16T14:42:10.356731Z","iopub.status.idle":"2026-09-16T14:42:10.626453Z","shell.execute_reply.started":"2026-09-16T14:42:10.356709Z","shell.execute_reply":"2026-09-16T14:42:10.625837Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"DATA = \"/kaggle/input/competitions/aptos2019-blindness-detection\"\ntrain = pd.read_csv(f\"{DATA}/train.csv\")\n\nprint(train.shape)\ntrain.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:42:10.627719Z","iopub.execute_input":"2026-09-16T14:42:10.627992Z","iopub.status.idle":"2026-09-16T14:42:10.660718Z","shell.execute_reply.started":"2026-09-16T14:42:10.627971Z","shell.execute_reply":"2026-09-16T14:42:10.660165Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"counts = train[\"diagnosis\"].value_counts().sort_index()\nnames = [\"no DR\", \"mild\", \"moderate\", \"severe\", \"proliferative\"]\n\nfor grade, n in counts.items():\n    print(f\"{grade} {names[grade]:15s} {n:5d} {n / len(train) * 100:5.1f}%\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:42:10.661586Z","iopub.execute_input":"2026-09-16T14:42:10.661859Z","iopub.status.idle":"2026-09-16T14:42:10.674902Z","shell.execute_reply.started":"2026-09-16T14:42:10.661838Z","shell.execute_reply":"2026-09-16T14:42:10.674202Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sizes = []\nfor code in train[\"id_code\"].sample(30, random_state=0):\n    with Image.open(f\"{DATA}/train_images/{code}.png\") as im:\n        sizes.append(im.size)\n\nfor s in sorted(set(sizes)):\n    print(s)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:42:10.675758Z","iopub.execute_input":"2026-09-16T14:42:10.676033Z","iopub.status.idle":"2026-09-16T14:42:11.187233Z","shell.execute_reply.started":"2026-09-16T14:42:10.676013Z","shell.execute_reply":"2026-09-16T14:42:11.186458Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"fig, axes = plt.subplots(2, 5, figsize=(20, 8))\n\nfor grade in range(5):\n    picks = train[train[\"diagnosis\"] == grade][\"id_code\"].sample(2, random_state=1).values\n    for row in range(2):\n        ax = axes[row, grade]\n        ax.imshow(Image.open(f\"{DATA}/train_images/{picks[row]}.png\"))\n        ax.set_title(f\"grade {grade}: {names[grade]}\" if row ==0 else \"\")\n        ax.axis(\"off\")\n        \nplt.tight_layout","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:42:11.189913Z","iopub.execute_input":"2026-09-16T14:42:11.190717Z","iopub.status.idle":"2026-09-16T14:42:16.042982Z","shell.execute_reply.started":"2026-09-16T14:42:11.190681Z","shell.execute_reply":"2026-09-16T14:42:16.041986Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Grade 3 is the problem class. 193 images out of 3,662. If you do a plain random 80/20 split, the validation set gets about 39 severe cases, and random chance alone can swing that badly. Two runs with different seeds would give you different scores for reasons that have nothing to do with the model. So the split has to be stratified: the same class proportions in train and validation, enforced rather than hoped for.","metadata":{}},{"cell_type":"code","source":"from sklearn.model_selection import train_test_split\n\ntr, va = train_test_split(\n    train,\n    test_size=0.2,\n    stratify=train[\"diagnosis\"],\n    random_state=42\n)\n\nprint(tr[\"diagnosis\"].value_counts(normalize=True).sort_index())\nprint(va[\"diagnosis\"].value_counts(normalize=True).sort_index())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:42:16.044202Z","iopub.execute_input":"2026-09-16T14:42:16.044864Z","iopub.status.idle":"2026-09-16T14:42:16.828435Z","shell.execute_reply.started":"2026-09-16T14:42:16.044837Z","shell.execute_reply":"2026-09-16T14:42:16.827766Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Suppose a microaneurysm is around 15 pixels across in the 4288 wide image. Resize that image straight down to 224 and you have divided by 19, so the lesion is now under a single pixel. It is gone. But take the 819 wide image, resize to 224, and you have only divided by 3.7, so the same lesion survives at 4 pixels.\n\nSame disease, same grade label, and the model sees it at completely different scales depending on which camera took the photo. It has no way to learn a consistent feature for it. Worse, image resolution is probably correlated with which clinic the patient attended, so the model can pick up on that instead of on pathology.\n\nThe fix is to normalize the retina, not the image. Every one of these photos is a circle of retina on a black background, and the amount of black varies with the camera's aspect ratio. If you crop tightly to the circle first and then resize, the retina occupies the same number of pixels in every image, so a lesion of a given real size lands at a consistent scale.","metadata":{}},{"cell_type":"code","source":"import cv2\n\ndef crop_to_retina(img, tol=7):\n    \"\"\"Trim the back border so only the retina circle remains\"\"\"\n    gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)\n    mask = gray > tol\n    if mask.sum() == 0:\n        return img\n    coords = np.argwhere(mask)\n    y0, x0 = coords.min(axis=0)\n    y1, x1 = coords.max(axis=0)\n    return img[y0:y1, x0:x1]\n    \ndef load_image(code, size=224, folder=\"train_images\"):\n    img = cv2.imread(f\"{DATA}/train_images/{code}.png\")\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    img = crop_to_retina(img)\n    img = cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA)\n    return img    ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:42:16.829315Z","iopub.execute_input":"2026-09-16T14:42:16.82967Z","iopub.status.idle":"2026-09-16T14:42:17.007109Z","shell.execute_reply.started":"2026-09-16T14:42:16.829649Z","shell.execute_reply":"2026-09-16T14:42:17.006328Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Now we turn 3,662 PNG files into arrays the model can consume.","metadata":{}},{"cell_type":"code","source":"from tqdm.notebook import tqdm\n\nIMG_SIZE = 224\n\ndef build_arrays(df, size=IMG_SIZE):\n    X = np.zeros((len(df), size, size, 3), dtype=np.uint8)\n    y = np.zeros(len(df), dtype=np.int32)\n    for i, (code, grade) in enumerate(\n        tqdm(zip(df[\"id_code\"], df[\"diagnosis\"]), total=len(df))\n    ):\n        X[i] = load_image(code, size=size)\n        y[i] = grade\n    return X, y\nX_tr, y_tr = build_arrays(tr)\nX_va, y_va = build_arrays(va)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:42:17.007966Z","iopub.execute_input":"2026-09-16T14:42:17.008343Z","iopub.status.idle":"2026-09-16T14:51:59.441537Z","shell.execute_reply.started":"2026-09-16T14:42:17.008305Z","shell.execute_reply":"2026-09-16T14:51:59.440701Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(X_tr.shape, X_tr.dtype, f\"{X_tr.nbytes / 1e9:.2f} GB\")\nprint(X_va.shape, X_va.dtype)\nprint(\"train:\", np.bincount(y_tr))\nprint(\"val:  \", np.bincount(y_va))\nprint(\"pixel range:\", X_tr.min(), X_tr.max())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:51:59.442718Z","iopub.execute_input":"2026-09-16T14:51:59.443038Z","iopub.status.idle":"2026-09-16T14:51:59.531669Z","shell.execute_reply.started":"2026-09-16T14:51:59.443003Z","shell.execute_reply":"2026-09-16T14:51:59.530988Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"np.save(\"/kaggle/working/X_tr.npy\", X_tr)\nnp.save(\"/kaggle/working/y_tr.npy\", y_tr)\nnp.save(\"/kaggle/working/X_va.npy\", X_va)\nnp.save(\"/kaggle/working/y_va.npy\", y_va)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:51:59.532557Z","iopub.execute_input":"2026-09-16T14:51:59.53284Z","iopub.status.idle":"2026-09-16T14:51:59.818543Z","shell.execute_reply.started":"2026-09-16T14:51:59.532819Z","shell.execute_reply":"2026-09-16T14:51:59.817649Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Networks train badly on inputs in the range 0 to 255. The gradients that come back are proportional to the input magnitudes, so large inputs produce large weight updates, and the first few steps can throw the weights somewhere useless. Scaling to 0 to 1 fixes it.","metadata":{}},{"cell_type":"code","source":"from tensorflow import keras\nfrom tensorflow.keras import layers\n\ninputs = keras.Input(shape=(IMG_SIZE, IMG_SIZE, 3))\nx = layers.Rescaling(1.0/255)(inputs)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:51:59.819487Z","iopub.execute_input":"2026-09-16T14:51:59.819859Z","iopub.status.idle":"2026-09-16T14:52:11.748108Z","shell.execute_reply.started":"2026-09-16T14:51:59.819828Z","shell.execute_reply":"2026-09-16T14:52:11.747512Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"fig, axes = plt.subplots(2, 5, figsize=(18, 8))\nfor grade in range(5):\n    idx = np.where(y_tr == grade)[0][:2]\n    for row in range(2):\n        axes[row, grade].imshow(X_tr[idx[row]])\n        axes[row, grade].set_title(f\"grade {grade}\" if row == 0 else \"\")\n        axes[row, grade].axis(\"off\")\nplt.tight_layout()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T14:52:11.748861Z","iopub.execute_input":"2026-09-16T14:52:11.749341Z","iopub.status.idle":"2026-09-16T14:52:12.804551Z","shell.execute_reply.started":"2026-09-16T14:52:11.749316Z","shell.execute_reply":"2026-09-16T14:52:12.803571Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from tensorflow import keras\nfrom tensorflow.keras import layers\n\nmodel = keras.Sequential([\n    keras.Input(shape=(224,224,3)),\n    layers.Rescaling(1.0 / 225),\n\n    layers.Conv2D(32, 3, activation=\"relu\", padding=\"same\"),\n    layers.MaxPooling2D(2),\n\n    layers.Conv2D(64, 3, activation=\"relu\", padding=\"same\"),\n    layers.MaxPooling2D(2),\n\n    layers.Conv2D(128, 3, activation=\"relu\", padding=\"same\"),\n    layers.MaxPooling2D(2),\n\n    layers.Conv2D(128, 3, activation=\"relu\", padding=\"same\"),\n    layers.MaxPooling2D(2),\n\n    layers.GlobalAveragePooling2D(),\n    layers.Dropout(0.3),\n    layers.Dense(5, activation=\"softmax\"),\n])\n\nmodel.summary()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T15:14:39.532751Z","iopub.execute_input":"2026-09-16T15:14:39.533541Z","iopub.status.idle":"2026-09-16T15:14:39.595727Z","shell.execute_reply.started":"2026-09-16T15:14:39.533509Z","shell.execute_reply":"2026-09-16T15:14:39.595004Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"model.compile(\n    optimizer=keras.optimizers.Adam(1e-3),\n    loss=\"sparse_categorical_crossentropy\",\n    metrics=[\"accuracy\"],\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T15:18:46.356765Z","iopub.execute_input":"2026-09-16T15:18:46.357534Z","iopub.status.idle":"2026-09-16T15:18:46.365694Z","shell.execute_reply.started":"2026-09-16T15:18:46.357503Z","shell.execute_reply":"2026-09-16T15:18:46.36498Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"history = model.fit(\n    X_tr, y_tr,\n    validation_data=(X_va, y_va),\n    epochs=15,\n    batch_size=32,\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T15:19:15.641359Z","iopub.execute_input":"2026-09-16T15:19:15.641926Z","iopub.status.idle":"2026-09-16T15:20:46.592264Z","shell.execute_reply.started":"2026-09-16T15:19:15.641898Z","shell.execute_reply":"2026-09-16T15:20:46.591494Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"hist = history.history\nepochs = range(1, len(hist[\"accuracy\"]) + 1)\n\nfig, ax = plt.subplots(1, 2, figsize=(14, 5))\n\nax[0].plot(epochs, hist[\"accuracy\"], label=\"train\")\nax[0].plot(epochs, hist[\"val_accuracy\"], label=\"val\")\nax[0].axhline(0.493, ls=\"--\", c=\"gray\", label=\"majority floor\")\nax[0].set_title(\"accuracy\"); ax[0].set_xlabel(\"epoch\"); ax[0].legend()\n\nax[1].plot(epochs, hist[\"loss\"], label=\"train\")\nax[1].plot(epochs, hist[\"val_loss\"], label=\"val\")\nax[1].set_title(\"loss\"); ax[1].set_xlabel(\"epoch\"); ax[1].legend()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T15:26:56.380443Z","iopub.execute_input":"2026-09-16T15:26:56.380973Z","iopub.status.idle":"2026-09-16T15:26:56.678108Z","shell.execute_reply.started":"2026-09-16T15:26:56.380947Z","shell.execute_reply":"2026-09-16T15:26:56.677239Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import confusion_matrix, classification_report\n\nprobs = model.predict(X_va, batch_size=64)\npred = probs.argmax(axis=1)\n\nprint(confusion_matrix(y_va, pred))\nprint()\nprint(classification_report(y_va, pred, digits=3,\n      target_names=[\"no DR\", \"mild\", \"moderate\", \"severe\", \"proliferative\"]))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T15:28:21.770206Z","iopub.execute_input":"2026-09-16T15:28:21.770857Z","iopub.status.idle":"2026-09-16T15:28:25.157856Z","shell.execute_reply.started":"2026-09-16T15:28:21.770827Z","shell.execute_reply":"2026-09-16T15:28:25.156993Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import cohen_kappa_score\nprint(round(cohen_kappa_score(y_va, pred, weights=\"quadratic\"), 4))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T15:32:48.280946Z","iopub.execute_input":"2026-09-16T15:32:48.281487Z","iopub.status.idle":"2026-09-16T15:32:48.287868Z","shell.execute_reply.started":"2026-09-16T15:32:48.281459Z","shell.execute_reply":"2026-09-16T15:32:48.287063Z"}},"outputs":[],"execution_count":null}]}