{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.10"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Cassava Leaf Disease Classification — Individual CV Task\n\n**Problem.** Cassava is a staple food crop across East Africa, including Tanzania. Farmers lose significant yield to leaf diseases that are hard to tell apart by eye (Cassava Mosaic Disease, Cassava Bacterial Blight, Cassava Green Mottle, Cassava Brown Streak Disease) vs. Healthy leaves. This notebook trains a deep CNN to classify a leaf photo into one of 5 classes, which could back a phone-based diagnostic tool for farmers.\n\n**Dataset.** Kaggle [\"Cassava Leaf Disease Classification\"](https://www.kaggle.com/competitions/cassava-leaf-disease-classification) — 21,397 labeled training images, 5 classes (satisfies the ≥10,000 image requirement).\n\nOn Kaggle, add the competition dataset via **+ Add Input → cassava-leaf-disease-classification**. It will be mounted at:\n```\n/kaggle/input/cassava-leaf-disease-classification/train_images/*.jpg\n/kaggle/input/cassava-leaf-disease-classification/train.csv\n```\n\n**What this notebook demonstrates** (mapped to task requirements):\n1. Full training loop (not inference-only) with checkpointing.\n2. Hyperparameter tuning via Optuna (lr, weight_decay, batch_size, CBAM reduction ratio).\n3. Two architectures compared: ResNet-50+CBAM vs EfficientNet-B0+CBAM.\n4. Training time / GPU memory / throughput analysis, measured (not assumed) via `TrainLogger`.\n5. CPU vs GPU step-time benchmark producing concrete numbers to justify GPU/TPU use.\n\n**Custom / original components** (individual-task requirement):\n1. **CBAM** (Convolutional Block Attention Module) — channel + spatial attention — implemented from scratch (no torchvision/timm import), spliced onto the backbone's deepest feature map before pooling. Not available out-of-the-box in either backbone.\n2. **Class-balanced Focal Loss**, with class weights computed directly from this dataset's own label distribution (effective-number-of-samples re-weighting, Cui et al. 2019 style), replacing plain cross-entropy to handle real class imbalance.\n3. **`SunGlareShadow`** — a leaf-specific augmentation that synthesizes plausible outdoor lighting artifacts (glare patch + soft shadow), since field photos of cassava leaves are taken in variable sunlight. This is domain-specific, not a stock torchvision transform.\n\n> On Kaggle, remember to turn on a **GPU accelerator** (Settings → Accelerator → GPU T4 x2 or P100) before running training/benchmark cells.\n","metadata":{}},{"cell_type":"markdown","source":"## 0. Setup","metadata":{}},{"cell_type":"code","source":"# Kaggle usually ships torch/torchvision/pandas/sklearn/Pillow pre-installed.\n# Optuna is not preinstalled -> install it (works with internet ON in notebook settings).\n!pip install -q optuna\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport time\nimport json\nimport random\nfrom dataclasses import dataclass\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image, ImageDraw, ImageFilter\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.cuda.amp import autocast, GradScaler\nfrom torchvision import transforms, models\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import f1_score, accuracy_score, confusion_matrix\n\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(\"Using device:\", DEVICE)\n\n# --- Kaggle paths (auto-detected) ------------------------------------------\n# The exact folder name under /kaggle/input can vary depending on how the\n# dataset/competition was attached (e.g. \"cassava-leaf-disease-classification\"\n# vs \"cassava-leaf-disease-classification-dataset\"), so we search for train.csv\n# instead of hardcoding the path.\ndef _find_data_dir(root=\"/kaggle/input\"):\n    if not os.path.isdir(root):\n        return None\n    for dirpath, _, filenames in os.walk(root):\n        if \"train.csv\" in filenames and os.path.isdir(os.path.join(dirpath, \"train_images\")):\n            return dirpath\n    return None\n\nDATA_DIR = _find_data_dir()\n\nif DATA_DIR is None:\n    available = os.listdir(\"/kaggle/input\") if os.path.isdir(\"/kaggle/input\") else []\n    raise FileNotFoundError(\n        \"Could not find train.csv + train_images/ anywhere under /kaggle/input.\\n\"\n        f\"Currently attached inputs: {available}\\n\"\n        \"Fix: in the notebook editor, open the right-hand panel -> '+ Add Input', \"\n        \"search for 'cassava-leaf-disease-classification', and attach it \"\n        \"(accept competition rules first if prompted). Then Restart & Run All.\"\n    )\n\nIMG_DIR = os.path.join(DATA_DIR, \"train_images\")\nCSV_PATH = os.path.join(DATA_DIR, \"train.csv\")\nOUTPUT_DIR = \"/kaggle/working\"\nNUM_CLASSES = 5\n\nprint(\"Found dataset at:\", DATA_DIR)\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 1. Custom augmentation — `SunGlareShadow` (original component #3)\n\nRandomly overlays a soft bright \"glare\" ellipse and/or a soft dark \"shadow\" ellipse onto the PIL image, mimicking uneven sunlight through a canopy — a realistic nuisance factor for field-collected leaf photos that generic torchvision transforms (flip / rotate / color-jitter) do not model. Applied stochastically, training only.","metadata":{}},{"cell_type":"code","source":"class SunGlareShadow:\n    def __init__(self, p=0.4, max_patches=2):\n        self.p = p\n        self.max_patches = max_patches\n\n    def __call__(self, img: Image.Image) -> Image.Image:\n        if random.random() > self.p:\n            return img\n        img = img.convert(\"RGB\")\n        overlay = Image.new(\"RGBA\", img.size, (0, 0, 0, 0))\n        draw = ImageDraw.Draw(overlay)\n        w, h = img.size\n        for _ in range(random.randint(1, self.max_patches)):\n            cx, cy = random.randint(0, w), random.randint(0, h)\n            rx, ry = random.randint(w // 8, w // 3), random.randint(h // 8, h // 3)\n            bbox = [cx - rx, cy - ry, cx + rx, cy + ry]\n            if random.random() < 0.5:\n                draw.ellipse(bbox, fill=(255, 255, 220, 90))   # glare\n            else:\n                draw.ellipse(bbox, fill=(0, 0, 0, 90))          # shadow\n        overlay = overlay.filter(ImageFilter.GaussianBlur(radius=w // 20))\n        return Image.alpha_composite(img.convert(\"RGBA\"), overlay).convert(\"RGB\")\n\n\ndef get_transforms(img_size=224):\n    train_tf = transforms.Compose([\n        transforms.Resize((img_size, img_size)),\n        transforms.RandomHorizontalFlip(),\n        transforms.RandomVerticalFlip(),\n        transforms.RandomRotation(20),\n        SunGlareShadow(p=0.4),                      # <-- custom augmentation\n        transforms.ColorJitter(0.2, 0.2, 0.2),\n        transforms.ToTensor(),\n        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),\n    ])\n    eval_tf = transforms.Compose([\n        transforms.Resize((img_size, img_size)),\n        transforms.ToTensor(),\n        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),\n    ])\n    return train_tf, eval_tf\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Quick visual sanity check of the augmentation:","metadata":{}},{"cell_type":"code","source":"import matplotlib.pyplot as plt\n\nsample_df = pd.read_csv(CSV_PATH)\nsample_path = os.path.join(IMG_DIR, sample_df.iloc[0][\"image_id\"])\nsample_img = Image.open(sample_path).convert(\"RGB\")\n\naug = SunGlareShadow(p=1.0)  # force it on for the demo\nfig, axes = plt.subplots(1, 4, figsize=(14, 4))\naxes[0].imshow(sample_img); axes[0].set_title(\"original\"); axes[0].axis(\"off\")\nfor i in range(1, 4):\n    axes[i].imshow(aug(sample_img)); axes[i].set_title(f\"augmented #{i}\"); axes[i].axis(\"off\")\nplt.tight_layout()\nplt.show()\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Dataset","metadata":{}},{"cell_type":"code","source":"class CassavaDataset(Dataset):\n    def __init__(self, df: pd.DataFrame, img_dir: str, transform=None):\n        self.df = df.reset_index(drop=True)\n        self.img_dir = img_dir\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        path = os.path.join(self.img_dir, row[\"image_id\"])\n        img = Image.open(path).convert(\"RGB\")\n        if self.transform:\n            img = self.transform(img)\n        return img, int(row[\"label\"])\n\n\ndef load_splits(val_size=0.15, test_size=0.1):\n    df = pd.read_csv(CSV_PATH)\n    train_df, temp_df = train_test_split(\n        df, test_size=val_size + test_size, stratify=df[\"label\"], random_state=SEED\n    )\n    rel_test = test_size / (val_size + test_size)\n    val_df, test_df = train_test_split(\n        temp_df, test_size=rel_test, stratify=temp_df[\"label\"], random_state=SEED\n    )\n    return train_df, val_df, test_df\n\n\ntrain_df, val_df, test_df = load_splits()\nprint(\"train:\", len(train_df), \" val:\", len(val_df), \" test:\", len(test_df))\ntrain_df[\"label\"].value_counts().sort_index()\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. Custom component #1 — CBAM implemented from scratch\n\nConvolutional Block Attention Module (Woo et al. 2018), re-implemented directly from the paper description — no `torchvision`/`timm` import — and manually spliced onto the backbone's deepest feature map before global pooling + classifier. Works for both ResNet-50 (2048 channels) and EfficientNet-B0 (1280 channels).","metadata":{}},{"cell_type":"code","source":"class ChannelAttention(nn.Module):\n    def __init__(self, channels, reduction=16):\n        super().__init__()\n        hidden = max(channels // reduction, 8)\n        self.mlp = nn.Sequential(\n            nn.Linear(channels, hidden),\n            nn.ReLU(inplace=True),\n            nn.Linear(hidden, channels),\n        )\n        self.avg_pool = nn.AdaptiveAvgPool2d(1)\n        self.max_pool = nn.AdaptiveMaxPool2d(1)\n\n    def forward(self, x):\n        b, c, _, _ = x.shape\n        avg_out = self.mlp(self.avg_pool(x).view(b, c))\n        max_out = self.mlp(self.max_pool(x).view(b, c))\n        scale = torch.sigmoid(avg_out + max_out).view(b, c, 1, 1)\n        return x * scale\n\n\nclass SpatialAttention(nn.Module):\n    def __init__(self, kernel_size=7):\n        super().__init__()\n        self.conv = nn.Conv2d(2, 1, kernel_size, padding=kernel_size // 2, bias=False)\n\n    def forward(self, x):\n        avg_out = torch.mean(x, dim=1, keepdim=True)\n        max_out, _ = torch.max(x, dim=1, keepdim=True)\n        attn = torch.sigmoid(self.conv(torch.cat([avg_out, max_out], dim=1)))\n        return x * attn\n\n\nclass CBAM(nn.Module):\n    \"\"\"Convolutional Block Attention Module (Woo et al. 2018) — reimplemented\n    from the paper description, not imported from a library. Sequentially\n    applies channel attention then spatial attention.\"\"\"\n\n    def __init__(self, channels, reduction=16, kernel_size=7):\n        super().__init__()\n        self.channel_attn = ChannelAttention(channels, reduction)\n        self.spatial_attn = SpatialAttention(kernel_size)\n\n    def forward(self, x):\n        x = self.channel_attn(x)\n        x = self.spatial_attn(x)\n        return x\n\n\nclass CBAMBackboneWrapper(nn.Module):\n    \"\"\"\n    Wraps a torchvision backbone and splices a CBAM block onto its deepest\n    feature map before global pooling + classifier.\n    \"\"\"\n\n    def __init__(self, feature_extractor: nn.Module, feature_channels: int,\n                 num_classes: int, reduction=16, dropout=0.3):\n        super().__init__()\n        self.features = feature_extractor         # backbone up to last conv stage\n        self.cbam = CBAM(feature_channels, reduction=reduction)\n        self.pool = nn.AdaptiveAvgPool2d(1)\n        self.classifier = nn.Sequential(\n            nn.Dropout(dropout),\n            nn.Linear(feature_channels, num_classes),\n        )\n\n    def forward(self, x):\n        x = self.features(x)\n        x = self.cbam(x)                            # <-- custom attention injected\n        x = self.pool(x).flatten(1)\n        return self.classifier(x)\n\n\ndef build_resnet50_cbam(num_classes=NUM_CLASSES, reduction=16, pretrained=True):\n    base = models.resnet50(weights=models.ResNet50_Weights.DEFAULT if pretrained else None)\n    feature_extractor = nn.Sequential(*list(base.children())[:-2])  # drop avgpool+fc\n    return CBAMBackboneWrapper(feature_extractor, 2048, num_classes, reduction)\n\n\ndef build_efficientnet_b0_cbam(num_classes=NUM_CLASSES, reduction=16, pretrained=True):\n    base = models.efficientnet_b0(\n        weights=models.EfficientNet_B0_Weights.DEFAULT if pretrained else None\n    )\n    feature_extractor = base.features  # conv stages only, 1280 channels out\n    return CBAMBackboneWrapper(feature_extractor, 1280, num_classes, reduction)\n\n\nARCH_BUILDERS = {\n    \"resnet50\": build_resnet50_cbam,\n    \"efficientnet_b0\": build_efficientnet_b0_cbam,\n}\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. Custom component #2 — Class-Balanced Focal Loss\n\nFocal loss (Lin et al. 2017) combined with per-class effective-number-of-samples weights (Cui et al. 2019), computed from this dataset's *own* training-split label counts rather than a generic 1/freq weighting. Implemented manually, not from `torchvision`/`torchmetrics`.","metadata":{}},{"cell_type":"code","source":"def compute_effective_num_weights(labels, num_classes, beta=0.999):\n    counts = np.bincount(labels, minlength=num_classes).astype(np.float64)\n    counts = np.clip(counts, 1, None)\n    effective_num = 1.0 - np.power(beta, counts)\n    weights = (1.0 - beta) / effective_num\n    weights = weights / weights.sum() * num_classes\n    return torch.tensor(weights, dtype=torch.float32)\n\n\nclass ClassBalancedFocalLoss(nn.Module):\n    def __init__(self, class_weights: torch.Tensor, gamma=2.0):\n        super().__init__()\n        self.register_buffer(\"class_weights\", class_weights)\n        self.gamma = gamma\n\n    def forward(self, logits, targets):\n        log_probs = F.log_softmax(logits, dim=1)\n        probs = log_probs.exp()\n        ce = F.nll_loss(log_probs, targets, reduction=\"none\")\n        pt = probs.gather(1, targets.unsqueeze(1)).squeeze(1)\n        focal_term = (1 - pt) ** self.gamma\n        w = self.class_weights[targets]\n        loss = w * focal_term * ce\n        return loss.mean()\n\n\n# Preview the computed weights on the training split\n_preview_weights = compute_effective_num_weights(train_df[\"label\"].values, NUM_CLASSES)\nprint(\"Class-balanced weights:\", _preview_weights)\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. Train / eval loops with instrumentation (requirement iv)","metadata":{}},{"cell_type":"code","source":"@dataclass\nclass RunConfig:\n    arch: str = \"resnet50\"\n    lr: float = 3e-4\n    weight_decay: float = 1e-4\n    batch_size: int = 32\n    epochs: int = 60\n    reduction: int = 16\n    img_size: int = 224\n    use_amp: bool = True\n\n\nclass TrainLogger:\n    \"\"\"Tracks wall-clock time and peak GPU memory per epoch so the\n    performance-analysis section is backed by real numbers.\"\"\"\n\n    def __init__(self):\n        self.records = []\n\n    def start_epoch(self):\n        if DEVICE.type == \"cuda\":\n            torch.cuda.reset_peak_memory_stats()\n        self._t0 = time.time()\n\n    def end_epoch(self, epoch, n_images, train_loss, val_metrics):\n        elapsed = time.time() - self._t0\n        peak_mem_mb = (\n            torch.cuda.max_memory_allocated() / 1e6 if DEVICE.type == \"cuda\" else 0.0\n        )\n        rec = {\n            \"epoch\": epoch,\n            \"epoch_time_s\": round(elapsed, 2),\n            \"images_per_sec\": round(n_images / elapsed, 1),\n            \"peak_gpu_mem_mb\": round(peak_mem_mb, 1),\n            \"train_loss\": round(train_loss, 4),\n            **val_metrics,\n        }\n        self.records.append(rec)\n        print(rec)\n        return rec\n\n    def save(self, path):\n        pd.DataFrame(self.records).to_csv(path, index=False)\n\n\ndef evaluate(model, loader, criterion):\n    model.eval()\n    all_preds, all_targets, losses = [], [], []\n    with torch.no_grad():\n        for x, y in loader:\n            x, y = x.to(DEVICE), y.to(DEVICE)\n            logits = model(x)\n            loss = criterion(logits, y)\n            losses.append(loss.item())\n            all_preds.extend(logits.argmax(1).cpu().numpy())\n            all_targets.extend(y.cpu().numpy())\n    return {\n        \"val_loss\": round(float(np.mean(losses)), 4),\n        \"val_acc\": round(accuracy_score(all_targets, all_preds), 4),\n        \"val_f1_macro\": round(f1_score(all_targets, all_preds, average=\"macro\"), 4),\n    }, all_targets, all_preds\n\n\ndef train_model(cfg: RunConfig, train_df, val_df, save_path=None, max_batches=None):\n    train_tf, eval_tf = get_transforms(cfg.img_size)\n    train_ds = CassavaDataset(train_df, IMG_DIR, train_tf)\n    val_ds = CassavaDataset(val_df, IMG_DIR, eval_tf)\n    train_loader = DataLoader(train_ds, batch_size=cfg.batch_size, shuffle=True,\n                               num_workers=2, pin_memory=True)\n    val_loader = DataLoader(val_ds, batch_size=cfg.batch_size, shuffle=False,\n                             num_workers=2, pin_memory=True)\n\n    model = ARCH_BUILDERS[cfg.arch](reduction=cfg.reduction).to(DEVICE)\n    class_weights = compute_effective_num_weights(\n        train_df[\"label\"].values, NUM_CLASSES\n    ).to(DEVICE)\n    criterion = ClassBalancedFocalLoss(class_weights, gamma=2.0)\n    optimizer = torch.optim.AdamW(model.parameters(), lr=cfg.lr,\n                                   weight_decay=cfg.weight_decay)\n    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=cfg.epochs)\n    scaler = GradScaler(enabled=cfg.use_amp and DEVICE.type == \"cuda\")\n\n    logger = TrainLogger()\n    best_f1 = -1.0\n    for epoch in range(1, cfg.epochs + 1):\n        model.train()\n        logger.start_epoch()\n        running_loss, n_seen = 0.0, 0\n        for i, (x, y) in enumerate(train_loader):\n            if max_batches and i >= max_batches:   # for quick HPO trials\n                break\n            x, y = x.to(DEVICE), y.to(DEVICE)\n            optimizer.zero_grad()\n            with autocast(enabled=cfg.use_amp and DEVICE.type == \"cuda\"):\n                logits = model(x)\n                loss = criterion(logits, y)\n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n            running_loss += loss.item() * x.size(0)\n            n_seen += x.size(0)\n        scheduler.step()\n        val_metrics, _, _ = evaluate(model, val_loader, criterion)\n        rec = logger.end_epoch(epoch, n_seen, running_loss / max(n_seen, 1), val_metrics)\n        if save_path and rec[\"val_f1_macro\"] > best_f1:\n            best_f1 = rec[\"val_f1_macro\"]\n            torch.save(model.state_dict(), save_path)\n\n    return model, logger, best_f1\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. GPU vs CPU benchmark (requirement v)\n\nConcrete, measured justification for using GPU/TPU acceleration rather than assuming it.","metadata":{}},{"cell_type":"code","source":"def benchmark_device(arch=\"resnet50\", batch_size=32, img_size=224, n_iters=20):\n    model_cpu = ARCH_BUILDERS[arch]().to(\"cpu\").eval()\n    dummy = torch.randn(batch_size, 3, img_size, img_size)\n\n    def timed_forward_backward(model, x, device, iters):\n        model = model.to(device)\n        x = x.to(device)\n        model.train()\n        opt = torch.optim.SGD(model.parameters(), lr=1e-3)\n        criterion = nn.CrossEntropyLoss()\n        y = torch.randint(0, NUM_CLASSES, (x.size(0),)).to(device)\n        if device == \"cuda\":\n            torch.cuda.synchronize()\n        t0 = time.time()\n        for _ in range(iters):\n            opt.zero_grad()\n            out = model(x)\n            loss = criterion(out, y)\n            loss.backward()\n            opt.step()\n        if device == \"cuda\":\n            torch.cuda.synchronize()\n        return (time.time() - t0) / iters\n\n    results = {}\n    cpu_time = timed_forward_backward(model_cpu, dummy, \"cpu\", n_iters)\n    results[\"cpu_sec_per_step\"] = round(cpu_time, 4)\n\n    if torch.cuda.is_available():\n        model_gpu = ARCH_BUILDERS[arch]().to(\"cuda\")\n        torch.cuda.reset_peak_memory_stats()\n        gpu_time = timed_forward_backward(model_gpu, dummy, \"cuda\", n_iters)\n        results[\"gpu_sec_per_step\"] = round(gpu_time, 4)\n        results[\"speedup_x\"] = round(cpu_time / gpu_time, 1)\n        results[\"gpu_peak_mem_mb\"] = round(\n            torch.cuda.max_memory_allocated() / 1e6, 1\n        )\n    else:\n        results[\"note\"] = \"No CUDA device available in this environment.\"\n\n    print(json.dumps(results, indent=2))\n    return results\n\n\nbenchmark_results = benchmark_device(arch=\"resnet50\")\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Hyperparameter tuning (requirement ii) — Optuna\n\nRuns a short search over `lr`, `weight_decay`, `batch_size`, and CBAM `reduction`, using a small `epoch_budget` and `max_batches` cap per trial to keep this fast in a notebook session. Increase `n_trials`/`epoch_budget`/`max_batches` for a more thorough search once you've confirmed the pipeline runs end-to-end.","metadata":{}},{"cell_type":"code","source":"import optuna\n\ndef run_hpo(train_df, val_df, n_trials=8, arch=\"resnet50\", epoch_budget=2, max_batches=40):\n    def objective(trial):\n        cfg = RunConfig(\n            arch=arch,\n            lr=trial.suggest_float(\"lr\", 1e-5, 5e-3, log=True),\n            weight_decay=trial.suggest_float(\"weight_decay\", 1e-6, 1e-2, log=True),\n            batch_size=trial.suggest_categorical(\"batch_size\", [16, 32, 64]),\n            reduction=trial.suggest_categorical(\"reduction\", [8, 16, 32]),\n            epochs=epoch_budget,\n        )\n        _, _, best_f1 = train_model(cfg, train_df, val_df, max_batches=max_batches)\n        return best_f1\n\n    study = optuna.create_study(direction=\"maximize\")\n    study.optimize(objective, n_trials=n_trials)\n    print(\"Best trial:\", study.best_trial.params, \"f1=\", study.best_value)\n    return study\n\n# Uncomment to run the search (can take a while even with the small budget above):\n# hpo_study = run_hpo(train_df, val_df, arch=\"resnet50\")\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. Train a single architecture\n\nSet `ARCH` to `\"resnet50\"` or `\"efficientnet_b0\"` and run. Checkpoints and the per-epoch log are written to `/kaggle/working/`.","metadata":{}},{"cell_type":"code","source":"ARCH = \"resnet50\"     # or \"efficientnet_b0\"\nEPOCHS = 8\n\ncfg = RunConfig(arch=ARCH, epochs=EPOCHS)\nmodel, logger, best_f1 = train_model(\n    cfg, train_df, val_df,\n    save_path=os.path.join(OUTPUT_DIR, f\"{ARCH}_best.pt\"),\n)\nlogger.save(os.path.join(OUTPUT_DIR, f\"{ARCH}_epoch_log.csv\"))\nprint(f\"Best val macro-F1 for {ARCH}: {best_f1}\")\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9. Compare both architectures\n\nTrains ResNet-50+CBAM and EfficientNet-B0+CBAM under the same config and compares validation macro-F1 (requirement iii).","metadata":{}},{"cell_type":"code","source":"COMPARE_EPOCHS = 8\ncompare_results = {}\n\nfor arch in ARCH_BUILDERS:\n    cfg = RunConfig(arch=arch, epochs=COMPARE_EPOCHS)\n    m, lg, f1 = train_model(\n        cfg, train_df, val_df,\n        save_path=os.path.join(OUTPUT_DIR, f\"{arch}_best.pt\"),\n    )\n    lg.save(os.path.join(OUTPUT_DIR, f\"{arch}_epoch_log.csv\"))\n    compare_results[arch] = f1\n\nprint(\"Architecture comparison (val macro-F1):\", compare_results)\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 10. Final evaluation on held-out test split","metadata":{}},{"cell_type":"code","source":"_, eval_tf = get_transforms(cfg.img_size)\ntest_ds = CassavaDataset(test_df, IMG_DIR, eval_tf)\ntest_loader = DataLoader(test_ds, batch_size=cfg.batch_size, shuffle=False, num_workers=2)\n\nclass_weights = compute_effective_num_weights(train_df[\"label\"].values, NUM_CLASSES).to(DEVICE)\ncriterion = ClassBalancedFocalLoss(class_weights, gamma=2.0)\n\ntest_metrics, y_true, y_pred = evaluate(model, test_loader, criterion)\nprint(\"Test metrics:\", test_metrics)\n\ncm = confusion_matrix(y_true, y_pred)\nfig, ax = plt.subplots(figsize=(5, 5))\nim = ax.imshow(cm, cmap=\"Blues\")\nax.set_xlabel(\"Predicted\"); ax.set_ylabel(\"True\"); ax.set_title(\"Confusion matrix\")\nfor i in range(cm.shape[0]):\n    for j in range(cm.shape[1]):\n        ax.text(j, i, cm[i, j], ha=\"center\", va=\"center\")\nplt.colorbar(im)\nplt.show()\n","metadata":{},"outputs":[],"execution_count":null}]}