{"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"},{"sourceId":14164178,"sourceType":"datasetVersion","datasetId":9025870},{"sourceId":14255588,"sourceType":"datasetVersion","datasetId":9095970}],"dockerImageVersionId":31193,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"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-12-24T08:50:51.252685Z","iopub.execute_input":"2025-12-24T08:50:51.253039Z","iopub.status.idle":"2025-12-24T08:50:51.604604Z","shell.execute_reply.started":"2025-12-24T08:50:51.253012Z","shell.execute_reply":"2025-12-24T08:50:51.603945Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =========================================================================================\n# 🌿 CSIRO Biomass - V20.0 PRODUCTION FIX\n# Fixes: Path Duplication, Missing Metadata in Test, Micro-Dataset Tuning\n# =========================================================================================\n\nimport os\nimport math\nimport gc\nimport random\nimport copy\nimport warnings\nfrom typing import List, Dict, Tuple, Optional\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image, ImageOps\nimport cv2 \n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.cuda.amp import autocast, GradScaler\nimport torchvision.transforms as T\nimport timm\nfrom sklearn.model_selection import GroupKFold\nfrom sklearn.preprocessing import LabelEncoder\nimport xgboost as xgb\n\nfrom tqdm.auto import tqdm\n\nwarnings.filterwarnings('ignore')\n\n# -------------------- Config --------------------\nclass CFG:\n    seed = 42\n    img_size = 384\n    batch_size = 16 \n    num_workers = 0 \n    \n    # 针对 357 张微样本的训练策略\n    epochs = 40        \n    ema_decay = 0.98   \n    lr = 1e-4\n    backbone = 'convnext_base'\n    log_transform = True\n    \n    # 路径\n    img_dir_train = '/kaggle/input/csiro-biomass/train'\n    img_dir_test = '/kaggle/input/csiro-biomass/test'\n    train_long_csv = '/kaggle/input/csiro-biomass/train.csv'\n    test_meta_csv = '/kaggle/input/csiro-biomass/test.csv'\n    sample_submission = '/kaggle/input/csiro-biomass/sample_submission.csv'\n    exp_dir = '/kaggle/working/exp_v20'\n    \n    # 权重\n    pretrained_model_path = '/kaggle/input/convnext-base-384/convnext_base_384.pth'\n\n    device = 'cuda' if torch.cuda.is_available() else 'cpu'\n    \n    target_cols = ['Dry_Green_g','Dry_Dead_g','Dry_Clover_g','GDM_g','Dry_Total_g']\n    loss_weights = [0.1, 0.1, 0.1, 0.2, 0.5] \n    target_weights = {'Dry_Green_g':0.1,'Dry_Dead_g':0.1,'Dry_Clover_g':0.1,'GDM_g':0.2,'Dry_Total_g':0.5}\n    \n    # 表格特征\n    tabular_num_cols = ['month_sin', 'month_cos', 'Height_Ave_cm', 'Pre_GSHH_NDVI'] \n    tabular_cat_cols = ['State', 'Species']\n    \n    n_folds = 5\n    tta = True\n\nos.makedirs(CFG.exp_dir, exist_ok=True)\n\n# -------------------- Utilities --------------------\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)\n\nseed_everything()\n\ndef long_to_wide(long_df: pd.DataFrame) -> pd.DataFrame:\n    df = long_df.copy()\n    if 'sample_id' not in df.columns or 'target' not in df.columns:\n        return df \n    df[['image_id','tname']] = df['sample_id'].str.rsplit('__', n=1, expand=True)\n    wide = df.pivot(index='image_id', columns='tname', values='target').reset_index()\n    for t in CFG.target_cols:\n        if t not in wide.columns: wide[t] = np.nan\n    return wide[['image_id'] + CFG.target_cols]\n\ndef weighted_r2_strict(y_true: np.ndarray, y_pred: np.ndarray) -> float:\n    n, m = y_true.shape\n    w_vec = np.array([CFG.target_weights[t] for t in CFG.target_cols], dtype=np.float64)\n    w = np.tile(w_vec, n)\n    y_true_f = y_true.reshape(-1)\n    y_pred_f = y_pred.reshape(-1)\n    y_bar_w = np.sum(w * y_true_f) / np.sum(w)\n    ss_res = np.sum(w * (y_true_f - y_pred_f) **2)\n    ss_tot = np.sum(w * (y_true_f - y_bar_w)** 2)\n    return float(1.0 - ss_res / (ss_tot + 1e-6))\n\nclass ImageCleaner:\n    def __init__(self, crop_margin=0.02, bottom_crop=0.10):\n        self.crop_margin = crop_margin\n        self.bottom_crop = bottom_crop\n\n    def process(self, img_rgb):\n        img = self._safe_crop(img_rgb)\n        img = self._remove_date_stamp(img)\n        return img\n\n    def _safe_crop(self, img):\n        h, w = img.shape[:2]\n        y_start = int(h * self.crop_margin)\n        y_end = int(h * (1.0 - self.bottom_crop))\n        x_start = int(w * self.crop_margin)\n        x_end = int(w * (1.0 - self.crop_margin))\n        if y_end <= y_start or x_end <= x_start: return img\n        return img[y_start:y_end, x_start:x_end]\n\n    def _remove_date_stamp(self, img):\n        hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)\n        lower_orange = np.array([5, 150, 150])\n        upper_orange = np.array([25, 255, 255])\n        mask = cv2.inRange(hsv, lower_orange, upper_orange)\n        kernel = np.ones((3,3), np.uint8)\n        mask = cv2.dilate(mask, kernel, iterations=2)\n        if np.sum(mask) > 0:\n            img = cv2.inpaint(img, mask, 3, cv2.INPAINT_TELEA)\n        return img\n\nclass WeightedMSELoss(nn.Module):\n    def __init__(self, weights, device):\n        super().__init__()\n        self.weights = torch.tensor(weights).to(device)\n    def forward(self, input, target):\n        mse = (input - target) ** 2\n        return torch.mean(mse * self.weights)\n\n# -------------------- Tabular Processor --------------------\nclass TabularProcessor:\n    def __init__(self, num_cols: List[str]=None):\n        self.num_cols = num_cols or []\n        self.num_stats = {} \n\n    def fit(self, df: pd.DataFrame):\n        for c in self.num_cols:\n            col = pd.to_numeric(df[c], errors='coerce')\n            mean = float(col.mean(skipna=True)) if not col.dropna().empty else 0.0\n            std = float(col.std(skipna=True)) if not col.dropna().empty else 1.0\n            self.num_stats[c] = {'mean': mean, 'std': std}\n\n    def transform_row(self, row: pd.Series) -> np.ndarray:\n        nums = []\n        for c in self.num_cols:\n            v = row.get(c, np.nan)\n            if np.isnan(v): v = self.num_stats[c]['mean']\n            mean = self.num_stats[c]['mean']\n            std = self.num_stats[c]['std'] if self.num_stats[c]['std']!=0 else 1.0\n            nums.append((v - mean) / std)\n        return np.array(nums, dtype=np.float32)\n\n# -------------------- Dataset --------------------\nclass PaddingResize:\n    def __init__(self, size, fill=0):\n        self.size = size\n        self.fill = fill\n    def __call__(self, img):\n        w, h = img.size\n        ratio = self.size / max(w, h)\n        new_w, new_h = int(w * ratio), int(h * ratio)\n        img = img.resize((new_w, new_h), Image.Resampling.BICUBIC)\n        new_img = Image.new('RGB', (self.size, self.size), (self.fill, self.fill, self.fill))\n        new_img.paste(img, ((self.size - new_w) // 2, (self.size - new_h) // 2))\n        return new_img\n\nclass PastureDataset(Dataset):\n    def __init__(self, df: pd.DataFrame, img_dir: str, tab_processor: TabularProcessor=None, transforms=None, is_test=False):\n        self.df = df.reset_index(drop=True)\n        self.img_dir = img_dir\n        self.tab_processor = tab_processor\n        self.transforms = transforms\n        self.is_test = is_test\n        self.cleaner = ImageCleaner()\n        self.padding_resize = PaddingResize(CFG.img_size)\n        \n        # ✅ [路径自检] 检查第一张图片\n        if len(self.df) > 0:\n            # 无论 CSV 怎么写，我们只取文件名，然后拼接到 img_dir\n            fname = os.path.basename(str(self.df.iloc[0]['image_path']))\n            check_path = os.path.join(self.img_dir, fname)\n            if not os.path.exists(check_path):\n                print(f\"⚠️ WARNING: Dataset check failed at {check_path}\")\n\n    def __len__(self):\n        return len(self.df)\n\n    def load_image(self, path: str):\n        img_cv = cv2.imread(path)\n        if img_cv is None: \n            # 最后的防线：返回灰图\n            return Image.new('RGB', (CFG.img_size, CFG.img_size), (128, 128, 128))\n        img_cv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)\n        img_cv = self.cleaner.process(img_cv)\n        img_pil = Image.fromarray(img_cv)\n        img_pil = self.padding_resize(img_pil)\n        return img_pil\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        \n        # ✅ [路径修复] 无论 CSV 里是 'train/ID.jpg' 还是 'ID.jpg'，只取文件名\n        fname = os.path.basename(str(row['image_path']))\n        full_path = os.path.join(self.img_dir, fname)\n        \n        img = self.load_image(full_path)\n\n        if self.transforms is not None:\n            img = self.transforms(img)\n            \n        tab_num = torch.tensor(0) \n        cat_idxs = torch.tensor(0)\n        \n        if self.tab_processor is not None:\n            nums = self.tab_processor.transform_row(row)\n            tab_num = torch.from_numpy(nums)\n            cat_vals = []\n            for col in CFG.tabular_cat_cols:\n                val = row.get(col, 0)\n                cat_vals.append(int(val))\n            cat_idxs = torch.tensor(cat_vals, dtype=torch.long)\n            \n        if self.is_test:\n            return img, tab_num, cat_idxs, row['image_id']\n        \n        targets = row[CFG.target_cols].values.astype(np.float32)\n        return img, tab_num, cat_idxs, torch.from_numpy(targets)\n\ntrain_transforms = T.Compose([\n    T.RandomHorizontalFlip(p=0.5),\n    T.RandomVerticalFlip(p=0.15),\n    T.ToTensor(),\n    T.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])\n])\n\nvalid_transforms = T.Compose([\n    T.ToTensor(),\n    T.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])\n])\n\n# -------------------- Model --------------------\nclass TabularEncoder(nn.Module):\n    def __init__(self, num_in, cat_cardinalities, embed_dim=8):\n        super().__init__()\n        self.num_mlp = nn.Linear(num_in, 32)\n        self.embeddings = nn.ModuleList([\n            nn.Embedding(card, embed_dim) for card in cat_cardinalities\n        ])\n        total_dim = 32 + len(cat_cardinalities) * embed_dim\n        self.out_mlp = nn.Linear(total_dim, 128)\n\n    def forward(self, x_num, x_cat):\n        x_n = F.relu(self.num_mlp(x_num))\n        x_c_list = []\n        for i, embed_layer in enumerate(self.embeddings):\n            x_c_list.append(embed_layer(x_cat[:, i]))\n        x_c = torch.cat(x_c_list, dim=1)\n        x_all = torch.cat([x_n, x_c], dim=1)\n        return self.out_mlp(x_all)\n\nclass MultimodalModel(nn.Module):\n    def __init__(self, backbone_name=CFG.backbone, pretrained=False, num_tab=0, cat_cards=[], n_targets=5, head_dim=512, dropout=0.3):\n        super().__init__()\n        self.backbone = timm.create_model(backbone_name, pretrained=False, num_classes=0, global_pool='avg')\n        \n        if os.path.exists(CFG.pretrained_model_path):\n            print(f\"Loading local weights: {CFG.pretrained_model_path}\")\n            try:\n                chk = torch.load(CFG.pretrained_model_path, map_location='cpu')\n                sd = chk['model'] if 'model' in chk else chk\n                new_sd = {k.replace('module.', '').replace('backbone.', ''): v for k, v in sd.items()}\n                self.backbone.load_state_dict(new_sd, strict=False)\n            except:\n                if pretrained: self.backbone = timm.create_model(backbone_name, pretrained=True, num_classes=0)\n        elif pretrained:\n             self.backbone = timm.create_model(backbone_name, pretrained=True, num_classes=0)\n\n        feat_dim = self.backbone.num_features\n        self.tab_enc = None\n        if num_tab > 0 and len(cat_cards) > 0:\n            self.tab_enc = TabularEncoder(num_tab, cat_cards)\n            fusion_dim = feat_dim + 128\n        else:\n            fusion_dim = feat_dim\n            \n        self.head = nn.Sequential(\n            nn.Linear(fusion_dim, head_dim),\n            nn.ReLU(),\n            nn.Dropout(dropout),\n            nn.Linear(head_dim, n_targets)\n        )\n\n    def forward(self, img, tab_num=None, tab_cat=None, return_feats=False):\n        f = self.backbone(img)\n        if return_feats: return f\n        \n        if self.tab_enc is not None and tab_num is not None:\n            tabf = self.tab_enc(tab_num, tab_cat)\n            f_fuse = torch.cat([f, tabf], dim=1)\n        else:\n            f_fuse = f\n        return self.head(f_fuse)\n\n# -------------------- EMA & Loops --------------------\nclass ModelEMA:\n    def __init__(self, model, decay=CFG.ema_decay):\n        self.decay = decay\n        self.ema = copy.deepcopy(model)\n        self.ema.to(CFG.device)\n        self.ema.eval()\n        for p in self.ema.parameters(): p.requires_grad_(False)\n\n    def update(self, model):\n        with torch.no_grad():\n            msd = model.state_dict()\n            esd = self.ema.state_dict()\n            for k, v in esd.items():\n                if v.dtype.is_floating_point:\n                    v.copy_(v * self.decay + msd[k].detach() * (1. - self.decay))\n\ndef train_one_epoch(model, loader, optimizer, loss_fn, device, scaler, ema=None):\n    model.train()\n    running_loss = 0.0\n    for imgs, tabs_n, tabs_c, targets in tqdm(loader, leave=False):\n        imgs = imgs.to(device)\n        tabs_n = tabs_n.to(device)\n        tabs_c = tabs_c.to(device)\n        targets = targets.to(device)\n        \n        optimizer.zero_grad()\n        with autocast():\n            preds = model(imgs, tabs_n, tabs_c)\n            loss = loss_fn(preds, targets)\n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n        if ema: ema.update(model)\n        running_loss += loss.item() * imgs.size(0)\n    return running_loss / len(loader.dataset)\n\ndef valid_epoch(model, loader, device):\n    model.eval()\n    preds_list = []\n    with torch.no_grad():\n        for imgs, tabs_n, tabs_c, _ in loader:\n            imgs = imgs.to(device)\n            tabs_n = tabs_n.to(device)\n            tabs_c = tabs_c.to(device)\n            p = model(imgs, tabs_n, tabs_c)\n            preds_list.append(p.cpu().numpy())\n    return np.vstack(preds_list)\n\n# -------------------- Main Runner --------------------\ndef prepare_dataframes():\n    long_train = pd.read_csv(CFG.train_long_csv)\n    wide = long_to_wide(long_train)\n    train_csv_all = pd.read_csv(CFG.train_long_csv)\n    train_csv_all[['image_id','tname']] = train_csv_all['sample_id'].str.rsplit('__', n=1, expand=True)\n    \n    cols_needed = ['image_path', 'site_id', 'Sampling_Date'] + CFG.tabular_num_cols + CFG.tabular_cat_cols\n    existing = [c for c in cols_needed if c in train_csv_all.columns]\n    \n    meta_train = train_csv_all[['image_id'] + existing].drop_duplicates(subset=['image_id'])\n    \n    df = wide.merge(meta_train, on='image_id', how='left')\n    \n    # 日期处理\n    if 'Sampling_Date' in df.columns:\n        df['Sampling_Date'] = pd.to_datetime(df['Sampling_Date'], errors='coerce')\n        df['month'] = df['Sampling_Date'].dt.month.fillna(0)\n        df['month_sin'] = np.sin(2 * np.pi * df['month']/12.0)\n        df['month_cos'] = np.cos(2 * np.pi * df['month']/12.0)\n    \n    for c in CFG.tabular_num_cols:\n        if c not in df.columns: df[c] = 0.0\n        df[c] = df[c].fillna(0.0)\n        \n    encoders = {}\n    cat_cards = []\n    for col in CFG.tabular_cat_cols:\n        if col not in df.columns: df[col] = 'Unknown'\n        df[col] = df[col].fillna('Unknown').astype(str)\n        le = LabelEncoder()\n        df[col] = le.fit_transform(df[col])\n        encoders[col] = le\n        cat_cards.append(len(le.classes_))\n        \n    if 'site_id' not in df.columns: df['site_id'] = np.arange(len(df))\n    return df, encoders, cat_cards\n\ndef run_cv(df, cat_cards):\n    device = CFG.device\n    tab_proc = TabularProcessor(num_cols=CFG.tabular_num_cols)\n    tab_proc.fit(df)\n    \n    gkf = GroupKFold(n_splits=CFG.n_folds)\n    groups = df['site_id'].values\n    oof_preds = np.zeros((len(df), len(CFG.target_cols)))\n    \n    for fold, (tr_idx, val_idx) in enumerate(gkf.split(df, groups=groups)):\n        print(f\"\\n=== Fold {fold} ===\")\n        train_df = df.iloc[tr_idx].reset_index(drop=True)\n        val_df = df.iloc[val_idx].reset_index(drop=True)\n        \n        train_df_tr = train_df.copy()\n        if CFG.log_transform: \n            train_df_tr[CFG.target_cols] = np.log1p(train_df_tr[CFG.target_cols].values)\n            \n        train_ds = PastureDataset(train_df_tr, CFG.img_dir_train, tab_proc, train_transforms)\n        val_ds = PastureDataset(val_df, CFG.img_dir_train, tab_proc, valid_transforms)\n        \n        train_dl = DataLoader(train_ds, batch_size=CFG.batch_size, shuffle=True, num_workers=CFG.num_workers, drop_last=True)\n        val_dl = DataLoader(val_ds, batch_size=CFG.batch_size, shuffle=False, num_workers=CFG.num_workers)\n        \n        model = MultimodalModel(\n            pretrained=False, num_tab=len(CFG.tabular_num_cols), cat_cards=cat_cards, n_targets=len(CFG.target_cols)\n        ).to(device)\n        \n        optimizer = torch.optim.AdamW(model.parameters(), lr=CFG.lr, weight_decay=1e-5)\n        loss_fn = WeightedMSELoss(CFG.loss_weights, device)\n        scaler = GradScaler()\n        ema = ModelEMA(model)\n        \n        best_score = -999\n        best_path = os.path.join(CFG.exp_dir, f'model_fold{fold}.pth')\n        \n        for ep in range(CFG.epochs):\n            train_loss = train_one_epoch(model, train_dl, optimizer, loss_fn, device, scaler, ema)\n            val_p = valid_epoch(ema.ema, val_dl, device)\n            if CFG.log_transform: val_p = np.expm1(val_p)\n            score = weighted_r2_strict(val_df[CFG.target_cols].values, val_p)\n            print(f\"Ep {ep+1} | Loss: {train_loss:.4f} | R2: {score:.4f}\")\n            if score > best_score:\n                best_score = score\n                torch.save(ema.ema.state_dict(), best_path)\n                \n        # Feature Extraction\n        print(\"Extracting features for XGBoost...\")\n        model.load_state_dict(torch.load(best_path))\n        model.eval()\n        \n        def get_feats(loader):\n            fs = []\n            with torch.no_grad():\n                for imgs, _, _, _ in loader:\n                    imgs = imgs.to(device)\n                    f = model(imgs, return_feats=True)\n                    fs.append(f.cpu().numpy())\n            return np.vstack(fs)\n            \n        train_dl_seq = DataLoader(train_ds, batch_size=CFG.batch_size, shuffle=False, num_workers=CFG.num_workers)\n        img_feats_tr = get_feats(train_dl_seq)\n        img_feats_val = get_feats(val_dl)\n        \n        def get_tab_numpy(dframe):\n            nums = []\n            for r_idx in range(len(dframe)):\n                nums.append(tab_proc.transform_row(dframe.iloc[r_idx]))\n            nums = np.vstack(nums)\n            cats = dframe[CFG.tabular_cat_cols].values\n            return np.hstack([nums, cats])\n            \n        tab_tr = get_tab_numpy(train_df)\n        tab_val = get_tab_numpy(val_df)\n        \n        X_train = np.hstack([img_feats_tr, tab_tr])\n        X_val = np.hstack([img_feats_val, tab_val])\n        \n        y_train = train_df[CFG.target_cols].values\n        if CFG.log_transform: y_train = np.log1p(y_train)\n        \n        clf = xgb.XGBRegressor(\n            n_estimators=1000, learning_rate=0.03, max_depth=6,\n            tree_method='hist', device=device, random_state=CFG.seed, n_jobs=-1\n        )\n        clf.fit(X_train, y_train)\n        \n        p_log = clf.predict(X_val)\n        p = np.expm1(p_log) if CFG.log_transform else p_log\n        \n        oof_preds[val_idx] = p\n        clf.save_model(os.path.join(CFG.exp_dir, f'xgb_fold{fold}.json'))\n        \n        del model, ema, clf\n        torch.cuda.empty_cache()\n        gc.collect()\n        \n    print(f\"Overall OOF R2: {weighted_r2_strict(df[CFG.target_cols].values, oof_preds):.4f}\")\n    return tab_proc \n\n# -------------------- Inference & Post-Process --------------------\ndef post_process_biomass(preds):\n    green = preds[:, 0]\n    dead = preds[:, 1]\n    clover = preds[:, 2]\n    gdm = preds[:, 3]\n    total = preds[:, 4]\n    \n    green = np.maximum(0, green)\n    dead = np.maximum(0, dead)\n    clover = np.maximum(0, clover)\n    total = np.maximum(0, total)\n    \n    comp_sum = green + dead + clover\n    scale = total / (comp_sum + 1e-6)\n    \n    green *= scale\n    dead *= scale\n    clover *= scale\n    gdm = green + clover\n    \n    preds[:, 0] = green\n    preds[:, 1] = dead\n    preds[:, 2] = clover\n    preds[:, 3] = gdm\n    preds[:, 4] = total\n    return preds\n\ndef inference(tab_proc, encoders, cat_cards):\n    print(\"\\nStarting Inference...\")\n    test_meta = pd.read_csv(CFG.test_meta_csv)\n    test_meta[['image_id', 'tmp']] = test_meta['sample_id'].str.rsplit('__', n=1, expand=True)\n    \n    # ✅ [修复] 动态构建存在的列，避免 KeyError\n    existing_cols = ['image_id', 'image_path'] + [c for c in CFG.tabular_num_cols if c in test_meta.columns]\n    test_df = test_meta[existing_cols].drop_duplicates(subset=['image_id']).reset_index(drop=True)\n    \n    # ✅ [修复] 补全缺失的 Metadata 列 (填默认值)\n    if 'Sampling_Date' not in test_df.columns:\n        test_df['month_sin'] = 0.0\n        test_df['month_cos'] = 0.0\n    else:\n        test_df['Sampling_Date'] = pd.to_datetime(test_df['Sampling_Date'], errors='coerce')\n        test_df['month'] = test_df['Sampling_Date'].dt.month.fillna(0)\n        test_df['month_sin'] = np.sin(2 * np.pi * test_df['month']/12.0)\n        test_df['month_cos'] = np.cos(2 * np.pi * test_df['month']/12.0)\n        \n    for c in CFG.tabular_num_cols:\n        if c not in test_df.columns: test_df[c] = 0.0\n        test_df[c] = test_df[c].fillna(0.0)\n        \n    for c in CFG.tabular_cat_cols:\n        if c not in test_df.columns: test_df[c] = 'Unknown'\n        test_df[c] = test_df[c].astype(str).map(lambda x: encoders[c].transform([x])[0] if x in encoders[c].classes_ else 0)\n\n    test_ds = PastureDataset(test_df, CFG.img_dir_test, tab_proc, valid_transforms, is_test=True)\n    test_dl = DataLoader(test_ds, batch_size=CFG.batch_size, shuffle=False, num_workers=CFG.num_workers)\n    \n    device = CFG.device\n    fold_preds = []\n    \n    for fold in range(CFG.n_folds):\n        print(f\"Inf Fold {fold}\")\n        model = MultimodalModel(\n            pretrained=False, num_tab=len(CFG.tabular_num_cols), cat_cards=cat_cards, n_targets=len(CFG.target_cols)\n        ).to(device)\n        model.load_state_dict(torch.load(os.path.join(CFG.exp_dir, f'model_fold{fold}.pth')))\n        model.eval()\n        \n        feats = []\n        with torch.no_grad():\n            for imgs, _, _, _ in tqdm(test_dl, leave=False):\n                imgs = imgs.to(device)\n                f = model(imgs, return_feats=True)\n                feats.append(f.cpu().numpy())\n        img_feats = np.vstack(feats)\n        \n        nums = []\n        for i in range(len(test_df)):\n            nums.append(tab_proc.transform_row(test_df.iloc[i]))\n        nums = np.vstack(nums)\n        cats = test_df[CFG.tabular_cat_cols].values\n        \n        X_test = np.hstack([img_feats, nums, cats])\n        \n        clf = xgb.XGBRegressor()\n        clf.load_model(os.path.join(CFG.exp_dir, f'xgb_fold{fold}.json'))\n        \n        p = clf.predict(X_test)\n        if CFG.log_transform: p = np.expm1(p)\n        fold_preds.append(p)\n        \n        del model, clf\n        torch.cuda.empty_cache()\n        \n    avg_preds = np.mean(fold_preds, axis=0)\n    avg_preds = post_process_biomass(avg_preds)\n    \n    sub_rows = []\n    for i, row in test_df.iterrows():\n        iid = row['image_id']\n        for j, col in enumerate(CFG.target_cols):\n            sub_rows.append({'sample_id': f\"{iid}__{col}\", 'target': avg_preds[i, j]})\n            \n    sub = pd.DataFrame(sub_rows)\n    \n    # 强制对齐 Sample Submission (防止 Scoring Error)\n    if os.path.exists(CFG.sample_submission):\n        ss = pd.read_csv(CFG.sample_submission)\n        pred_map = dict(zip(sub['sample_id'], sub['target']))\n        ss['target'] = ss['sample_id'].map(pred_map).fillna(0)\n        ss.to_csv('submission.csv', index=False)\n    else:\n        sub.to_csv('submission.csv', index=False)\n        \n    print(\"✅ Submission Saved!\")\n\nif __name__ == '__main__':\n    df, encoders, cat_cards = prepare_dataframes()\n    tab_proc = run_cv(df, cat_cards)\n    inference(tab_proc, encoders, cat_cards)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-24T08:50:51.605737Z","iopub.execute_input":"2025-12-24T08:50:51.605973Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}