{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.11.13"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":112509,"databundleVersionId":14254895,"sourceType":"competition"},{"sourceId":271932618,"sourceType":"kernelVersion"}],"dockerImageVersionId":31154,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true},"papermill":{"default_parameters":{},"duration":675.316664,"end_time":"2025-10-29T22:37:02.179399","environment_variables":{},"exception":null,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2025-10-29T22:25:46.862735","version":"2.6.0"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"a4db9485-c0d1-4133-938b-435610dd9831","cell_type":"markdown","source":"## CSIRO simple baseline\n\n**Predict pasture biomass components** from field images.  \nFor each image, we must estimate five target variables (in grams):\n\n- **Dry_Green_g** → Dry green vegetation (non-clover)  \n- **Dry_Dead_g** → Dry dead material  \n- **Dry_Clover_g** → Dry clover biomass  \n- **GDM_g** → Green dry matter  \n- **Dry_Total_g** → Total dry biomass  \n\n**Evaluation metric:** Weighted average of the **R² score** across the five targets.\n\n---\n\n## Baseline Strategy\n\nA **simple but competitive multi-output regression model** that predicts all biomass components jointly.\n","metadata":{}},{"id":"70b84431","cell_type":"markdown","source":"## Setup and Configuration\n","metadata":{}},{"id":"5fc65096","cell_type":"code","source":"# =========================\n# 0) Imports & config\n# =========================\nimport os, gc, math, random, time, json\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\n\nfrom sklearn.model_selection import GroupKFold\nfrom sklearn.metrics import r2_score\nfrom sklearn.preprocessing import OneHotEncoder\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\n\nimport timm\nfrom safetensors.torch import load_file \nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nimport cv2\n\nSEED = 42\nIMG_SIZE = 448\nBATCH_SIZE = 16\nEPOCHS = 10\nLR = 2e-4\nBACKBONE = \"tf_efficientnetv2_s\"   # try \"convnext_base\" if you have more GPU\nUSE_META = True                     # toggle meta features on/off\nMETA_OHE_TOP_N = 30                 # top N categories kept per column, rest grouped as \"Other\"\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n\nTARGETS = [\"Dry_Green_g\", \"Dry_Dead_g\", \"Dry_Clover_g\", \"GDM_g\", \"Dry_Total_g\"]\nTARGET_WEIGHTS = np.array([1,1,1,1,1], dtype=np.float32)  # equal weights unless competition states otherwise\nTARGET_WEIGHTS = TARGET_WEIGHTS / TARGET_WEIGHTS.sum()\n\nrandom.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED); torch.cuda.manual_seed_all(SEED)\n\nINPUT_DIR = Path(\"/kaggle/input/csiro-biomass\")\nWORK_DIR = Path(\"/kaggle/working\")\n\nsubdirs = [p for p in INPUT_DIR.iterdir() if p.is_dir()]\nCOMP_DIR = subdirs[0] if len(subdirs)==1 else INPUT_DIR  # fallback\n\nprint(\"Using data from:\", COMP_DIR)","metadata":{"_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","execution":{"iopub.status.busy":"2025-10-30T15:03:56.318710Z","iopub.execute_input":"2025-10-30T15:03:56.319621Z","iopub.status.idle":"2025-10-30T15:04:39.432753Z","shell.execute_reply.started":"2025-10-30T15:03:56.319583Z","shell.execute_reply":"2025-10-30T15:04:39.431771Z"},"papermill":{"duration":47.957057,"end_time":"2025-10-29T22:26:38.280910","exception":false,"start_time":"2025-10-29T22:25:50.323853","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"41dc21ff-2bfd-439a-a7b6-269f0a8cac26","cell_type":"markdown","source":"## Load and Prepare the Data","metadata":{}},{"id":"e7b93e46","cell_type":"code","source":"# =========================\n# 1) Load data & pivot wide\n# =========================\ntrain = pd.read_csv(COMP_DIR / \"train.csv\")\ntest  = pd.read_csv(COMP_DIR / \"test.csv\")\nprint(train.shape, test.shape)\n\n# Extract image_id (basename without extension) for grouping and for sample_id construction\ndef path_to_id(p):\n    return Path(p).stem\n\ntrain[\"image_id\"] = train[\"image_path\"].apply(path_to_id)\ntest[\"image_id\"]  = test[\"image_path\"].apply(path_to_id)\n\n# Wide targets per image\npivot_targets = train.pivot_table(index=\"image_id\", columns=\"target_name\", values=\"target\", aggfunc=\"first\").reset_index()\n# Grab representative per-image meta (same across 5 target rows)\nmeta_cols = [\"image_path\",\"Sampling_Date\",\"State\",\"Species\",\"Pre_GSHH_NDVI\",\"Height_Ave_cm\"]\nmeta_per_img = train.drop_duplicates(subset=[\"image_id\"])[[\"image_id\"] + meta_cols]\n\ndf = pivot_targets.merge(meta_per_img, on=\"image_id\", how=\"left\")\nassert all(t in df.columns for t in TARGETS)\n\nprint(\"Wide train shape:\", df.shape)\ndf.head()\n","metadata":{"execution":{"iopub.status.busy":"2025-10-30T15:04:39.434157Z","iopub.execute_input":"2025-10-30T15:04:39.434454Z","iopub.status.idle":"2025-10-30T15:04:39.530640Z","shell.execute_reply.started":"2025-10-30T15:04:39.434436Z","shell.execute_reply":"2025-10-30T15:04:39.529952Z"},"papermill":{"duration":0.341261,"end_time":"2025-10-29T22:26:38.625072","exception":false,"start_time":"2025-10-29T22:26:38.283811","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"40d78df7-31dc-4ab6-a5b3-46b238d80825","cell_type":"markdown","source":"## Create Meta Features","metadata":{}},{"id":"a273bb8e","cell_type":"code","source":"# =========================\n# 2) Meta preprocessing\n# =========================\n# We’ll one-hot 'State' and the Top-N frequent categories of 'Species' (rest -> 'Other').\ndef topn_ohe_fit(series, top_n):\n    vc = series.value_counts()\n    cats = list(vc.head(top_n).index)\n    return cats\n\ndef topn_ohe_transform(series, cats, prefix):\n    out = pd.DataFrame({f\"{prefix}_{c}\": (series == c).astype(int) for c in cats})\n    if not out.shape[1]:\n        return pd.DataFrame(index=series.index)\n    # 'Other'\n    out[f\"{prefix}_Other\"] = (~series.isin(cats)).astype(int)\n    return out\n\nstate_cats = topn_ohe_fit(df[\"State\"].fillna(\"Unknown\"), top_n=10)\nspecies_cats = topn_ohe_fit(df[\"Species\"].fillna(\"Unknown\"), top_n=META_OHE_TOP_N)\n\nstate_ohe   = topn_ohe_transform(df[\"State\"].fillna(\"Unknown\"), state_cats, \"State\")\nspecies_ohe = topn_ohe_transform(df[\"Species\"].fillna(\"Unknown\"), species_cats, \"Species\")\n\nmeta_num = df[[\"Pre_GSHH_NDVI\",\"Height_Ave_cm\"]].copy()\nmeta_num = meta_num.fillna(meta_num.median())\n\nMETA_FEATS = pd.concat([meta_num, state_ohe, species_ohe], axis=1).astype(np.float32)\nMETA_DIM = META_FEATS.shape[1] if USE_META else 0\nprint(\"META_DIM:\", META_DIM)\n\n# Save mapping to apply same OHE on test\nmeta_map = {\n    \"state_cats\": state_cats,\n    \"species_cats\": species_cats,\n    \"meta_num_cols\": [\"Pre_GSHH_NDVI\",\"Height_Ave_cm\"]\n}","metadata":{"execution":{"iopub.status.busy":"2025-10-30T15:04:39.531385Z","iopub.execute_input":"2025-10-30T15:04:39.531666Z","iopub.status.idle":"2025-10-30T15:04:39.553662Z","shell.execute_reply.started":"2025-10-30T15:04:39.531647Z","shell.execute_reply":"2025-10-30T15:04:39.552829Z"},"papermill":{"duration":0.023835,"end_time":"2025-10-29T22:26:38.651936","exception":false,"start_time":"2025-10-29T22:26:38.628101","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"79732b97-2729-4ee5-b4db-468721370c7b","cell_type":"markdown","source":"## Dataset and Augmentations\nalbumentations need internet access a pre-dl lib should resolve","metadata":{}},{"id":"19554bb6","cell_type":"code","source":"# =========================\n# 3) Albumentations & Dataset\n# =========================\ntrain_tfms = A.Compose([\n    A.LongestMaxSize(max_size=IMG_SIZE),\n    A.PadIfNeeded(IMG_SIZE, IMG_SIZE, border_mode=cv2.BORDER_REFLECT_101),\n    A.HorizontalFlip(p=0.5),\n    A.VerticalFlip(p=0.2),\n    A.ColorJitter(p=0.2),\n    A.Normalize(),\n    ToTensorV2(),\n])\n\nvalid_tfms = A.Compose([\n    A.LongestMaxSize(max_size=IMG_SIZE),\n    A.PadIfNeeded(IMG_SIZE, IMG_SIZE, border_mode=cv2.BORDER_REFLECT_101),\n    A.CenterCrop(IMG_SIZE, IMG_SIZE, p=1.0),\n    A.Normalize(),\n    ToTensorV2(),\n])\n\nclass PastureDataset(Dataset):\n    def __init__(self, df_img, targets, img_root, tfms, meta=None):\n        self.df = df_img.reset_index(drop=True)\n        self.targets = targets  # np array [N, 5] or None (for test)\n        self.img_root = Path(img_root)\n        self.tfms = tfms\n        self.meta = meta.astype(np.float32) if meta is not None else None\n        self.image_paths = self.df[\"image_path\"].values\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        img_path_rel = self.image_paths[idx]\n        img_fp = self.img_root / img_path_rel\n        img = cv2.imread(str(img_fp))\n        if img is None:\n            img_fp_alt = Path(str(self.img_root)) / \"train\" / Path(img_path_rel).name\n            img = cv2.imread(str(img_fp_alt))\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        if self.tfms:\n            img = self.tfms(image=img)[\"image\"]\n\n        x_meta = None\n        if self.meta is not None:\n            x_meta = torch.from_numpy(self.meta[idx])\n\n        y = None\n        if self.targets is not None:\n            y = torch.tensor(self.targets[idx], dtype=torch.float32)\n\n        return img, x_meta, y","metadata":{"execution":{"iopub.status.busy":"2025-10-30T15:04:39.555450Z","iopub.execute_input":"2025-10-30T15:04:39.555829Z","iopub.status.idle":"2025-10-30T15:04:39.585170Z","shell.execute_reply.started":"2025-10-30T15:04:39.555809Z","shell.execute_reply":"2025-10-30T15:04:39.584440Z"},"papermill":{"duration":0.01892,"end_time":"2025-10-29T22:26:38.673686","exception":false,"start_time":"2025-10-29T22:26:38.654766","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"0c6143ae-1097-47f5-babc-e731397d2489","cell_type":"markdown","source":"## Model Architecture\n- **timm** backbone (EfficientNetV2-S by default) for image feature extraction.\n- small **MLP head** that combines image and meta features.\n- The final layer outputs 5 regression values (one per biomass component).\n\nWe can also experiment with convnextv2, swinv2, or efficientformer.","metadata":{}},{"id":"2a293db3","cell_type":"code","source":"# =========================\n# 4) Model (Enhanced Meta + Multi-Head)\n# =========================\nimport torch\nimport torch.nn as nn\nimport timm\nfrom safetensors.torch import load_file\n\nclass MultiHeadRegressor(nn.Module):\n    def __init__(self, \n                 backbone=\"tf_efficientnetv2_s\", \n                 meta_dim=0,\n                 pretrained=True, \n                 pretrained_path=None):\n        \"\"\"\n        Enhanced model combining:\n          - A shared CNN backbone (e.g., EfficientNetV2)\n          - A stronger MLP for metadata\n          - Separate output heads for each biomass target\n        \"\"\"\n        super().__init__()\n\n        # ---------------------\n        # Backbone\n        # ---------------------\n        self.backbone = timm.create_model(\n            backbone,\n            pretrained=False,   # We'll load manually if path is provided\n            num_classes=0,\n            global_pool='avg'\n        )\n\n        # Optional manual pretrained weights\n        if pretrained and pretrained_path is not None:\n            print(f\"Loading pretrained weights from: {pretrained_path}\")\n            if pretrained_path.endswith(\".safetensors\"):\n                state_dict = load_file(pretrained_path)\n            else:\n                state_dict = torch.load(pretrained_path, map_location=\"cpu\")\n                if \"state_dict\" in state_dict:\n                    state_dict = state_dict[\"state_dict\"]\n            missing, unexpected = self.backbone.load_state_dict(state_dict, strict=False)\n            print(f\"Loaded weights — missing: {len(missing)}, unexpected: {len(unexpected)}\")\n\n        in_dim = self.backbone.num_features\n        self.meta_dim = meta_dim\n\n        # ---------------------\n        # Stronger meta MLP\n        # ---------------------\n        if meta_dim > 0:\n            self.meta_mlp = nn.Sequential(\n                nn.Linear(meta_dim, 128),\n                nn.ReLU(),\n                nn.BatchNorm1d(128),\n                nn.Dropout(0.2),\n                nn.Linear(128, 64),\n                nn.ReLU(),\n                nn.BatchNorm1d(64),\n            )\n            in_dim = in_dim + 64  # concatenate image + meta embeddings\n\n        # ---------------------\n        # Multi-head regression\n        # ---------------------\n        hidden = 256\n        self.heads = nn.ModuleDict({\n            t: nn.Sequential(\n                nn.Linear(in_dim, hidden),\n                nn.ReLU(),\n                nn.Dropout(0.3),\n                nn.Linear(hidden, 1)\n            ) for t in TARGETS\n        })\n\n    def forward(self, x, meta=None):\n        feats = self.backbone(x)\n        if self.meta_dim > 0 and meta is not None:\n            meta_emb = self.meta_mlp(meta)\n            feats = torch.cat([feats, meta_emb], dim=1)\n\n        # Predict each target independently\n        outs = []\n        for t in TARGETS:\n            outs.append(self.heads[t](feats))\n        out = torch.cat(outs, dim=1)  # shape [B, 5]\n        return out\n","metadata":{"execution":{"iopub.status.busy":"2025-10-30T15:04:39.585948Z","iopub.execute_input":"2025-10-30T15:04:39.586195Z","iopub.status.idle":"2025-10-30T15:04:39.597178Z","shell.execute_reply.started":"2025-10-30T15:04:39.586172Z","shell.execute_reply":"2025-10-30T15:04:39.596331Z"},"papermill":{"duration":0.011621,"end_time":"2025-10-29T22:26:38.688248","exception":false,"start_time":"2025-10-29T22:26:38.676627","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"4d36e051-928a-45c8-9960-188f87b1e0db","cell_type":"markdown","source":"## Training Functions and Evaluation Metric","metadata":{}},{"id":"2af67f5c","cell_type":"code","source":"# =========================\n# 5) Metric & training loop\n# =========================\ndef weighted_r2(y_true, y_pred):\n    # y_true, y_pred: numpy arrays [N, 5]\n    r2s = []\n    for i in range(len(TARGETS)):\n        r2s.append(r2_score(y_true[:, i], y_pred[:, i]))\n    r2s = np.array(r2s)\n    return float((r2s * TARGET_WEIGHTS).sum()), r2s\n\ndef train_one_epoch(model, loader, optimizer, scaler, criterion, lam_total_consistency=0.0):\n    model.train()\n    total_loss = 0.0\n    for imgs, metas, ys in loader:\n        imgs = imgs.to(DEVICE)\n        ys = ys.to(DEVICE)\n        metas = metas.to(DEVICE) if metas is not None else None\n\n        optimizer.zero_grad()\n        with torch.cuda.amp.autocast(enabled=True):\n            preds = model(imgs, metas)\n            loss = criterion(preds, ys)\n            # Optional soft constraint: Dry_Total ≈ Dry_Green + Dry_Dead + Dry_Clover\n            if lam_total_consistency > 0:\n                total_est = preds[:, 0] + preds[:, 1] + preds[:, 2]  # order must match TARGETS!\n                loss_cons = nn.functional.l1_loss(total_est, preds[:, 4])\n                loss = loss + lam_total_consistency * loss_cons\n\n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n        total_loss += loss.item() * imgs.size(0)\n    return total_loss / len(loader.dataset)\n\n@torch.no_grad()\ndef validate(model, loader, criterion):\n    model.eval()\n    total_loss = 0.0\n    all_y, all_p = [], []\n    for imgs, metas, ys in loader:\n        imgs = imgs.to(DEVICE)\n        metas = metas.to(DEVICE) if metas is not None else None\n        ys = ys.to(DEVICE)\n        preds = model(imgs, metas)\n        loss = criterion(preds, ys)\n        total_loss += loss.item() * imgs.size(0)\n        all_y.append(ys.cpu().numpy())\n        all_p.append(preds.cpu().numpy())\n    all_y = np.concatenate(all_y)\n    all_p = np.concatenate(all_p)\n    wr2, r2s = weighted_r2(all_y, all_p)\n    return total_loss / len(loader.dataset), wr2, r2s, all_p, all_y","metadata":{"execution":{"iopub.status.busy":"2025-10-30T15:04:39.598020Z","iopub.execute_input":"2025-10-30T15:04:39.598306Z","iopub.status.idle":"2025-10-30T15:04:39.618797Z","shell.execute_reply.started":"2025-10-30T15:04:39.598283Z","shell.execute_reply":"2025-10-30T15:04:39.617909Z"},"papermill":{"duration":0.012655,"end_time":"2025-10-29T22:26:38.716178","exception":false,"start_time":"2025-10-29T22:26:38.703523","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"4a0d4046-0f39-4984-b60a-f3aeaed4ca32","cell_type":"markdown","source":"## Cross-Validation Strategy\n\n**GroupKFold (5 folds)**:\n- Groups are based on image_id to prevent data leakage between multiple target rows from the same image.\n- Each fold is trained and validated independently.\n","metadata":{}},{"id":"c67dae40","cell_type":"code","source":"# =========================\n# 6) Build CV folds\n# =========================\n# Group by image to avoid leakage across the 5 target rows originally\ngroups = df[\"image_id\"].values\ngkf = GroupKFold(n_splits=5)\n\n# Targets matrix Y [N, 5]\nY = df[TARGETS].values.astype(np.float32)\n\n# Compose meta features matrix aligned with df rows\nMETA_MAT = META_FEATS.values if USE_META else None\n\n# Where are the actual image files?\n# train images usually under COMP_DIR/\"train\" or per paths in df[\"image_path\"].\nIMG_ROOT = COMP_DIR  # we will resolve using relative paths like \"train/IDxxx.jpg\"","metadata":{"execution":{"iopub.status.busy":"2025-10-30T15:04:39.619803Z","iopub.execute_input":"2025-10-30T15:04:39.620082Z","iopub.status.idle":"2025-10-30T15:04:39.638618Z","shell.execute_reply.started":"2025-10-30T15:04:39.620052Z","shell.execute_reply":"2025-10-30T15:04:39.637925Z"},"papermill":{"duration":0.009587,"end_time":"2025-10-29T22:26:38.728639","exception":false,"start_time":"2025-10-29T22:26:38.719052","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"facc5c35-8541-4b27-bb5f-edc2a991e2da","cell_type":"markdown","source":"## Model Training Across Folds","metadata":{}},{"id":"f901b2b0","cell_type":"code","source":"# =========================\n# 7) Train CV and out-of-fold predictions\n# =========================\noof_preds = np.zeros_like(Y, dtype=np.float32)\nfold_scores = []\nfor fold, (tr_idx, va_idx) in enumerate(gkf.split(df, groups=groups)):\n    print(f\"\\n===== FOLD {fold} =====\")\n\n    tr_df, va_df = df.iloc[tr_idx], df.iloc[va_idx]\n    tr_Y, va_Y = Y[tr_idx], Y[va_idx]\n    tr_meta = META_MAT[tr_idx] if USE_META else None\n    va_meta = META_MAT[va_idx] if USE_META else None\n\n    tr_ds = PastureDataset(tr_df, tr_Y, IMG_ROOT, train_tfms, tr_meta)\n    va_ds = PastureDataset(va_df, va_Y, IMG_ROOT, valid_tfms, va_meta)\n\n    tr_loader = DataLoader(tr_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=2, pin_memory=True, drop_last=True)\n    va_loader = DataLoader(va_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\n\n    #model = TimmRegressor(backbone=BACKBONE, out_dim=len(TARGETS), meta_dim=(META_DIM if USE_META else 0)).to(DEVICE)\n    #model = TimmRegressor(backbone=BACKBONE,out_dim=len(TARGETS),meta_dim=(META_DIM if USE_META else 0),pretrained=True, pretrained_path=\"/kaggle/input/timm-weights/timm_weights/tf_efficientnetv2_s_21ft1k-d7dafa41.safetensors\").to(DEVICE)\n    model = MultiHeadRegressor(\n        backbone=BACKBONE, \n        meta_dim=(META_DIM if USE_META else 0), \n        pretrained=True,\n        pretrained_path=\"/kaggle/input/timm-weights/timm_weights/tf_efficientnetv2_s_21ft1k-d7dafa41.safetensors\"  # or specify custom weights\n    ).to(DEVICE)\n    optimizer = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=1e-4)\n    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)\n    scaler = torch.cuda.amp.GradScaler(enabled=(DEVICE==\"cuda\"))\n    criterion = nn.MSELoss()\n\n    best_wr2 = -1e9\n    best_state = None\n\n    for epoch in range(1, EPOCHS+1):\n        tr_loss = train_one_epoch(model, tr_loader, optimizer, scaler, criterion, lam_total_consistency=0.02)\n        va_loss, va_wr2, va_r2s, va_p, va_y = validate(model, va_loader, criterion)\n        scheduler.step()\n\n        print(f\"Epoch {epoch:02d} | tr_loss {tr_loss:.4f} | va_loss {va_loss:.4f} | WR2 {va_wr2:.4f} | per-target R2 {np.round(va_r2s,3)}\")\n        if va_wr2 > best_wr2:\n            best_wr2 = va_wr2\n            best_state = {k:v.cpu() for k,v in model.state_dict().items()}\n\n    print(f\"Fold {fold} best WR2: {best_wr2:.4f}\")\n    fold_scores.append(best_wr2)\n\n    # Save OOF predictions with the best model\n    model.load_state_dict({k:v.to(DEVICE) for k,v in best_state.items()})\n    va_loss, va_wr2, va_r2s, va_p, va_y = validate(model, va_loader, criterion)\n    oof_preds[va_idx] = va_p\n\n    # Save fold weights for inference ensembling later (optional)\n    torch.save(best_state, WORK_DIR / f\"model_fold{fold}.pt\")\n    del model; gc.collect(); torch.cuda.empty_cache()\n\ncv_wr2, cv_r2s = weighted_r2(Y, oof_preds)\nprint(\"\\nCV weighted R2:\", cv_wr2, \" | per-target:\", np.round(cv_r2s, 4))\nnp.save(WORK_DIR / \"oof_preds.npy\", oof_preds)","metadata":{"execution":{"iopub.status.busy":"2025-10-30T15:04:39.639526Z","iopub.execute_input":"2025-10-30T15:04:39.639904Z","iopub.status.idle":"2025-10-30T15:15:50.222554Z","shell.execute_reply.started":"2025-10-30T15:04:39.639877Z","shell.execute_reply":"2025-10-30T15:15:50.221724Z"},"papermill":{"duration":614.954787,"end_time":"2025-10-29T22:36:53.686133","exception":false,"start_time":"2025-10-29T22:26:38.731346","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"5f4b3887-1503-4867-ab62-b2d905a8b463","cell_type":"markdown","source":"## Inference and Submission File","metadata":{}},{"id":"b4afddde","cell_type":"code","source":"# =========================\n# 8) Inference on test & submission\n# =========================\n# Build test meta with the same OHE mapping\ndef build_test_meta(test_df, comp_dir):\n    # test.csv has image_path, target_name per row; we need 1 row per image to run the model once per image\n    test_imgs = test_df.drop_duplicates(subset=[\"image_id\"])[[\"image_id\",\"image_path\"]].copy()\n    # We don't have Sampling_Date/State/Species in test.csv according to the prompt; if they exist, keep them\n    # Here we’ll set missing meta to neutral values:\n    meta_num = pd.DataFrame({\n        \"Pre_GSHH_NDVI\": np.full(len(test_imgs), df[\"Pre_GSHH_NDVI\"].median()),\n        \"Height_Ave_cm\": np.full(len(test_imgs), df[\"Height_Ave_cm\"].median()),\n    })\n    state_series = pd.Series([\"Unknown\"] * len(test_imgs))\n    species_series = pd.Series([\"Unknown\"] * len(test_imgs))\n    state_ohe = topn_ohe_transform(state_series, state_cats, \"State\")\n    species_ohe = topn_ohe_transform(species_series, species_cats, \"Species\")\n    meta_mat = pd.concat([meta_num, state_ohe, species_ohe], axis=1).astype(np.float32)\n    return test_imgs, meta_mat\n\ntest_imgs, test_meta = build_test_meta(test, COMP_DIR)\n\n# Dataloader for test images\nclass TestImageDataset(Dataset):\n    def __init__(self, df_img, img_root, tfms, meta=None):\n        self.df = df_img.reset_index(drop=True)\n        self.img_root = Path(img_root)\n        self.tfms = tfms\n        self.meta = meta.astype(np.float32) if meta is not None else None\n        self.image_paths = self.df[\"image_path\"].values\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        img_fp = self.img_root / self.image_paths[idx]\n        img = cv2.imread(str(img_fp))\n        if img is None:\n            img_fp_alt = Path(str(self.img_root)) / \"test\" / Path(self.image_paths[idx]).name\n            img = cv2.imread(str(img_fp_alt))\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        img = self.tfms(image=img)[\"image\"]\n        x_meta = torch.from_numpy(self.meta[idx]) if self.meta is not None else None\n        return img, x_meta\n\ntest_ds = TestImageDataset(test_imgs, COMP_DIR, valid_tfms, test_meta.values if USE_META else None)\ntest_loader = DataLoader(test_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\n\n# Load fold models and ensemble (mean)\nfold_preds = []\nfor fold in range(5):\n    model = MultiHeadRegressor(backbone=BACKBONE, meta_dim=(META_DIM if USE_META else 0)).to(DEVICE)\n    state_dict = torch.load(WORK_DIR / f\"model_fold{fold}.pt\", map_location=DEVICE)\n    model.load_state_dict(state_dict)\n    model.eval()\n    preds_all = []\n    with torch.no_grad():\n        for imgs, metas in test_loader:\n            imgs = imgs.to(DEVICE)\n            metas = metas.to(DEVICE) if USE_META else None\n            p = model(imgs, metas).cpu().numpy()\n            preds_all.append(p)\n    preds_all = np.vstack(preds_all)  # [num_test_images, 5]\n    fold_preds.append(preds_all)\n    del model; gc.collect(); torch.cuda.empty_cache()\n\npreds_mean = np.mean(fold_preds, axis=0)  # [N_test_images, 5]\npreds_mean = np.clip(preds_mean, a_min=0.0, a_max=None)  # biomass can’t be negative\n\n# Map back to long format using test.csv order:\n# test has multiple rows per image (one per target_name). We need sample_id,target accordingly.\nimgid_to_row = {iid:i for i, iid in enumerate(test_imgs[\"image_id\"].values)}\n\npred_rows = []\nfor _, r in test.iterrows():\n    iid = r[\"image_id\"]\n    tname = r[\"target_name\"]\n    col_idx = TARGETS.index(tname)\n    pred = preds_mean[imgid_to_row[iid], col_idx]\n    pred_rows.append((r[\"sample_id\"], float(pred)))\n\nsub_df = pd.DataFrame(pred_rows, columns=[\"sample_id\",\"target\"])\nsub_df.to_csv(\"submission.csv\", index=False)\nsub_df.head(10)\n","metadata":{"execution":{"iopub.status.busy":"2025-10-30T15:17:21.041470Z","iopub.execute_input":"2025-10-30T15:17:21.041766Z","iopub.status.idle":"2025-10-30T15:17:27.191797Z","shell.execute_reply.started":"2025-10-30T15:17:21.041745Z","shell.execute_reply":"2025-10-30T15:17:27.191010Z"},"papermill":{"duration":5.758244,"end_time":"2025-10-29T22:36:59.452009","exception":false,"start_time":"2025-10-29T22:36:53.693765","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"b1cfb92d-c489-496e-b085-6588dab61960","cell_type":"markdown","source":"Thanks for reading made with <3","metadata":{}}]}