{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":3649,"databundleVersionId":46718,"sourceType":"competition"}],"dockerImageVersionId":31154,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# =========================\n# Cell 1: Config & Setup\n# =========================\nimport os, io, re, time, math, glob, random, shutil, copy\nfrom dataclasses import dataclass\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import DataLoader, Dataset\nfrom torch.amp import GradScaler, autocast\nimport torchvision\nimport torchvision.transforms as T\nfrom torchvision.transforms import RandAugment, RandomErasing\n\n# -------------------------\n# Assignment Requirements:\n# - Random init (no pretrain): satisfied by custom net below\n# - Advanced optimizer: AdamW  ✅\n# - Cosine LR w/ warmup: SequentialLR(LinearLR → CosineAnnealingLR) ✅\n# - Advanced augmentation: CutMix (and explicit CutOut) ✅\n# -------------------------\n\n@dataclass\nclass Cfg:\n    work_dir: str = \"./work\"\n    seed: int = 1337\n    device: str = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n\n    # Data\n    img_size: int = 32\n    batch_size: int = 256\n    num_workers: int = 4\n\n    # Train\n    epochs: int = 200               # more runway for cosine\n    base_lr: float = 3e-3           # AdamW typical starting point\n    weight_decay: float = 0.02      # decoupled weight decay\n    # Optimizer-specific\n    adamw_betas: tuple = (0.9, 0.999)\n    adamw_eps: float = 1e-8\n\n    # Aug & regularization\n    use_mixup: bool = True\n    use_cutmix: bool = True\n    mix_alpha: float = 0.2\n    cut_alpha: float = 1.0\n    use_cutout: bool = True         # explicit CutOut toggle\n    cutout_holes: int = 1\n    cutout_max_frac: float = 0.5    # max hole side as fraction of H/W\n\n    # EMA & AMP\n    use_ema: bool = True\n    ema_decay: float = 0.999\n    use_amp: bool = True\n\n    # LR schedule\n    warmup_epochs: int = 10         # Linear warmup steps\n    cosine_min_lr: float = 1e-6     # floor on cosine\n\n    # Submission\n    submit_stream: bool = True\n    tta_hflip: bool = True\n    submit_batch: int = 1024\n\ncfg = Cfg()\nos.makedirs(cfg.work_dir, exist_ok=True)\n\n# Repro & device\ndef set_seed(seed=1337):\n    random.seed(seed); np.random.seed(seed)\n    torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = False\n    torch.backends.cudnn.benchmark = True\n\nset_seed(cfg.seed)\ndevice = torch.device(cfg.device)\nprint(\"Device:\", device)\n\n# CIFAR-10 normalization & classes\nCIFAR_MEAN = (0.4914, 0.4822, 0.4465)\nCIFAR_STD  = (0.2470, 0.2435, 0.2616)\nCLASS_NAMES = [\"airplane\",\"automobile\",\"bird\",\"cat\",\"deer\",\"dog\",\"frog\",\"horse\",\"ship\",\"truck\"]\n\n\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-10-18T09:30:23.479268Z","iopub.execute_input":"2025-10-18T09:30:23.479811Z","iopub.status.idle":"2025-10-18T09:30:33.337994Z","shell.execute_reply.started":"2025-10-18T09:30:23.479787Z","shell.execute_reply":"2025-10-18T09:30:33.337304Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# =========================\n# Cell 2: Data & Dataloaders\n# =========================\n# Explicit CutOut (advanced aug) implementation\nclass Cutout(nn.Module):\n    \"\"\"\n    CutOut: zero out one or more random square patches in the image.\n    - holes: number of squares\n    - max_frac: max side length as a fraction of image H/W\n    Applies per-sample on a batch tensor (C,H,W) or PIL->tensor in transforms.\n    \"\"\"\n    def __init__(self, holes=1, max_frac=0.5):\n        super().__init__()\n        self.holes = holes\n        self.max_frac = max_frac\n\n    def forward(self, img):\n        # Works on tensor images after ToTensor (C,H,W)\n        if not torch.is_tensor(img):\n            return img\n        C, H, W = img.shape\n        for _ in range(self.holes):\n            side = int(self.max_frac * min(H, W) * random.random())\n            if side <= 0: \n                continue\n            cx = random.randrange(W)\n            cy = random.randrange(H)\n            x1 = max(cx - side // 2, 0); x2 = min(cx + side // 2, W)\n            y1 = max(cy - side // 2, 0); y2 = min(cy + side // 2, H)\n            img[:, y1:y2, x1:x2] = 0.0\n        return img\n\n\n# Strong but stable augmentations (advanced: RandAugment + CutOut + optional RandomErasing)\ntrain_tfms = T.Compose([\n    T.RandomCrop(cfg.img_size, padding=4),\n    T.RandomHorizontalFlip(),\n    RandAugment(num_ops=2, magnitude=7),         # tuned down a bit for stability\n    T.ToTensor(),\n    T.Normalize(CIFAR_MEAN, CIFAR_STD),\n    Cutout(holes=cfg.cutout_holes, max_frac=cfg.cutout_max_frac) if cfg.use_cutout else nn.Identity(),\n    RandomErasing(p=0.2, scale=(0.02, 0.2), ratio=(0.3, 3.3), value='random'),\n])\n\ntest_tfms  = T.Compose([\n    T.ToTensor(),\n    T.Normalize(CIFAR_MEAN, CIFAR_STD),\n])\n\ntrain_set = torchvision.datasets.CIFAR10(root=\"./data\", train=True,  download=True, transform=train_tfms)\ntest_set  = torchvision.datasets.CIFAR10(root=\"./data\", train=False, download=True, transform=test_tfms)\n\ndef to_onehot(y, num_classes=10):\n    return F.one_hot(y, num_classes=num_classes).float()\n\ndef onehot_smooth(y, n_classes=10, eps=0.1):\n    y_oh = F.one_hot(y, n_classes).float()\n    return y_oh * (1 - eps) + eps / n_classes\n\ndef mixup_cutmix_collate(batch):\n    # Advanced augmentation at batch level (CutMix / MixUp)\n    images, labels = zip(*batch)\n    x = torch.stack(images, dim=0)\n    y = torch.tensor(labels, dtype=torch.long)\n    y_oh = to_onehot(y, 10)\n\n    if cfg.use_cutmix and random.random() < 0.5:\n        # ---- CutMix (advanced aug) ----\n        lam = np.random.beta(cfg.cut_alpha, cfg.cut_alpha)\n        B, C, H, W = x.size()\n        idx = torch.randperm(B)\n\n        cut_w = int(W * math.sqrt(1 - lam))\n        cut_h = int(H * math.sqrt(1 - lam))\n        cx = random.randrange(W)\n        cy = random.randrange(H)\n        x1 = max(cx - cut_w // 2, 0); x2 = min(cx + cut_w // 2, W)\n        y1 = max(cy - cut_h // 2, 0); y2 = min(cy + cut_h // 2, H)\n\n        x[:, :, y1:y2, x1:x2] = x[idx, :, y1:y2, x1:x2]\n        lam_adj = 1.0 - ((x2 - x1) * (y2 - y1)) / float(W * H)\n        y_oh = lam_adj * y_oh + (1 - lam_adj) * y_oh[idx]\n\n    elif cfg.use_mixup:\n        # ---- MixUp (also advanced) ----\n        lam = np.random.beta(cfg.mix_alpha, cfg.mix_alpha)\n        B = x.size(0); idx = torch.randperm(B)\n        x = lam * x + (1 - lam) * x[idx]\n        y_oh = lam * y_oh + (1 - lam) * y_oh[idx]\n\n    else:\n        # No batch-level aug -> use label smoothing\n        y_oh = onehot_smooth(y, 10, eps=0.1)\n\n    return x, y_oh\n\ntrain_loader = DataLoader(\n    train_set, batch_size=cfg.batch_size, shuffle=True, drop_last=True,\n    num_workers=cfg.num_workers, pin_memory=True,\n    collate_fn=mixup_cutmix_collate,\n)\nval_loader = DataLoader(\n    test_set, batch_size=1024, shuffle=False,\n    num_workers=cfg.num_workers, pin_memory=True\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-18T09:30:37.109725Z","iopub.execute_input":"2025-10-18T09:30:37.110690Z","iopub.status.idle":"2025-10-18T09:30:45.788356Z","shell.execute_reply.started":"2025-10-18T09:30:37.110660Z","shell.execute_reply":"2025-10-18T09:30:45.787579Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =========================\n# Cell 3: Model (custom), Optimizer (AdamW), LR (Warmup+Cosine), EMA, AMP\n# =========================\nimport copy\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.amp import GradScaler\n\n# -------- Custom residual network (random init, no pretrain) --------\nclass BasicBlock(nn.Module):\n    expansion = 1\n    def __init__(self, in_ch, out_ch, stride=1):\n        super().__init__()\n        self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride=stride, padding=1, bias=False)\n        self.bn1   = nn.BatchNorm2d(out_ch)\n        self.relu  = nn.ReLU(inplace=True)\n        self.conv2 = nn.Conv2d(out_ch, out_ch, 3, stride=1, padding=1, bias=False)\n        self.bn2   = nn.BatchNorm2d(out_ch)\n\n        self.proj = None\n        if stride != 1 or in_ch != out_ch:\n            self.proj = nn.Sequential(\n                nn.Conv2d(in_ch, out_ch, 1, stride=stride, bias=False),\n                nn.BatchNorm2d(out_ch)\n            )\n\n        # Random init (He/Kaiming); zero-init last BN in residual branch\n        for m in self.modules():\n            if isinstance(m, nn.Conv2d):\n                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')\n            elif isinstance(m, nn.BatchNorm2d):\n                nn.init.ones_(m.weight); nn.init.zeros_(m.bias)\n        nn.init.zeros_(self.bn2.weight)\n\n    def forward(self, x):\n        identity = x\n        out = self.relu(self.bn1(self.conv1(x)))\n        out = self.bn2(self.conv2(out))\n        if self.proj is not None:\n            identity = self.proj(identity)\n        out = self.relu(out + identity)\n        return out\n\ndef _make_layer(in_ch, out_ch, num_blocks, first_stride):\n    layers = [BasicBlock(in_ch, out_ch, stride=first_stride)]\n    for _ in range(num_blocks - 1):\n        layers.append(BasicBlock(out_ch, out_ch, stride=1))\n    return nn.Sequential(*layers)\n\nclass SmallResNet(nn.Module):\n    \"\"\"\n    CIFAR-10 residual net (custom, random-init):\n      Stem: 3x3, 64\n      Stages: [64]x3, [128]x3, [256]x3, [512]x3 (stride=2 at first block of stages 2–4)\n      Head: GAP -> Dropout(0.1) -> FC(512, 10)\n    \"\"\"\n    def __init__(self, num_classes=10, p_drop=0.1):\n        super().__init__()\n        self.stem = nn.Sequential(\n            nn.Conv2d(3, 64, 3, stride=1, padding=1, bias=False),\n            nn.BatchNorm2d(64),\n            nn.ReLU(inplace=True),\n        )\n        self.layer1 = _make_layer(64,   64,  num_blocks=3, first_stride=1)  # 32x32\n        self.layer2 = _make_layer(64,   128, num_blocks=3, first_stride=2)  # 16x16\n        self.layer3 = _make_layer(128,  256, num_blocks=3, first_stride=2)  # 8x8\n        self.layer4 = _make_layer(256,  512, num_blocks=3, first_stride=2)  # 4x4\n        self.pool   = nn.AdaptiveAvgPool2d((1,1))\n        self.drop   = nn.Dropout(p=p_drop)\n        self.fc     = nn.Linear(512, num_classes)\n\n        nn.init.kaiming_normal_(self.fc.weight, mode='fan_out', nonlinearity='relu')\n        nn.init.zeros_(self.fc.bias)\n\n    def forward(self, x):\n        x = self.stem(x)\n        x = self.layer1(x)\n        x = self.layer2(x)\n        x = self.layer3(x)\n        x = self.layer4(x)\n        x = self.pool(x)\n        x = torch.flatten(x, 1)\n        x = self.drop(x)\n        x = self.fc(x)\n        return x\n\n# Instantiate model\nmodel = SmallResNet(num_classes=10).to(device)\n\n# -------- AdamW with proper weight-decay grouping (exclude BN/bias) --------\ndef split_weights(m):\n    decay, no_decay = [], []\n    for name, p in m.named_parameters():\n        if not p.requires_grad:\n            continue\n        if p.ndim == 1 or name.endswith(\".bias\"):  # BN/bias -> no weight decay\n            no_decay.append(p)\n        else:\n            decay.append(p)\n    return [\n        {\"params\": decay,    \"weight_decay\": cfg.weight_decay},\n        {\"params\": no_decay, \"weight_decay\": 0.0},\n    ]\n\noptimizer = torch.optim.AdamW(\n    split_weights(model),\n    lr=cfg.base_lr,\n    betas=cfg.adamw_betas,\n    eps=cfg.adamw_eps\n)\n\n# -------- Cosine annealing with warmup (SequentialLR) --------\n# NOTE: LinearLR requires 0 < start_factor <= 1. Use a tiny epsilon to approximate 0.\nwarmup = torch.optim.lr_scheduler.LinearLR(\n    optimizer,\n    start_factor=1e-3,             # was 0.0 -> invalid; start near zero safely\n    end_factor=1.0,\n    total_iters=cfg.warmup_epochs\n)\ncosine = torch.optim.lr_scheduler.CosineAnnealingLR(\n    optimizer,\n    T_max=max(1, cfg.epochs - cfg.warmup_epochs),\n    eta_min=cfg.cosine_min_lr\n)\nscheduler = torch.optim.lr_scheduler.SequentialLR(\n    optimizer,\n    schedulers=[warmup, cosine],\n    milestones=[cfg.warmup_epochs]\n)\n\n# -------- Losses (supports soft targets from MixUp/CutMix) --------\ndef soft_ce_loss(logits, targets):\n    log_probs = F.log_softmax(logits, dim=1)\n    return -(targets * log_probs).sum(dim=1).mean()\n\nce = nn.CrossEntropyLoss()\n\n# -------- AMP (CUDA-only) --------\nscaler = GradScaler(\n    device='cuda' if device.type == 'cuda' else 'cpu',\n    enabled=(cfg.use_amp and device.type == 'cuda')\n)\n\n# -------- EMA (unchanged) --------\nclass ModelEMA:\n    def __init__(self, model, decay=cfg.ema_decay):\n        self.decay = decay\n        self.ema = copy.deepcopy(model).to(next(model.parameters()).device)\n        self.ema.eval()\n        for p in self.ema.parameters():\n            p.requires_grad_(False)\n        self._backup = None\n\n    @torch.no_grad()\n    def update(self, model):\n        msd = model.state_dict(); esd = self.ema.state_dict()\n        for k in esd.keys():\n            if esd[k].dtype.is_floating_point:\n                esd[k].mul_(self.decay).add_(msd[k].detach(), alpha=1.0 - self.decay)\n            else:\n                esd[k].copy_(msd[k])\n\n    def store(self, model):  self._backup = copy.deepcopy(model.state_dict())\n    def copy_to(self, model): model.load_state_dict(self.ema.state_dict(), strict=True)\n    def restore(self, model):\n        if self._backup is not None:\n            model.load_state_dict(self._backup, strict=True); self._backup = None\n\nema = ModelEMA(model) if cfg.use_ema else None\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-18T09:33:19.514320Z","iopub.execute_input":"2025-10-18T09:33:19.514648Z","iopub.status.idle":"2025-10-18T09:33:19.793330Z","shell.execute_reply.started":"2025-10-18T09:33:19.514624Z","shell.execute_reply":"2025-10-18T09:33:19.792746Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =========================\n# Cell 4: Train / Eval / Loop\n# =========================\ndef _train_acc_from_logits(logits, targets):\n    preds = logits.argmax(1)\n    tgt = targets.argmax(1) if targets.ndim == 2 else targets\n    return (preds == tgt).float().mean().item()\n\ndef train_one_epoch(epoch):\n    model.train()\n    running_loss, running_acc, n = 0.0, 0.0, 0\n    t0 = time.time()\n    for images, targets in train_loader:\n        images = images.to(device, non_blocking=True)\n        targets = targets.to(device, non_blocking=True)\n        optimizer.zero_grad(set_to_none=True)\n\n        with autocast(device_type='cuda', enabled=(cfg.use_amp and device.type=='cuda')):\n            logits = model(images)\n            loss = soft_ce_loss(logits, targets) if targets.ndim == 2 else ce(logits, targets)\n\n        scaler.scale(loss).backward()\n        # Gradient clipping for stability\n        scaler.unscale_(optimizer)\n        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)\n        scaler.step(optimizer)\n        scaler.update()\n\n        if ema: ema.update(model)\n\n        bs = images.size(0)\n        running_loss += loss.item() * bs\n        running_acc  += _train_acc_from_logits(logits.detach(), targets) * bs\n        n += bs\n\n    scheduler.step()\n    return running_loss / n, running_acc / n, time.time() - t0\n\n@torch.no_grad()\ndef evaluate(use_ema=True):\n    if use_ema and ema:\n        ema.store(model); ema.copy_to(model)\n    model.eval()\n    total_loss, total_acc, n = 0.0, 0.0, 0\n    for images, labels in val_loader:\n        images = images.to(device, non_blocking=True)\n        labels = labels.to(device, non_blocking=True)\n        logits = model(images)\n        loss = ce(logits, labels)\n        acc = (logits.argmax(1) == labels).float().mean().item()\n        bs = images.size(0)\n        total_loss += loss.item() * bs\n        total_acc  += acc * bs\n        n += bs\n    if use_ema and ema:\n        ema.restore(model)\n    return total_loss / n, total_acc / n\n\n# Training loop\nbest_acc = 0.0\nbest_path = os.path.join(cfg.work_dir, \"best_model.pth\")\n\nfor epoch in range(cfg.epochs):\n    tr_loss, tr_acc, dt = train_one_epoch(epoch)\n    val_loss, val_acc = evaluate(use_ema=True)\n\n    if val_acc > best_acc:\n        best_acc = val_acc\n        torch.save((ema.ema if (cfg.use_ema and ema) else model).state_dict(), best_path)\n\n    # Note: With SequentialLR, optimizer.param_groups[0]['lr'] updates per epoch\n    lr_now = optimizer.param_groups[0]['lr']\n    print(f\"Epoch {epoch+1:03d}/{cfg.epochs} | lr {lr_now:.6f} | \"\n          f\"train_loss {tr_loss:.4f} acc {tr_acc:.4f} | \"\n          f\"val_loss {val_loss:.4f} acc {val_acc:.4f} | {dt:.1f}s\")\n\nprint(\"Best Val Acc:\", best_acc)\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-18T09:33:26.983169Z","iopub.execute_input":"2025-10-18T09:33:26.983432Z","iopub.status.idle":"2025-10-18T11:10:26.151911Z","shell.execute_reply.started":"2025-10-18T09:33:26.983412Z","shell.execute_reply":"2025-10-18T11:10:26.151059Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =========================\n# Cell 5: Offline test accuracy (same split as val_loader)\n# =========================\nassert os.path.exists(best_path), f\"Checkpoint not found: {best_path}\"\ntest_loader_off = DataLoader(test_set, batch_size=1024, shuffle=False,\n                             num_workers=cfg.num_workers, pin_memory=True)\n\neval_model = SmallResNet(num_classes=10).to(device).eval()\nstate = torch.load(best_path, map_location=device)\neval_model.load_state_dict(state, strict=True)\neval_model.eval()\n\ncorrect, total = 0, 0\nwith torch.inference_mode():\n    for xb, yb in test_loader_off:\n        xb, yb = xb.to(device, non_blocking=True), yb.to(device, non_blocking=True)\n        pred = eval_model(xb).argmax(1)\n        correct += (pred == yb).sum().item()\n        total   += yb.size(0)\nfinal_acc = correct / total\nprint(f\"[OFFLINE] CIFAR-10 test accuracy: {final_acc:.4f} ({final_acc*100:.2f}%)\")\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-18T11:10:29.507419Z","iopub.execute_input":"2025-10-18T11:10:29.508115Z","iopub.status.idle":"2025-10-18T11:10:31.996425Z","shell.execute_reply.started":"2025-10-18T11:10:29.508082Z","shell.execute_reply":"2025-10-18T11:10:31.995437Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =========================\n# Cell 6: Kaggle test extraction (unchanged)\n# =========================\n!pip -q install py7zr\nimport py7zr\ncandidates = glob.glob('/kaggle/input/**/test.7z', recursive=True)\narchive_path = candidates[0]\nshutil.rmtree('test', ignore_errors=True)\nos.makedirs('test', exist_ok=True)\nwith py7zr.SevenZipFile(archive_path, mode='r') as z:\n    z.extractall(path='test')\npng_files = glob.glob('test/**/*.png', recursive=True)\nprint(\"PNG files:\", len(png_files))\nassert len(png_files) == 300000, f\"Expected 300000, got {len(png_files)}\"","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-18T11:10:37.247257Z","iopub.execute_input":"2025-10-18T11:10:37.247569Z","iopub.status.idle":"2025-10-18T11:12:44.074906Z","shell.execute_reply.started":"2025-10-18T11:10:37.247544Z","shell.execute_reply":"2025-10-18T11:12:44.074188Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n\n# =========================\n# Cell 7: Kaggle submission (unchanged, with hflip TTA)\n# =========================\nimport os, re, glob\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision.transforms as T\nimport torch\n\npng_count = len(glob.glob('test/**/*.png', recursive=True))\nif png_count != 300000:\n    raise RuntimeError(\n        f\"'test' folder not ready: found {png_count} PNGs. \"\n        \"Run Cell 6 to extract the Kaggle test set, then rerun this cell.\"\n    )\n\nval_tfms = T.Compose([\n    T.Resize((cfg.img_size, cfg.img_size), antialias=True),\n    T.ToTensor(),\n    T.Normalize(CIFAR_MEAN, CIFAR_STD),\n])\n\nclass KaggleTestDataset(Dataset):\n    def __init__(self, root=\"test\", transform=None):\n        self.transform = transform\n        files = glob.glob(os.path.join(root, \"**\", \"*.png\"), recursive=True)\n        def keyfn(p):\n            b = os.path.basename(p)\n            m = re.match(r\"(\\d+)\", os.path.splitext(b)[0])\n            return int(m.group(1)) if m else 10**12\n        files.sort(key=keyfn)\n        self.paths = files\n        self.ids = [int(os.path.splitext(os.path.basename(p))[0]) for p in self.paths]\n    def __len__(self): return len(self.paths)\n    def __getitem__(self, i):\n        x = self.transform(Image.open(self.paths[i]).convert(\"RGB\"))\n        return x, self.ids[i]\n\ntest_ds = KaggleTestDataset(transform=val_tfms)\ntest_loader_submit = DataLoader(\n    test_ds,\n    batch_size=cfg.submit_batch,\n    shuffle=False,\n    num_workers=cfg.num_workers,\n    pin_memory=True\n)\n\nassert os.path.exists(best_path), f\"Checkpoint not found: {best_path}. Train first.\"\nsubmit_model = SmallResNet(num_classes=10).to(device).eval()\nsubmit_model.load_state_dict(torch.load(best_path, map_location=device), strict=True)\nsubmit_model.eval()\n\nall_ids, all_preds = [], []\nwith torch.inference_mode():\n    for xb, idb in test_loader_submit:\n        xb = xb.to(device, non_blocking=True)\n        logits = submit_model(xb)\n        if cfg.tta_hflip:\n            logits = (logits + submit_model(torch.flip(xb, dims=[3]))) / 2.0\n        preds = logits.argmax(1).cpu().numpy()\n        all_ids.append(idb.numpy()); all_preds.append(preds)\n\nids = np.concatenate(all_ids)\nlabels_idx = np.concatenate(all_preds)\nlabels = [CLASS_NAMES[i] for i in labels_idx]\n\nsub = pd.DataFrame({\"id\": ids, \"label\": labels}).sort_values(\"id\")\nassert sub.shape[0] == 300000, f\"Expected 300000 rows, got {sub.shape[0]}\"\nassert sub[\"id\"].min() == 1 and sub[\"id\"].max() == 300000, \"IDs must span 1..300000\"\nassert sub[\"id\"].nunique() == 300000, \"IDs must be unique\"\n\nout_csv = os.path.join(cfg.work_dir, \"submission.csv\")\nos.makedirs(cfg.work_dir, exist_ok=True)\nsub.to_csv(out_csv, index=False)\nprint(sub.head(), \"\\nWrote:\", out_csv)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-18T11:15:36.362663Z","iopub.execute_input":"2025-10-18T11:15:36.362959Z","iopub.status.idle":"2025-10-18T11:17:14.491416Z","shell.execute_reply.started":"2025-10-18T11:15:36.362942Z","shell.execute_reply":"2025-10-18T11:17:14.490607Z"}},"outputs":[],"execution_count":null}]}