{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python"},"accelerator":"GPU"},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"## 1. Configuration","metadata":{}},{"cell_type":"code","source":"SEED            = 42\nEPOCHS          = 150\nWARMUP_EPOCHS   = 5\nBATCH_SIZE      = 512\nPEAK_LR         = 2e-3      # AdamW peak learning rate (reached at the end of warmup)\nWEIGHT_DECAY    = 5e-2      # decoupled weight decay (conv / linear weights only)\nBETAS           = (0.9, 0.999)\nMIN_LR_FACTOR   = 0.0       # cosine decays to PEAK_LR * MIN_LR_FACTOR\nCUTMIX_PROB     = 0.5       # per batch: CutMix with this probability, otherwise Cutout\nCUTMIX_ALPHA    = 1.0\nCUTOUT_SIZE     = 16\nDROPOUT         = 0.1\nWIDTHS          = (64, 128, 256, 512)\nDEPTHS          = (2, 2, 2, 2)\nHOLDOUT         = 5000      # images held out for validation curves; set to 0 to train on all 50,000\nTTA             = True      # average predictions over the image and its horizontal flip\nWORK_DIR        = '/kaggle/working'\nRAW_DIR         = '/tmp/cifar10_raw'","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Imports and e-mail notification helper\nThe e-mail credentials are read from **Kaggle Secrets**, so no personal data is stored in this notebook.\nIf the secrets are missing or the internet is off, the notebook simply skips the e-mail.","metadata":{}},{"cell_type":"code","source":"import os, sys, glob, math, time, random, shutil, subprocess, traceback, smtplib, ssl\nimport multiprocessing as mp\nfrom contextlib import contextmanager\nfrom email.message import EmailMessage\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\nimport matplotlib.pyplot as plt\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\n\nos.makedirs(WORK_DIR, exist_ok=True)\nRUN_START = time.time()\n\n\ndef _get_secret(name):\n    try:\n        from kaggle_secrets import UserSecretsClient\n        return UserSecretsClient().get_secret(name)\n    except Exception:\n        return None\n\n\nNOTIFY_EMAIL = _get_secret('NOTIFY_EMAIL')\nGMAIL_APP_PASSWORD = _get_secret('GMAIL_APP_PASSWORD')\nprint('E-mail notifications:', 'ON' if (NOTIFY_EMAIL and GMAIL_APP_PASSWORD) else 'OFF (secrets not attached)')\n\n\ndef send_email(subject, body):\n    if not (NOTIFY_EMAIL and GMAIL_APP_PASSWORD):\n        print(f'[email skipped] {subject}')\n        return\n    msg = EmailMessage()\n    msg['Subject'] = subject\n    msg['From'] = NOTIFY_EMAIL\n    msg['To'] = NOTIFY_EMAIL\n    msg.set_content(body)\n    try:\n        with smtplib.SMTP_SSL('smtp.gmail.com', 465, context=ssl.create_default_context(), timeout=30) as server:\n            server.login(NOTIFY_EMAIL, GMAIL_APP_PASSWORD.replace(' ', ''))\n            server.send_message(msg)\n        print(f'[email sent] {subject}')\n    except Exception as e:\n        print(f'[email failed] {e}')\n\n\n@contextmanager\ndef notify_on_error(stage):\n    \"\"\"Send an e-mail with the traceback if this stage crashes, then re-raise.\"\"\"\n    try:\n        yield\n    except Exception:\n        tb = traceback.format_exc()\n        mins = (time.time() - RUN_START) / 60\n        send_email(f'[Kaggle CIFAR-10] FAILED during {stage}',\n                   f'The run crashed after {mins:.1f} min during: {stage}\\n\\n{tb[-6000:]}')\n        raise\n\n\ndef seed_everything(seed):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n\n\nseed_everything(SEED)\nprint('PyTorch', torch.__version__)","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. GPU check","metadata":{}},{"cell_type":"code","source":"with notify_on_error('GPU check'):\n    if not torch.cuda.is_available():\n        raise RuntimeError('No GPU found. In the notebook settings set Accelerator = \"GPU T4 x2\".')\n    device = torch.device('cuda')\n    gpu_name = torch.cuda.get_device_name(0)\n    try:\n        _t = torch.randn(2, 3, 8, 8, device=device)\n        F.conv2d(_t, torch.randn(4, 3, 3, 3, device=device)).sum().item()\n    except Exception as e:\n        raise RuntimeError(f'GPU \"{gpu_name}\" is not usable with this PyTorch build ({e}). '\n                           'Switch the accelerator to \"GPU T4 x2\".') from e\n    torch.backends.cudnn.benchmark = True\n    USE_AMP = True\n    print('Using', gpu_name, '| capability', torch.cuda.get_device_capability(0))\n\n\ndef autocast():\n    return torch.amp.autocast(device_type=device.type, dtype=torch.float16, enabled=USE_AMP)","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. Load the Kaggle data\n`train.7z` holds 50,000 labelled PNGs; `test.7z` holds 300,000 PNGs (10,000 real test images mixed with 290,000 decoys).\nBoth are loaded up-front so that any data problem shows up in the first minutes rather than after training.","metadata":{}},{"cell_type":"code","source":"def find_competition_dir():\n    hits = sorted(glob.glob('/kaggle/input/**/trainLabels.csv', recursive=True))\n    if not hits:\n        raise FileNotFoundError('Competition data not found. Use \"Add Input\" → Competitions → '\n                                '\"CIFAR-10 - Object Recognition in Images\".')\n    return os.path.dirname(hits[0])\n\n\ndef extract_7z(archive, out_dir):\n    os.makedirs(out_dir, exist_ok=True)\n    t0 = time.time()\n    if shutil.which('7z'):\n        subprocess.run(['7z', 'x', archive, f'-o{out_dir}', '-y'], check=True,\n                       stdout=subprocess.DEVNULL)\n    else:\n        try:\n            import py7zr\n        except ImportError:\n            subprocess.run([sys.executable, '-m', 'pip', 'install', '-q', 'py7zr'], check=True)\n            import py7zr\n        with py7zr.SevenZipFile(archive, 'r') as z:\n            z.extractall(out_dir)\n    print(f'extracted {os.path.basename(archive)} in {time.time() - t0:.0f}s')\n\n\ndef find_image_dir(root):\n    for dirpath, _, files in os.walk(root):\n        if '1.png' in files:\n            return dirpath\n    raise FileNotFoundError(f'No 1.png found under {root}')\n\n\ndef get_image_dir(comp_dir, split):\n    archive = os.path.join(comp_dir, f'{split}.7z')\n    if os.path.exists(archive):\n        out = os.path.join(RAW_DIR, split)\n        extract_7z(archive, out)\n        return find_image_dir(out)\n    return find_image_dir(os.path.join(comp_dir, split))   # already-extracted copy\n\n\ndef _read_png(path):\n    with Image.open(path) as im:\n        return np.asarray(im.convert('RGB'), dtype=np.uint8)\n\n\ndef load_pngs(folder, ids):\n    paths = [os.path.join(folder, f'{i}.png') for i in ids]\n    t0 = time.time()\n    with mp.get_context('fork').Pool(os.cpu_count()) as pool:\n        imgs = pool.map(_read_png, paths, chunksize=1024)\n    arr = np.stack(imgs)\n    print(f'loaded {len(arr):,} images from {folder} in {time.time() - t0:.0f}s -> {arr.shape}')\n    return arr\n\n\nwith notify_on_error('data loading'):\n    COMP_DIR = find_competition_dir()\n    print('competition data:', COMP_DIR, os.listdir(COMP_DIR))\n\n    labels_df = pd.read_csv(os.path.join(COMP_DIR, 'trainLabels.csv'))\n    sample_sub = pd.read_csv(os.path.join(COMP_DIR, 'sampleSubmission.csv'))\n    CLASSES = sorted(labels_df['label'].unique())\n    class_to_idx = {c: i for i, c in enumerate(CLASSES)}\n    print('classes:', CLASSES)\n\n    X_all = load_pngs(get_image_dir(COMP_DIR, 'train'), labels_df['id'].values)\n    Y_all = labels_df['label'].map(class_to_idx).values.astype(np.int64)\n    X_test = load_pngs(get_image_dir(COMP_DIR, 'test'), sample_sub['id'].values)\n    shutil.rmtree(RAW_DIR, ignore_errors=True)   # free the disk space used by the PNGs","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. Train / hold-out split and normalisation\nThe whole training set lives on the GPU as `uint8` (≈150 MB), and all augmentation runs on the GPU, so there is no CPU data-loader bottleneck.","metadata":{}},{"cell_type":"code","source":"rng = np.random.default_rng(SEED)\nperm_all = rng.permutation(len(X_all))\nval_idx, tr_idx = perm_all[:HOLDOUT], perm_all[HOLDOUT:]\nX_train_np, Y_train_np = X_all[tr_idx], Y_all[tr_idx]\nX_val, Y_val = X_all[val_idx], Y_all[val_idx]\nn_train, n_val = len(X_train_np), len(X_val)\nprint(f'train: {n_train:,}  hold-out: {n_val:,}  test: {len(X_test):,}')\n\nmean = np.array([(X_train_np[..., c].astype(np.float32) / 255).mean() for c in range(3)])\nstd = np.array([(X_train_np[..., c].astype(np.float32) / 255).std() for c in range(3)])\nprint('mean', mean.round(4), 'std', std.round(4))\nMEAN = torch.tensor(mean, dtype=torch.float32, device=device).view(1, 3, 1, 1)\nSTD = torch.tensor(std, dtype=torch.float32, device=device).view(1, 3, 1, 1)\n\nX_tr = torch.from_numpy(X_train_np).to(device).permute(0, 3, 1, 2).contiguous()   # uint8, NCHW\nY_tr = torch.from_numpy(Y_train_np).to(device)\n\n\ndef to_float(x_u8):\n    \"\"\"uint8 NCHW -> normalised float32.\"\"\"\n    return (x_u8.float() / 255.0 - MEAN) / STD","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. Data augmentation (all on the GPU)\n* **Random crop** (4-pixel reflect padding) + **horizontal flip** — the basic part.\n* **CutMix** (Yun et al., 2019): with probability 0.5 a batch gets a random box pasted in from a shuffled copy of the batch; the label is mixed in proportion to the box area.\n* **Cutout** (DeVries & Taylor, 2017): batches that don't get CutMix get a random 16×16 square zeroed out in every image.","metadata":{}},{"cell_type":"code","source":"def crop_flip(x, pad=4):\n    B, C, H, W = x.shape\n    dev = x.device\n    xp = F.pad(x, (pad, pad, pad, pad), mode='reflect')\n    oy = torch.randint(0, 2 * pad + 1, (B,), device=dev)\n    ox = torch.randint(0, 2 * pad + 1, (B,), device=dev)\n    rows = oy[:, None] + torch.arange(H, device=dev)              # B x H\n    cols = ox[:, None] + torch.arange(W, device=dev)              # B x W\n    flip = torch.rand(B, device=dev) < 0.5\n    cols = torch.where(flip[:, None], cols.flip(1), cols)         # flip = read columns backwards\n    b = torch.arange(B, device=dev)[:, None, None, None]\n    c = torch.arange(C, device=dev)[None, :, None, None]\n    return xp[b, c, rows[:, None, :, None], cols[:, None, None, :]]\n\n\ndef cutout(x, size=CUTOUT_SIZE):\n    B, C, H, W = x.shape\n    dev = x.device\n    cy = torch.randint(0, H, (B, 1, 1), device=dev)\n    cx = torch.randint(0, W, (B, 1, 1), device=dev)\n    ys = torch.arange(H, device=dev).view(1, H, 1)\n    xs = torch.arange(W, device=dev).view(1, 1, W)\n    half = size // 2\n    mask = (ys >= cy - half) & (ys < cy + half) & (xs >= cx - half) & (xs < cx + half)\n    return x.masked_fill(mask[:, None], 0.0)                      # 0 == dataset mean after normalisation\n\n\ndef cutmix(x, y, alpha=CUTMIX_ALPHA):\n    B, C, H, W = x.shape\n    lam = np.random.beta(alpha, alpha)\n    perm = torch.randperm(B, device=x.device)\n    cut = math.sqrt(1.0 - lam)\n    ch, cw = int(H * cut), int(W * cut)\n    cy, cx = np.random.randint(H), np.random.randint(W)\n    y1, y2 = np.clip(cy - ch // 2, 0, H), np.clip(cy + ch // 2, 0, H)\n    x1, x2 = np.clip(cx - cw // 2, 0, W), np.clip(cx + cw // 2, 0, W)\n    x = x.clone()\n    x[:, :, y1:y2, x1:x2] = x[perm, :, y1:y2, x1:x2]\n    lam = 1.0 - ((y2 - y1) * (x2 - x1)) / (H * W)                 # exact area ratio after clipping\n    return x, y[perm], float(lam)\n\n\ndef show_batch(x, titles, path, ncols=8):\n    imgs = (x * STD + MEAN).clamp(0, 1).permute(0, 2, 3, 1).cpu().numpy()\n    nrows = math.ceil(len(imgs) / ncols)\n    fig, axes = plt.subplots(nrows, ncols, figsize=(1.6 * ncols, 1.9 * nrows))\n    for ax, img, t in zip(axes.flat, imgs, titles):\n        ax.imshow(img)\n        ax.set_title(t, fontsize=7)\n        ax.axis('off')\n    plt.tight_layout()\n    plt.savefig(path, dpi=150, bbox_inches='tight')\n    plt.show()\n\n\ntorch.manual_seed(0); np.random.seed(0)\n_x = to_float(X_tr[:8])\n_y = Y_tr[:8]\n_cut = cutout(crop_flip(_x))\n_mix, _y2, _lam = cutmix(crop_flip(_x), _y)\nshow_batch(torch.cat([_cut, _mix]),\n           [f'cutout: {CLASSES[i]}' for i in _y.tolist()] +\n           [f'{CLASSES[a]} {_lam:.2f} / {CLASSES[b]}' for a, b in zip(_y.tolist(), _y2.tolist())],\n           f'{WORK_DIR}/augmentation_examples.png')\nseed_everything(SEED)","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Model — `CifarResSENet` (own design)\nBuilt for 32×32 inputs:\n* **Stem**: two 3×3 conv–BN–ReLU layers at full resolution (no early down-sampling, which would throw away detail in tiny images).\n* **4 stages** (64→128→256→512 channels at 32², 16², 8², 4²), each with 2 residual blocks.\n* **Residual block**: conv3×3–BN–ReLU–conv3×3–BN → **Squeeze-and-Excitation** channel gate → add shortcut → ReLU.\n* **Down-sampling shortcut**: 2×2 average pool then 1×1 conv (keeps all spatial information, unlike a strided 1×1 conv).\n* **Zero-initialised last BN** in each block, so every block starts as the identity — stabilises early training with an adaptive optimizer.\n* **Head**: concatenated global *average* and *max* pooling → dropout → linear classifier.","metadata":{}},{"cell_type":"code","source":"class SqueezeExcite(nn.Module):\n    def __init__(self, channels, reduction=8):\n        super().__init__()\n        hidden = max(16, channels // reduction)\n        self.fc1 = nn.Conv2d(channels, hidden, 1)\n        self.fc2 = nn.Conv2d(hidden, channels, 1)\n\n    def forward(self, x):\n        w = x.mean((2, 3), keepdim=True)\n        w = torch.sigmoid(self.fc2(F.relu(self.fc1(w), inplace=True)))\n        return x * w\n\n\nclass ResidualSEBlock(nn.Module):\n    def __init__(self, cin, cout, stride=1):\n        super().__init__()\n        self.conv1 = nn.Conv2d(cin, cout, 3, stride, 1, bias=False)\n        self.bn1 = nn.BatchNorm2d(cout)\n        self.conv2 = nn.Conv2d(cout, cout, 3, 1, 1, bias=False)\n        self.bn2 = nn.BatchNorm2d(cout)\n        self.se = SqueezeExcite(cout)\n        if stride != 1 or cin != cout:\n            layers = [nn.AvgPool2d(stride, stride)] if stride != 1 else []\n            layers += [nn.Conv2d(cin, cout, 1, bias=False), nn.BatchNorm2d(cout)]\n            self.shortcut = nn.Sequential(*layers)\n        else:\n            self.shortcut = nn.Identity()\n\n    def forward(self, x):\n        out = F.relu(self.bn1(self.conv1(x)), inplace=True)\n        out = self.se(self.bn2(self.conv2(out)))\n        return F.relu(out + self.shortcut(x), inplace=True)\n\n\nclass CifarResSENet(nn.Module):\n    def __init__(self, num_classes=10, widths=WIDTHS, depths=DEPTHS, dropout=DROPOUT):\n        super().__init__()\n        w0 = widths[0]\n        self.stem = nn.Sequential(\n            nn.Conv2d(3, w0 // 2, 3, 1, 1, bias=False), nn.BatchNorm2d(w0 // 2), nn.ReLU(inplace=True),\n            nn.Conv2d(w0 // 2, w0, 3, 1, 1, bias=False), nn.BatchNorm2d(w0), nn.ReLU(inplace=True))\n        blocks, cin = [], w0\n        for stage, (w, d) in enumerate(zip(widths, depths)):\n            for j in range(d):\n                stride = 2 if (stage > 0 and j == 0) else 1\n                blocks.append(ResidualSEBlock(cin, w, stride))\n                cin = w\n        self.blocks = nn.Sequential(*blocks)\n        self.dropout = nn.Dropout(dropout)\n        self.fc = nn.Linear(2 * cin, num_classes)\n        self._init_weights()\n\n    def _init_weights(self):\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                if m.bias is not None:\n                    nn.init.zeros_(m.bias)\n            elif isinstance(m, nn.BatchNorm2d):\n                nn.init.ones_(m.weight)\n                nn.init.zeros_(m.bias)\n            elif isinstance(m, nn.Linear):\n                nn.init.normal_(m.weight, 0, 0.01)\n                nn.init.zeros_(m.bias)\n        for m in self.modules():\n            if isinstance(m, ResidualSEBlock):\n                nn.init.zeros_(m.bn2.weight)        # each block starts as the identity\n\n    def forward(self, x):\n        x = self.blocks(self.stem(x))\n        x = torch.cat([x.mean((2, 3)), x.amax((2, 3))], dim=1)\n        return self.fc(self.dropout(x))\n\n\nmodel = CifarResSENet(num_classes=len(CLASSES)).to(device).to(memory_format=torch.channels_last)\nn_params = sum(p.numel() for p in model.parameters())\nprint(model)\nprint(f'\\nTrainable parameters: {n_params / 1e6:.2f} M (all randomly initialised)')","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. Optimizer (AdamW) and learning-rate schedule (linear warmup + cosine annealing)\nWeight decay is applied only to conv / linear weights, not to BatchNorm parameters or biases.\nThe schedule is updated every optimisation step.","metadata":{}},{"cell_type":"code","source":"decay, no_decay = [], []\nfor name, p in model.named_parameters():\n    (decay if p.ndim > 1 else no_decay).append(p)\noptimizer = torch.optim.AdamW([{'params': decay, 'weight_decay': WEIGHT_DECAY},\n                               {'params': no_decay, 'weight_decay': 0.0}],\n                              lr=PEAK_LR, betas=BETAS, eps=1e-8)\n\nsteps_per_epoch = n_train // BATCH_SIZE\ntotal_steps = EPOCHS * steps_per_epoch\nwarmup_steps = WARMUP_EPOCHS * steps_per_epoch\n\n\ndef lr_factor(step):\n    if step < warmup_steps:\n        return (step + 1) / warmup_steps\n    t = (step - warmup_steps) / max(1, total_steps - warmup_steps)\n    return MIN_LR_FACTOR + (1 - MIN_LR_FACTOR) * 0.5 * (1 + math.cos(math.pi * t))\n\n\nscheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_factor)\nscaler = torch.amp.GradScaler(device.type, enabled=USE_AMP)\ncriterion = nn.CrossEntropyLoss()\n\nplt.figure(figsize=(7, 3))\nplt.plot(np.arange(total_steps) / steps_per_epoch, [PEAK_LR * lr_factor(s) for s in range(total_steps)])\nplt.xlabel('epoch'); plt.ylabel('learning rate')\nplt.title(f'AdamW LR: {WARMUP_EPOCHS}-epoch linear warmup + cosine annealing')\nplt.grid(alpha=0.3); plt.tight_layout()\nplt.savefig(f'{WORK_DIR}/lr_schedule.png', dpi=150)\nplt.show()\nprint(f'{steps_per_epoch} steps/epoch, {total_steps:,} steps in total')","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9. Training","metadata":{}},{"cell_type":"code","source":"@torch.no_grad()\ndef predict_proba(X_nhwc, tta=False, bs=2048):\n    model.eval()\n    out = []\n    for i in range(0, len(X_nhwc), bs):\n        xb = torch.from_numpy(X_nhwc[i:i + bs]).to(device, non_blocking=True).permute(0, 3, 1, 2)\n        x = to_float(xb).contiguous(memory_format=torch.channels_last)\n        with autocast():\n            p = model(x).float().softmax(1)\n            if tta:\n                p = (p + model(torch.flip(x, dims=[3])).float().softmax(1)) / 2\n        out.append(p.cpu())\n    return torch.cat(out).numpy()\n\n\ndef evaluate(X, Y, tta=False):\n    p = predict_proba(X, tta=tta)\n    loss = float(-np.log(np.clip(p[np.arange(len(Y)), Y], 1e-12, None)).mean())\n    acc = float((p.argmax(1) == Y).mean())\n    return loss, acc, p\n\n\nwith notify_on_error('training'):\n    history = []\n    train_start = time.time()\n    for epoch in range(EPOCHS):\n        model.train()\n        t0 = time.time()\n        perm = torch.randperm(n_train, device=device)\n        loss_sum = torch.zeros((), device=device)\n        n_cutmix = 0\n        for i in range(steps_per_epoch):\n            idx = perm[i * BATCH_SIZE:(i + 1) * BATCH_SIZE]\n            x = crop_flip(to_float(X_tr[idx]))\n            y = Y_tr[idx]\n            if random.random() < CUTMIX_PROB:\n                x, y2, lam = cutmix(x, y)\n                n_cutmix += 1\n            else:\n                x, y2, lam = cutout(x), y, 1.0\n            x = x.contiguous(memory_format=torch.channels_last)\n            with autocast():\n                logits = model(x)\n                loss = lam * criterion(logits, y) + (1.0 - lam) * criterion(logits, y2)\n            optimizer.zero_grad(set_to_none=True)\n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n            scheduler.step()\n            loss_sum += loss.detach()\n\n        train_loss = loss_sum.item() / steps_per_epoch\n        lr_now = optimizer.param_groups[0]['lr']\n        if n_val > 0:\n            val_loss, val_acc, _ = evaluate(X_val, Y_val)\n        else:\n            val_loss, val_acc = float('nan'), float('nan')\n        dt = time.time() - t0\n        history.append(dict(epoch=epoch + 1, lr=lr_now, train_loss=train_loss,\n                            val_loss=val_loss, val_acc=val_acc, seconds=dt))\n        print(f'epoch {epoch + 1:3d}/{EPOCHS} | lr {lr_now:.2e} | train loss {train_loss:.4f} | '\n              f'val loss {val_loss:.4f} | val acc {val_acc * 100:6.2f}% | cutmix {n_cutmix}/{steps_per_epoch} | {dt:5.1f}s',\n              flush=True)\n        if epoch == 0:\n            eta = dt * EPOCHS / 60\n            send_email('[Kaggle CIFAR-10] training started',\n                       f'Epoch 1 took {dt:.1f}s on {gpu_name} -> about {eta:.0f} min for {EPOCHS} epochs.\\n'\n                       f'Hold-out accuracy after epoch 1: {val_acc * 100:.2f}%')\n\n    train_minutes = (time.time() - train_start) / 60\n    torch.save(model.state_dict(), f'{WORK_DIR}/cifar_resse_net.pt')\n    hist_df = pd.DataFrame(history)\n    hist_df.to_csv(f'{WORK_DIR}/training_history.csv', index=False)\n    print(f'\\nTraining finished in {train_minutes:.1f} min')","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 10. Training curves and hold-out evaluation","metadata":{}},{"cell_type":"code","source":"fig, axes = plt.subplots(1, 3, figsize=(15, 3.8))\naxes[0].plot(hist_df.epoch, hist_df.train_loss, label='train (with CutMix/Cutout)')\nif n_val > 0:\n    axes[0].plot(hist_df.epoch, hist_df.val_loss, label='hold-out')\naxes[0].set_title('loss'); axes[0].set_xlabel('epoch'); axes[0].legend()\nif n_val > 0:\n    axes[1].plot(hist_df.epoch, hist_df.val_acc * 100)\n    axes[1].set_ylim(max(0, hist_df.val_acc.max() * 100 - 20), 100)\naxes[1].set_title('hold-out accuracy (%)'); axes[1].set_xlabel('epoch')\naxes[2].plot(hist_df.epoch, hist_df.lr)\naxes[2].set_title('learning rate'); axes[2].set_xlabel('epoch')\nfor ax in axes:\n    ax.grid(alpha=0.3)\nplt.tight_layout()\nplt.savefig(f'{WORK_DIR}/training_curves.png', dpi=150)\nplt.show()\n\nholdout_acc = holdout_acc_tta = None\nif n_val > 0:\n    _, holdout_acc, _ = evaluate(X_val, Y_val, tta=False)\n    _, holdout_acc_tta, p_val = evaluate(X_val, Y_val, tta=True)\n    print(f'Hold-out accuracy: {holdout_acc * 100:.2f}%  |  with flip-TTA: {holdout_acc_tta * 100:.2f}%')\n\n    pred = p_val.argmax(1)\n    cm = np.zeros((len(CLASSES), len(CLASSES)), dtype=int)\n    np.add.at(cm, (Y_val, pred), 1)\n    fig, ax = plt.subplots(figsize=(7, 6))\n    ax.imshow(cm, cmap='Blues')\n    ax.set_xticks(range(len(CLASSES))); ax.set_xticklabels(CLASSES, rotation=45, ha='right')\n    ax.set_yticks(range(len(CLASSES))); ax.set_yticklabels(CLASSES)\n    for r in range(len(CLASSES)):\n        for c in range(len(CLASSES)):\n            ax.text(c, r, cm[r, c], ha='center', va='center', fontsize=7,\n                    color='white' if cm[r, c] > cm.max() / 2 else 'black')\n    ax.set_xlabel('predicted'); ax.set_ylabel('true')\n    ax.set_title(f'Hold-out confusion matrix (acc {holdout_acc_tta * 100:.2f}%)')\n    plt.tight_layout()\n    plt.savefig(f'{WORK_DIR}/confusion_matrix.png', dpi=150)\n    plt.show()\n    per_class = pd.Series(cm.diagonal() / cm.sum(1), index=CLASSES)\n    print((per_class * 100).round(2).to_string())","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 11. Predict the 300,000 test images and write `submission.csv`","metadata":{}},{"cell_type":"code","source":"with notify_on_error('test prediction'):\n    t0 = time.time()\n    p_test = predict_proba(X_test, tta=TTA)\n    submission = sample_sub.copy()\n    submission['label'] = [CLASSES[i] for i in p_test.argmax(1)]\n    submission.to_csv(f'{WORK_DIR}/submission.csv', index=False)\n    print(f'predicted {len(submission):,} images in {time.time() - t0:.0f}s')\n    print(submission.head())\n    print(submission['label'].value_counts().to_string())","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 12. Summary (also e-mailed)","metadata":{}},{"cell_type":"code","source":"total_minutes = (time.time() - RUN_START) / 60\nsummary = '\\n'.join([\n    'Kaggle CIFAR-10 run finished.',\n    '',\n    f'Model: CifarResSENet, {n_params / 1e6:.2f} M parameters, random init (no pre-trained weights)',\n    f'Optimizer: AdamW (lr {PEAK_LR}, wd {WEIGHT_DECAY}), {WARMUP_EPOCHS}-epoch warmup + cosine annealing',\n    f'Augmentation: crop + flip, CutMix (p={CUTMIX_PROB}) / Cutout ({CUTOUT_SIZE}x{CUTOUT_SIZE})',\n    f'Epochs: {EPOCHS}, batch {BATCH_SIZE}, training time {train_minutes:.1f} min on {gpu_name}',\n    (f'Hold-out accuracy ({n_val} images): {holdout_acc * 100:.2f}% / with flip-TTA {holdout_acc_tta * 100:.2f}%'\n     if n_val > 0 else 'Trained on all 50,000 images (no hold-out).'),\n    f'Total run time: {total_minutes:.1f} min',\n    '',\n    'Next: open this notebook version on Kaggle -> Output -> submission.csv -> Submit.',\n])\nprint(summary)\nsend_email('[Kaggle CIFAR-10] finished — submission.csv ready', summary)","metadata":{},"outputs":[],"execution_count":null}]}