{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":112509,"databundleVersionId":14254895,"sourceType":"competition"},{"sourceId":286813572,"sourceType":"kernelVersion"},{"sourceId":4535,"sourceType":"modelInstanceVersion","modelInstanceId":3327,"modelId":986}],"dockerImageVersionId":31236,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Species Classifier\n\nThe purpose of this notebook is to develop a model that predicts the target species from images.\nThis classifier can be used at runtime to infer species information and provide it as an additional input to a regression model.\n\nThe approach consists in:\n- dividing rectangular images in 2 equal squares (left and right)\n- extract features for each part with dino v2\n- concat features\n- train a classifier head on the extracted features\n- fine-tune dino v2 on LayerNorms\n\nCredits:\nNotebook structure\n- https://www.kaggle.com/code/sasaleaf/csiro-pytorch-baseline-train/notebook#%F0%9F%8C%B1-PyTorch-CNN-Baseline\n\nModel base architecture\n- https://www.kaggle.com/code/none00000/lb-0-57-infer-model-code","metadata":{}},{"cell_type":"code","source":"!pip install --no-index --find-links /kaggle/input/transformers-wheel transformers==4.39.0 protobuf==3.20.3","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-01T18:21:59.712712Z","iopub.execute_input":"2026-01-01T18:21:59.713049Z","iopub.status.idle":"2026-01-01T18:22:03.329082Z","shell.execute_reply.started":"2026-01-01T18:21:59.713012Z","shell.execute_reply":"2026-01-01T18:22:03.327749Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport pandas as pd\nimport random\nimport numpy as np\nimport torch\nimport gc\n\nimport math\nfrom PIL import Image\nfrom collections import Counter\nfrom sklearn.model_selection import KFold, StratifiedKFold\nimport matplotlib.pyplot as plt\nimport albumentations as A\nimport torch.optim as optim\n\nimport cv2\nfrom albumentations.pytorch import ToTensorV2\n\nimport os\nimport pandas as pd\nfrom PIL import Image\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms, models\nfrom transformers import AutoModel","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-01T18:22:03.331727Z","iopub.execute_input":"2026-01-01T18:22:03.332152Z","iopub.status.idle":"2026-01-01T18:22:21.580391Z","shell.execute_reply.started":"2026-01-01T18:22:03.332107Z","shell.execute_reply":"2026-01-01T18:22:21.579524Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"base_path = '/kaggle/input/csiro-biomass/'","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-01T18:22:21.581366Z","iopub.execute_input":"2026-01-01T18:22:21.581845Z","iopub.status.idle":"2026-01-01T18:22:21.586309Z","shell.execute_reply.started":"2026-01-01T18:22:21.581814Z","shell.execute_reply":"2026-01-01T18:22:21.585289Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class CONFIG():\n    train_path = os.path.join(base_path, 'train')\n    test_path = os.path.join(base_path, 'test')\n    test_csv_path = os.path.join(base_path, 'test.csv')\n    train_csv_path = os.path.join(base_path, 'train.csv')\n    data_dir = os.path.join(base_path,  \"\")\n    nFolds = 5\n    seed = 42\n    pretrained = False\n    pretrained_weights_path = os.path.join(base_path, \"\")\n    best_model_dir = \"\"\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    epochs = 15\n    batch_size = 8\n    lr = 1e-3\n    eta_min = 1e-5\n    weight_decay = 1e-6\n    \n    # image\n    img_size_h = 224\n    img_size_w = 224\n    in_chans = 3\n    \n    # target columns\n    target_cols = [\n        \"Dry_Clover_g\",\n        \"Dry_Dead_g\",\n        \"Dry_Green_g\",\n        \"Dry_Total_g\",\n        \"GDM_g\"\n    ]\n    n_targets = 3\n    # we can predict multiple configs\n    targets_configs = [\"Dry_Green_g\", \"Dry_Total_g\", \"GDM_g\"]\n    weights = np.array([0.1, 0.1, 0.1, 0.5, 0.2])\n    mapping = {\"Dry_Clover_g\": 0, \"Dry_Dead_g\": 1, \"Dry_Green_g\": 2, \"Dry_Total_g\": 3, \"GDM_g\": 4}","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-01T18:22:21.587741Z","iopub.execute_input":"2026-01-01T18:22:21.588115Z","iopub.status.idle":"2026-01-01T18:22:21.610443Z","shell.execute_reply.started":"2026-01-01T18:22:21.588085Z","shell.execute_reply":"2026-01-01T18:22:21.609406Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ImageNet normalization constants\nIMAGENET_MEAN = [0.485, 0.456, 0.406]\nIMAGENET_STD  = [0.229, 0.224, 0.225]\n\nclass SpeciesDataset(Dataset):\n    \"\"\"\n    Dataset for predicting species from images.\n\n    Returns:\n        - Training/validation: x_left, x_right, species_id\n        - Test: x_left, x_right (no species_id)\n    \"\"\"\n    def __init__(self, df, transforms=None, input_res=224, is_test=False):\n        self.df = df.reset_index(drop=True)\n        self.image_paths = df[\"image_path\"].values\n        self.transforms = transforms\n        self.input_res = input_res\n        self.is_test = is_test\n\n        if not self.is_test:\n            if \"species_id\" not in df.columns:\n                raise ValueError(\"species_id column is missing in training dataframe\")\n            self.species_ids = df[\"species_id\"].values\n            if self.species_ids.dtype.type is np.str_:\n                # Map string species to integer IDs\n                unique_species = np.unique(self.species_ids)\n                self.species_map = {s: i for i, s in enumerate(unique_species)}\n                self.species_ids = np.array([self.species_map[s] for s in self.species_ids], dtype=np.int64)\n        else:\n            self.species_ids = None  # No labels for test\n\n    def __len__(self):\n        return len(self.df)\n\n    def to_tensor(self, img):\n        img = torch.from_numpy(img).permute(2, 0, 1).float() / 255.0\n        return img\n\n    def split_into_two_squares(self, img):\n        H, W, _ = img.shape\n        if W < H:\n            raise ValueError(f\"Expected W >= H, got image shape {img.shape}\")\n        left = img[:, :H, :]\n        right = img[:, W-H:, :]\n        return left, right\n\n    def __getitem__(self, idx):\n        # Load image\n        image_path = self.image_paths[idx]\n        full_path = os.path.join(config.data_dir, image_path)\n        img = cv2.imread(full_path)\n        if img is None:\n            raise FileNotFoundError(f\"Image not found: {image_path}\")\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n\n        # Split into two squares\n        x_left, x_right = self.split_into_two_squares(img)\n\n        # Resize\n        x_left  = cv2.resize(x_left, (self.input_res, self.input_res), interpolation=cv2.INTER_LINEAR)\n        x_right = cv2.resize(x_right, (self.input_res, self.input_res), interpolation=cv2.INTER_LINEAR)\n\n        # Apply transforms\n        if self.transforms:\n            x_left  = self.transforms(image=x_left)[\"image\"]\n            x_right = self.transforms(image=x_right)[\"image\"]\n        else:\n            x_left  = self.to_tensor(x_left)\n            x_right = self.to_tensor(x_right)\n\n        if self.is_test:\n            return x_left, x_right\n        else:\n            species_id = torch.tensor(self.species_ids[idx], dtype=torch.long)\n            return x_left, x_right, species_id\n\n# -------------------------------------------------------------\n#  Training & Validation Loops\n# -------------------------------------------------------------\ndef train_one_epoch(model, loader, optimizer, criterion, device):\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n\n    for img_left, img_right, labels in loader:\n        img_left, img_right, labels = img_left.to(device), img_right.to(device), labels.to(device)\n\n        optimizer.zero_grad()\n        logits = model(img_left, img_right)\n\n        loss = criterion(logits, labels)\n        loss.backward()\n        optimizer.step()\n\n        running_loss += loss.item() * img_left.size(0)\n\n        # accuracy\n        preds = logits.argmax(dim=1)\n        correct += (preds == labels).sum().item()\n        total += labels.size(0)\n\n    avg_loss = running_loss / total\n    acc = correct / total\n\n    return avg_loss, acc\n\n@torch.no_grad()\ndef val_fn(model, dataloader, criterion, device):\n    \"\"\"\n    Validation loop for species classification\n    \"\"\"\n    model.eval()\n\n    total_loss = 0.0\n    correct = 0\n    total = 0\n\n    for img_left, img_right, species_ids in dataloader:\n        img_left = img_left.to(device)\n        img_right = img_right.to(device)\n        species_ids = species_ids.to(device)  # (B,) long\n\n        # Forward pass\n        logits = model(img_left, img_right)  # (B, num_species)\n\n        # Loss\n        loss = criterion(logits, species_ids)\n        total_loss += loss.item() * species_ids.size(0)\n\n        # Accuracy\n        preds = logits.argmax(dim=1)\n        correct += (preds == species_ids).sum().item()\n        total += species_ids.size(0)\n\n    val_loss = total_loss / total\n    val_acc = correct / total\n\n    return val_loss, val_acc\n\n\ndef weighted_r2_score(y_true: np.ndarray, y_pred: np.ndarray):\n    \"\"\"\n    Metric\n    y_true, y_pred: shape (N, 5)\n    \"\"\"\n    weights = np.array([0.1, 0.1, 0.1, 0.2, 0.5])\n    r2_scores = []\n    \n    for i in range(y_true.shape[1]):\n        y_t = y_true[:, i]\n        y_p = y_pred[:, i]\n        ss_res = np.sum((y_t - y_p) ** 2)\n        ss_tot = np.sum((y_t - np.mean(y_t)) ** 2)\n        r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0\n        r2_scores.append(r2)\n        \n    r2_scores = np.array(r2_scores)\n    weighted_r2 = np.sum(r2_scores * weights) / np.sum(weights)\n    return weighted_r2, r2_scores\n\n# ======== 3. Preprocessing ========\ndef get_processed_data():\n    \"\"\"\n    'long' -> 'wide'\n    \"\"\"\n    train_df = pd.read_csv(config.train_csv_path)\n    \n    # unique id\n    train_df[\"image_id\"] = train_df[\"image_path\"].apply(lambda x: x.split('/')[-1].split('.')[0])\n    \n    # pivot\n    train_pivot = train_df.pivot(\n        index=\"image_id\", \n        columns=\"target_name\", \n        values=\"target\"\n    ).reset_index()\n    \n    meta_df = train_df.drop_duplicates(subset=\"image_id\").drop(\n        columns=[\"sample_id\", \"target_name\", \"target\"]\n    )\n    \n    train_processed_df = meta_df.merge(train_pivot, on=\"image_id\", how=\"left\")\n    \n    # CV fold\n    #kf = KFold(n_splits=config.nFolds, shuffle=True, random_state=config.seed)\n    #train_processed_df[\"fold\"] = -1\n    #for fold, (train_idx, val_idx) in enumerate(kf.split(train_processed_df)):\n    #    train_processed_df.loc[val_idx, \"fold\"] = fold\n\n    # StratifiedKFold\n    skf = StratifiedKFold(n_splits=config.nFolds, shuffle=True, random_state=config.seed)\n\n    train_processed_df[\"fold\"] = -1\n\n    # Add the stratification column (State)\n    for fold, (train_idx, val_idx) in enumerate(skf.split(train_processed_df, train_processed_df[\"Species\"])):\n        train_processed_df.loc[val_idx, \"fold\"] = fold\n        \n    return train_processed_df\n\ndef get_transforms(is_train):\n    if is_train:\n        return A.Compose([\n            A.RandomResizedCrop((config.img_size_h, config.img_size_w), scale=(0.8, 1.0)),\n            A.HorizontalFlip(p=0.5),\n            A.Rotate(limit=15, p=0.5),\n            A.ColorJitter(brightness=0.1, contrast=0.1, saturation=0.1, hue=0.05, p=0.5),\n            A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),\n            ToTensorV2(),\n        ])\n    else:\n        return A.Compose([\n            A.Resize(config.img_size_h, config.img_size_w),\n            A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),\n            ToTensorV2(),\n        ])\n    \ndef get_two_stream_transforms(is_train):\n    if is_train:\n        return A.Compose([\n            A.HorizontalFlip(p=0.5),\n            A.Rotate(limit=15, p=0.5),\n            A.ColorJitter(brightness=0.1, contrast=0.1, saturation=0.1, hue=0.05, p=0.5),\n            A.Resize(224, 224),  # ensure DINOv2 input size\n            A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),\n            ToTensorV2(),\n        ])\n    else:\n        return A.Compose([\n            A.Resize(224, 224),\n            A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),\n            ToTensorV2(),\n        ])\n        \n# ======== 8. Inference ========\ndef run_inference(species_list):\n    print(\"\\n======== Starting Species Inference ========\")\n    \n    # Load test dataframe\n    test_df = pd.read_csv(config.test_csv_path)\n    test_unique_df = test_df.drop_duplicates(subset=\"image_path\").reset_index(drop=True)\n    \n    # Test dataset and loader\n    test_dataset = SpeciesDataset(\n        test_unique_df, \n        transforms=get_two_stream_transforms(is_train=False), \n        is_test=True\n    )\n    test_loader = DataLoader(\n        test_dataset, batch_size=config.batch_size * 2, shuffle=False,\n        num_workers=os.cpu_count(), pin_memory=True\n    )\n    \n    # Collect predictions from all folds\n    all_fold_preds = []\n    for fold in range(config.nFolds):\n        print(f\"Predicting with Fold {fold+1}...\")\n        model_path = os.path.join(config.best_model_dir, f\"model_fold_classifier_{fold}.pth\")\n\n        model = DINOv2SpeciesClassifier(num_species=len(species_list), freeze_backbone=True).to(config.device)\n        try:\n            model.load_state_dict(torch.load(model_path))\n        except FileNotFoundError:\n            print(f\"Warning: Model file {model_path} not found. Skipping fold {fold+1}.\")\n            continue\n            \n        model.eval()\n        \n        fold_preds = []\n        with torch.no_grad():\n            for img_left, img_right in test_loader:\n                img_left, img_right = img_left.to(config.device), img_right.to(config.device)\n                logits = model(img_left, img_right)      # (B, num_species)\n                fold_preds.append(logits.cpu().numpy())\n        \n        all_fold_preds.append(np.concatenate(fold_preds, axis=0))\n    \n    if not all_fold_preds:\n        print(\"Error: No models were loaded. Cannot perform inference.\")\n        return\n\n    # Average logits across folds\n    avg_logits = np.mean(all_fold_preds, axis=0)      # (n_test_images, num_species)\n\n    # Predicted species IDs\n    species_preds = np.argmax(avg_logits, axis=1)     # (n_test_images,)\n\n    # Add predictions to test_unique_df\n    test_unique_df[\"species_id\"] = species_preds\n\n    # Optional: map back to species names if you have a mapping\n    if hasattr(config, \"species_id2name\"):\n        test_unique_df[\"pred_species_name\"] = test_unique_df[\"pred_species_id\"].map(config.species_id2name)\n\n    print(\"Inference complete. Species predictions added to test dataframe.\")\n    print(test_unique_df.head())\n\n    return test_unique_df\n\nclass EarlyStopping:\n    def __init__(self, patience=5, min_delta=1e-4):\n        self.patience = patience\n        self.min_delta = min_delta\n        self.counter = 0\n        self.best_score = None\n\n    def step(self, score):\n        if self.best_score is None or score < self.best_score - self.min_delta:\n            self.best_score = score\n            self.counter = 0\n            return True\n        else:\n            self.counter += 1\n            return False\n\n    def should_stop(self):\n        return self.counter >= self.patience\n    \nclass DINOv2SpeciesClassifier(nn.Module):\n    def __init__(\n        self,\n        num_species,\n        model_path=\"/kaggle/input/dinov2/pytorch/large/1\",\n        freeze_backbone=True,\n        hidden_dim=512,\n        dropout=0.1,\n    ):\n        super().__init__()\n\n        self.backbone = AutoModel.from_pretrained(model_path)\n        embed_dim = self.backbone.config.hidden_size\n\n        # Concatenate left+right, so first layer should accept 2*embed_dim\n        self.head = nn.Sequential(\n            nn.Linear(embed_dim * 2, hidden_dim),\n            nn.GELU(),\n            nn.Dropout(dropout),\n            nn.Linear(hidden_dim, num_species),\n        )\n\n        if freeze_backbone:\n            for p in self.backbone.parameters():\n                p.requires_grad = False\n\n    def forward(self, x_left, x_right):\n        outputs_l = self.backbone(pixel_values=x_left)\n        cls_l = outputs_l.last_hidden_state[:, 0]\n\n        outputs_r = self.backbone(pixel_values=x_right)\n        cls_r = outputs_r.last_hidden_state[:, 0]\n\n        combined = torch.cat([cls_l, cls_r], dim=1)\n        return self.head(combined)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-01T18:22:21.611578Z","iopub.execute_input":"2026-01-01T18:22:21.611903Z","iopub.status.idle":"2026-01-01T18:22:21.657883Z","shell.execute_reply.started":"2026-01-01T18:22:21.611867Z","shell.execute_reply":"2026-01-01T18:22:21.656955Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"config = CONFIG()\ntrain_df = get_processed_data()\nspecies_list = train_df[\"Species\"].unique()\nspecies2id = {s: i for i, s in enumerate(species_list)}\ntrain_df[\"species_id\"] = train_df[\"Species\"].map(species2id)\n\noof_predictions = np.zeros((len(train_df)))\n\nfor fold in range(config.nFolds):\n    print(f\"\\n======== FOLD {fold+1}/{config.nFolds} ========\")\n\n    # -----------------------------\n    # Split fold\n    # -----------------------------\n    train_fold_df = train_df[train_df.fold != fold].reset_index(drop=True)\n    val_fold_df_with_idx = train_df[train_df.fold == fold]\n    val_indices = val_fold_df_with_idx.index\n    val_fold_df = val_fold_df_with_idx.reset_index(drop=True)\n\n    # -----------------------------\n    # Datasets & loaders\n    # -----------------------------\n    train_dataset = SpeciesDataset(\n        train_fold_df,\n        transforms=get_transforms(is_train=True),\n    )\n    val_dataset = SpeciesDataset(\n        val_fold_df,\n        transforms=get_transforms(is_train=False),\n    )\n\n    train_loader = DataLoader(\n        train_dataset,\n        batch_size=config.batch_size,\n        shuffle=True,\n        num_workers=os.cpu_count(),\n        pin_memory=True,\n    )\n\n    val_loader = DataLoader(\n        val_dataset,\n        batch_size=config.batch_size * 2,\n        shuffle=False,\n        num_workers=os.cpu_count(),\n        pin_memory=True,\n    )\n\n    # -----------------------------\n    # Model\n    # -----------------------------\n    model = DINOv2SpeciesClassifier(\n        num_species=len(species_list),\n        freeze_backbone=True,\n    ).to(config.device)\n\n    criterion = nn.CrossEntropyLoss()\n    best_val = -np.inf\n    model_path = f\"model_fold_classifier_{fold}.pth\"\n\n    # ======================================================\n    # STAGE 1 — Train head only\n    # ======================================================\n    print(\"Training head only...\")\n\n    early_stop = EarlyStopping(patience=7)\n\n    optimizer = torch.optim.AdamW([\n        {\"params\": model.head.parameters(), \"lr\": config.lr, \"weight_decay\": 1e-3},\n        {\"params\": filter(lambda p: p.requires_grad, model.backbone.parameters()), \n        \"lr\": config.lr * 0.01, \"weight_decay\": 1e-5},\n    ])\n\n    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(\n        optimizer,\n        T_max=config.epochs,\n        eta_min=config.eta_min,\n    )\n\n    for epoch in range(config.epochs):\n        train_loss, _ = train_one_epoch(\n            model,\n            train_loader,\n            optimizer,\n            criterion,\n            config.device,\n        )\n\n        val_loss, val_acc = val_fn(\n            model,\n            val_loader,\n            criterion,\n            config.device,\n        )\n\n        print(\n            f\"[HEAD] Epoch {epoch+1}/{config.epochs} \"\n            f\"- Train: {train_loss:.4f} | Val: {val_loss:.4f} |  Val acc: {val_acc:.4f}\"\n        )\n\n        scheduler.step()\n\n        # ---- Save best model ----\n        if val_acc > best_val:\n            best_val = val_acc\n            torch.save(model.state_dict(), model_path)\n\n        # ---- Early stopping logic ----\n        early_stop.step(val_loss)\n        if early_stop.should_stop():\n            print(\"Early stopping (head)\")\n            break\n\n    # ======================================================\n    # STAGE 2 — LayerNorm-only fine-tuning\n    # ======================================================\n    print(\"Fine-tuning LayerNorm only...\")\n\n    # Freeze entire backbone\n    for param in model.backbone.parameters():\n        param.requires_grad = False\n\n    # Unfreeze only LayerNorms\n    for name, param in model.backbone.named_parameters():\n        if \"norm\" in name.lower():\n            param.requires_grad = True\n\n    early_stop = EarlyStopping(patience=4)\n\n    optimizer = torch.optim.AdamW(\n        [\n            {\n                \"params\": model.head.parameters(),\n                \"lr\": config.lr,\n                \"weight_decay\": 1e-3,\n            },\n            {\n                \"params\": filter(\n                    lambda p: p.requires_grad,\n                    model.backbone.parameters(),\n                ),\n                \"lr\": config.lr * 0.01,\n                \"weight_decay\": 1e-5,\n            },\n        ]\n    )\n\n    scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(\n        optimizer,\n        mode=\"min\",\n        factor=0.3,\n        patience=2,\n    )\n\n    for epoch in range(config.epochs // 2):\n        train_loss, acc = train_one_epoch(\n            model,\n            train_loader,\n            optimizer,\n            criterion,\n            config.device,\n        )\n\n        val_loss, val_acc = val_fn(\n            model,\n            val_loader,\n            criterion,\n            config.device,\n        )\n\n        print(\n            f\"[FT] Epoch {epoch+1}/{config.epochs//2} \"\n            f\"- Train: {train_loss:.4f} | Val: {val_loss:.4f} | Val ACC: {val_acc:.4f} \"\n        )\n\n        scheduler.step(val_loss)\n\n        # ---- Save best model ----\n        if val_acc > best_val:\n            best_val = val_acc\n            torch.save(model.state_dict(), model_path)\n\n        # ---- Early stopping logic ----\n        early_stop.step(val_loss)\n        if early_stop.should_stop():\n            print(\"Early stopping (head)\")\n            break\n\n    # -----------------------------\n    # OOF predictions (species)\n    # -----------------------------\n    print(f\"Loading best model from {model_path}\")\n\n    model.load_state_dict(torch.load(model_path))\n    model.eval()\n\n    fold_species_preds = []\n\n    with torch.no_grad():\n        for img_left, img_right, _ in val_loader:\n            img_left = img_left.to(config.device)\n            img_right = img_right.to(config.device)\n\n            logits = model(img_left, img_right)   # (B, num_species)\n            preds = logits.argmax(dim=1)          # (B,)\n\n            fold_species_preds.append(preds.cpu().numpy())\n\n    fold_species_preds = np.concatenate(fold_species_preds, axis=0)\n\n    # Save OOF species predictions\n    oof_predictions[val_indices] = fold_species_preds","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-01T18:22:21.659233Z","iopub.execute_input":"2026-01-01T18:22:21.659592Z","execution_failed":"2026-01-01T18:23:21.729Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import accuracy_score, f1_score, classification_report\n# -----------------------------\n# Final OOF score (classification)\n# -----------------------------\ny_true = train_df[\"species_id\"].values\ny_pred = oof_predictions\n\nacc = accuracy_score(y_true, y_pred)\nf1_macro = f1_score(y_true, y_pred, average=\"macro\")\nf1_weighted = f1_score(y_true, y_pred, average=\"weighted\")\n\nprint(\"\\n======== CV Finished ========\")\nprint(f\"OOF Accuracy      : {acc:.4f}\")\nprint(f\"OOF Macro F1      : {f1_macro:.4f}\")\nprint(f\"OOF Weighted F1   : {f1_weighted:.4f}\")\n\nprint(\"\\nPer-class report:\")\nprint(classification_report(y_true, y_pred))\n\nprint(\"----------------------------------------------------------------------------------------------\")\nprint(\"--------------------------------------------END-----------------------------------------------\")\nprint(\"----------------------------------------------------------------------------------------------\")\nrun_inference(species_list)","metadata":{"trusted":true,"execution":{"execution_failed":"2026-01-01T18:23:21.729Z"}},"outputs":[],"execution_count":null}]}