{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"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"},"colab":{"provenance":[]}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Train a Classifier on the Chest X-ray Dataset to Classify Pneumonia\n\nIn this notebook we will train a classifier using PyTorch Lightning to predict whether a chest X-Ray of a patient shows signs of pneumonia (lung opacities) or not based on the [RSNA Pneumonia Detection Challenge](https://www.kaggle.com/c/rsna-pneumonia-detection-challenge).","metadata":{"id":"bea9108a"}},{"cell_type":"code","source":"# install (only needed once):\n# !pip install pytorch-lightning","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:01.784616Z","iopub.execute_input":"2026-07-08T12:46:01.784937Z","iopub.status.idle":"2026-07-08T12:46:01.790488Z","shell.execute_reply.started":"2026-07-08T12:46:01.784901Z","shell.execute_reply":"2026-07-08T12:46:01.788977Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torchvision\nfrom torchvision import transforms\nimport torchmetrics\nimport pytorch_lightning as pl # for easy training implementation\nfrom pytorch_lightning.callbacks import ModelCheckpoint # to frequently store the weights\nfrom pytorch_lightning.loggers import TensorBoardLogger # to log to the tensorboard\nfrom pathlib import Path # for convenient path handling\nimport pydicom # for reading dicom files\nimport numpy as np\nimport cv2\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport matplotlib.patches as patches\nfrom tqdm.notebook import tqdm # for a nice progress bar\nimport random\nfrom sklearn.model_selection import train_test_split","metadata":{"id":"03ee0d9b","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:01.792373Z","iopub.execute_input":"2026-07-08T12:46:01.792732Z","iopub.status.idle":"2026-07-08T12:46:26.405281Z","shell.execute_reply.started":"2026-07-08T12:46:01.792694Z","shell.execute_reply":"2026-07-08T12:46:26.404291Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Data\n1. **stage_2_train_labels.csv**\n\n    In this challenge competitors are predicting whether pneumonia exists in a given image. They do so by predicting bounding boxes around areas of the lung. Samples without bounding boxes are negative and contain no definitive evidence of pneumonia. Samples with bounding boxes indicate evidence of pneumonia.\n\n    Columns are: \n    \n    * **`patientIds`**\n    * **bounding boxes `x` `y` `width` `height`**. \n    * `Target`, a binary target column indicating pneumonia or non-pneumonia.\n\n    When making predictions, competitors should predict as many bounding boxes as they feel are necessary, in the format: `confidence` `x` `y` `width` `height`. For more information, visit /kaggle/input/competitions/rsna-pneumonia-detection-challenge/stage_2_sample_submission.csv\n\n    *Note: Any given single image (specified by `patientId` in stage_2_train_labels.csv) may contain multiple boxes corresponding to possible pneumonia locations.*\n\n\n\n2. **stage_2_train_images.zip** and **stage_2_test_images.zip**\n\n    All provided images are in DICOM format. They contain a combination of header metadata as well as underlying raw image arrays for pixel data.","metadata":{}},{"cell_type":"markdown","source":"```\nimport os\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        print(os.path.join(dirname, filename))```","metadata":{}},{"cell_type":"code","source":"import os\nos.listdir(\"/kaggle/input/competitions/rsna-pneumonia-detection-challenge/\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:26.407254Z","iopub.execute_input":"2026-07-08T12:46:26.407782Z","iopub.status.idle":"2026-07-08T12:46:26.415553Z","shell.execute_reply.started":"2026-07-08T12:46:26.407750Z","shell.execute_reply":"2026-07-08T12:46:26.414815Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Exploring the Labels","metadata":{}},{"cell_type":"code","source":"# specify the current directory\nos.chdir(\"/kaggle/input/competitions/rsna-pneumonia-detection-challenge/\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:26.416850Z","iopub.execute_input":"2026-07-08T12:46:26.417295Z","iopub.status.idle":"2026-07-08T12:46:26.432112Z","shell.execute_reply.started":"2026-07-08T12:46:26.417260Z","shell.execute_reply":"2026-07-08T12:46:26.431151Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"os.getcwd()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:26.433404Z","iopub.execute_input":"2026-07-08T12:46:26.433940Z","iopub.status.idle":"2026-07-08T12:46:26.450531Z","shell.execute_reply.started":"2026-07-08T12:46:26.433821Z","shell.execute_reply":"2026-07-08T12:46:26.449720Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!ls","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:26.451558Z","iopub.execute_input":"2026-07-08T12:46:26.451818Z","iopub.status.idle":"2026-07-08T12:46:26.608295Z","shell.execute_reply.started":"2026-07-08T12:46:26.451787Z","shell.execute_reply":"2026-07-08T12:46:26.607151Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"labels = pd.read_csv(\"stage_2_train_labels.csv\")","metadata":{"id":"bd15e260","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:26.609848Z","iopub.execute_input":"2026-07-08T12:46:26.610159Z","iopub.status.idle":"2026-07-08T12:46:26.704931Z","shell.execute_reply.started":"2026-07-08T12:46:26.610120Z","shell.execute_reply":"2026-07-08T12:46:26.704078Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"labels.head(10)","metadata":{"id":"0a2c08d1","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:26.706456Z","iopub.execute_input":"2026-07-08T12:46:26.706719Z","iopub.status.idle":"2026-07-08T12:46:26.740661Z","shell.execute_reply.started":"2026-07-08T12:46:26.706692Z","shell.execute_reply":"2026-07-08T12:46:26.739700Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"labels.Target.unique()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:26.743644Z","iopub.execute_input":"2026-07-08T12:46:26.744004Z","iopub.status.idle":"2026-07-08T12:46:26.751444Z","shell.execute_reply.started":"2026-07-08T12:46:26.743974Z","shell.execute_reply":"2026-07-08T12:46:26.750540Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Data Visualizations","metadata":{}},{"cell_type":"code","source":"ROOT_PATH = Path(\"stage_2_train_images\")","metadata":{"id":"0f022d9b","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:26.752468Z","iopub.execute_input":"2026-07-08T12:46:26.752758Z","iopub.status.idle":"2026-07-08T12:46:26.765589Z","shell.execute_reply.started":"2026-07-08T12:46:26.752714Z","shell.execute_reply":"2026-07-08T12:46:26.764588Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Any `patientId` may contain multiple boxes corresponding to possible pneumonia locations.","metadata":{}},{"cell_type":"code","source":"unique_patientIds = labels.patientId.unique()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:26.766610Z","iopub.execute_input":"2026-07-08T12:46:26.767565Z","iopub.status.idle":"2026-07-08T12:46:26.787724Z","shell.execute_reply.started":"2026-07-08T12:46:26.767531Z","shell.execute_reply":"2026-07-08T12:46:26.786897Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"fig, axes = plt.subplots(3, 3, figsize=(9, 9))\n\nfor ax, patient_id in zip(axes.ravel(), unique_patientIds[:9]):\n    dcm_path = (ROOT_PATH / patient_id).with_suffix(\".dcm\")\n    dcm = pydicom.dcmread(dcm_path).pixel_array\n\n    patient_rows = labels.loc[labels.patientId == patient_id]\n    target = patient_rows[\"Target\"].iloc[0]\n\n    ax.imshow(dcm, cmap=\"bone\")\n\n    if target == 1:  # only draw boxes if there actually are any\n        for _, row in patient_rows.iterrows():\n            color = (random.random(), random.random(), random.random())\n            rect = patches.Rectangle(\n                (row[\"x\"], row[\"y\"]),\n                row[\"width\"], row[\"height\"],\n                linewidth=2, edgecolor=color, facecolor=\"none\")\n            ax.add_patch(rect)\n\n    ax.set_title(f\"Target: {target}\")\n    ax.axis(\"off\")\n\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:26.788902Z","iopub.execute_input":"2026-07-08T12:46:26.789270Z","iopub.status.idle":"2026-07-08T12:46:29.404241Z","shell.execute_reply.started":"2026-07-08T12:46:26.789225Z","shell.execute_reply":"2026-07-08T12:46:29.403286Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"axes.ravel() output: [axes[0,0], axes[0,1], axes[0,2], axes[1,0], axes[1,1], axes[1,2], axes[2,0], axes[2,1], axes[2,2]]\n\nzip takes two (or more) iterables and pairs them up element-by-element:\n`zip([a, b, c], [1, 2, 3])` yields: (a, 1), (b, 2), (c, 3)","metadata":{}},{"cell_type":"markdown","source":"### Exploring Detailed Labels","metadata":{}},{"cell_type":"code","source":"df_detailed = pd.read_csv('stage_2_detailed_class_info.csv')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:29.405574Z","iopub.execute_input":"2026-07-08T12:46:29.405866Z","iopub.status.idle":"2026-07-08T12:46:29.456744Z","shell.execute_reply.started":"2026-07-08T12:46:29.405837Z","shell.execute_reply":"2026-07-08T12:46:29.455932Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"df_detailed[\"class\"].value_counts()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:29.457748Z","iopub.execute_input":"2026-07-08T12:46:29.458040Z","iopub.status.idle":"2026-07-08T12:46:29.470118Z","shell.execute_reply.started":"2026-07-08T12:46:29.458009Z","shell.execute_reply":"2026-07-08T12:46:29.469318Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"labels.Target.value_counts()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:29.471457Z","iopub.execute_input":"2026-07-08T12:46:29.472282Z","iopub.status.idle":"2026-07-08T12:46:29.482604Z","shell.execute_reply.started":"2026-07-08T12:46:29.472233Z","shell.execute_reply":"2026-07-08T12:46:29.481754Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Preprocess","metadata":{"id":"90a69267"}},{"cell_type":"markdown","source":"### Label Duplication Removal","metadata":{}},{"cell_type":"code","source":"# Count how many duplicates exist\nlabels[\"patientId\"].duplicated().sum()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:29.483859Z","iopub.execute_input":"2026-07-08T12:46:29.484264Z","iopub.status.idle":"2026-07-08T12:46:29.502105Z","shell.execute_reply.started":"2026-07-08T12:46:29.484218Z","shell.execute_reply":"2026-07-08T12:46:29.501130Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"note that the goal of this task is to classify not to detect the pneumonia ... so we delete replicated patient-ids","metadata":{}},{"cell_type":"code","source":"labels = labels.drop_duplicates(subset=\"patientId\", keep=\"first\")","metadata":{"id":"5c2651f8","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:29.503250Z","iopub.execute_input":"2026-07-08T12:46:29.503640Z","iopub.status.idle":"2026-07-08T12:46:29.520609Z","shell.execute_reply.started":"2026-07-08T12:46:29.503609Z","shell.execute_reply":"2026-07-08T12:46:29.519710Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"labels.Target.value_counts()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:29.521797Z","iopub.execute_input":"2026-07-08T12:46:29.522162Z","iopub.status.idle":"2026-07-08T12:46:29.535621Z","shell.execute_reply.started":"2026-07-08T12:46:29.522116Z","shell.execute_reply":"2026-07-08T12:46:29.534845Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Now, this dataset consists of a unique patient id, the coordinates where pneumonia occurs, and the binary target variable that indicates whether a pneumonia is present in the given x-ray image or not.","metadata":{}},{"cell_type":"markdown","source":"### Dicom Reading & Effective storage\n\nIn order to efficiently handle our data in the Dataloader, we convert the X-Ray images stored in the DICOM format to numpy arrays. \n\nAfterwards we compute the overall mean and standard deviation of the pixels of the whole dataset, for the purpose of normalization.\n\nThen the created numpy images are stored in two separate folders according to their binary label:\n* 0: All X-Rays which do not show signs of pneumonia\n* 1: All X-Rays which show signs of pneumonia\n\nTo do so, we iterate over the patient ids and concat the patient ID with the ROOT_PATH.\n\nWe then directly save the standardized and resized files into the corresponding directory (0 for healthy, 1 for pneumonia).\nThis allows to take advantage of the ready-to-use torchvision **DatasetFolder** for simple file reading\n\n\nWe standardize all images by the maximum pixel value in the provided dataset, 255.\nAll images are resized to 224x224.\n\nTo compute dataset mean and standard deviation, we compute the sum of the pixel values as well as the sum of the squared pixel values for each subject.\nThis allows to compute the overall mean and standard deviation without keeping the whole dataset in memory.","metadata":{}},{"cell_type":"code","source":"# create the folder in the writable directory\nSAVE_PATH = Path(\"/kaggle/working/Processed-Pneumonia-Classification\") # to save our processed files","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:29.536652Z","iopub.execute_input":"2026-07-08T12:46:29.537355Z","iopub.status.idle":"2026-07-08T12:46:29.549057Z","shell.execute_reply.started":"2026-07-08T12:46:29.537320Z","shell.execute_reply":"2026-07-08T12:46:29.548186Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# --- Precompute the train/val split before the loop ---\ntrain_idx, val_idx = train_test_split(\n    labels.index,\n    test_size=0.2,       \n    shuffle=True,\n    stratify=labels.Target,  # keeps class balance consistent across train/val\n    random_state=42 # for reproducability\n)\ntrain_idx = set(train_idx)  # fast lookup inside the loop\n\nsums, sums_squared = 0, 0\nn_train = 0  # needed to average the running stats correctly\n\nfor c, patient_id in enumerate(tqdm(labels.patientId)):\n    dcm_path = (ROOT_PATH / patient_id).with_suffix(\".dcm\")\n    dcm = pydicom.dcmread(dcm_path).pixel_array / 255  # standardize 8-bit (256 grayscales)\n    \n    # dcm.shape = (1024, 1024) >> way to large to be handeled by Deep Learning models at the moment\n    # Let's use a shape of 224x224\n    dcm_array = cv2.resize(dcm, (224, 224)).astype(np.float16) # Saving as float16 ia about disk space efficiency. \n\n    label = labels.Target.iloc[c]\n    idx = labels.index[c]\n\n    train_or_val = \"train\" if idx in train_idx else \"val\"\n    current_save_path = SAVE_PATH / train_or_val / str(label)\n    current_save_path.mkdir(parents=True, exist_ok=True)\n    # parents=true >> creates all parent directories up to the file\n    # exist_ok=true >> prevents throwing an error if the directory already exists\n    \n    np.save(current_save_path / patient_id, dcm_array) # Save the array in the corresponding directory\n\n    normalizer = dcm_array.shape[0] * dcm_array.shape[1]\n    \n    if train_or_val == \"train\": \n        sums += np.sum(dcm_array) / normalizer\n        sums_squared += (dcm_array ** 2).sum() / normalizer\n        n_train += 1\n\nmean = sums / n_train\nstd = np.sqrt(sums_squared / n_train - mean ** 2) # this is the standard \"E[X²] − E[X]²\" trick for variance\n\nmean, std","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:46:29.550258Z","iopub.execute_input":"2026-07-08T12:46:29.550607Z","iopub.status.idle":"2026-07-08T12:55:08.066742Z","shell.execute_reply.started":"2026-07-08T12:46:29.550563Z","shell.execute_reply":"2026-07-08T12:55:08.065990Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"We will use mean and std later in the dataloader to normalize our data","metadata":{}},{"cell_type":"markdown","source":"## DataLoaders\n\nWe've preprocessed our DICOM/X-ray images earlier and saved each one as a .npy file with np.float16 type (a common pattern to avoid redoing expensive preprocessing every epoch), this reads that array back into memory.\n\n`load_file()` upconverts from float16 → float32. This matters because PyTorch (and most deep learning libraries) default to float32 for model weights and computations on CPU/GPU. Many operations either don't support float16 well on CPU, or can suffer from numerical instability (overflow/underflow, loss of precision during backprop) if you train directly in float16 without special handling (like mixed-precision training with torch.cuda.amp).","metadata":{"id":"6c009dc5"}},{"cell_type":"code","source":"def load_file(path):\n    return np.load(path).astype(np.float32)","metadata":{"id":"c43546bb","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:55:08.067700Z","iopub.execute_input":"2026-07-08T12:55:08.068011Z","iopub.status.idle":"2026-07-08T12:55:08.072519Z","shell.execute_reply.started":"2026-07-08T12:55:08.067979Z","shell.execute_reply":"2026-07-08T12:55:08.071697Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_transforms = transforms.Compose([transforms.ToTensor(),\n                                       transforms.Normalize(mean, std),\n                                       transforms.RandomAffine(degrees=(-5, 5), # random rotation\n                                                               translate=(0, 0.05), # random vertical shift\n                                                               scale=(0.9, 1.1)), # random zoom in/out\n                                       transforms.RandomResizedCrop((224, 224), scale=(0.35, 1))])\n\nval_transforms = transforms.Compose([transforms.ToTensor(),\n                                     transforms.Normalize(mean, std)])\n\n\ntrain_dataset = torchvision.datasets.DatasetFolder(f\"{SAVE_PATH}/train/\", loader=load_file, extensions=\"npy\", transform=train_transforms)\nval_dataset = torchvision.datasets.DatasetFolder(f\"{SAVE_PATH}/val/\", loader=load_file, extensions=\"npy\", transform=val_transforms)","metadata":{"id":"3b5923d0","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:55:08.073685Z","iopub.execute_input":"2026-07-08T12:55:08.073973Z","iopub.status.idle":"2026-07-08T12:55:08.168233Z","shell.execute_reply.started":"2026-07-08T12:55:08.073942Z","shell.execute_reply":"2026-07-08T12:55:08.167371Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"These three augmentations work together to simulate the kind of natural, minor variation that occurs across real chest X-ray captures, without distorting the image enough to invalidate the anatomy or labels.\n\nIn practice, when a patient is X-rayed, they're never positioned with pixel-perfect identical alignment each time: their torso may be tilted very slightly, positioned a bit higher or lower in the frame, or captured marginally closer/farther from the detector. The augmentation pipeline mimics exactly that kind of variability.\n\n`degrees=(-5, 5)` covers slight tilt in patient posture or the imaging angle. The range is kept narrow since anything beyond a few degrees would no longer resemble a realistic X-ray and could distort the position of anatomical structures relative to each other.\n\n`translate=(0, 0.05)` covers minor vertical repositioning, since patients aren't always framed at exactly the same height in the image. Horizontal shift is deliberately left at zero, preserving the left-right symmetry of the chest, which matters for correctly localizing pathology to the left or right lung.\n\n`scale=(0.9, 1.1)` covers small zoom differences, reflecting natural variation in how close or far the chest appears depending on patient size, distance from the detector, or machine calibration. A scale of 0.9 shrinks the image to 90% of its original size (zooms out slightly — you'd see a bit more \"background\"/padding around the edges). A scale of 1.1 enlarges it to 110% of its original size (zooms in slightly — you lose a bit of the outer edges of the image).\n\nTogether, these three augmentations make the model more robust to minor real-world positioning differences across patients and scans, rather than letting it overfit to one exact framing, angle, or zoom level from the training set. At the same time, all three are deliberately constrained to small ranges, since chest X-rays follow a fairly standardized format, and aggressive augmentation here could distort anatomy enough to make labels inaccurate or training counterproductive.","metadata":{}},{"cell_type":"code","source":"print(f\"There are {len(train_dataset)} train images and {len(val_dataset)} val images\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:55:08.169143Z","iopub.execute_input":"2026-07-08T12:55:08.169426Z","iopub.status.idle":"2026-07-08T12:55:08.174236Z","shell.execute_reply.started":"2026-07-08T12:55:08.169397Z","shell.execute_reply":"2026-07-08T12:55:08.173383Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"x_ray, label = train_dataset[0]\nx_ray, label","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:55:08.175732Z","iopub.execute_input":"2026-07-08T12:55:08.176092Z","iopub.status.idle":"2026-07-08T12:55:08.321087Z","shell.execute_reply.started":"2026-07-08T12:55:08.176050Z","shell.execute_reply":"2026-07-08T12:55:08.320378Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"fig, axes = plt.subplots(2, 2, figsize=(9, 9))\nnp.random.seed(42)\nfor ax in axes.ravel():\n    random_index = np.random.randint(0, len(train_dataset))\n    x_ray, label = train_dataset[random_index]\n    ax.imshow(x_ray[0], cmap=\"bone\") # x_ray[0] because the DatasetFolder class from torchvision.datasets adds an extra dimension at the end\n    ax.set_title(label)\n    ax.axis(\"off\")","metadata":{"id":"d6dc3f77","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:55:08.322086Z","iopub.execute_input":"2026-07-08T12:55:08.322475Z","iopub.status.idle":"2026-07-08T12:55:08.915634Z","shell.execute_reply.started":"2026-07-08T12:55:08.322441Z","shell.execute_reply":"2026-07-08T12:55:08.914510Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## DataLoaders","metadata":{"id":"c454a029"}},{"cell_type":"code","source":"batch_size = 64\nnum_workers = 4\n\ntrain_loader = torch.utils.data.DataLoader(train_dataset, batch_size=batch_size, num_workers=num_workers, shuffle=True)\nval_loader = torch.utils.data.DataLoader(val_dataset, batch_size=batch_size, num_workers=num_workers, shuffle=False)\n","metadata":{"id":"ae1382a9","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:55:08.916661Z","iopub.execute_input":"2026-07-08T12:55:08.916963Z","iopub.status.idle":"2026-07-08T12:55:08.923299Z","shell.execute_reply.started":"2026-07-08T12:55:08.916932Z","shell.execute_reply":"2026-07-08T12:55:08.922236Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# dataset.targets is a list of integer labels, one per sample\nunique, counts = np.unique(train_dataset.targets, return_counts=True)\ndict(zip(unique, counts))","metadata":{"id":"6b15c945","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:55:08.927518Z","iopub.execute_input":"2026-07-08T12:55:08.927899Z","iopub.status.idle":"2026-07-08T12:55:08.942020Z","shell.execute_reply.started":"2026-07-08T12:55:08.927840Z","shell.execute_reply":"2026-07-08T12:55:08.941130Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"another way to do so:","metadata":{}},{"cell_type":"code","source":"from collections import Counter\n\nCounter(train_dataset.targets)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:55:08.943150Z","iopub.execute_input":"2026-07-08T12:55:08.944083Z","iopub.status.idle":"2026-07-08T12:55:08.958656Z","shell.execute_reply.started":"2026-07-08T12:55:08.944035Z","shell.execute_reply":"2026-07-08T12:55:08.957644Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"we can see that our dataset is imbalanced >> much more images without pneumonia than there are with pneumonia.\n1. do nothing >> sometimes works surprisingly well\n2. use weighted loss >> assigns a higher loss to erroneous predictions\n3. oversampling\n\nHere we use the weighted loss method.\n\n### pos_weight choice:\nWhat it actually does?\n\nNormally, the loss treats every mistake equally. If your dataset is imbalanced (say, mostly \"no pneumonia\" images), the model can get away with just predicting \"no pneumonia\" most of the time and still score a low average loss — it's barely ever punished for missing actual pneumonia cases, since there are so few of them.\n\n`pos_weight` fixes this by multiplying the loss for positive-class examples by some factor, making mistakes on the positive class \"hurt more\" during training — pushing the model to pay more attention to the minority class instead of ignoring it.\n\nThe standard formula:\nThe most common, well-justified starting point:\n`pos_weight = num_negative_samples / num_positive_samples`\n\nFor your pneumonia dataset:\n```\nnum_negative = (labels.Target == 0).sum()\nnum_positive = (labels.Target == 1).sum()\npos_weight_value = num_negative / num_positive\n```\n\nYou can compute this directly from your labels dataframe:\n```\nvalue_counts = labels.Target.value_counts()\nprint(value_counts)\n```\n\n```\npos_weight_value = value_counts[0] / value_counts[1]\nprint(f\"pos_weight: {pos_weight_value:.2f}\")\n```\nThen plug it straight into your model:\n`model = PneumoniaModel(pos_weight=pos_weight_value)`\n\nImportant: compute this only from your training set\nJust like the mean/std normalization stats from earlier in this conversation, pos_weight should be computed only from train data, not the full dataset — otherwise you're leaking information about your validation distribution into training decisions.\n\n\n**Beyond the formula: treat it as a tunable hyperparameter**\nThe formula above is a mathematically principled starting point, not a hard rule. In practice:\n\n- If your model still misses too many positive cases (low recall on pneumonia), you can increase pos_weight further (e.g. multiply the computed value by 1.5–2x) to push the model to be more aggressive about flagging pneumonia.\n- If your model over-predicts pneumonia (low precision, too many false alarms), you can dial it back down.\n- This is a classic precision/recall tradeoff — for a medical screening task like pneumonia detection, you'd typically want to lean toward higher recall (catching real cases matters more than the cost of some false alarms), so slightly increasing pos_weight above the computed baseline is a reasonable choice, but it's ultimately something you should tune based on your validation metrics (ideally tracking precision/recall or a confusion matrix, not just raw accuracy — which can be misleading on imbalanced data).","metadata":{"id":"85988f29"}},{"cell_type":"code","source":"pos_weight_value = counts[0] / counts[1]\nprint(f\"pos_weight: {pos_weight_value:.2f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T12:55:08.959902Z","iopub.execute_input":"2026-07-08T12:55:08.960343Z","iopub.status.idle":"2026-07-08T12:55:08.973483Z","shell.execute_reply.started":"2026-07-08T12:55:08.960296Z","shell.execute_reply":"2026-07-08T12:55:08.972609Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Model Creation in PyTorch Lightening","metadata":{"id":"58963856"}},{"cell_type":"code","source":"torchvision.models.resnet18()","metadata":{"id":"85b8324e","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T13:05:12.427350Z","iopub.execute_input":"2026-07-08T13:05:12.428260Z","iopub.status.idle":"2026-07-08T13:05:12.623556Z","shell.execute_reply.started":"2026-07-08T13:05:12.428217Z","shell.execute_reply":"2026-07-08T13:05:12.622635Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"When working with pytorch: you inherit the model from from the nn.Module. When working with pytorch lightening: pl.LighteningModule\n\n### The big idea: what problem does Lightning solve?\nIn plain PyTorch, training a model means writing your own loop:\n```\nfor epoch in range(num_epochs):\n    for batch in dataloader:\n        optimizer.zero_grad()\n        pred = model(x)\n        loss = loss_fn(pred, y)\n        loss.backward()\n        optimizer.step()\n    # ...then repeat similar code for validation, logging, checkpointing, GPU handling...\n```\nYou end up rewriting the same boilerplate (loop over epochs, loop over batches, move data to GPU, log things, save checkpoints) in every project.\n\n**PyTorch Lightning's idea:** separate the boring, repetitive \"engineering\" part (the loop itself) from the interesting \"science\" part (what happens in one step). You just tell Lightning:\n\n- \"here's my model\"\n- \"here's what to do with one batch during training\"\n- \"here's what to do with one batch during validation\"\n- \"here's my optimizer\"\n\n...and Lightning handles the loop, the GPU/CPU movement, logging infrastructure, etc.\n\nSo our `PneumoniaModel` class is really just a set of instructions, not a loop you run yourself. Lightning (via a Trainer object we will create in the following) reads these instructions and drives everything.","metadata":{"id":"a4de3f5c"}},{"cell_type":"code","source":"class PneumoniaModel(pl.LightningModule):\n    \"\"\"\n    Binary pneumonia classifier built on a ResNet18 backbone,\n    adapted for single-channel (gray-scale) chest X-ray input.\n\n    Args:\n        pos_weight: Positive-class weight for BCEwithLogitsLoss, used\n        to counteract class imbalance (fewer positive pneumonia cases)\n\n        lr: Learning rate for the Adam optimizer\n    \"\"\"\n\n    NUM_OUTPUTS = 1 # binary classification > single logit output\n    def __init__(self, pos_weight: float = 1.0, lr: float = 1e-4):\n        \n        super().__init__()\n\n        self.save_hyperparameters()  \n        \n        self.lr = lr\n        \n        self.model = self._build_backbone()\n        \n        self.loss_fn = torch.nn.BCEWithLogitsLoss(pos_weight=torch.tensor([pos_weight], dtype=torch.float32))\n\n        # metrics: track accuracy over time\n        self.train_acc = torchmetrics.Accuracy(task=\"binary\")\n        self.val_acc = torchmetrics.Accuracy(task=\"binary\")\n\n    def _build_backbone(self) -> torch.nn.Module:\n        \"\"\"\n        Builds a ResNet18 adapted for single-channel input and binary output\n        \"\"\"\n        model = torchvision.models.resnet18()\n        \n        # Replace conv1: 3 input channels (RGB) -> 1 (grayscale X-ray)\n        model.conv1 = torch.nn.Conv2d(\n            in_channels=1,\n            out_channels=64,\n            kernel_size=(7, 7),\n            stride= (2, 2),\n            padding= (3, 3),\n            bias = False,\n        )\n        # Replace the classification head: 1000 classes -> 1 logit (binary task)\n        model.fc = torch.nn.Linear(\n            in_features=model.fc.in_features,\n            out_features=self.NUM_OUTPUTS,\n            bias=True,\n        )\n        return model\n        \n        \n\n    def forward(self, x: torch.Tensor) -> torch.Tensor:\n        return self.model(x)\n\n    def _shared_step(self, batch, stage: str) -> torch.Tensor:\n        \"\"\"\n        Runs a forward pass + loss/accuracy logging, shared by\n        train and val steps.\n        One \"step\" = processing one batch\n        \"\"\"\n        x_ray, label = batch\n        label = label.float() # required dtype for BCEWithLogitsLoss\n\n        # the probability of the positive class — the probability of the negative class is just 1 - p\n        logits = self(x_ray)[:, 0] # (batch_size, 1) -> (batch_size,) to match label shape\n        loss = self.loss_fn(logits, label)\n\n        acc_metric = self.train_acc if stage == \"train\" else self.val_acc\n        self.log(f\"{stage}_loss\", loss)\n        self.log(f\"{stage}_step_acc\", acc_metric(torch.sigmoid(logits), label.int()))\n        return loss\n    \n    def training_step(self, batch, batch_idx: int) -> torch.Tensor:\n        return self._shared_step(batch, stage=\"train\")\n    \n    def validation_step(self, batch, batch_idx: int) -> torch.Tensor:\n        return self._shared_step(batch, stage=\"val\")\n\n    def on_train_epoch_end(self) -> None:\n        self.log(\"train_epoch_acc\", self.train_acc.compute())\n        self.train_acc.reset() # since torchmetrics accumulates state across the entire run by default\n\n    def on_validation_epoch_end(self) -> None:\n        self.log(\"val_epoch_acc\", self.val_acc.compute())\n        self.train_acc.reset()\n\n    def configure_optimizers(self):\n        optimizer = torch.optim.Adam(self.model.parameters(), lr=self.lr)\n        return optimizer","metadata":{"id":"e71674a3","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T13:05:19.565989Z","iopub.execute_input":"2026-07-08T13:05:19.566417Z","iopub.status.idle":"2026-07-08T13:05:19.585688Z","shell.execute_reply.started":"2026-07-08T13:05:19.566359Z","shell.execute_reply":"2026-07-08T13:05:19.584882Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Putting it all together — the mental model\nThink of your class as answering four questions, and Lightning fills in everything else:\nQuestion\\Method\n- What are my building blocks? `__init__`\n- How does data flow through my model? `forward` \n- What do I do with one batch? `training_step / validation_step`\n- How do I update my weights? `configure_optimizers`\n        \n**`super().__init__()`**: means \"run the parent class's (pl.LightningModule's) setup first, before adding my own stuff.\" This is required so Lightning's internal machinery works.\n\n**`self.save_hyperparameters()`**: is a Lightning-specific convenience: it automatically remembers the arguments you passed in (pos_weight, lr) and saves them alongside your checkpoints/logger. Purely for bookkeeping — not required for training to work. So you can always trace back what hyperparameters a given checkpoint was trained with.\n\nYou'll notice we never call `self.forward(x)` directly elsewhere — instead we write **`self(x)`**. That's a PyTorch convention: calling the model like a function (`self(x)`) automatically triggers `forward()` under the hood, plus some internal bookkeeping. Just know `self(x)` and `self.forward(x)` do the same thing here.\n\n**`self.log(...)`** tells Lightning \"please track this number and show it to me\" (e.g. in a progress bar, TensorBoard, etc.). You don't need to manually print or store these — Lightning handles the display for you.\n\nReturn the loss in **`_shared_step`** function — this is important: Lightning needs the loss to be returned so it can automatically call `.backward()` and update the model's weights. You never write `loss.backward()` yourself — Lightning does it for you because you returned loss.\n\nWhat the underscore means in **`_shared_step`** — yes, it's a convention, not a Lightning-specific rule. It's a general Python convention.\nIn Python, a leading underscore (`_shared_step`) is a signal to other programmers (and to yourself, later) that says:\n\"This method is an internal implementation detail. It's not part of the public interface — don't call it directly from outside this class.\"\n\nCompare:\n```\ndef training_step(self, batch, batch_idx):\n# no underscore -> \"public\" method\ndef _shared_step(self, batch, stage):\n# underscore -> \"private/internal\" helper\n```\n\n`training_step` and `validation_step` are meant to be called from outside — specifically, Lightning's Trainer calls them for you. They're part of the \"contract\" between your class and Lightning.\n`_shared_step` is not something Lightning knows about or calls directly. It's just a helper you created to avoid duplicating code between training_step and validation_step. It only makes sense to call it from inside the class.\n\nImportant nuance: Python doesn't actually enforce this\nUnlike some languages (Java, C++) that have real private keywords that block access, Python's underscore is purely a convention — a form of communication between programmers, not a technical restriction. Nothing stops you from writing `model._shared_step(batch, \"train\")` from outside the class; Python will happily run it. The underscore just signals \"this isn't designed for that, proceed at your own risk.\"\nThis is sometimes summarized as Python's philosophy: \"we're all consenting adults here\" — the language trusts you to respect the convention rather than mechanically forbidding it.\n\nWhy it's useful here specifically\nWithout the underscore, if someone (including future-you) is skimming the class, shared_step sitting next to training_step and validation_step might look like it's another Lightning hook — like it's also automatically called at some special time. The underscore makes it visually obvious: this one's different, it's just a regular helper method I wrote for myself, not something Lightning is orchestrating.\n\n\nQuick reference:\nConvention: Meaning\n- `method_name`: Public — part of the intended interface, safe to call from outside\n- `_method_name`: Internal/private — implementation detail, don't rely on it externally\n- `__method_name`: Name-mangled (rarely used) — stronger internal-use signal, changes the actual attribute name internally\n\n\n**_method_name` vs. `__method_name`**\n\n**Single underscore: `_method_name`**\n\nPure convention, no real effect. Python does nothing special with it. It's just a signal to humans: \"this is internal, don't use it from outside.\" The method works exactly the same as one without an underscore.\n```\nclass Foo:\n    def _helper(self):\n        return \"I still work exactly the same\"\n\nf = Foo()\nf._helper()  # works fine, Python doesn't stop you\n```\n\n**Double underscore: `__method_name`**\nPython actually changes the name behind the scenes. This is called name mangling. Python internally renames `__method_name` to `_ClassName__method_name`, which makes it genuinely awkward (though not impossible) to access from outside the class.\n```\nclass Foo:\n    def __helper(self):\n        return \"I got renamed internally\"\n\nf = Foo()\nf.__helper()          # ❌ AttributeError — this name doesn't exist anymore\nf._Foo__helper()      # ✅ works, but ugly and clearly not meant to be used this way\n```\n\nWhy does double underscore exist?\nMainly to avoid name clashes in inheritance. If a subclass accidentally defines a method with the same name as one in the parent class, mangling keeps them from silently overwriting each other.\n```\nclass Parent:\n    def __secret(self):\n        return \"parent's version\"\n    def reveal(self):\n        return self.__secret()  # actually calls self._Parent__secret()\n\nclass Child(Parent):\n    def __secret(self):  # this does NOT override Parent's __secret\n        return \"child's version\"  # actually becomes self._Child__secret()\n\nParent.__secret gets mangled to _Parent__secret\nChild.__secret gets mangled to _Child__secret\n\n\nc = Child()\nprint(c.reveal())  # prints \"parent's version\" -- no accidental override happened\n```\nwhy??\n`__secret` gets name-mangled at definition time — each class's `__secret` becomes its own uniquely-named attribute, based on which class it was defined in.\n\n`Parent.__secret` gets mangled to `_Parent__secret`\n`Child.__secret` gets mangled to `_Child__secret`\nThese are now two completely different, unrelated names — not an override at all, even though they look identical in the source code.\n\nThen look at reveal:\nThis method is defined inside Parent, so at mangling time it becomes:\n```\ndef reveal(self):\n    return self._Parent__secret()   # hardcoded to look specifically for _Parent__secret\n```\nSo when you call c.reveal():\n\nc is a Child instance, but reveal() was defined in Parent.\nBecause of mangling, reveal() is permanently wired to call `self._Parent__secret()` — it has no idea `_Child__secret` even exists.\nPython looks up `_Parent__secret` on the instance → finds Parent's version → runs it.\n\nThe contrast with single underscore:\nIf you'd used `_secret` (single underscore) instead, there'd be no mangling — Child's _secret would simply overwrite/override Parent's _secret in the normal way, and c.reveal() would print \"child's version\" instead.\n\nThis is exactly the point of double-underscore name mangling: it prevents accidental overriding across parent/child classes, at the cost of also preventing intentional overriding — which is a trade-off, and part of why single underscore is far more common in everyday code (including your _shared_step), while double underscore is reserved for specific cases where you deliberately want to avoid subclass interference.\n\n\nFor `_shared_step` case: single underscore is the right and common choice. Double underscore is overkill here — we're not worried about subclass name collisions, we just want to signal \"this is a helper, not a Lightning hook.\"\n\n\n**`training_step` / `validation_step`:** These are special method names that Lightning specifically looks for. You never call training_step yourself — Lightning's Trainer automatically calls `training_step(batch, batch_idx)` once for every batch during training, and `validation_step(batch, batch_idx)` once for every batch during validation. That's the whole \"magic\": Lightning knows to look for methods with these exact names and calls them at the right time, in the right loop.\n`batch_idx` just tells you which batch number you're on (0, 1, 2, ...) within the current epoch — most of the time you don't need to use it, but Lightning always passes it in.\n\n**`on_train_epoch_end` / `on_validation_epoch_end`:** An epoch = one full pass through your entire dataset (all batches, not just one). These methods are, again, special names Lightning looks for and calls automatically — once at the end of every training epoch / validation epoch.\n\n**`configure_optimizers`:** This is the last special method Lightning looks for. It answers: which optimizer (the algorithm that updates the model's weights based on the loss) should be used, and at what learning rate? Lightning calls this once at the start of training and uses whatever you return to perform the actual weight updates — using the `loss` you returned from `training_step` behind the scenes.\n\nVarious loggers are supported including the tensorboard logger, a simple text-based logger, and also the biases and weights logger. Throughout this notebook, we use the tensorboard logger.","metadata":{"id":"424666ed"}},{"cell_type":"markdown","source":"## Create Checkpoint Callback\nto store checkpoints during training","metadata":{"id":"c38f6af0"}},{"cell_type":"code","source":"checkpoint_callback = ModelCheckpoint(\n    dirpath=\"/kaggle/working/Processed-Pneumonia-Classification/checkpoints\",   # your own fixed folder\n    filename=\"pneumonia-{epoch:02d}-{val_epoch_acc:.3f}\",\n    monitor=\"val_epoch_acc\",\n    mode=\"max\",\n    save_top_k=1,\n)","metadata":{"id":"49196186","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T13:05:31.264443Z","iopub.execute_input":"2026-07-08T13:05:31.265389Z","iopub.status.idle":"2026-07-08T13:05:31.272948Z","shell.execute_reply.started":"2026-07-08T13:05:31.265348Z","shell.execute_reply":"2026-07-08T13:05:31.271733Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Common values for save_top_k**\n- `save_top_k=1` Keep only the single best checkpoint (most common choice — saves disk space)\n- `save_top_k=10` Keep the 10 best checkpoints (useful if you want to later ensemble multiple good checkpoints, or compare a few candidates)\n- `save_top_k=-1` Keep every checkpoint, no deletion (useful for debugging or if you want a full training history)\n- `save_top_k=0` Don't save any checkpoints based on this callback at all","metadata":{}},{"cell_type":"markdown","source":"**Where weights are stored?**\nDefault location: If you didn't specify a custom path, Lightning defaults to:`/kaggle/working/lightning_logs/version_X/checkpoints/`\n\nThe version_0, version_1, etc. folders increment each time you re-run trainer.fit(...) in the same session (so you don't overwrite previous runs' checkpoints).\n\n**How to find the exact path programmatically?**\nThe cleanest way — ask the callback directly, right after training:\nprint(checkpoint_callback.best_model_path)   # path to the single best checkpoint\nprint(checkpoint_callback.best_k_models)      # dict of {path: score} for all saved top-k checkpoints\n\n**Setting a custom, predictable path (recommended)**\nRather than relying on the auto-generated `lightning_logs/version_X/...` structure, it's often cleaner to specify your own directory explicitly.\nThis way you always know exactly where to look — `/kaggle/working/checkpoints/` — without digging through auto-versioned folders, and the filename itself will tell you the epoch and validation accuracy at a glance.\n\n**Important: Kaggle-specific gotcha**\nFiles in `/kaggle/working/` only persist if you do a \"Save and Run All\" (Commit) — as we discussed earlier — since that's what actually saves the output directory as part of the notebook version. If you just leave the notebook running interactively (or do a Quick Save without the \"save output\" option), those checkpoint files will disappear once your session ends, and you won't be able to retrieve them later.\n\n`{epoch:02d}` → looks up \"epoch\" in callback_metrics, formats it as a zero-padded 2-digit integer (e.g. 03, 12)\n`{val_epoch_acc:.3f}` → looks up \"val_epoch_acc\" in callback_metrics, formats it as a float with 3 decimal places (e.g. 0.842)\n","metadata":{}},{"cell_type":"markdown","source":"## Create a Trainer","metadata":{"id":"877f50a4"}},{"cell_type":"markdown","source":"Trainer documentation: https://pytorch-lightning.readthedocs.io/en/latest/common/trainer.html","metadata":{}},{"cell_type":"code","source":"gpus = 2 # no gpus we can use, 0 for cpu training\ntrainer = pl.Trainer(accelerator=\"gpu\",\n                     devices=2,\n                     logger=TensorBoardLogger(save_dir=f\"{SAVE_PATH}/logs\"),\n                     log_every_n_steps=1,\n                     callbacks=checkpoint_callback,\n                     max_epochs=30)","metadata":{"id":"1b346cc8","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T10:12:52.428461Z","iopub.execute_input":"2026-07-08T10:12:52.429074Z","iopub.status.idle":"2026-07-08T10:12:52.488125Z","shell.execute_reply.started":"2026-07-08T10:12:52.429044Z","shell.execute_reply":"2026-07-08T10:12:52.487579Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(trainer.callback_metrics)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Load Tensorboard\n\nTensorBoardLogger doesn't save weights — it saves training metrics and metadata over time, so you can visualize things like loss curves and accuracy trends. \n\nHere, you launch the TensorBoard dashboard before calling trainer.fit(...), ideally in the cell just before it (or even a separate cell). Since TensorBoard auto-refreshes periodically, this lets you literally watch the loss/accuracy curves update live as training progresses across your 30 epochs — very useful for a long-running job where you want to spot problems early (e.g. loss exploding, or val accuracy plateauing) without waiting for all 30 epochs to finish.\n\nThe other option is to launch it after training finishes (post-hoc review).\nThis is simpler if you just want to review the final results afterward, rather than babysit training in real time.","metadata":{}},{"cell_type":"code","source":"%load_ext tensorboard\n%tensorboard --logdir {SAVE_PATH}/logs","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"This shows you exactly what keys are available at any given point — useful for double-checking spelling before you commit to a monitor= or filename= string, rather than debugging after a crash.","metadata":{}},{"cell_type":"code","source":"model = PneumoniaModel(pos_weight=pos_weight_value)\ntrainer.fit(model, train_loader, val_loader)","metadata":{"id":"e5b85593","trusted":true,"execution":{"iopub.status.busy":"2026-07-08T10:12:56.015085Z","iopub.execute_input":"2026-07-08T10:12:56.015948Z","iopub.status.idle":"2026-07-08T10:33:15.294648Z","shell.execute_reply.started":"2026-07-08T10:12:56.015916Z","shell.execute_reply":"2026-07-08T10:33:15.293588Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Practical note for Kaggle specifically**\nKaggle notebooks do support inline TensorBoard via `%tensorboard`, but it can occasionally be a bit finicky (rendering issues, or needing a manual refresh) compared to running locally or in Colab. If it doesn't render properly inline, an alternative is to just inspect the metrics directly via the logger's CSV/scalar data, or use a `CSVLogger` alongside `TensorBoardLogger` as a simpler backup:","metadata":{}},{"cell_type":"code","source":"from pytorch_lightning.loggers import CSVLogger\n\ncsv_logger = CSVLogger(save_dir=f\"{SAVE_PATH}/logs\", name=\"csv_logs\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"This writes a plain `metrics.csv` file you can load with pandas and plot manually with matplotlib if TensorBoard's inline rendering gives you trouble on Kaggle.","metadata":{}},{"cell_type":"markdown","source":"## Download weights","metadata":{}},{"cell_type":"markdown","source":"All the weights:","metadata":{}},{"cell_type":"code","source":"for f in os.listdir(\"/kaggle/working/checkpoints\"):\n    print(f)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import shutil\n\nbest_path = checkpoint_callback.best_model_path\nshutil.copy(best_path, \"/kaggle/working/best_model.ckpt\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Then after committing, you'll see a single clearly-labeled best_model.ckpt in the Output tab, instead of having to hunt through nested checkpoints/ folders.","metadata":{}},{"cell_type":"code","source":"from IPython.display import FileLink\nFileLink(\"/kaggle/working/best_model.ckpt\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Model Evaluation","metadata":{"id":"aa1c2957"}},{"cell_type":"code","source":"device = torch.device(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")\n\nmodel = PneumoniaModel.load_from_checkpoint(checkpoint_callback.best_model_path,\n                                            map_location=device) # or load_from_checkpoint(PATH TO .ckpt FILE)\n\nprint(model.hparams)         \nprint(model.hparams.lr)       \nprint(model.hparams.pos_weight) \n\nmodel.eval() # pytorch automaically prints a summary of the model\nmodel.to(device) # with map_location=device technically redundant, but harmless\n\npreds = []\nlabels = []\n\nwith torch.no_grad():\n    for data, label in tqdm(val_dataset):\n        data = data.to(device).float().unsqueeze(0)\n        #unsqueeze(0) to add the batch axis in front of all other dims\n        pred = torch.sigmoid(model(data)[0].cpu()) # [0] to remove the batch channel dimension\n        preds.append(pred)\n        labels.append(label)\n\npreds = torch.tensor(preds)\nlabels = torch.tensor(labels).int() # torch metrics always wants the labels to be of type integers\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"we should take a look at accuracy to get an overall intuition how well our model performs.\n\nNext, as our dataset is imbalanced, we absolutely have to inspect precision and recall. Those values tell us whether our model has a problem of predicting one of the two classes.\n\nAnd last but not the least, we can compute the confusion matrix.","metadata":{"id":"b00284cf"}},{"cell_type":"code","source":"acc = torchmetrics.Accuracy(task=\"binary\")(preds, labels)\nprecision = torchmetrics.Precision(task=\"binary\")(preds, labels)\nrecall = torchmetrics.Recall(task=\"binary\")(preds, labels)\ncm = torchmetrics.ConfusionMatrix(num_classes=2, task=\"binary\")(preds, labels)\n\nprint(f\"Val Accuracy: {acc}\")\nprint(f\"Val Precision: {precision}\")\nprint(f\"Val Recall: {recall}\")\nprint(f\"Confusion Matrix: {cm}\")","metadata":{"id":"b7944e4f","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"high recall (high FPR): we can see that the recall is much higher than the precision >> our model rarely misses a case of pneumonia\n\nlow precision (low FNR): many images without pneumonia are also called as pneumonia\n\nP=positive >> with pneumonia\nN=negative >> without pneumonia\n\nNOTE::: It is better to have high FPR than to miss too many pneumonia (high FNR)\n\nAn alternative to retraining with a weighted loss is to reduce the classification threshold from 0.5 to e.g 0.25. It produces way less false negatives but increases the number of False positives. <br />\nThis is called the precision-recall tradeoff\n\n","metadata":{"id":"07543b21"}},{"cell_type":"code","source":"cm_threshed = torchmetrics.ConfusionMatrix(num_classes=2, threshold=0.25)(preds, labels)\ncm_threshed","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"","metadata":{}},{"cell_type":"markdown","source":"","metadata":{}},{"cell_type":"code","source":"import torch\nimport torchmetrics\nfrom tqdm import tqdm\n\n\ndef load_trained_model(checkpoint_path: str, device: torch.device) -> \"PneumoniaModel\":\n    \"\"\"Loads a PneumoniaModel from a checkpoint and prepares it for inference.\"\"\"\n    model = PneumoniaModel.load_from_checkpoint(checkpoint_path, map_location=device)\n    model.eval()       # disables dropout/batchnorm training behavior\n    model.to(device)   # redundant with map_location, but explicit and harmless\n\n    print(\"Loaded model hyperparameters:\")\n    print(model.hparams)\n    return model\n\n\n@torch.no_grad()\ndef run_inference(model: \"PneumoniaModel\", val_loader, device: torch.device):\n    \"\"\"\n    Runs the model over every batch in `val_loader` and collects predicted\n    probabilities alongside ground-truth labels.\n\n    Returns:\n        preds: (N,) tensor of predicted probabilities (post-sigmoid)\n        true_labels: (N,) tensor of ground-truth integer labels\n    \"\"\"\n    preds, true_labels = [], []\n\n    for images, batch_labels in tqdm(val_loader, desc=\"Running inference\"):\n        images = images.to(device).float()          # (batch_size, C, H, W) -- already batched by DataLoader\n        logits = model(images)[:, 0]                  # (batch_size, 1) -> (batch_size,)\n        probs = torch.sigmoid(logits).cpu()\n\n        preds.append(probs)\n        true_labels.append(batch_labels)\n\n    preds = torch.cat(preds)                       # (N,) tensor of probabilities across all batches\n    true_labels = torch.cat(true_labels).int()       # torchmetrics expects integer labels\n    return preds, true_labels\n\n\ndef compute_metrics(preds: torch.Tensor, true_labels: torch.Tensor, threshold: float = 0.5) -> dict:\n    \"\"\"Computes standard binary classification metrics given predicted probabilities.\"\"\"\n    accuracy = torchmetrics.Accuracy(task=\"binary\", threshold=threshold)(preds, true_labels)\n    precision = torchmetrics.Precision(task=\"binary\", threshold=threshold)(preds, true_labels)\n    recall = torchmetrics.Recall(task=\"binary\", threshold=threshold)(preds, true_labels)\n    confusion_matrix = torchmetrics.ConfusionMatrix(task=\"binary\", threshold=threshold)(preds, true_labels)\n\n    return {\n        \"accuracy\": accuracy,\n        \"precision\": precision,\n        \"recall\": recall,\n        \"confusion_matrix\": confusion_matrix,\n    }\n\n\ndef print_metrics(metrics: dict) -> None:\n    \"\"\"Pretty-prints the computed metrics dictionary.\"\"\"\n    print(f\"Val Accuracy:  {metrics['accuracy']:.4f}\")\n    print(f\"Val Precision: {metrics['precision']:.4f}\")\n    print(f\"Val Recall:    {metrics['recall']:.4f}\")\n    print(f\"Confusion Matrix:\\n{metrics['confusion_matrix']}\")\n\n\n# ---------------------------------------------------------------------------\n# Main evaluation flow\n# ---------------------------------------------------------------------------\ndevice = torch.device(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")\n\nmodel = load_trained_model(checkpoint_callback.best_model_path, device)\n\npreds, true_labels = run_inference(model, val_loader, device)\n\nmetrics = compute_metrics(preds, true_labels, threshold=0.5)\nprint_metrics(metrics)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Interpretability","metadata":{"id":"5cdb7723"}},{"cell_type":"markdown","source":"we will learn how to use [Class Acvitation Maps (CAM)](https://arxiv.org/abs/1512.04150). to visualize image regions which are the most important for the deicion of a classifier.","metadata":{"id":"5209afef"}},{"cell_type":"markdown","source":"### Method: CAM","metadata":{"id":"e6851ca3"}},{"cell_type":"markdown","source":"The key idea of CAM is to multiply the output of the last convolutional layer (BasicBlock 1 of layer 4) $A_k$ (consisting of k channels) with the parameters $w$ of the subsequent fully connected layer to compute an activation map $M$:\n$$ M = \\sum_k w_kA_k$$\n\nTo do so, we need to access this particular output of the trained resnet18.<br />\nLet's recap the resnet architecture:","metadata":{}},{"cell_type":"code","source":"temp_model = torchvision.models.resnet18()\ntemp_model","metadata":{"id":"eb4b987f","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"We can see that the last convolution layer is conv2 in layer 4. In fact, we can also take the output of the subsequent batch norm layer >> all layers except the last 2 (avgpool and fc)\n\nwhy we used bn2 instead of conv2?\n\nalthough we converted our network to a list, we still cannot access the individual layers within the sequential blocks. If you want to do that, you also have to convert these blocks into a list which is very complicated\n\nIn pytorch, we can convert the network to a generator by using the children function, allowing us to loop over all layers (make the model iteratable) and also note that we can simply convert a generator to a list.","metadata":{"id":"c0780504"}},{"cell_type":"code","source":"list(temp_model.children())[:-2] # get all layers up to avgpool","metadata":{"id":"182df2b4","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"we can use `torch.nn.Sequential` to convert the list of layers into a pytorch model.\n\nstar operator: to unpack the list of layers into positional arguments.","metadata":{"id":"b8d69c48"}},{"cell_type":"code","source":"torch.nn.Sequential(*list(temp_model.children())[:-2])","metadata":{"id":"5d18ec2b","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"now that we have threw away the adaptive global average pooling and fully connected layer, we need to manuslly add them back to the network.","metadata":{"id":"b5389527"}},{"cell_type":"code","source":"class PneumoniaModel2(pl.LighteningModule):\n    def __init__(self):\n        super().__init__()\n        self.model = torchvision.models.resnet18()\n        self.model.conv1 = torch.nn.Conv2d(1,\n                                           64,\n                                           kernel_size=(7, 7),\n                                           stride= (2, 2),\n                                           padding= (3, 3),\n                                           bias = False)\n        self.model.fc = torch.nn.Linear(in_features=512,\n                                        out_features=1,\n                                        bias=True)\n\n        self.feature_map = torch.nn.Sequential(*list(self.model.children())[:-2])\n\n    def forward(self, data):\n        feature_map = self.feature_map(data)\n        # compresses the 7*7*512 featuremap into a tensor of size 1*1*512 by taking the mean\n        avg_pool_output = torch.nn.functional.adaptive_avg_pool2d(input=feature_map, output_size=(1, 1))\n        # flatten by creating a vector of size 512\n        avg_output_flatten = torch.flatten(avg_pool_output)\n        pred = self.model.fc(avg_output_flatten)\n        return pred, feature_map","metadata":{"id":"0e6f837a","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"model2 = PneumoniaModel2.load_from_checkpoint(\"weights/weights_3.ckpt\", strict=False)\nmodel2.eval()","metadata":{"id":"68ccb326","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"`strict=False`: we changed the structure of our model by adding the self.feature_map variable. However, since in the weights, `self.feature_map` does not exist when training the model, we did not store explicit weights for this variable. Therefore, pytorch will throw an error.\n\n when passing `strict=False`, it simply loads all the weights it can match and ignores the rest.","metadata":{"id":"08647e77"}},{"cell_type":"code","source":"def cam(model, img):\n    with torch.no_grad():\n        pred, features = model(img.unsqueeze(0)) #feature shape: 512*7*7\n    \n    b, c, h, w = features.shape\n    features = features.reshape((c, h*w))\n    print(features.shape)\n\n    # extract the weights of the fc layer:\n    weight_params = list(model.model.fc.parameters())[0]\n    # model.model.fc.parameters() is a generator containing all the weights\n    # list() converts it to a list\n    # [0]: weight, [1]: bias\n    \n    weight = weight_params[0].detach() # Remove gradient information from weight parameters to enable numpy computation in future\n    print(weight.shape)\n    \n    cam = torch.matmul(weight, features) # This yields a vector of 49 (7x7 elements)\n    \n    # Normalize and standardize the class activation map\n    cam = cam - torch.min(cam)\n    cam_img = cam / torch.max(cam)\n\n    cam_img = cam_img.reshape((7, 7)).cpu()\n\n    return cam_img, torch.sigmoid(pred)","metadata":{"id":"51038c9e","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def visualize(img, cam, pred):\n    img = img[0] # remove the channel dimension\n    cam = transforms.functional.resize(cam.unsqueeze(0), (224, 224))[0]\n    # resize expects a channel dimension that is why we added unsqueeze(0)\n    # [0] removes the channel dimension for matplotlib\n    fig, axis = plt.subplot(1, 2)\n    axis[0].imshow(img, cmap=\"bone\")\n    axis[1].imshow(img, cmap=\"bone\")\n    axis[1].imshow(cam, cmap=\"jet\", alpha=0.5)\n    plt.title(pred>0.5) #true: pneumonia","metadata":{"id":"f04d4a54","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"img = val_dataset[-6][0]\nactivation_map, pred = cam(model2, img)\nvisualize(img, activation_map, pred)\n","metadata":{"id":"797f7dbb","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Awesome, you made it! <br />\nYou can try out this method for all neural networks with one restriction: The classic CAM algorithm requrires a specific network architecture:<br />\nLast Convolution $\\rightarrow$ Global Average Pool $\\rightarrow$ 1 FC Layer.\n\nFor other network architectures, you either need to adapt the architecture, followed by using different methods such as GradCAM or ScoreCAM (https://arxiv.org/abs/1610.02391), (https://arxiv.org/abs/1910.01279)","metadata":{}}]}