{"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":[{"sourceId":112509,"databundleVersionId":14254895,"sourceType":"competition"}],"dockerImageVersionId":31153,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# CSIRO Image2Biomass — Project Overview (Brief)\n\n## 1. Objective\nPredict pasture biomass components from top-view images and associated metadata such as NDVI, height, and species information.  \nThe model estimates five targets — `Dry_Clover_g`, `Dry_Dead_g`, `Dry_Green_g`, `Dry_Total_g`, and `GDM_g` — using a **single multi-task deep learning model**.\n\n---\n\n## 2. Dataset\nThe dataset contains:\n- RGB pasture images (`train/` and `test/` folders)\n- Metadata and biomass targets in `train.csv`\n- `test.csv` for inference and `sample_submission.csv` for submission format\n\nEach image has multiple biomass component measurements, resulting in a **long-format dataset**.\n\n---\n\n## 3. Preprocessing\n- **Handling missing values:** All missing `target` values are filled with the mean of their respective target columns to prevent training instability.  \n- **Pivoting:** The dataset is transformed from long to wide format so each image has one row with five target columns.  \n- **Encoding:** Categorical columns like `State` and `Species` are label-encoded.  \n- **Scaling:** Numerical features such as NDVI and height are standardized.  \n- **Augmentation:** Training images are augmented using random crops, flips, brightness, and rotation to improve generalization.\n\n---\n\n## 4. Model Architecture\n- A **single multi-task model** built using a `timm` CNN backbone (e.g., EfficientNet).\n- Image features and tabular features (NDVI, height, state, species) are extracted separately and then fused.\n- The final layer outputs five regression values corresponding to the biomass components.\n- **Loss:** Mean Squared Error (MSE)  \n- **Optimizer:** AdamW  \n- **Metric:** Root Mean Squared Error (RMSE)\n\n---\n\n## 5. Training Strategy\n- **Cross-validation:** 5-Fold GroupKFold is used to ensure all data from the same image stays in one fold.\n- Each fold trains independently, and the best-performing checkpoint (lowest validation RMSE) is saved.\n- Results are averaged across folds for robustness.\n\n---\n\n## 6. Inference & Submission\n- Trained fold models are loaded for inference.\n- Predictions from all folds are averaged to create final results.\n- Negative predictions are clipped to zero.\n- The final output follows the Kaggle submission format (`sample_id`, `target`) and is saved as `submission.csv`.\n\n---\n\n## 7. Key Takeaways\n- Filling NaNs early is essential for stable training.\n- GroupKFold prevents data leakage across the same image.\n- Multi-task learning improves efficiency and correlation between targets.\n- Ensemble averaging across folds yields smoother, more reliable predictions.\n- Model checkpoints and preprocessing artifacts (encoders/scaler) are stored for reproducibility.\n\n---\n\n## 8. Limitations\n\n- **Limited dataset size:** The number of unique images is relatively small, which may restrict the model’s generalization on unseen vegetation patterns or lighting conditions.  \n- **Imbalanced targets:** Certain biomass components (e.g., Dry Clover vs. Dry Dead) may have uneven distributions, potentially biasing the model toward more common classes.  \n- **Simplified feature fusion:** The model combines image and tabular features through simple concatenation, which might not fully capture complex interactions between environmental and visual cues.  \n- **Environmental variability:** Differences in lighting, soil color, and camera orientation can introduce noise that basic augmentations may not fully mitigate.  \n- **2D representation only:** The approach relies solely on RGB imagery — lacking depth or spectral information (e.g., multispectral or hyperspectral data) that could improve biomass estimation accuracy.  \n- **Limited explainability:** While Grad-CAM or other XAI techniques can be applied, this implementation does not deeply interpret model decisions.  \n\n\n\n\n","metadata":{}},{"cell_type":"code","source":"#libraries\nimport os\nimport math\nimport random\nfrom pathlib import Path\nimport joblib\nimport pandas as pd\nimport numpy as np\nfrom sklearn.model_selection import GroupKFold\nfrom sklearn.preprocessing import StandardScaler, LabelEncoder\nfrom sklearn.metrics import mean_squared_error\nfrom tqdm import tqdm\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nimport timm\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nimport cv2","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:23:14.092269Z","iopub.execute_input":"2025-11-03T17:23:14.092774Z","iopub.status.idle":"2025-11-03T17:23:26.351024Z","shell.execute_reply.started":"2025-11-03T17:23:14.092748Z","shell.execute_reply":"2025-11-03T17:23:26.350265Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#configs\nclass CFG:\n    seed = 42\n    data_dir = Path('/kaggle/input/csiro-biomass')   # <--- your path\n    train_csv = data_dir / 'train.csv'\n    test_csv = data_dir / 'test.csv'\n    train_image_dir = data_dir / 'train'\n    test_image_dir = data_dir / 'test'\n    img_size = 384\n    backbone = 'tf_efficientnet_b3.ns_jft_in1k'\n    pretrained = True\n    batch_size = 16\n    n_epochs = 10\n    lr = 1e-4\n    weight_decay = 1e-6\n    n_splits = 5\n    device = 'cuda' if torch.cuda.is_available() else 'cpu'\n    num_workers = 4\n    targets = ['Dry_Clover_g','Dry_Dead_g','Dry_Green_g','Dry_Total_g','GDM_g']\n    model_dir = Path('./models')\nCFG.model_dir.mkdir(parents=True, exist_ok=True)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:23:42.580428Z","iopub.execute_input":"2025-11-03T17:23:42.580713Z","iopub.status.idle":"2025-11-03T17:23:42.632349Z","shell.execute_reply.started":"2025-11-03T17:23:42.580691Z","shell.execute_reply":"2025-11-03T17:23:42.631391Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#helpers\ndef seed_everything(seed=CFG.seed):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\nseed_everything()\n\ndef rmse(y_true, y_pred):\n    return math.sqrt(mean_squared_error(y_true, y_pred))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:23:45.484103Z","iopub.execute_input":"2025-11-03T17:23:45.484986Z","iopub.status.idle":"2025-11-03T17:23:45.495304Z","shell.execute_reply.started":"2025-11-03T17:23:45.484952Z","shell.execute_reply":"2025-11-03T17:23:45.494518Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#read CSVs\ntrain_long = pd.read_csv(CFG.train_csv) \ntest_long = pd.read_csv(CFG.test_csv)    \n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:23:47.347455Z","iopub.execute_input":"2025-11-03T17:23:47.348241Z","iopub.status.idle":"2025-11-03T17:23:47.375554Z","shell.execute_reply.started":"2025-11-03T17:23:47.348187Z","shell.execute_reply":"2025-11-03T17:23:47.374640Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_long.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:23:49.441418Z","iopub.execute_input":"2025-11-03T17:23:49.441702Z","iopub.status.idle":"2025-11-03T17:23:49.465393Z","shell.execute_reply.started":"2025-11-03T17:23:49.441681Z","shell.execute_reply":"2025-11-03T17:23:49.464513Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#read train.csv first\ntrain_long = pd.read_csv(CFG.train_csv)\n\n#fill NaN targets with mean of their target_name column\ntrain_long['target'] = train_long.groupby('target_name')['target'].transform(\n    lambda x: x.fillna(x.mean())\n)\n\n#if there are still NaNs (all values missing for a target_name), fill with 0\ntrain_long['target'] = train_long['target'].fillna(0.0)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:23:51.766686Z","iopub.execute_input":"2025-11-03T17:23:51.766958Z","iopub.status.idle":"2025-11-03T17:23:51.800490Z","shell.execute_reply.started":"2025-11-03T17:23:51.766938Z","shell.execute_reply":"2025-11-03T17:23:51.799688Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#pivot train to image-level (wide) so each row = image with 5 target columns\ndef pivot_train_long_to_wide(df_long):\n    meta_cols = ['sample_id','image_path','Sampling_Date','State','Species','Pre_GSHH_NDVI','Height_Ave_cm']\n    pivot = df_long.pivot(index='sample_id', columns='target_name', values='target').reset_index()\n    meta = df_long[meta_cols].drop_duplicates('sample_id').set_index('sample_id')\n    pivot = pivot.set_index('sample_id').join(meta).reset_index()\n    for t in CFG.targets:\n        if t not in pivot.columns:\n            pivot[t] = 0.0\n    return pivot\n\ntrain_wide = pivot_train_long_to_wide(train_long)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:23:53.801371Z","iopub.execute_input":"2025-11-03T17:23:53.802055Z","iopub.status.idle":"2025-11-03T17:23:53.829274Z","shell.execute_reply.started":"2025-11-03T17:23:53.802025Z","shell.execute_reply":"2025-11-03T17:23:53.828459Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#fill NaNs in the long-format train table\ntrain_long = pd.read_csv(CFG.train_csv)   # if not already loaded\ntrain_long['target'] = train_long.groupby('target_name')['target'].transform(lambda x: x.fillna(x.mean()))\n#final fallback if a target_name had all NaNs (mean would be NaN)\ntrain_long['target'] = train_long['target'].fillna(0.0)\n\n#optional quick check in long df\nprint(\"Per-target NaNs in train_long (should be 0):\")\nprint(train_long.groupby('target_name')['target'].apply(lambda x: x.isna().sum()))\n\ntrain_wide = pivot_train_long_to_wide(train_long)\n\n#if any NaNs persist in the wide target columns, fill per-column mean\nfor t in CFG.targets:\n    if train_wide[t].isna().any():\n        mean_val = train_wide[t].mean()\n        if np.isnan(mean_val):\n            mean_val = 0.0\n        train_wide[t] = train_wide[t].fillna(mean_val)\n\n# no NaNs confirmation\nprint(\"Per-target NaNs in train_wide after filling:\")\nprint(train_wide[CFG.targets].isna().sum())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:23:56.413584Z","iopub.execute_input":"2025-11-03T17:23:56.414096Z","iopub.status.idle":"2025-11-03T17:23:56.440489Z","shell.execute_reply.started":"2025-11-03T17:23:56.414070Z","shell.execute_reply":"2025-11-03T17:23:56.439869Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#resolve local image paths (ensure these paths exist on your machine / notebook server)\ndef resolve_image_local_path(img_path, images_root):\n    p = Path(img_path)\n    if p.exists():\n        return str(p)\n    candidate = images_root / p.name\n    if candidate.exists():\n        return str(candidate)\n    candidate2 = images_root / img_path\n    if candidate2.exists():\n        return str(candidate2)\n    #fallback: return the full joined path attempt\n    return str(images_root / p.name)\n\ntrain_wide['image_local_path'] = train_wide['image_path'].apply(lambda p: resolve_image_local_path(p, CFG.train_image_dir))\ntest_long['image_local_path'] = test_long['image_path'].apply(lambda p: resolve_image_local_path(p, CFG.test_image_dir))\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:24:01.008550Z","iopub.execute_input":"2025-11-03T17:24:01.008830Z","iopub.status.idle":"2025-11-03T17:24:01.416098Z","shell.execute_reply.started":"2025-11-03T17:24:01.008809Z","shell.execute_reply":"2025-11-03T17:24:01.415551Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Encoders & Scaler\n#fit LabelEncoders on combined values\n\ndef safe_col(df, col):\n    return df[col] if col in df.columns else pd.Series(['NA'] * len(df))\n\nle_state = LabelEncoder()\nle_species = LabelEncoder()\nstate_combined = pd.concat([train_wide['State'].fillna('NA'), safe_col(test_long,'State').fillna('NA')])\nspecies_combined = pd.concat([train_wide['Species'].fillna('NA'), safe_col(test_long,'Species').fillna('NA')])\nle_state.fit(state_combined)\nle_species.fit(species_combined)\ntrain_wide['State_enc'] = le_state.transform(train_wide['State'].fillna('NA'))\ntrain_wide['Species_enc'] = le_species.transform(train_wide['Species'].fillna('NA'))\n\n#save encoders\njoblib.dump(le_state, CFG.model_dir / 'le_state.joblib')\njoblib.dump(le_species, CFG.model_dir / 'le_species.joblib')\n\n#fit scaler for NDVI & Height\nnum_cols = ['Pre_GSHH_NDVI','Height_Ave_cm']\nscaler = StandardScaler()\nscaler.fit(train_wide[num_cols].fillna(0.0))\ntrain_wide[['ndvi_scaled','height_scaled']] = scaler.transform(train_wide[num_cols].fillna(0.0))\njoblib.dump(scaler, CFG.model_dir / 'scaler.joblib')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:24:05.220589Z","iopub.execute_input":"2025-11-03T17:24:05.220860Z","iopub.status.idle":"2025-11-03T17:24:05.240815Z","shell.execute_reply.started":"2025-11-03T17:24:05.220841Z","shell.execute_reply":"2025-11-03T17:24:05.240218Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#load dataset\nclass BiomassDataset(Dataset):\n    def __init__(self, df_wide_or_long, mode='train', transforms=None):\n        self.mode = mode\n        self.transforms = transforms\n        rows = []\n        if mode == 'train':\n            #df_wide: expand each image into multiple rows (one per target)\n            for _, r in df_wide_or_long.iterrows():\n                for t_idx, tname in enumerate(CFG.targets):\n                    rows.append({\n                        'sample_id': f\"{r['sample_id']}__{tname}\",\n                        'image_local_path': r['image_local_path'],\n                        'state_enc': int(r['State_enc']) if 'State_enc' in r else 0,\n                        'species_enc': int(r['Species_enc']) if 'Species_enc' in r else 0,\n                        'ndvi': float(r['ndvi_scaled']) if 'ndvi_scaled' in r else 0.0,\n                        'height': float(r['height_scaled']) if 'height_scaled' in r else 0.0,\n                        'target_name': tname,\n                        'target_idx': t_idx,\n                        'target': float(r[tname])\n                    })\n        else:\n            #inference: df_long rows (test_long), map enc & scaled numeric (if present)\n            le_st = joblib.load(CFG.model_dir / 'le_state.joblib')\n            le_sp = joblib.load(CFG.model_dir / 'le_species.joblib')\n            scal = joblib.load(CFG.model_dir / 'scaler.joblib')\n            for _, r in df_wide_or_long.iterrows():\n                state_val = r.get('State', 'NA')\n                species_val = r.get('Species', 'NA')\n                #encode if seen else assign 0\n                try:\n                    state_enc = int(le_st.transform([state_val.fillna('NA')])[0]) if hasattr(state_val, 'fillna') else int(le_st.transform([state_val])[0])\n                except Exception:\n                    state_enc = 0\n                try:\n                    species_enc = int(le_sp.transform([species_val.fillna('NA')])[0]) if hasattr(species_val, 'fillna') else int(le_sp.transform([species_val])[0])\n                except Exception:\n                    species_enc = 0\n                # scale numeric if present\n                ndvi_val = r.get('Pre_GSHH_NDVI', 0.0)\n                height_val = r.get('Height_Ave_cm', 0.0)\n                try:\n                    ndvi_scaled, height_scaled = scal.transform([[ndvi_val, height_val]])[0]\n                except Exception:\n                    ndvi_scaled, height_scaled = 0.0, 0.0\n                rows.append({\n                    'sample_id': r['sample_id'],\n                    'image_local_path': r['image_local_path'],\n                    'state_enc': state_enc,\n                    'species_enc': species_enc,\n                    'ndvi': float(ndvi_scaled),\n                    'height': float(height_scaled),\n                    'target_name': r['target_name'],\n                    'target_idx': CFG.targets.index(r['target_name']),\n                    'target': 0.0\n                })\n        self.df = pd.DataFrame(rows).reset_index(drop=True)\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        r = self.df.iloc[idx]\n        img_path = r['image_local_path']\n        if Path(img_path).exists():\n            img = cv2.imread(img_path)[:,:,::-1]\n        else:\n            img = np.zeros((CFG.img_size, CFG.img_size, 3), dtype=np.uint8)\n        if self.transforms:\n            img = self.transforms(image=img)['image']\n        tab = np.array([r['ndvi'], r['height'], r['state_enc'], r['species_enc']], dtype=np.float32)\n        return {\n            'image': img,\n            'tab': torch.tensor(tab, dtype=torch.float32),\n            'target': torch.tensor(r['target'], dtype=torch.float32),\n            'target_idx': torch.tensor(r['target_idx'], dtype=torch.long),\n            'sample_id': r['sample_id'],\n            'target_name': r['target_name']\n        }\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:24:19.141559Z","iopub.execute_input":"2025-11-03T17:24:19.141835Z","iopub.status.idle":"2025-11-03T17:24:19.153987Z","shell.execute_reply.started":"2025-11-03T17:24:19.141816Z","shell.execute_reply":"2025-11-03T17:24:19.153342Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_transforms = A.Compose([\n    A.SmallestMaxSize(max_size=CFG.img_size),\n    A.RandomCrop(height=CFG.img_size, width=CFG.img_size),\n    A.HorizontalFlip(p=0.5),\n    A.VerticalFlip(p=0.5),\n    A.RandomBrightnessContrast(p=0.4),\n    A.ShiftScaleRotate(p=0.5),\n    A.Normalize(),\n    ToTensorV2()\n])\n\nvalid_transforms = A.Compose([\n    A.Resize(height=CFG.img_size, width=CFG.img_size),\n    A.Normalize(),\n    ToTensorV2()\n])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:24:22.996408Z","iopub.execute_input":"2025-11-03T17:24:22.997065Z","iopub.status.idle":"2025-11-03T17:24:23.013778Z","shell.execute_reply.started":"2025-11-03T17:24:22.997036Z","shell.execute_reply":"2025-11-03T17:24:23.013005Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Model\nclass MultiTaskModel(nn.Module):\n    def __init__(self, backbone_name=CFG.backbone, pretrained=CFG.pretrained, n_tab=4, n_out=5, dropout=0.3):\n        super().__init__()\n        try:\n            self.backbone = timm.create_model(backbone_name, pretrained=pretrained, num_classes=0, global_pool='avg')\n        except Exception:\n            self.backbone = timm.create_model('resnet50', pretrained=pretrained, num_classes=0, global_pool='avg')\n        #try to get channel dimension (num_features). If not available, we'll infer in forward (but timm usually provides num_features).\n        feat_dim = getattr(self.backbone, 'num_features', None)\n        if feat_dim is None:\n            #fallback: assume a safe default (will be corrected dynamically in forward if necessary)\n            feat_dim = 1536\n\n        self.img_head = nn.Sequential(nn.Linear(feat_dim, 512), nn.ReLU(), nn.Dropout(dropout))\n        self.tab_head = nn.Sequential(nn.Linear(n_tab, 128), nn.ReLU(), nn.Dropout(dropout))\n        self.fusion = nn.Sequential(nn.Linear(512 + 128, 256), nn.ReLU(), nn.Dropout(dropout), nn.Linear(256, n_out))\n\n    def forward(self, x_img, x_tab):\n        feat = self.backbone.forward_features(x_img)\n        #some backbones return (B, C, H, W), some return (B, C) or tuple/list\n        if isinstance(feat, (tuple, list)):\n            feat = feat[0]\n        if feat.dim() == 4:\n            #global pool spatial dims to get (B, C, 1, 1) -> (B, C)\n            feat = nn.functional.adaptive_avg_pool2d(feat, 1).squeeze(-1).squeeze(-1)\n        #now feat is (B, C)\n        #if img_head input size mismatch (rare), resize first linear accordingly:\n        if feat.size(1) != self.img_head[0].in_features:\n            #recreate img_head with correct in_features (keeps dropout and activation)\n            in_feat = feat.size(1)\n            self.img_head = nn.Sequential(nn.Linear(in_feat, 512), nn.ReLU(), nn.Dropout(self.img_head[2].p if isinstance(self.img_head[2], nn.Dropout) else 0.3))\n        img_v = self.img_head(feat)\n        tab_v = self.tab_head(x_tab)\n        x = torch.cat([img_v, tab_v], dim=1)\n        out = self.fusion(x)\n        return out","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:24:28.398731Z","iopub.execute_input":"2025-11-03T17:24:28.399442Z","iopub.status.idle":"2025-11-03T17:24:28.408767Z","shell.execute_reply.started":"2025-11-03T17:24:28.399418Z","shell.execute_reply":"2025-11-03T17:24:28.407092Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#loss selection helper\ndef compute_batch_loss(preds, target_vals, target_idxs, criterion):\n    batch_idx = torch.arange(preds.size(0), device=preds.device)\n    pred_selected = preds[batch_idx, target_idxs]   # shape (B,)\n    loss = criterion(pred_selected, target_vals)\n    return loss, pred_selected.detach().cpu().numpy()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:24:32.374192Z","iopub.execute_input":"2025-11-03T17:24:32.374743Z","iopub.status.idle":"2025-11-03T17:24:32.378756Z","shell.execute_reply.started":"2025-11-03T17:24:32.374719Z","shell.execute_reply":"2025-11-03T17:24:32.377891Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#GroupKFold CV and training\ngkf = GroupKFold(n_splits=CFG.n_splits)\ngroups = train_wide['sample_id'].values\n\nfold_models = []\noof_predictions = []\n\nfor fold, (tr_idx, val_idx) in enumerate(gkf.split(train_wide, groups=groups)):\n    print(f\"\\n=== Fold {fold} ===\")\n    train_rows = train_wide.iloc[tr_idx].reset_index(drop=True)\n    val_rows = train_wide.iloc[val_idx].reset_index(drop=True)\n\n    train_ds = BiomassDataset(train_rows, mode='train', transforms=train_transforms)\n    val_ds = BiomassDataset(val_rows, mode='train', transforms=valid_transforms)\n\n    train_loader = DataLoader(train_ds, batch_size=CFG.batch_size, shuffle=True, num_workers=CFG.num_workers, pin_memory=True)\n    val_loader = DataLoader(val_ds, batch_size=CFG.batch_size, shuffle=False, num_workers=CFG.num_workers, pin_memory=True)\n\n    model = MultiTaskModel().to(CFG.device)\n    optimizer = torch.optim.AdamW(model.parameters(), lr=CFG.lr, weight_decay=CFG.weight_decay)\n    criterion = nn.MSELoss()\n\n    best_rmse = float('inf')\n    best_state = None\n\n    for epoch in range(CFG.n_epochs):\n        #train\n        model.train()\n        train_losses = []\n        for batch in tqdm(train_loader, desc=f\"Fold{fold}-Train\", leave=False):\n            imgs = batch['image'].to(CFG.device)\n            tabs = batch['tab'].to(CFG.device)\n            targets = batch['target'].to(CFG.device)\n            tidx = batch['target_idx'].to(CFG.device)\n            preds = model(imgs, tabs)\n            loss, _ = compute_batch_loss(preds, targets, tidx, criterion)\n            optimizer.zero_grad()\n            loss.backward()\n            optimizer.step()\n            train_losses.append(loss.item())\n        train_loss = np.mean(train_losses)\n\n        #valid\n        model.eval()\n        val_preds = []\n        val_targs = []\n        val_losses = []\n        with torch.no_grad():\n            for batch in tqdm(val_loader, desc=f\"Fold{fold}-Valid\", leave=False):\n                imgs = batch['image'].to(CFG.device)\n                tabs = batch['tab'].to(CFG.device)\n                targets = batch['target'].to(CFG.device)\n                tidx = batch['target_idx'].to(CFG.device)\n                preds = model(imgs, tabs)\n                loss, pred_sel = compute_batch_loss(preds, targets, tidx, criterion)\n                val_losses.append(loss.item())\n                val_preds.append(pred_sel)\n                val_targs.append(targets.detach().cpu().numpy())\n        if len(val_preds) == 0:\n            val_rmse = float('inf')\n        else:\n            #val_preds = np.concatenate(val_preds)\n            #val_targs = np.concatenate(val_targs)\n            if len(val_preds) == 0 or len(val_targs) == 0:\n                    # no predictions collected at all\n                val_rmse = float('nan')\n                print(\"Warning: no validation predictions/targets were collected this epoch/fold.\")\n            else:\n                val_preds = np.concatenate(val_preds)\n                val_targs = np.concatenate(val_targs)\n\n    #filter finite rows\n                mask = np.isfinite(val_preds) & np.isfinite(val_targs)\n                if mask.sum() == 0:\n                   val_rmse = float('nan')\n                   print(\"Warning: all validation predictions or targets are NaN/inf for this epoch/fold.\")\n                else:\n                   if mask.sum() < len(val_preds):\n                       print(f\"Warning: dropping {len(val_preds) - mask.sum()} NaN/inf rows from validation before RMSE.\")\n                   val_rmse = rmse(val_targs[mask], val_preds[mask])\n        print(f\"Fold {fold} Epoch {epoch} - train_loss: {train_loss:.4f}  val_rmse: {val_rmse:.4f}\")\n\n        if val_rmse < best_rmse:\n            best_rmse = val_rmse\n            best_state = model.state_dict()\n\n    #save best model for fold\n    model_path = CFG.model_dir / f\"model_fold{fold}.pth\"\n    torch.save(best_state, model_path)\n    fold_models.append(model_path)\n    print(f\"Fold {fold} done - best val RMSE: {best_rmse:.4f} - saved {model_path}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T17:24:35.467486Z","iopub.execute_input":"2025-11-03T17:24:35.468128Z","iopub.status.idle":"2025-11-03T20:58:03.527338Z","shell.execute_reply.started":"2025-11-03T17:24:35.468095Z","shell.execute_reply":"2025-11-03T20:58:03.526451Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#base-level test frame (one row per base image)\ndef base_id_from_sample_id(sid):\n    return sid.split('__')[0] if '__' in sid else sid\n\ntest_long['base_id'] = test_long['sample_id'].apply(base_id_from_sample_id)\ntest_base = test_long.drop_duplicates('base_id').reset_index(drop=True)\n\n#add encodings and scaled numerics for test_base\nle_st = joblib.load(CFG.model_dir / 'le_state.joblib')\nle_sp = joblib.load(CFG.model_dir / 'le_species.joblib')\nscal = joblib.load(CFG.model_dir / 'scaler.joblib')\n\ndef get_state_enc(val):\n    try:\n        return int(le_st.transform([val if pd.notna(val) else 'NA'])[0])\n    except Exception:\n        return 0\ndef get_species_enc(val):\n    try:\n        return int(le_sp.transform([val if pd.notna(val) else 'NA'])[0])\n    except Exception:\n        return 0\nstate_encs = [get_state_enc(v) for v in test_base.get('State', ['NA']*len(test_base))]\nspec_encs = [get_species_enc(v) for v in test_base.get('Species', ['NA']*len(test_base))]\n\n#scale numeric features (NDVI, Height) if present\nndvi_list = test_base.get('Pre_GSHH_NDVI', [0.0]*len(test_base)).fillna(0.0) if 'Pre_GSHH_NDVI' in test_base else [0.0]*len(test_base)\nheight_list = test_base.get('Height_Ave_cm', [0.0]*len(test_base)).fillna(0.0) if 'Height_Ave_cm' in test_base else [0.0]*len(test_base)\nscaled = scal.transform(np.column_stack([ndvi_list, height_list]))\ntest_base['ndvi_scaled'] = scaled[:,0]\ntest_base['height_scaled'] = scaled[:,1]\ntest_base['State_enc'] = state_encs\ntest_base['Species_enc'] = spec_encs\ntest_base['image_local_path'] = test_base['image_path'].apply(lambda p: resolve_image_local_path(p, CFG.test_image_dir))\n\n#for each fold model, predict per base image\nfold_preds_list = []\nfor model_path in fold_models:\n    model = MultiTaskModel().to(CFG.device)\n    model.load_state_dict(torch.load(model_path, map_location=CFG.device))\n    model.eval()\n    preds_for_fold = {}\n    with torch.no_grad():\n        for _, r in tqdm(test_base.iterrows(), total=len(test_base), desc=f\"Infer {model_path.name}\", leave=False):\n            img_path = r['image_local_path']\n            if Path(img_path).exists():\n                img = cv2.imread(img_path)[:,:,::-1]\n            else:\n                img = np.zeros((CFG.img_size, CFG.img_size, 3), dtype=np.uint8)\n            img_t = valid_transforms(image=img)['image'].unsqueeze(0).to(CFG.device)\n            tab = torch.tensor([[r['ndvi_scaled'], r['height_scaled'], int(r['State_enc']), int(r['Species_enc'])]], dtype=torch.float32).to(CFG.device)\n            out = model(img_t, tab).detach().cpu().numpy()[0]   # shape (5,)\n            out = np.clip(out, 0.0, None)\n            preds_for_fold[r['base_id']] = out\n    fold_preds_list.append(preds_for_fold)\n\n#average predictions across folds\navg_preds = {}\nfor base_id in fold_preds_list[0].keys():\n    stacked = np.stack([fold_preds[base_id] for fold_preds in fold_preds_list], axis=0)\n    avg = np.mean(stacked, axis=0)\n    avg_preds[base_id] = avg\n\n#submission format\npreds_df = pd.DataFrame([{'sample_id': bid, **{CFG.targets[i]: avg_preds[bid][i] for i in range(len(CFG.targets))}} for bid in avg_preds.keys()])\npreds_long = preds_df.set_index('sample_id')[CFG.targets].stack().reset_index()\npreds_long.columns = ['sample_id_base','target_name','target']\npreds_long['sample_id'] = preds_long['sample_id_base'].astype(str) + '__' + preds_long['target_name'].astype(str)\nsubmission = preds_long[['sample_id','target']].reset_index(drop=True)\nsubmission.to_csv('submission.csv', index=False)\nprint(\"Saved submission.csv with shape:\", submission.shape)\nprint(submission.head(8))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-03T20:58:52.565714Z","iopub.execute_input":"2025-11-03T20:58:52.565989Z","iopub.status.idle":"2025-11-03T20:58:55.500142Z","shell.execute_reply.started":"2025-11-03T20:58:52.565966Z","shell.execute_reply":"2025-11-03T20:58:55.499474Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}