{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":113558,"databundleVersionId":14878066,"sourceType":"competition"},{"sourceId":273198292,"sourceType":"kernelVersion"},{"sourceId":273489140,"sourceType":"kernelVersion"},{"sourceId":289453473,"sourceType":"kernelVersion"}],"dockerImageVersionId":31236,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import subprocess\nimport sys\n\n# Install required packages\npackages = [\n    'timm',\n    'albumentations'\n]\n\nfor package in packages:\n    try:\n        __import__(package.replace('-', '_'))\n        print(f'✓ {package} already installed')\n    except ImportError:\n        print(f'Installing {package}...')\n        subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-q', package])\n        print(f'✓ {package} installed')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-05T05:43:29.483235Z","iopub.execute_input":"2026-01-05T05:43:29.483738Z","iopub.status.idle":"2026-01-05T05:44:14.935380Z","shell.execute_reply.started":"2026-01-05T05:43:29.483709Z","shell.execute_reply":"2026-01-05T05:44:14.934341Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"","metadata":{}},{"cell_type":"markdown","source":"## Setup & Imports","metadata":{}},{"cell_type":"code","source":"import os\nimport sys\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader, random_split\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nimport timm\nfrom pathlib import Path\nimport time\nimport warnings\nwarnings.filterwarnings('ignore')\n\n# Set random seeds\ntorch.manual_seed(42)\nnp.random.seed(42)\n\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f\"Device: {DEVICE}\")\nprint(f\"PyTorch: {torch.__version__}\")\nif torch.cuda.is_available():\n    print(f\"GPU: {torch.cuda.get_device_name(0)}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-05T05:44:14.937168Z","iopub.execute_input":"2026-01-05T05:44:14.938295Z","iopub.status.idle":"2026-01-05T05:44:15.812962Z","shell.execute_reply.started":"2026-01-05T05:44:14.938231Z","shell.execute_reply":"2026-01-05T05:44:15.812301Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Configuration","metadata":{}},{"cell_type":"code","source":"# Paths - Detect if running on Kaggle or locally\nKAGGLE_OUTPUT = Path('/kaggle/working')\nKAGGLE_INPUT = Path('/kaggle/input')\n\n# Check if running on Kaggle\nif KAGGLE_INPUT.exists() and KAGGLE_OUTPUT.exists():\n    print(\"Running on Kaggle - looking for dataset...\")\n    \n    # List available datasets\n    available_datasets = list(KAGGLE_INPUT.glob('*'))\n    print(f\"Available datasets: {[d.name for d in available_datasets]}\")\n    \n    # Look for the dataset in /kaggle/input\n    dataset_dirs = list(KAGGLE_INPUT.glob('*recodai*')) + list(KAGGLE_INPUT.glob('*sifd*')) + list(KAGGLE_INPUT.glob('*forgery*'))\n    \n    if dataset_dirs:\n        DATASET_PATH = dataset_dirs[0]\n        print(f\"Found dataset: {DATASET_PATH.name}\")\n    else:\n        # If not found, prompt user to add dataset\n        print(\"\\n⚠ Dataset not found in /kaggle/input\")\n        print(\"Please add the 'recodai-luc-scientific-image-forgery-detection' dataset:\")\n        print(\"1. Click the '+' button next to 'Input'\")\n        print(\"2. Search for 'recodai-luc-scientific-image-forgery-detection'\")\n        print(\"3. Add it as input and restart the notebook\")\n        raise FileNotFoundError(\"Dataset not added to Kaggle notebook. Please add the competition dataset as input.\")\n    \n    TRAIN_IMAGES = DATASET_PATH / 'train_images'\n    TRAIN_MASKS = DATASET_PATH / 'train_masks'\n    TEST_IMAGES = DATASET_PATH / 'test_images'\n    OUTPUT_DIR = KAGGLE_OUTPUT\n    \n    # Verify paths exist\n    if not TRAIN_IMAGES.exists():\n        raise FileNotFoundError(f\"Train images not found at {TRAIN_IMAGES}\")\n    if not TRAIN_MASKS.exists():\n        raise FileNotFoundError(f\"Train masks not found at {TRAIN_MASKS}\")\n    if not TEST_IMAGES.exists():\n        raise FileNotFoundError(f\"Test images not found at {TEST_IMAGES}\")\n    \n    print(f\"✓ All dataset paths verified\")\nelse:\n    # Running locally\n    print(\"Running locally...\")\n    SIFD_ROOT = Path('/home/swatson/work/MachineLearning/kaggle/SIFD_short')\n    TRAIN_IMAGES = SIFD_ROOT / 'train_images'\n    TRAIN_MASKS = SIFD_ROOT / 'train_masks'\n    TEST_IMAGES = SIFD_ROOT / 'test_images'\n    OUTPUT_DIR = SIFD_ROOT / 'output'\n\n# Ensure output directory exists\nOUTPUT_DIR.mkdir(parents=True, exist_ok=True)\n\n# Training config\nIMG_SIZE = 512\nBATCH_SIZE = 4\nEPOCHS = 22\nLR = 1e-4\nVAL_SPLIT = 0.15\nTRAIN_SUBSET = 3250\n\n# Inference config\nCLASSIFIER_THRESHOLD = 0.5\nSEG_THRESHOLD = 0.35\nMIN_AREA = 300\n\nprint(f\"\\nConfiguration:\")\nprint(f\"  IMG_SIZE: {IMG_SIZE}\")\nprint(f\"  BATCH_SIZE: {BATCH_SIZE}\")\nprint(f\"  EPOCHS: {EPOCHS}\")\nprint(f\"  LR: {LR}\")\nprint(f\"  TRAIN_SUBSET: {TRAIN_SUBSET}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-05T05:44:15.813908Z","iopub.execute_input":"2026-01-05T05:44:15.814312Z","iopub.status.idle":"2026-01-05T05:44:15.827267Z","shell.execute_reply.started":"2026-01-05T05:44:15.814290Z","shell.execute_reply":"2026-01-05T05:44:15.826667Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def get_image_files(directory):\n    \"\"\"Get all image files recursively\"\"\"\n    images = []\n    for root, dirs, files in os.walk(directory):\n        for file in files:\n            if file.lower().endswith(('.png', '.jpg', '.jpeg', '.tiff')):\n                images.append(os.path.join(root, file))\n    return sorted(images)\n\ndef match_images_and_masks_npy(image_dir, mask_dir):\n    \"\"\"Match images with .npy masks by filename\"\"\"\n    images = []\n    masks = []\n    \n    # Get all npy mask files\n    mask_dict = {}\n    for f in os.listdir(mask_dir):\n        if f.endswith('.npy'):\n            stem = f.replace('.npy', '')\n            mask_dict[stem] = os.path.join(mask_dir, f)\n    \n    # Find matching images\n    for root, dirs, files in os.walk(image_dir):\n        for file in files:\n            if file.lower().endswith(('.png', '.jpg', '.jpeg', '.tiff')):\n                img_path = os.path.join(root, file)\n                stem = Path(file).stem\n                if stem in mask_dict:\n                    images.append(img_path)\n                    masks.append(mask_dict[stem])\n    \n    return sorted(images), sorted(masks)\n\nprint(\"Loading data...\")\ntrain_images, train_masks = match_images_and_masks_npy(TRAIN_IMAGES, TRAIN_MASKS)\ntest_images = get_image_files(TEST_IMAGES)\n\nprint(f\"Training pairs: {len(train_images)}\")\nprint(f\"Test images: {len(test_images)}\")\n\ntrain_images = train_images[:min(TRAIN_SUBSET, len(train_images))]\ntrain_masks = train_masks[:min(TRAIN_SUBSET, len(train_masks))]\nprint(f\"Using {len(train_images)} training images\")\n\n# Create binary labels from masks (1=forged, 0=authentic)\n# A mask is considered forged if it has any positive pixels\ntrain_labels = []\nfor mask_path in train_masks:\n    if mask_path.endswith('.npy'):\n        mask = np.load(mask_path).astype(np.uint8)\n    else:\n        mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)\n        mask = (mask > 127).astype(np.uint8) * 255\n    # Label is 1 if mask has any positive pixels (forged), 0 otherwise (authentic)\n    label = 1 if mask.sum() > 0 else 0\n    train_labels.append(label)\n\ntrain_labels = np.array(train_labels)\nprint(f\"Forged samples: {(train_labels == 1).sum()}, Authentic samples: {(train_labels == 0).sum()}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-05T05:44:15.828553Z","iopub.execute_input":"2026-01-05T05:44:15.828761Z","iopub.status.idle":"2026-01-05T05:44:43.140434Z","shell.execute_reply.started":"2026-01-05T05:44:15.828743Z","shell.execute_reply":"2026-01-05T05:44:43.139651Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Data Loading Functions","metadata":{}},{"cell_type":"code","source":"def get_image_files(directory):\n    \"\"\"Get all image files recursively\"\"\"\n    images = []\n    for root, dirs, files in os.walk(directory):\n        for file in files:\n            if file.lower().endswith(('.png', '.jpg', '.jpeg', '.tiff')):\n                images.append(os.path.join(root, file))\n    return sorted(images)\n\ndef match_images_and_masks_npy(image_dir, mask_dir):\n    \"\"\"Match images with .npy masks by filename\"\"\"\n    images = []\n    masks = []\n    \n    # Get all npy mask files\n    mask_dict = {}\n    for f in os.listdir(mask_dir):\n        if f.endswith('.npy'):\n            stem = f.replace('.npy', '')\n            mask_dict[stem] = os.path.join(mask_dir, f)\n    \n    # Find matching images\n    for root, dirs, files in os.walk(image_dir):\n        for file in files:\n            if file.lower().endswith(('.png', '.jpg', '.jpeg', '.tiff')):\n                img_path = os.path.join(root, file)\n                # Try exact stem match\n                stem = Path(file).stem\n                if stem in mask_dict:\n                    images.append(img_path)\n                    masks.append(mask_dict[stem])\n    \n    return sorted(images), sorted(masks)\n\nprint(f\"Data loading functions defined\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-05T05:44:43.141556Z","iopub.execute_input":"2026-01-05T05:44:43.141933Z","iopub.status.idle":"2026-01-05T05:44:43.148463Z","shell.execute_reply.started":"2026-01-05T05:44:43.141910Z","shell.execute_reply":"2026-01-05T05:44:43.147731Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Model Architecture","metadata":{}},{"cell_type":"code","source":"class ConvBlock(nn.Module):\n    def __init__(self, in_channels, out_channels):\n        super().__init__()\n        self.conv = nn.Sequential(\n            nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, bias=False),\n            nn.BatchNorm2d(out_channels),\n            nn.ReLU(inplace=True)\n        )\n    \n    def forward(self, x):\n        return self.conv(x)\n\nclass FPNDecoder(nn.Module):\n    def __init__(self, encoder_channels, pyramid_channels=256, segmentation_channels=128):\n        super().__init__()\n        self.encoder_channels = encoder_channels\n        \n        # Lateral connections (reduce channel dimension)\n        self.lateral_convs = nn.ModuleList([\n            nn.Conv2d(ch, pyramid_channels, kernel_size=1) \n            for ch in encoder_channels[1:]  # Skip first (input)\n        ])\n        \n        # Output smoothing convs\n        self.output_convs = nn.ModuleList([\n            nn.Conv2d(pyramid_channels, pyramid_channels, kernel_size=3, padding=1)\n            for _ in range(len(encoder_channels) - 1)\n        ])\n        \n        # Segmentation head\n        self.seg_blocks = nn.ModuleList([\n            ConvBlock(pyramid_channels, segmentation_channels)\n            for _ in range(len(encoder_channels) - 1)\n        ])\n        \n        # Final output\n        self.final = nn.Sequential(\n            ConvBlock(segmentation_channels * (len(encoder_channels) - 1), segmentation_channels),\n            nn.Conv2d(segmentation_channels, 1, kernel_size=1)\n        )\n    \n    def forward(self, features):\n        # features[0] is input image, features[1:] are encoder outputs\n        pyramid_features = []\n        \n        # Build pyramid from top to bottom\n        x = self.lateral_convs[-1](features[-1])  # Start from deepest\n        pyramid_features.append(self.output_convs[-1](x))\n        \n        for i in range(len(features) - 3, -1, -1):  # Go up the pyramid\n            lateral = self.lateral_convs[i](features[i + 1])\n            x = F.interpolate(x, size=lateral.shape[2:], mode='nearest')\n            x = x + lateral\n            pyramid_features.append(self.output_convs[i](x))\n        \n        pyramid_features = pyramid_features[::-1]  # Reverse to bottom-up\n        \n        # Segmentation head for each level\n        seg_outputs = []\n        for i, feat in enumerate(pyramid_features):\n            seg = self.seg_blocks[i](feat)\n            seg_outputs.append(seg)\n        \n        # Upsample all to same size\n        target_size = seg_outputs[0].shape[2:]\n        upsampled = [seg_outputs[0]]\n        for seg in seg_outputs[1:]:\n            upsampled.append(F.interpolate(seg, size=target_size, mode='bilinear', align_corners=False))\n        \n        # Concatenate and final conv\n        merged = torch.cat(upsampled, dim=1)\n        output = self.final(merged)\n        \n        return output\n\nclass AttentionGate(nn.Module):\n    def __init__(self, in_channels):\n        super().__init__()\n        hidden1 = max(8, in_channels // 2)\n        hidden2 = max(4, in_channels // 4)\n        self.conv = nn.Sequential(\n            nn.Conv2d(in_channels, hidden1, kernel_size=1),\n            nn.BatchNorm2d(hidden1),\n            nn.ReLU(inplace=True),\n            nn.Conv2d(hidden1, hidden2, kernel_size=3, padding=1),\n            nn.BatchNorm2d(hidden2),\n            nn.ReLU(inplace=True),\n            nn.Conv2d(hidden2, 1, kernel_size=1),\n            nn.Sigmoid()\n        )\n    \n    def forward(self, x):\n        return x * self.conv(x)\n\nclass AttentionFPN(nn.Module):\n    def __init__(self, encoder='resnet50', pretrained=False):\n        super().__init__()\n        # Load encoder from timm (no pretrained weights - works offline)\n        self.encoder = timm.create_model(\n            encoder,\n            pretrained=pretrained,\n            features_only=True,\n            out_indices=(0, 1, 2, 3, 4)\n        )\n        \n        # Get channel dimensions from encoder\n        with torch.no_grad():\n            dummy = torch.randn(1, 3, 224, 224)\n            features = self.encoder(dummy)\n            encoder_channels = [f.shape[1] for f in features]\n        \n        # FPN decoder\n        self.decoder = FPNDecoder(encoder_channels)\n        \n        # Attention gate\n        self.attention = AttentionGate(1)\n    \n    def forward(self, x):\n        # Get encoder features\n        features = self.encoder(x)\n        \n        # Decode with FPN\n        out = self.decoder(features)\n        \n        # Upsample to input size\n        out = F.interpolate(out, size=x.shape[2:], mode='bilinear', align_corners=False)\n        \n        # Apply attention\n        return self.attention(out)\n\nclass BinaryClassifier(nn.Module):\n    def __init__(self, backbone='efficientnet_b2', pretrained=False):\n        super().__init__()\n        self.backbone = timm.create_model(\n            backbone,\n            pretrained=pretrained,\n            num_classes=0\n        )\n        num_features = self.backbone.num_features\n        self.classifier = nn.Sequential(\n            nn.Linear(num_features, 128),\n            nn.BatchNorm1d(128),\n            nn.ReLU(),\n            nn.Dropout(0.4),\n            nn.Linear(128, 1)\n        )\n    \n    def forward(self, x):\n        features = self.backbone(x)\n        return self.classifier(features)\n\n# Initialize two segmentation models for ensemble (no pretrained weights for offline use)\nseg_model_1 = AttentionFPN(encoder='resnet50', pretrained=False).to(DEVICE)\nseg_model_2 = AttentionFPN(encoder='resnet34', pretrained=False).to(DEVICE)\nclassifier_model = BinaryClassifier(backbone='efficientnet_b2', pretrained=False).to(DEVICE)\n\nprint(\"Models initialized (2 segmentation + 1 classifier) - no pretrained weights\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-05T05:44:43.149303Z","iopub.execute_input":"2026-01-05T05:44:43.149531Z","iopub.status.idle":"2026-01-05T05:44:44.411647Z","shell.execute_reply.started":"2026-01-05T05:44:43.149505Z","shell.execute_reply":"2026-01-05T05:44:44.411012Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class ForgeryDataset(Dataset):\n    def __init__(self, image_paths, mask_paths, size=512, augment=True):\n        self.image_paths = image_paths\n        self.mask_paths = mask_paths\n        self.size = size\n        \n        if augment:\n            self.transform = A.Compose([\n                A.Resize(size, size),\n                A.HorizontalFlip(p=0.5),\n                A.VerticalFlip(p=0.5),\n                A.Rotate(limit=20, p=0.5),\n                A.Affine(scale=(0.9, 1.1), p=0.3),\n                A.GaussNoise(p=0.3),\n                A.GaussianBlur(blur_limit=3, p=0.2),\n                A.RandomBrightnessContrast(p=0.3),\n                A.Normalize(),\n                ToTensorV2(),\n            ], is_check_shapes=False)\n        else:\n            self.transform = A.Compose([\n                A.Resize(size, size),\n                A.Normalize(),\n                ToTensorV2(),\n            ], is_check_shapes=False)\n    \n    def __len__(self):\n        return len(self.image_paths)\n    \n    def __getitem__(self, idx):\n        image = cv2.imread(self.image_paths[idx])\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        \n        # Load mask - handle both .npy and image formats\n        mask_path = self.mask_paths[idx]\n        if mask_path.endswith('.npy'):\n            mask = np.load(mask_path).astype(np.uint8)\n            # Ensure mask is 2D\n            if len(mask.shape) == 3:\n                mask = mask[:, :, 0]\n        else:\n            mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)\n            mask = (mask > 127).astype(np.uint8) * 255\n        \n        # Ensure mask dimensions match image exactly\n        if mask.shape[0] != image.shape[0] or mask.shape[1] != image.shape[1]:\n            mask = cv2.resize(mask, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_NEAREST)\n        \n        transformed = self.transform(image=image, mask=mask)\n        \n        # transformed['mask'] shape after ToTensorV2: [H, W]\n        # Add channel dimension to make it [1, H, W] for the loss function\n        mask_tensor = transformed['mask'].unsqueeze(0).float() / 255.0\n        \n        return transformed['image'], mask_tensor\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-05T05:44:44.412439Z","iopub.execute_input":"2026-01-05T05:44:44.412654Z","iopub.status.idle":"2026-01-05T05:44:44.421558Z","shell.execute_reply.started":"2026-01-05T05:44:44.412635Z","shell.execute_reply":"2026-01-05T05:44:44.420860Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Create segmentation dataset and loaders\nprint(\"\\nCreating data loaders...\")\ndataset = ForgeryDataset(train_images, train_masks, augment=True)\nval_size = int(len(dataset) * VAL_SPLIT)\ntrain_size = len(dataset) - val_size\ntrain_dataset, val_dataset = random_split(dataset, [train_size, val_size])\n\ntrain_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)\nval_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)\n\nprint(f\"Training set: {len(train_dataset)} images ({len(train_loader)} batches)\")\nprint(f\"Validation set: {len(val_dataset)} images ({len(val_loader)} batches)\")\n\n# Create separate binary classifier dataset\nprint(\"\\nCreating classifier dataset...\")\nclass BinaryClassifierDataset(Dataset):\n    def __init__(self, image_paths, labels, size=384, augment=True):\n        self.image_paths = image_paths\n        self.labels = labels\n        self.size = size\n        \n        if augment:\n            self.transform = A.Compose([\n                A.Resize(size, size),\n                A.HorizontalFlip(p=0.5),\n                A.VerticalFlip(p=0.5),\n                A.Rotate(limit=20, p=0.5),\n                A.Affine(scale=(0.9, 1.1), p=0.3),\n                A.GaussNoise(p=0.3),\n                A.GaussianBlur(blur_limit=3, p=0.2),\n                A.RandomBrightnessContrast(p=0.3),\n                A.Normalize(),\n                ToTensorV2(),\n            ])\n        else:\n            self.transform = A.Compose([\n                A.Resize(size, size),\n                A.Normalize(),\n                ToTensorV2(),\n            ])\n    \n    def __len__(self):\n        return len(self.image_paths)\n    \n    def __getitem__(self, idx):\n        image = cv2.imread(self.image_paths[idx])\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        \n        transformed = self.transform(image=image)\n        label = torch.tensor([self.labels[idx]], dtype=torch.float32)\n        \n        return transformed['image'], label\n\nclf_dataset = BinaryClassifierDataset(train_images, train_labels, size=384, augment=True)\nclf_val_size = int(len(clf_dataset) * VAL_SPLIT)\nclf_train_size = len(clf_dataset) - clf_val_size\nclf_train_dataset, clf_val_dataset = random_split(clf_dataset, [clf_train_size, clf_val_size])\n\nclf_train_loader = DataLoader(clf_train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)\nclf_val_loader = DataLoader(clf_val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)\n\nprint(f\"Classifier training set: {len(clf_train_dataset)} images ({len(clf_train_loader)} batches)\")\nprint(f\"Classifier validation set: {len(clf_val_dataset)} images ({len(clf_val_loader)} batches)\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-05T05:44:44.422447Z","iopub.execute_input":"2026-01-05T05:44:44.422738Z","iopub.status.idle":"2026-01-05T05:44:44.450609Z","shell.execute_reply.started":"2026-01-05T05:44:44.422716Z","shell.execute_reply":"2026-01-05T05:44:44.449854Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Training Functions with Validation","metadata":{}},{"cell_type":"code","source":"def train_epoch(model, train_loader, optimizer, criterion, device):\n    model.train()\n    total_loss = 0\n    for images, masks in train_loader:\n        images, masks = images.to(device), masks.to(device)\n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, masks)\n        loss.backward()\n        optimizer.step()\n        total_loss += loss.item()\n    return total_loss / len(train_loader)\n\ndef validate(model, val_loader, criterion, device):\n    model.eval()\n    total_loss = 0\n    with torch.no_grad():\n        for images, masks in val_loader:\n            images, masks = images.to(device), masks.to(device)\n            outputs = model(images)\n            loss = criterion(outputs, masks)\n            total_loss += loss.item()\n    return total_loss / len(val_loader)\n\ndef train_model(model, train_loader, val_loader, device, epochs=15, lr=1e-4, model_name=\"Model\"):\n    optimizer = optim.Adam(model.parameters(), lr=lr)\n    criterion = nn.BCEWithLogitsLoss()\n    best_val_loss = float('inf')\n    patience = 3\n    patience_counter = 0\n    \n    for epoch in range(epochs):\n        train_loss = train_epoch(model, train_loader, optimizer, criterion, device)\n        val_loss = validate(model, val_loader, criterion, device)\n        \n        print(f\"[{model_name}] Epoch {epoch+1}/{epochs}: Train={train_loss:.4f}, Val={val_loss:.4f}\", end=\"\")\n        \n        if val_loss < best_val_loss:\n            best_val_loss = val_loss\n            patience_counter = 0\n            print(\" ✓\")\n        else:\n            patience_counter += 1\n            print()\n            if patience_counter >= patience:\n                print(f\"Early stopping at epoch {epoch+1}\")\n                break\n    \n    return model\n\nprint(\"Training functions defined\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-05T05:44:44.451685Z","iopub.execute_input":"2026-01-05T05:44:44.452024Z","iopub.status.idle":"2026-01-05T05:44:44.459939Z","shell.execute_reply.started":"2026-01-05T05:44:44.452004Z","shell.execute_reply":"2026-01-05T05:44:44.459344Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Train All Models","metadata":{}},{"cell_type":"code","source":"print(\"\\n=== Training Segmentation Model 1 (ResNet50) ===\")\nseg_model_1 = train_model(seg_model_1, train_loader, val_loader, DEVICE, epochs=EPOCHS, lr=LR, model_name=\"Seg1\")\n\nprint(\"\\n=== Training Segmentation Model 2 (ResNet34) ===\")\nseg_model_2 = train_model(seg_model_2, train_loader, val_loader, DEVICE, epochs=EPOCHS, lr=LR, model_name=\"Seg2\")\n\nprint(\"\\n=== Training Classifier ===\")\nclassifier_model = train_model(classifier_model, clf_train_loader, clf_val_loader, DEVICE, epochs=EPOCHS, lr=LR, model_name=\"Clf\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-05T05:44:44.461653Z","iopub.execute_input":"2026-01-05T05:44:44.462046Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## RLE Encoding","metadata":{}},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def mask_to_rle(mask):\n    flat = mask.flatten(order='F')\n    if len(flat) == 0:\n        return \"\"\n    runs = []\n    current_val = flat[0]\n    count = 1\n    for i in range(1, len(flat)):\n        if flat[i] == current_val:\n            count += 1\n        else:\n            runs.append(f\"{count} {int(current_val)}\")\n            current_val = flat[i]\n            count = 1\n    runs.append(f\"{count} {int(current_val)}\")\n    return \" \".join(runs)\n\nprint(\"RLE encoding function defined\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"@torch.no_grad()\ndef run_inference_ensemble(classifier, seg_model_1, seg_model_2, test_image_paths, device):\n    \"\"\"Run inference with ensemble of 2 segmentation models\"\"\"\n    classifier.eval()\n    seg_model_1.eval()\n    seg_model_2.eval()\n    \n    results = []\n    \n    for idx, image_path in enumerate(test_image_paths):\n        if idx % 200 == 0:\n            print(f\"Processing {idx+1}/{len(test_image_paths)}\")\n        \n        try:\n            image = cv2.imread(image_path)\n            if image is None:\n                results.append({'case_id': Path(image_path).stem, 'annotation': 'authentic'})\n                continue\n            \n            orig_h, orig_w = image.shape[:2]\n            image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n            \n            # Classifier\n            image_clf = cv2.resize(image_rgb, (384, 384)).astype(np.float32) / 255.0\n            image_clf = torch.from_numpy(image_clf).permute(2, 0, 1).unsqueeze(0).to(device)\n            clf_prob = torch.sigmoid(classifier(image_clf)).item()\n            \n            if clf_prob < CLASSIFIER_THRESHOLD:\n                results.append({'case_id': Path(image_path).stem, 'annotation': 'authentic'})\n                continue\n            \n            # Ensemble segmentation (average of 2 models)\n            image_seg = cv2.resize(image_rgb, (512, 512)).astype(np.float32) / 255.0\n            image_seg = torch.from_numpy(image_seg).permute(2, 0, 1).unsqueeze(0).to(device)\n            \n            seg_prob_1 = torch.sigmoid(seg_model_1(image_seg)).squeeze()\n            seg_prob_2 = torch.sigmoid(seg_model_2(image_seg)).squeeze()\n            seg_prob = (seg_prob_1 + seg_prob_2) / 2.0  # Average\n            \n            mask = (seg_prob > SEG_THRESHOLD).cpu().numpy().astype(np.uint8)\n            mask = cv2.resize(mask, (orig_w, orig_h), interpolation=cv2.INTER_NEAREST)\n            \n            if mask.sum() == 0:\n                annotation = 'authentic'\n            else:\n                annotation = mask_to_rle(mask)\n            \n            results.append({'case_id': Path(image_path).stem, 'annotation': annotation})\n        \n        except Exception as e:\n            results.append({'case_id': Path(image_path).stem, 'annotation': 'authentic'})\n    \n    return results\n\nprint(\"Running ensemble inference...\")\ninference_results = run_inference_ensemble(classifier_model, seg_model_1, seg_model_2, test_images, DEVICE)\nprint(f\"Inference complete. Results: {len(inference_results)}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Save Submission","metadata":{}},{"cell_type":"code","source":"# Create submission DataFrame\nsubmission_df = pd.DataFrame(inference_results)\nprint(f\"Submission shape: {submission_df.shape}\")\nprint(f\"\\nFirst few entries:\")\nprint(submission_df.head())\n\n# Save to CSV\noutput_path = OUTPUT_DIR / 'submission.csv'\nsubmission_df.to_csv(output_path, index=False)\nprint(f\"\\n✓ Submission saved to: {output_path}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Validation","metadata":{}},{"cell_type":"code","source":"assert 'case_id' in submission_df.columns\nassert 'annotation' in submission_df.columns\nassert len(submission_df) > 0\nassert not submission_df['case_id'].duplicated().any()\nassert not submission_df['annotation'].isna().any()\nprint(\"✓ Submission format is valid!\")\nprint(f\"✓ Ready to submit to Kaggle\")","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}