{"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":[{"sourceType":"competition","sourceId":71885,"databundleVersionId":8143495},{"sourceType":"datasetVersion","sourceId":12448188,"datasetId":7852384,"databundleVersionId":13019767}],"dockerImageVersionId":31089,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"## Running this notebook in Kaggle\n\nBefore running:\n\n1. Attach the **Plant Disease Detection** dataset to the Kaggle notebook.\n2. Confirm that the dataset path is:\n\n```text\n/kaggle/input/plant-disease-detection/Dataset\n```\n\n3. Run cells from top to bottom.\n4. Exported files will appear in:\n\n```text\n/kaggle/working/exports\n```\n\nKey exported files include `best_model.pt`, `labels.json`, `config.json`, `metrics.json`, `classification_report.csv`, `confusion_matrix.png`, `per_class_accuracy.png`, and `gradcam_samples.png`.","metadata":{}},{"cell_type":"markdown","source":"# Smart Crop Health Monitoring: Plant Disease Detection\n\n**Clean Kaggle Notebook Version**\n\nThis notebook is the structured version of the original end-to-end Kaggle code.  \nThe earlier code was implemented in one large cell; this version separates the work into clear sections for readability, marking evidence, debugging, and presentation.\n\n## Project aim\n\nBuild and evaluate a deep-learning computer vision model that classifies plant leaf images into **23 disease/healthy classes** across five crops:\n\n- Apple\n- Corn/Maize\n- Pepper bell\n- Potato\n- Tomato\n\n## Final workflow\n\n1. Setup and configuration  \n2. Dataset audit and EDA  \n3. Duplicate candidate detection  \n4. Train/validation/test manifest creation  \n5. PyTorch Dataset and transforms  \n6. EfficientNet-B2 model training  \n7. Evaluation with metrics and visualisations  \n8. Grad-CAM explainability  \n9. Artifact export for VS Code and Hugging Face deployment","metadata":{}},{"cell_type":"markdown","source":"> ## 0. Setup\n\n**Purpose:** Imports core libraries, controls randomness for reproducibility, selects GPU/CPU, and enables mixed precision when CUDA is available.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Setup\n# ------------------------------------------------------------\nimport os\nimport json\nimport random\nimport warnings\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport seaborn as sns\n\nfrom PIL import Image, UnidentifiedImageError\nimport imagehash\n\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader, WeightedRandomSampler\nfrom torchvision import transforms, models\n\nfrom sklearn.metrics import (\n    classification_report,\n    confusion_matrix,\n    precision_recall_fscore_support,\n)\n\nwarnings.filterwarnings(\"ignore\")\n\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.cuda.manual_seed_all(SEED)\ntorch.backends.cudnn.deterministic = True\ntorch.backends.cudnn.benchmark = False\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nUSE_AMP = DEVICE.type == \"cuda\"\nprint(\"Using device:\", DEVICE)\nprint(\"AMP enabled:\", USE_AMP)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 1. Configuration\n\n**Purpose:** Defines Kaggle input/output paths, training hyperparameters, class labels, and writes deployment-ready `config.json` and `labels.json`.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Configuration\n# ------------------------------------------------------------\nINPUT_BASE = Path(\"/kaggle/input/plant-disease-detection/Dataset\")\nWORKING_BASE = Path(\"/kaggle/working/plant_disease_kaggle_first\")\nEXPORT_DIR = Path(\"/kaggle/working/exports\")\nWORKING_BASE.mkdir(parents=True, exist_ok=True)\nEXPORT_DIR.mkdir(parents=True, exist_ok=True)\n\nIMG_SIZE = 224\nBATCH_SIZE = 32\nNUM_WORKERS = 2\nEPOCHS = 20\nLR = 3e-4\nWEIGHT_DECAY = 1e-4\nTRAIN_RATIO = 0.70\nVAL_RATIO = 0.15\nPATIENCE = 5\nUSE_EFFICIENTNET = True\nLABEL_SMOOTHING = 0.1\nTOP_K = 5\n\n# IMPORTANT:\n# Set False when Kaggle internet is disabled and torchvision weights cannot be downloaded.\nUSE_PRETRAINED_WEIGHTS = False\n\nCATEGORIES = [\n    'Apple___Apple_scab', 'Apple___Black_rot', 'Apple___Cedar_apple_rust',\n    'Apple___healthy',\n    'Corn_(maize)___Cercospora_leaf_spot Gray_leaf_spot',\n    'Corn_(maize)___Common_rust_', 'Corn_(maize)___Northern_Leaf_Blight',\n    'Corn_(maize)___healthy',\n    'Pepper__bell___Bacterial_spot', 'Pepper__bell___healthy',\n    'Potato___Early_blight', 'Potato___Late_blight', 'Potato___healthy',\n    'Tomato_Bacterial_spot', 'Tomato_Early_blight', 'Tomato_Late_blight',\n    'Tomato_Leaf_Mold', 'Tomato_Septoria_leaf_spot',\n    'Tomato_Spider_mites_Two_spotted_spider_mite', 'Tomato__Target_Spot',\n    'Tomato__Tomato_YellowLeaf__Curl_Virus', 'Tomato__Tomato_mosaic_virus',\n    'Tomato_healthy',\n]\nNUM_CLASSES = len(CATEGORIES)\nVALID_EXTS = {\".jpg\", \".jpeg\", \".png\", \".bmp\", \".gif\", \".tif\", \".tiff\"}\n\nconfig = {\n    \"img_size\": IMG_SIZE,\n    \"batch_size\": BATCH_SIZE,\n    \"epochs\": EPOCHS,\n    \"lr\": LR,\n    \"weight_decay\": WEIGHT_DECAY,\n    \"train_ratio\": TRAIN_RATIO,\n    \"val_ratio\": VAL_RATIO,\n    \"patience\": PATIENCE,\n    \"use_efficientnet\": USE_EFFICIENTNET,\n    \"label_smoothing\": LABEL_SMOOTHING,\n    \"seed\": SEED,\n    \"top_k\": TOP_K,\n    \"use_pretrained_weights\": USE_PRETRAINED_WEIGHTS,\n}\nwith open(EXPORT_DIR / \"config.json\", \"w\", encoding=\"utf-8\") as f:\n    json.dump(config, f, indent=2)\nwith open(EXPORT_DIR / \"labels.json\", \"w\", encoding=\"utf-8\") as f:\n    json.dump(CATEGORIES, f, indent=2)\n\nprint(\"Classes:\", NUM_CLASSES)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Dataset Audit\n\n**Purpose:** Checks all image folders, validates readable files, records image dimensions, and exports `audit.csv` and `audit_summary.json`.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Dataset Audit\n# ------------------------------------------------------------\nrows = []\nfor category in CATEGORIES:\n    class_dir = INPUT_BASE / category\n    if not class_dir.exists():\n        rows.append({\n            \"class_name\": category,\n            \"path\": None,\n            \"filename\": None,\n            \"size_bytes\": None,\n            \"width\": None,\n            \"height\": None,\n            \"status\": \"missing_folder\",\n        })\n        continue\n\n    for img_path in class_dir.iterdir():\n        if img_path.suffix.lower() not in VALID_EXTS:\n            continue\n\n        status = \"ok\"\n        width = height = None\n        try:\n            with Image.open(img_path) as im:\n                im = im.convert(\"RGB\")\n                width, height = im.size\n        except (UnidentifiedImageError, OSError):\n            status = \"corrupt\"\n\n        rows.append({\n            \"class_name\": category,\n            \"path\": str(img_path),\n            \"filename\": img_path.name,\n            \"size_bytes\": img_path.stat().st_size if img_path.exists() else None,\n            \"width\": width,\n            \"height\": height,\n            \"status\": status,\n        })\n\naudit_df = pd.DataFrame(rows)\naudit_df.to_csv(EXPORT_DIR / \"audit.csv\", index=False)\n\naudit_summary = {\n    \"total_rows\": int(len(audit_df)),\n    \"ok_images\": int((audit_df[\"status\"] == \"ok\").sum()),\n    \"corrupt_images\": int((audit_df[\"status\"] == \"corrupt\").sum()),\n    \"missing_folders\": int((audit_df[\"status\"] == \"missing_folder\").sum()),\n    \"num_classes\": int(NUM_CLASSES),\n}\nwith open(EXPORT_DIR / \"audit_summary.json\", \"w\", encoding=\"utf-8\") as f:\n    json.dump(audit_summary, f, indent=2)\n\nprint(\"Audit summary:\", audit_summary)\nprint(audit_df[\"status\"].value_counts(dropna=False))\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. EDA\n\n**Purpose:** Explores class balance and image-size distributions so dataset quality and imbalance can be discussed in the report.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# EDA\n# ------------------------------------------------------------\nusable_audit = audit_df[audit_df[\"status\"] == \"ok\"].copy()\n\nclass_counts = usable_audit.groupby(\"class_name\").size().sort_values(ascending=False)\n\nplt.figure(figsize=(12, 8))\nsns.barplot(x=class_counts.values, y=class_counts.index, palette=\"viridis\")\nplt.title(\"Class Distribution\")\nplt.xlabel(\"Image Count\")\nplt.ylabel(\"Class\")\nplt.tight_layout()\nplt.savefig(EXPORT_DIR / \"class_distribution.png\", dpi=160)\nplt.show()\n\nplt.figure(figsize=(10, 4))\nplt.subplot(1, 2, 1)\nsns.histplot(audit_df[\"width\"].dropna(), bins=30, kde=True)\nplt.title(\"Width Distribution\")\nplt.subplot(1, 2, 2)\nsns.histplot(audit_df[\"height\"].dropna(), bins=30, kde=True)\nplt.title(\"Height Distribution\")\nplt.tight_layout()\nplt.savefig(EXPORT_DIR / \"image_size_distribution.png\", dpi=160)\nplt.show()\n\nclass_balance_df = class_counts.reset_index()\nclass_balance_df.columns = [\"class_name\", \"count\"]\nclass_balance_df[\"ratio_to_max\"] = class_balance_df[\"count\"] / class_balance_df[\"count\"].max()\nclass_balance_df.to_csv(EXPORT_DIR / \"class_balance_report.csv\", index=False)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. Duplicate Candidate Detection\n\n**Purpose:** Uses perceptual hashing to flag visually similar or duplicate images that may create leakage risks.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Duplicate Candidate Detection\n# ------------------------------------------------------------\ndef phash_safe(path):\n    try:\n        with Image.open(path) as im:\n            im = im.convert(\"RGB\")\n            return str(imagehash.phash(im))\n    except Exception:\n        return None\n\nsample_for_hash = usable_audit.copy()\nsample_for_hash[\"phash\"] = sample_for_hash[\"path\"].apply(phash_safe)\ndupes = sample_for_hash[sample_for_hash[\"phash\"].duplicated(keep=False)].sort_values(\"phash\")\ndupes.to_csv(EXPORT_DIR / \"duplicate_candidates.csv\", index=False)\nprint(\"Potential duplicate rows:\", len(dupes))\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. Create Train / Val / Test Manifests\n\n**Purpose:** Creates stratified train, validation, and test manifests so the final evaluation uses held-out images.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Create Train / Val / Test Manifests\n# ------------------------------------------------------------\nusable_df = audit_df[audit_df[\"status\"] == \"ok\"].copy()\n\ndef create_split_manifests(df, train_ratio=0.70, val_ratio=0.15, seed=42):\n    rng = random.Random(seed)\n    train_rows, val_rows, test_rows = [], [], []\n\n    for class_name, sub in df.groupby(\"class_name\"):\n        rows = sub.to_dict(\"records\")\n        rng.shuffle(rows)\n        n = len(rows)\n        n_train = int(n * train_ratio)\n        n_val = int(n * val_ratio)\n\n        train_rows.extend(rows[:n_train])\n        val_rows.extend(rows[n_train:n_train + n_val])\n        test_rows.extend(rows[n_train + n_val:])\n\n    return (\n        pd.DataFrame(train_rows).assign(split=\"train\"),\n        pd.DataFrame(val_rows).assign(split=\"val\"),\n        pd.DataFrame(test_rows).assign(split=\"test\"),\n    )\n\ntrain_df, val_df, test_df = create_split_manifests(usable_df, TRAIN_RATIO, VAL_RATIO, SEED)\ntrain_df.to_csv(EXPORT_DIR / \"train_manifest.csv\", index=False)\nval_df.to_csv(EXPORT_DIR / \"val_manifest.csv\", index=False)\ntest_df.to_csv(EXPORT_DIR / \"test_manifest.csv\", index=False)\n\nsplit_summary = pd.DataFrame({\n    \"split\": [\"train\", \"val\", \"test\"],\n    \"count\": [len(train_df), len(val_df), len(test_df)],\n})\nsplit_summary.to_csv(EXPORT_DIR / \"split_summary.csv\", index=False)\nprint(split_summary)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. Dataset Class\n\n**Purpose:** Implements a reusable PyTorch Dataset class for loading image paths and labels from manifest files.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Dataset Class\n# ------------------------------------------------------------\nclass ManifestImageDataset(Dataset):\n    def __init__(self, manifest_df, categories, transform=None):\n        self.df = manifest_df.reset_index(drop=True).copy()\n        self.categories = categories\n        self.class_to_idx = {c: i for i, c in enumerate(categories)}\n        self.idx_to_class = {i: c for c, i in self.class_to_idx.items()}\n        self.transform = transform\n        self.labels = [self.class_to_idx[c] for c in self.df[\"class_name\"].tolist()]\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        path = row[\"path\"]\n        label = self.class_to_idx[row[\"class_name\"]]\n\n        try:\n            img = Image.open(path).convert(\"RGB\")\n        except (UnidentifiedImageError, OSError):\n            return self.__getitem__((idx + 1) % len(self))\n\n        if self.transform:\n            img = self.transform(img)\n        return img, label\n\n    def class_weights(self):\n        counts = np.bincount(self.labels, minlength=len(self.idx_to_class))\n        weights = 1.0 / (counts + 1e-6)\n        return torch.tensor(weights, dtype=torch.float32)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Transforms\n\n**Purpose:** Defines training augmentation and validation/test preprocessing using ImageNet-style normalisation.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Transforms\n# ------------------------------------------------------------\ntrain_tf = transforms.Compose([\n    transforms.RandomResizedCrop(IMG_SIZE, scale=(0.7, 1.0)),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(),\n    transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.05),\n    transforms.RandomRotation(20),\n    transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n])\n\neval_tf = transforms.Compose([\n    transforms.Resize(int(IMG_SIZE * 1.14)),\n    transforms.CenterCrop(IMG_SIZE),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n])\n\ntrain_ds = ManifestImageDataset(train_df, CATEGORIES, train_tf)\nval_ds = ManifestImageDataset(val_df, CATEGORIES, eval_tf)\ntest_ds = ManifestImageDataset(test_df, CATEGORIES, eval_tf)\n\nsample_weights = train_ds.class_weights()[train_ds.labels]\nsampler = WeightedRandomSampler(sample_weights, num_samples=len(train_ds), replacement=True)\n\nloader_kwargs = dict(num_workers=NUM_WORKERS, pin_memory=(DEVICE.type == \"cuda\"))\ntrain_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, sampler=sampler, **loader_kwargs)\nval_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, **loader_kwargs)\ntest_loader = DataLoader(test_ds, batch_size=BATCH_SIZE, shuffle=False, **loader_kwargs)\n\nprint(\"Dataset sizes:\", len(train_ds), len(val_ds), len(test_ds))\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. Offline-Safe Model Factory\n\n**Purpose:** Builds EfficientNet-B2 or ResNet-50 without downloading pretrained weights, making the notebook safe for offline Kaggle execution.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Offline-Safe Model Factory\n# ------------------------------------------------------------\ndef build_model(num_classes, use_efficientnet=True, use_pretrained=False):\n    \"\"\"\n    Offline-safe model builder.\n    If use_pretrained=False, no download is attempted.\n    \"\"\"\n\n    if use_efficientnet:\n        try:\n            from torchvision.models import efficientnet_b2, EfficientNet_B2_Weights\n            eff_weights = EfficientNet_B2_Weights.DEFAULT if use_pretrained else None\n            model = efficientnet_b2(weights=eff_weights)\n            in_features = model.classifier[1].in_features\n            model.classifier = nn.Sequential(\n                nn.Dropout(0.3),\n                nn.Linear(in_features, num_classes),\n            )\n            print(\n                \"Using EfficientNet-B2 with pretrained weights\"\n                if use_pretrained else\n                \"Using EfficientNet-B2 without pretrained weights\"\n            )\n            return model\n        except Exception as e:\n            print(\"Falling back to ResNet-50:\", e)\n\n    from torchvision.models import resnet50, ResNet50_Weights\n    res_weights = ResNet50_Weights.DEFAULT if use_pretrained else None\n    model = resnet50(weights=res_weights)\n    model.fc = nn.Sequential(\n        nn.Dropout(0.3),\n        nn.Linear(model.fc.in_features, num_classes),\n    )\n    print(\n        \"Using ResNet-50 with pretrained weights\"\n        if use_pretrained else\n        \"Using ResNet-50 without pretrained weights\"\n    )\n    return model\n\nmodel = build_model(\n    NUM_CLASSES,\n    use_efficientnet=USE_EFFICIENTNET,\n    use_pretrained=USE_PRETRAINED_WEIGHTS,\n).to(DEVICE)\n\ncriterion = nn.CrossEntropyLoss(label_smoothing=LABEL_SMOOTHING)\noptimizer = optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\nscheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS, eta_min=1e-6)\nscaler = torch.cuda.amp.GradScaler(enabled=USE_AMP)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9. Early Stopping\n\n**Purpose:** Provides a small early-stopping helper to protect against overfitting and unnecessary training.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Early Stopping\n# ------------------------------------------------------------\nclass EarlyStopping:\n    def __init__(self, patience, path):\n        self.patience = patience\n        self.path = path\n        self.counter = 0\n        self.best_score = None\n        self.early_stop = False\n\n    def __call__(self, val_loss, model):\n        score = -val_loss\n        if self.best_score is None or score > self.best_score:\n            self.best_score = score\n            torch.save(model.state_dict(), self.path)\n            self.counter = 0\n        else:\n            self.counter += 1\n            if self.counter >= self.patience:\n                self.early_stop = True\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 10. Train / Validation Epoch\n\n**Purpose:** Defines reusable epoch functions for training and validation with AMP, gradient clipping, loss, and accuracy tracking.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Train / Validation Epoch\n# ------------------------------------------------------------\ndef run_epoch(model, loader, criterion, optimizer=None, phase=\"train\"):\n    is_train = phase == \"train\"\n    model.train() if is_train else model.eval()\n\n    total_loss, correct, total = 0.0, 0, 0\n    all_preds, all_labels = [], []\n\n    context = torch.enable_grad() if is_train else torch.no_grad()\n    with context:\n        for images, labels in loader:\n            images, labels = images.to(DEVICE), labels.to(DEVICE)\n\n            if is_train:\n                optimizer.zero_grad(set_to_none=True)\n\n            with torch.cuda.amp.autocast(enabled=USE_AMP):\n                outputs = model(images)\n                loss = criterion(outputs, labels)\n\n            if is_train:\n                scaler.scale(loss).backward()\n                scaler.unscale_(optimizer)\n                nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n                scaler.step(optimizer)\n                scaler.update()\n\n            total_loss += loss.item() * images.size(0)\n            preds = outputs.argmax(dim=1)\n            correct += (preds == labels).sum().item()\n            total += labels.size(0)\n\n            all_preds.extend(preds.detach().cpu().numpy().tolist())\n            all_labels.extend(labels.detach().cpu().numpy().tolist())\n\n    return total_loss / total, correct / total, all_preds, all_labels\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 11. Training Loop\n\n**Purpose:** Runs the full training process, saves the best model checkpoint, and exports training history.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Training Loop\n# ------------------------------------------------------------\nhistory = {\"train_loss\": [], \"val_loss\": [], \"train_acc\": [], \"val_acc\": []}\nstopper = EarlyStopping(PATIENCE, EXPORT_DIR / \"best_model.pt\")\n\nfor epoch in range(EPOCHS):\n    tr_loss, tr_acc, _, _ = run_epoch(model, train_loader, criterion, optimizer, \"train\")\n    vl_loss, vl_acc, _, _ = run_epoch(model, val_loader, criterion, phase=\"val\")\n    scheduler.step()\n\n    history[\"train_loss\"].append(tr_loss)\n    history[\"val_loss\"].append(vl_loss)\n    history[\"train_acc\"].append(tr_acc)\n    history[\"val_acc\"].append(vl_acc)\n\n    print(\n        f\"Epoch {epoch+1}/{EPOCHS} | \"\n        f\"train_loss={tr_loss:.4f} | train_acc={tr_acc:.4f} | \"\n        f\"val_loss={vl_loss:.4f} | val_acc={vl_acc:.4f}\"\n    )\n\n    stopper(vl_loss, model)\n    if stopper.early_stop:\n        print(\"Early stopping triggered.\")\n        break\n\nhistory_df = pd.DataFrame(history)\nhistory_df.to_csv(EXPORT_DIR / \"training_history.csv\", index=False)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 12. Training Curves\n\n**Purpose:** Plots loss and accuracy curves for technical evaluation and report evidence.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Training Curves\n# ------------------------------------------------------------\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\naxes[0].plot(history[\"train_loss\"], label=\"Train\", linewidth=2)\naxes[0].plot(history[\"val_loss\"], label=\"Val\", linewidth=2)\naxes[0].set_title(\"Loss\")\naxes[0].legend()\n\naxes[1].plot([x * 100 for x in history[\"train_acc\"]], label=\"Train\", linewidth=2)\naxes[1].plot([x * 100 for x in history[\"val_acc\"]], label=\"Val\", linewidth=2)\naxes[1].set_title(\"Accuracy\")\naxes[1].legend()\n\nplt.tight_layout()\nplt.savefig(EXPORT_DIR / \"training_curves.png\", dpi=160)\nplt.show()\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 13. Evaluation\n\n**Purpose:** Evaluates the best checkpoint on the test set and exports metrics, classification report, confusion matrix, and per-class accuracy.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Evaluation\n# ------------------------------------------------------------\nbest_model = build_model(\n    NUM_CLASSES,\n    use_efficientnet=USE_EFFICIENTNET,\n    use_pretrained=False,   # important when loading your own checkpoint\n).to(DEVICE)\n\nbest_model.load_state_dict(torch.load(EXPORT_DIR / \"best_model.pt\", map_location=DEVICE))\nbest_model.eval()\n\n_, _, all_preds, all_labels = run_epoch(best_model, test_loader, criterion, phase=\"val\")\n\nreport_dict = classification_report(\n    all_labels,\n    all_preds,\n    target_names=CATEGORIES,\n    digits=3,\n    output_dict=True,\n)\npd.DataFrame(report_dict).transpose().to_csv(EXPORT_DIR / \"classification_report.csv\")\n\ntest_acc = float((np.array(all_preds) == np.array(all_labels)).mean())\nmacro_precision, macro_recall, macro_f1, _ = precision_recall_fscore_support(\n    all_labels,\n    all_preds,\n    average=\"macro\",\n    zero_division=0,\n)\n\nmetrics_payload = {\n    \"test_accuracy\": test_acc,\n    \"macro_precision\": float(macro_precision),\n    \"macro_recall\": float(macro_recall),\n    \"macro_f1\": float(macro_f1),\n    \"epochs_completed\": len(history[\"train_loss\"]),\n}\nwith open(EXPORT_DIR / \"metrics.json\", \"w\", encoding=\"utf-8\") as f:\n    json.dump(metrics_payload, f, indent=2)\n\nprint(\"Metrics:\", metrics_payload)\n\ncm = confusion_matrix(all_labels, all_preds)\nrow_sums = cm.sum(axis=1, keepdims=True)\ncm_norm = np.divide(cm.astype(float), row_sums, out=np.zeros_like(cm, dtype=float), where=row_sums != 0)\n\nplt.figure(figsize=(18, 16))\nsns.heatmap(cm_norm, cmap=\"Blues\", xticklabels=CATEGORIES, yticklabels=CATEGORIES)\nplt.title(\"Normalised Confusion Matrix\")\nplt.xlabel(\"Predicted\")\nplt.ylabel(\"True\")\nplt.xticks(rotation=90, fontsize=7)\nplt.yticks(fontsize=7)\nplt.tight_layout()\nplt.savefig(EXPORT_DIR / \"confusion_matrix.png\", dpi=160)\nplt.show()\n\nper_class_acc = cm_norm.diagonal() * 100\nsort_idx = np.argsort(per_class_acc)\n\nplt.figure(figsize=(10, 12))\nplt.barh([CATEGORIES[i] for i in sort_idx], per_class_acc[sort_idx])\nplt.axvline(90, linestyle=\"--\", linewidth=1.2)\nplt.xlabel(\"Accuracy (%)\")\nplt.title(\"Per-Class Accuracy\")\nplt.tight_layout()\nplt.savefig(EXPORT_DIR / \"per_class_accuracy.png\", dpi=160)\nplt.show()\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 14. Grad-CAM\n\n**Purpose:** Generates Grad-CAM visual explanations so model attention can be inspected beyond accuracy metrics.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Grad-CAM\n# ------------------------------------------------------------\nclass GradCAM:\n    def __init__(self, model, target_layer):\n        self.model = model\n        self.gradients = None\n        self.activations = None\n        target_layer.register_forward_hook(self._save_activations)\n        target_layer.register_full_backward_hook(self._save_gradients)\n\n    def _save_activations(self, _, __, output):\n        self.activations = output.detach()\n\n    def _save_gradients(self, _, __, grad_output):\n        self.gradients = grad_output[0].detach()\n\n    def __call__(self, input_tensor, class_idx=None):\n        self.model.eval()\n        output = self.model(input_tensor)\n        if class_idx is None:\n            class_idx = output.argmax(dim=1).item()\n\n        self.model.zero_grad()\n        output[0, class_idx].backward()\n\n        weights = self.gradients.mean(dim=(2, 3), keepdim=True)\n        cam = (weights * self.activations).sum(dim=1).squeeze()\n        cam = torch.relu(cam).detach().cpu().numpy()\n        return (cam - cam.min()) / (cam.max() - cam.min() + 1e-8)\n\ndef get_last_conv(model):\n    last_conv = None\n    for module in model.modules():\n        if isinstance(module, nn.Conv2d):\n            last_conv = module\n    return last_conv\n\ngradcam = GradCAM(best_model, get_last_conv(best_model))\n\nindices = random.sample(range(len(test_ds)), min(4, len(test_ds)))\nfig, axes = plt.subplots(len(indices), 3, figsize=(12, 4 * len(indices)))\n\ndenorm = transforms.Compose([\n    transforms.Normalize(mean=[0., 0., 0.], std=[1 / 0.229, 1 / 0.224, 1 / 0.225]),\n    transforms.Normalize(mean=[-0.485, -0.456, -0.406], std=[1., 1., 1.]),\n])\n\nif len(indices) == 1:\n    axes = np.expand_dims(axes, axis=0)\n\nfor row, idx in enumerate(indices):\n    img_tensor, true_label = test_ds[idx]\n    input_t = img_tensor.unsqueeze(0).to(DEVICE)\n    cam = gradcam(input_t)\n    pred_label = best_model(input_t).argmax(dim=1).item()\n\n    img_display = denorm(img_tensor).permute(1, 2, 0).clamp(0, 1).numpy()\n    cam_resized = np.array(\n        Image.fromarray((cam * 255).astype(np.uint8)).resize((IMG_SIZE, IMG_SIZE), Image.BILINEAR)\n    ) / 255.0\n    heatmap = plt.cm.jet(cam_resized)[:, :, :3]\n    overlay = 0.5 * img_display + 0.5 * heatmap\n\n    axes[row, 0].imshow(img_display)\n    axes[row, 0].set_title(\"Original\")\n    axes[row, 0].axis(\"off\")\n\n    axes[row, 1].imshow(cam_resized, cmap=\"jet\")\n    axes[row, 1].set_title(\"CAM\")\n    axes[row, 1].axis(\"off\")\n\n    axes[row, 2].imshow(overlay)\n    axes[row, 2].set_title(\n        f\"True: {CATEGORIES[true_label]}\\nPred: {CATEGORIES[pred_label]}\",\n        fontsize=8,\n    )\n    axes[row, 2].axis(\"off\")\n\nplt.tight_layout()\nplt.savefig(EXPORT_DIR / \"gradcam_samples.png\", dpi=160)\nplt.show()\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 15. Single-Image Inference Check\n\n**Purpose:** Runs a demo prediction on one held-out image and exports a simple prediction JSON.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Single-Image Inference Check\n# ------------------------------------------------------------\ndef predict_single_image(model, image_path: str, labels: list[str], img_size: int = 224, top_k: int = 5):\n    transform = transforms.Compose([\n        transforms.Resize(int(img_size * 1.14)),\n        transforms.CenterCrop(img_size),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406],\n                             std=[0.229, 0.224, 0.225]),\n    ])\n\n    img = Image.open(image_path).convert(\"RGB\")\n    tensor = transform(img).unsqueeze(0).to(DEVICE)\n\n    with torch.no_grad():\n        logits = model(tensor)\n        probs = torch.softmax(logits, dim=1).squeeze()\n\n    top_probs, top_idxs = probs.topk(top_k)\n    return [(labels[idx], float(prob)) for prob, idx in zip(top_probs.tolist(), top_idxs.tolist())]\n\ndemo_row = test_df.iloc[0]\ndemo_result = predict_single_image(best_model, demo_row[\"path\"], CATEGORIES, IMG_SIZE, TOP_K)\n\ndemo_payload = {\n    \"image_path\": demo_row[\"path\"],\n    \"top_k_predictions\": demo_result,\n}\nwith open(EXPORT_DIR / \"demo_prediction.json\", \"w\", encoding=\"utf-8\") as f:\n    json.dump(demo_payload, f, indent=2)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 16. Final Artifact Inventory\n\n**Purpose:** Lists all generated artifacts so the Kaggle output folder can be checked before VS Code/Hugging Face deployment.","metadata":{}},{"cell_type":"code","source":"# ------------------------------------------------------------\n# Final Artifact Inventory\n# ------------------------------------------------------------\nartifact_list = sorted([p.name for p in EXPORT_DIR.iterdir()])\nwith open(EXPORT_DIR / \"artifact_inventory.json\", \"w\", encoding=\"utf-8\") as f:\n    json.dump({\"artifacts\": artifact_list}, f, indent=2)\n\nprint(\"Artifacts available in:\", EXPORT_DIR)\nprint(sorted([p.name for p in EXPORT_DIR.iterdir()]))\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}