{"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":"none","dataSources":[{"sourceId":98450,"databundleVersionId":11749951,"sourceType":"competition"}],"dockerImageVersionId":31192,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# This Python 3 environment comes with many helpful analytics libraries installed\n# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python\n# For example, here's several helpful packages to load\n\nimport numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\n\n# Input data files are available in the read-only \"../input/\" directory\n# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory\n\nimport os\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        print(os.path.join(dirname, filename))\n\n# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using \"Save & Run All\" \n# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-11-30T04:30:15.425482Z","iopub.execute_input":"2025-11-30T04:30:15.425766Z","iopub.status.idle":"2025-11-30T04:30:20.203718Z","shell.execute_reply.started":"2025-11-30T04:30:15.425744Z","shell.execute_reply":"2025-11-30T04:30:20.202167Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nfrom tqdm import tqdm\nimport numpy as np\nimport pandas as pd\n\nKAGGLE_DATA_PATH = \"/kaggle/input/beyond-visible-spectrum-ai-for-agriculture-2025\"\nTRAIN_CSV = os.path.join(KAGGLE_DATA_PATH, \"train.csv\")\nMAX_SAMPLES = 1000\n\ndef load_from_csv_lookup(kaggle_root=KAGGLE_DATA_PATH, train_csv=TRAIN_CSV, max_samples=MAX_SAMPLES):\n    print(f\"\\n📊 Building file index under: {kaggle_root}\")\n    # 1) Build dict: basename -> fullpath (if duplicates, keep first; report duplicates)\n    basename_to_path = {}\n    dup_count = 0\n    for root, _, files in os.walk(kaggle_root):\n        for f in files:\n            if f.startswith('.'):\n                continue\n            if f.lower().endswith('.npy') or f.lower().endswith('.mat'):\n                b = f.strip()\n                full = os.path.join(root, f)\n                if b in basename_to_path:\n                    dup_count += 1\n                else:\n                    basename_to_path[b] = full\n    print(f\"  Indexed {len(basename_to_path)} hyperspectral files (duplicates={dup_count})\")\n\n    # 2) Read train.csv\n    if not os.path.exists(train_csv):\n        raise FileNotFoundError(f\"train.csv not found at {train_csv}\")\n    df = pd.read_csv(train_csv)\n    if 'id' not in df.columns and 'filename' in df.columns:\n        df.rename(columns={'filename': 'id'}, inplace=True)\n    if 'id' not in df.columns:\n        raise ValueError(\"train.csv does not contain an 'id' column\")\n\n    # 3) Normalize ids: ensure they have .npy extension if needed\n    ids = df['id'].astype(str).str.strip().tolist()\n    # if IDs lack extension, try to append .npy\n    normalized = []\n    for sid in ids:\n        if sid in basename_to_path:\n            normalized.append(sid)\n        elif sid + '.npy' in basename_to_path:\n            normalized.append(sid + '.npy')\n        elif sid.lower() in basename_to_path:\n            normalized.append(sid.lower())\n        elif sid.lower() + '.npy' in basename_to_path:\n            normalized.append(sid.lower() + '.npy')\n        else:\n            normalized.append(None)  # mark missing\n\n    # 4) Load files that exist (respect MAX_SAMPLES)\n    data = []\n    labels = []\n    missing = []\n    for orig_id, mapped in zip(ids, normalized):\n        if mapped is None:\n            missing.append(orig_id)\n            continue\n        path = basename_to_path.get(mapped)\n        if path is None:\n            missing.append(orig_id)\n            continue\n        try:\n            arr = np.load(path)\n            # sanity check: must be 3D\n            if arr is None or getattr(arr, 'ndim', None) != 3:\n                print(f\"  ⚠️ skipping {path} — not 3D (shape={getattr(arr, 'shape', None)})\")\n                continue\n            data.append(arr.astype(np.float32))\n            # if train.csv has label column use it, else set dummy\n            if 'label' in df.columns:\n                # find label for this row\n                row_label = df.loc[df['id'] == orig_id, 'label'].values\n                if len(row_label) > 0:\n                    labels.append(row_label[0])\n                else:\n                    # fallback: check same id with extension\n                    lbl = df.loc[df['id'] == orig_id + '.npy', 'label'].values\n                    labels.append(lbl[0] if len(lbl) else -1)\n            else:\n                labels.append(-1)\n        except Exception as e:\n            print(f\"  ⚠️ Error loading {path}: {e}\")\n        if len(data) >= max_samples:\n            break\n\n    print(f\"\\n✓ Successfully loaded {len(data)} samples (requested max {max_samples})\")\n    if len(missing) > 0:\n        print(f\"❗ {len(missing)} ids from CSV were not found in file index (showing up to 10):\")\n        print(missing[:10])\n    return data, labels, basename_to_path\n\n# Example usage\ndata, labels, index = load_from_csv_lookup()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-30T04:30:20.206259Z","iopub.execute_input":"2025-11-30T04:30:20.206813Z","iopub.status.idle":"2025-11-30T04:30:50.478858Z","shell.execute_reply.started":"2025-11-30T04:30:20.206759Z","shell.execute_reply":"2025-11-30T04:30:50.477653Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ---------- PCA REDUCTION CELL (must run before training cell) ----------\nimport numpy as np\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.decomposition import PCA\n\nTARGET_H, TARGET_W = 32, 32\nPCA_BANDS = 30\n\ndef resize_image(img, th, tw):\n    h, w, b = img.shape\n    ys = np.linspace(0, h-1, th).astype(int)\n    xs = np.linspace(0, w-1, tw).astype(int)\n    return img[np.ix_(ys, xs)]\n\nprocessed = []\nprocessed_labels = []\n\nfor img, lab in zip(data, labels):\n    if img is None or img.ndim != 3:\n        continue\n    small = resize_image(img, TARGET_H, TARGET_W).astype(np.float32)\n    processed.append(small)\n    processed_labels.append(lab)\n\narr = np.stack(processed)  # (N,H,W,B)\nN, H, W, B = arr.shape\n\nflat = arr.reshape(-1, B)     # (N*H*W, bands)\nscaler = StandardScaler()\nflat_scaled = scaler.fit_transform(flat)\n\npca = PCA(n_components=PCA_BANDS)\nflat_pca = pca.fit_transform(flat_scaled)\n\ndata_pca_list = flat_pca.reshape(N, H, W, PCA_BANDS).tolist()\n\nprint(\"PCA done. Reduced image shape:\", np.array(data_pca_list[0]).shape)\n\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-30T04:30:50.479962Z","iopub.execute_input":"2025-11-30T04:30:50.480256Z","iopub.status.idle":"2025-11-30T04:31:09.384277Z","shell.execute_reply.started":"2025-11-30T04:30:50.480226Z","shell.execute_reply":"2025-11-30T04:31:09.382772Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport numpy as np\nimport matplotlib.pyplot as plt\n\n# Paths (adjust if needed)\nDATA_ROOT = \"/kaggle/input/beyond-visible-spectrum-ai-for-agriculture-2025\"\nTRAIN_CSV_PATH = os.path.join(DATA_ROOT, \"train.csv\")\n\n# 1) Load train.csv and build index of .npy files\ndf = pd.read_csv(TRAIN_CSV_PATH)\nids = df['id'].astype(str).str.strip().tolist()\nlabels = df['label'].tolist()\n\n# Build file index (same idea as your script)\nfile_index = {}\nfor r, _, files in os.walk(DATA_ROOT):\n    for f in files:\n        if f.lower().endswith(\".npy\"):\n            file_index[f] = os.path.join(r, f)\n\nprint(\"Found\", len(file_index), \"npy files\")\n\n# 2) Pick first 1–2 valid images\nloaded_imgs = []\nloaded_labels = []\nfor fid, lab in zip(ids, labels):\n    key = fid if fid.endswith(\".npy\") else fid + \".npy\"\n    if key in file_index:\n        arr = np.load(file_index[key])\n        if arr is not None and arr.ndim == 3:\n            loaded_imgs.append(arr)\n            loaded_labels.append(lab)\n            if len(loaded_imgs) >= 6:   # change to 1 if you want only one image\n                break\n\nprint(f\"Loaded {len(loaded_imgs)} hyperspectral images\")\n\n# 3) Helper to convert hyperspectral cube to RGB-like image\ndef hs_to_rgb(img, bands=(10, 30, 50)):\n    # img: (H, W, B)\n    r = img[:, :, bands[0]]\n    g = img[:, :, bands[1]]\n    b = img[:, :, bands[2]]\n\n    rgb = np.stack([r, g, b], axis=-1).astype(np.float32)\n\n    # robust normalization using percentiles\n    p1 = np.percentile(rgb, 1, axis=(0,1), keepdims=True)\n    p99 = np.percentile(rgb, 99, axis=(0,1), keepdims=True)\n    rgb = np.clip((rgb - p1) / (p99 - p1 + 1e-8), 0, 1)\n\n    return rgb\n    \n# 4) Show the images\nplt.figure(figsize=(10, 5))\nfor i, (img, lab) in enumerate(zip(loaded_imgs, loaded_labels)):\n    rgb = hs_to_rgb(img, bands=(5, 20, 40))  # you can try other band combos too\n    plt.subplot(1, len(loaded_imgs), i+1)\n    plt.imshow(rgb)\n    plt.axis('off')\n    plt.title(f\"Label: {lab}\")\nplt.tight_layout()\nplt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-30T04:31:09.385222Z","iopub.execute_input":"2025-11-30T04:31:09.385774Z","iopub.status.idle":"2025-11-30T04:31:11.342121Z","shell.execute_reply.started":"2025-11-30T04:31:09.385748Z","shell.execute_reply":"2025-11-30T04:31:11.340811Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ---------------- FULL END-TO-END TRAINING SCRIPT (single cell) ----------------\n# Copy-paste this entire cell into your Kaggle notebook and run.\nimport os, random, time\nfrom collections import Counter\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\n\nimport torch, torch.nn as nn, torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader, WeightedRandomSampler\nfrom sklearn.preprocessing import StandardScaler, LabelEncoder\nfrom sklearn.decomposition import PCA\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import confusion_matrix\n\n# -------------------- CONFIG --------------------\nKAGGLE_DATA_PATH = \"/kaggle/input/beyond-visible-spectrum-ai-for-agriculture-2025\"\nTRAIN_CSV_PATH = os.path.join(KAGGLE_DATA_PATH, \"train.csv\")\n\nSUBSET = 750                # 500-700 recommended for memory\nTARGET_H, TARGET_W = 32, 32  # small spatial dims -> reduces RAM\nPCA_BANDS = 40               # spectral dims after PCA\nPATCH_SIZE = 24\nPATCHES_PER_IMAGE = 16\nBATCH_SIZE = 64\nEPOCHS = 60\nLR = 1e-3\nWEIGHT_DECAY = 1e-5\nPATIENCE = 8                 # early stopping patience\nOUTPUT_PATH = \"/kaggle/working\"\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nUSE_AMP = torch.cuda.is_available()\nNUM_WORKERS = 0              # keep 0 on Kaggle to reduce memory overhead\n\nprint(\"Device:\", DEVICE, \"AMP:\", USE_AMP)\nprint(f\"SUBSET={SUBSET}, TARGET={TARGET_H}x{TARGET_W}, PCA_BANDS={PCA_BANDS}, PATCH_SIZE={PATCH_SIZE}, PATCHES_PER_IMAGE={PATCHES_PER_IMAGE}\")\n\n# -------------------- Optional: if you already loaded images set this True and provide existing_data/existing_labels --------------------\nLOAD_FROM_EXISTING = False\nexisting_data = None   # set to list of numpy arrays if LOAD_FROM_EXISTING=True\nexisting_labels = None\n\n# -------------------- UTIL: index files from dataset --------------------\ndef build_index(root):\n    idx = {}\n    for r, _, files in os.walk(root):\n        for f in files:\n            if f.lower().endswith(\".npy\"):\n                idx[f] = os.path.join(r, f)\n    return idx\n\n# -------------------- UTIL: simple resize (nearest sampling) --------------------\ndef resize_image(img, th, tw):\n    h, w, b = img.shape\n    ys = np.linspace(0, h-1, th).astype(int)\n    xs = np.linspace(0, w-1, tw).astype(int)\n    return img[np.ix_(ys, xs)]\n\n# -------------------- 1) LOAD SUBSET via train.csv --------------------\nif LOAD_FROM_EXISTING and existing_data is not None and existing_labels is not None:\n    print(\"Loading from existing in-memory data.\")\n    data = existing_data[:SUBSET]\n    labels = existing_labels[:SUBSET]\nelse:\n    print(\"Building file index under:\", KAGGLE_DATA_PATH)\n    index = build_index(KAGGLE_DATA_PATH)\n    print(\"Indexed\", len(index), \".npy files\")\n\n    if not os.path.exists(TRAIN_CSV_PATH):\n        raise FileNotFoundError(\"train.csv not found at: \" + TRAIN_CSV_PATH)\n    df = pd.read_csv(TRAIN_CSV_PATH)\n    if 'id' not in df.columns:\n        raise ValueError(\"train.csv must contain an 'id' column\")\n\n    ids = df['id'].astype(str).str.strip().tolist()\n    labels_csv = df['label'].tolist() if 'label' in df.columns else [None]*len(ids)\n\n    # sample subset indices randomly\n    all_indices = list(range(len(ids)))\n    random.seed(42)\n    random.shuffle(all_indices)\n    chosen = []\n    data = []\n    labels = []\n    for i in all_indices:\n        fid = ids[i]\n        key = fid if fid.endswith(\".npy\") else fid + \".npy\"\n        if key in index:\n            try:\n                arr = np.load(index[key])\n                if arr is None or arr.ndim != 3:\n                    continue\n                data.append(arr.astype(np.float32))\n                labels.append(labels_csv[i])\n                chosen.append(key)\n            except Exception as e:\n                # skip bad files\n                print(\"Skipping\", key, \"error:\", e)\n        if len(data) >= SUBSET:\n            break\n    print(f\"Loaded {len(data)} samples from CSV (requested {SUBSET}). Missing or corrupted files may be skipped.\")\n\nif len(data) == 0:\n    raise RuntimeError(\"No data loaded. Check dataset path and train.csv mapping.\")\n\n# -------------------- 2) Resize images to target small dims --------------------\nprocessed = []\nprocessed_labels = []\nfor img, lab in zip(data, labels):\n    try:\n        small = resize_image(img, TARGET_H, TARGET_W).astype(np.float32)\n        processed.append(small)\n        processed_labels.append(lab)\n    except Exception as e:\n        print(\"Skipping a sample during resize:\", e)\nprint(\"Resized images count:\", len(processed), \"shape example:\", processed[0].shape)\n\n# -------------------- 3) PCA (flatten across pixels) --------------------\nprint(\"Running StandardScaler + PCA on pixels (this may take a moment)...\")\narr = np.stack(processed, axis=0)   # (N,H,W,B)\nN, H, W, B = arr.shape\nflat = arr.reshape(-1, B)\n\nscaler = StandardScaler()\nflat_scaled = scaler.fit_transform(flat)\n\npca = PCA(n_components=PCA_BANDS, svd_solver='randomized', random_state=42)\nflat_reduced = pca.fit_transform(flat_scaled)\ndata_pca = flat_reduced.reshape(N, H, W, PCA_BANDS).astype(np.float32)\nprint(\"PCA done. Reduced shape:\", data_pca.shape)\n# convert to list for compatibility with downstream code\ndata_pca_list = [data_pca[i] for i in range(data_pca.shape[0])]\nlabels_list = processed_labels\n\n# -------------------- 4) Sample random patches per image --------------------\ndef sample_random_patches(image, n_patches, patch_size):\n    H, W, C = image.shape\n    patches = []\n    if H < patch_size or W < patch_size:\n        return patches\n    for _ in range(n_patches):\n        top = random.randint(0, H - patch_size)\n        left = random.randint(0, W - patch_size)\n        p = image[top:top+patch_size, left:left+patch_size, :].copy()\n        patches.append(p)\n    return patches\n\nprint(\"Sampling patches - this multiplies dataset size by PATCHES_PER_IMAGE...\")\nrandom.seed(42)\nall_patches = []\nall_patch_labels = []\nfor img, lab in zip(data_pca_list, labels_list):\n    ps = sample_random_patches(img, PATCHES_PER_IMAGE, PATCH_SIZE)\n    for p in ps:\n        all_patches.append(np.transpose(p, (2,0,1)).astype(np.float32))  # (C,H,W)\n        all_patch_labels.append(lab)\n\nall_patches = np.array(all_patches)\nprint(\"Total patches shape:\", all_patches.shape, \"Total labels:\", len(all_patch_labels))\nif len(all_patch_labels) == 0:\n    raise RuntimeError(\"No patches sampled - adjust PATCH_SIZE or check image sizes.\")\n\n# -------------------- 5) Label encode & split --------------------\nle = LabelEncoder()\ny_encoded = le.fit_transform(all_patch_labels)\nnum_classes = len(le.classes_)\nprint(\"Num classes:\", num_classes, \"class counts (sampled patches):\", Counter(y_encoded))\n\ncan_stratify = (min(Counter(y_encoded).values()) >= 2) and (num_classes > 1)\nX_train, X_val, y_train, y_val = train_test_split(\n    all_patches, y_encoded, test_size=0.15, random_state=42,\n    stratify=y_encoded if can_stratify else None\n)\nprint(\"Train/Val sizes (patches):\", len(X_train), len(X_val))\n\n# -------------------- 6) Dataset, augmentations, sampler --------------------\nAUG_PROB = 0.6\ndef augment_patch_np(x):\n    # x shape: (C,H,W)\n    # horizontal flip\n    if random.random() < 0.5:\n        x = x[:,:, ::-1].copy()\n    # vertical flip\n    if random.random() < 0.5:\n        x = x[:, ::-1, :].copy()\n    # band-wise gaussian noise\n    x = x + np.random.normal(0, 0.01, size=x.shape).astype(np.float32)\n    x = np.nan_to_num(x).astype(np.float32)\n    return x\n\nclass AugPatchDataset(Dataset):\n    def __init__(self, X, y, augment=False):\n        self.X = X\n        self.y = y\n        self.augment = augment\n    def __len__(self): return len(self.y)\n    def __getitem__(self, idx):\n        x = self.X[idx].copy()\n        label = int(self.y[idx])\n        if self.augment and random.random() < AUG_PROB:\n            x = augment_patch_np(x)\n        return torch.from_numpy(x).float(), torch.tensor(label).long()\n\ntrain_counts = Counter(y_train)\nclass_weights = {cls: 1.0 / count for cls, count in train_counts.items()}\nsample_weights = np.array([class_weights[y] for y in y_train], dtype=np.float32)\nsampler = WeightedRandomSampler(weights=sample_weights, num_samples=len(sample_weights), replacement=True)\n\ntrain_ds = AugPatchDataset(X_train, y_train, augment=True)\nval_ds = AugPatchDataset(X_val, y_val, augment=False)\n\ntrain_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, sampler=sampler, num_workers=NUM_WORKERS, pin_memory=True)\nval_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS, pin_memory=True)\n\n# -------------------- 7) Model --------------------\nclass BetterCNN(nn.Module):\n    def __init__(self, in_ch, n_classes):\n        super().__init__()\n        self.net = nn.Sequential(\n            nn.Conv2d(in_ch, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),\n            nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),\n            nn.MaxPool2d(2),\n            nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),\n            nn.Conv2d(128, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),\n            nn.AdaptiveAvgPool2d(1),\n        )\n        self.classifier = nn.Sequential(\n            nn.Flatten(),\n            nn.Dropout(0.4),\n            nn.Linear(128, 128),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(128, n_classes)\n        )\n    def forward(self, x):\n        x = self.net(x)\n        return self.classifier(x)\n\nin_ch = all_patches.shape[1]\nmodel = BetterCNN(in_ch=in_ch, n_classes=num_classes).to(DEVICE)\nprint(\"Model params:\", sum(p.numel() for p in model.parameters()))\n\n# -------------------- 8) Loss, optimizer, scheduler --------------------\nweights_for_loss = np.array([1.0 / (train_counts[i] if i in train_counts else 1.0) for i in range(num_classes)], dtype=np.float32)\nweights_for_loss = torch.tensor(weights_for_loss).to(DEVICE)\ncriterion = nn.CrossEntropyLoss(weight=weights_for_loss)\noptimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\nscheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)\nscaler = torch.cuda.amp.GradScaler() if USE_AMP else None\n\n# -------------------- 9) Training loop with early stopping --------------------\nbest_val = -1.0\nbest_epoch = -1\nno_improve = 0\nhistory = {'train_loss':[], 'train_acc':[], 'val_loss':[], 'val_acc':[]}\nstart_time = time.time()\n\nfor epoch in range(1, EPOCHS+1):\n    model.train()\n    running_loss = 0.0; running_correct = 0; running_total = 0\n    for xb, yb in train_loader:\n        xb = xb.to(DEVICE); yb = yb.to(DEVICE)\n        optimizer.zero_grad()\n        if USE_AMP:\n            with torch.cuda.amp.autocast():\n                out = model(xb); loss = criterion(out, yb)\n            scaler.scale(loss).backward()\n            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n            scaler.step(optimizer); scaler.update()\n        else:\n            out = model(xb); loss = criterion(out, yb)\n            loss.backward()\n            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n            optimizer.step()\n        running_loss += loss.item() * xb.size(0)\n        preds = out.argmax(dim=1)\n        running_correct += (preds == yb).sum().item()\n        running_total += xb.size(0)\n    scheduler.step()\n    train_loss = running_loss / max(1, running_total)\n    train_acc = 100.0 * running_correct / max(1, running_total)\n\n    # validation\n    model.eval()\n    v_loss = 0.0; v_correct = 0; v_total = 0\n    with torch.no_grad():\n        for xb, yb in val_loader:\n            xb = xb.to(DEVICE); yb = yb.to(DEVICE)\n            if USE_AMP:\n                with torch.cuda.amp.autocast():\n                    out = model(xb); loss = criterion(out, yb)\n            else:\n                out = model(xb); loss = criterion(out, yb)\n            v_loss += loss.item() * xb.size(0)\n            preds = out.argmax(dim=1)\n            v_correct += (preds == yb).sum().item()\n            v_total += xb.size(0)\n    val_loss = v_loss / max(1, v_total)\n    val_acc = 100.0 * v_correct / max(1, v_total)\n\n    history['train_loss'].append(train_loss); history['train_acc'].append(train_acc)\n    history['val_loss'].append(val_loss); history['val_acc'].append(val_acc)\n\n    print(f\"[{epoch}/{EPOCHS}] Train loss {train_loss:.4f} acc {train_acc:.2f}% | Val loss {val_loss:.4f} acc {val_acc:.2f}%\")\n\n    # early stopping & save best\n    if val_acc > best_val + 1e-6:\n        best_val = val_acc\n        best_epoch = epoch\n        no_improve = 0\n        torch.save(model.state_dict(), os.path.join(OUTPUT_PATH, \"best_model.pth\"))\n        print(\"  ✓ New best model saved:\", best_val)\n    else:\n        no_improve += 1\n    if no_improve >= PATIENCE:\n        print(f\"No improvement for {PATIENCE} epochs — early stopping.\")\n        break\n\ntime_elapsed = time.time() - start_time\nprint(\"Training finished in {:.1f}s — best val acc {:.2f}% at epoch {}\".format(time_elapsed, best_val, best_epoch))\n\n# -------------------- 10) Save final pipeline --------------------\nbest_model_path = os.path.join(OUTPUT_PATH, \"best_model.pth\")\nif os.path.exists(best_model_path):\n    model.load_state_dict(torch.load(best_model_path, map_location=DEVICE))\n\npca_bands_val = None\ntry:\n    if isinstance(data_pca_list, list) and len(data_pca_list) > 0:\n        pca_bands_val = data_pca_list[0].shape[2]\nexcept Exception:\n    pca_bands_val = PCA_BANDS\n\ntorch.save({\n    \"model_state_dict\": model.state_dict(),\n    \"label_encoder\": le,\n    \"config\": {\n        \"patch_size\": PATCH_SIZE,\n        \"patches_per_image\": PATCHES_PER_IMAGE,\n        \"pca_bands\": pca_bands_val\n    },\n    \"performance\": {\"best_val_acc\": best_val, \"history\": history}\n}, os.path.join(OUTPUT_PATH, \"complete_model_final.pth\"))\nprint(\"Saved complete_model_final.pth and best_model.pth to\", OUTPUT_PATH)\n\n# -------------------- 11) Final evaluation & plots --------------------\neval_loader = val_loader\nall_preds, all_trues = [], []\nmodel.eval()\nwith torch.no_grad():\n    for xb, yb in eval_loader:\n        xb = xb.to(DEVICE); yb = yb.to(DEVICE)\n        out = model(xb)\n        preds = out.argmax(dim=1).cpu().numpy()\n        all_preds.extend(preds)\n        all_trues.extend(yb.cpu().numpy())\n\nall_preds = np.array(all_preds); all_trues = np.array(all_trues)\noverall_acc = 100.0 * (all_preds == all_trues).mean()\nprint(f\"\\nFinal validation accuracy: {overall_acc:.2f}% (best during training: {best_val:.2f}%)\")\n\ncm = confusion_matrix(all_trues, all_preds, labels=range(num_classes))\nper_class_acc = cm.diagonal() / (cm.sum(axis=1) + 1e-12)\nprint(\"\\nWorst 10 classes by val acc:\")\nworst = np.argsort(per_class_acc)[:10]\nfor idx in worst:\n    print(f\"  idx {idx} label {le.classes_[idx]} acc {100*per_class_acc[idx]:.2f}% samples {int(cm.sum(axis=1)[idx])}\")\n\nplt.figure(figsize=(10,4))\nplt.subplot(1,2,1); plt.plot(history['train_loss'], label='train'); plt.plot(history['val_loss'], label='val'); plt.title('Loss'); plt.legend()\nplt.subplot(1,2,2); plt.plot(history['train_acc'], label='train'); plt.plot(history['val_acc'], label='val'); plt.title('Accuracy'); plt.legend()\nplt.tight_layout(); plt.show()\n\n# visualize a few val patches with predictions\nplt.figure(figsize=(12,3))\nsample_k = min(8, len(X_val))\nwith torch.no_grad():\n    sample_batch = torch.from_numpy(X_val[:sample_k]).float().to(DEVICE)\n    outs = model(sample_batch)\n    preds = outs.argmax(1).cpu().numpy()\nfor i in range(sample_k):\n    patch = X_val[i]\n    rgb = np.transpose(patch[:3], (1,2,0))\n    rgb = (rgb - rgb.min()) / (rgb.max() - rgb.min() + 1e-8)\n    plt.subplot(1, sample_k, i+1); plt.imshow(rgb); plt.axis('off'); plt.title(f\"P{preds[i]}\")\nplt.show()\n\nprint(\"Done. Best val acc: {:.2f}%\".format(best_val))\n# -----------------------------------------------------------------------------------------\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-30T05:12:22.395915Z","iopub.execute_input":"2025-11-30T05:12:22.396320Z","iopub.status.idle":"2025-11-30T06:04:42.425545Z","shell.execute_reply.started":"2025-11-30T05:12:22.396293Z","shell.execute_reply":"2025-11-30T06:04:42.423171Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import precision_score, recall_score, f1_score\n\n# Calculate metrics\nprecision = precision_score(all_trues, all_preds, average='weighted')\nrecall = recall_score(all_trues, all_preds, average='weighted')\nf1 = f1_score(all_trues, all_preds, average='weighted')\n\nprint(f\"Precision: {precision:.4f}\")\nprint(f\"Recall: {recall:.4f}\")\nprint(f\"F1 Score: {f1:.4f}\")\nprint(f\"Accuracy: {overall_acc:.4f}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-30T06:11:45.796141Z","iopub.execute_input":"2025-11-30T06:11:45.796989Z","iopub.status.idle":"2025-11-30T06:11:45.821118Z","shell.execute_reply.started":"2025-11-30T06:11:45.796953Z","shell.execute_reply":"2025-11-30T06:11:45.819805Z"}},"outputs":[],"execution_count":null}]}