{"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":"nvidiaTeslaT4","dataSources":[{"sourceId":112509,"databundleVersionId":14254895,"sourceType":"competition"}],"dockerImageVersionId":31154,"isInternetEnabled":true,"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},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# -------------------- Imports & Warning Handling --------------------\nimport os\nimport math\nimport gc\nimport random\nfrom pathlib import Path\nfrom typing import List, Dict, Tuple, Optional\n\nimport warnings\nwarnings.filterwarnings('ignore')  # 抑制冗余警告\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\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\n\nfrom tqdm.auto import tqdm\n\n# -------------------- Config (CFG) --------------------\nclass CFG:\n    seed = 42\n    img_size = 384\n    batch_size = 16\n    num_workers = 0  # 关闭多进程加载以避免错误\n    epochs = 12\n    lr = 1e-4\n    backbone = 'convnext_tiny'\n    log_transform = True\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_multimodal_pro_plus'\n    device = 'cuda' if torch.cuda.is_available() else 'cpu'\n    target_cols = ['Dry_Green_g','Dry_Dead_g','Dry_Clover_g','GDM_g','Dry_Total_g']\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    tabular_cols = ['ndvi']  # 数值型特征列\n    n_folds = 5  # 增加折叠数\n    ema_decay = 0.9999\n    tta = True\n\nos.makedirs(CFG.exp_dir, exist_ok=True)\n\n# -------------------- Utilities --------------------\n\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\n\ndef long_to_wide(long_df: pd.DataFrame) -> pd.DataFrame:\n    \"\"\"将长表格式转换为宽表格式（每个图像一行，包含所有目标值）\"\"\"\n    df = long_df.copy()\n    if 'sample_id' not in df.columns or 'target' not in df.columns:\n        raise ValueError('训练集CSV必须包含sample_id和target列')\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:\n            wide[t] = np.nan\n    return wide[['image_id'] + CFG.target_cols]\n\n\ndef weighted_r2_strict(y_true: np.ndarray, y_pred: np.ndarray) -> float:\n    \"\"\"计算题目要求的加权R²\"\"\"\n    n, m = y_true.shape\n    assert m == len(CFG.target_cols)\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)\n\n# -------------------- Tabular preprocessing --------------------\n\nclass TabularProcessor:\n    \"\"\"表格数据预处理：归一化数值特征，编码类别特征\"\"\"\n    def __init__(self, num_cols: List[str]=None, cat_cols: List[str]=None):\n        self.num_cols = num_cols or []\n        self.cat_cols = cat_cols or []\n        self.num_stats = {}  # 存储数值特征的均值和标准差\n        self.cat_maps = {}   # 存储类别特征的映射关系\n\n    def fit(self, df: pd.DataFrame):\n        # 自动检测特征类型（如果未指定）\n        if not self.num_cols and not self.cat_cols:\n            for c in df.columns:\n                if c in ['image_id','image_path'] + CFG.target_cols + ['site_id']:\n                    continue\n                if pd.api.types.is_numeric_dtype(df[c]):\n                    self.num_cols.append(c)\n                else:\n                    self.cat_cols.append(c)\n        # 计算数值特征的统计量\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        # 构建类别特征的映射（0预留为未知类别）\n        for c in self.cat_cols:\n            vals = df[c].fillna('NA').astype(str).unique().tolist()\n            self.cat_maps[c] = {v:i+1 for i,v in enumerate(vals)}\n\n    def transform_row(self, row: pd.Series) -> Tuple[np.ndarray, np.ndarray]:\n        # 处理数值特征\n        nums = []\n        for c in self.num_cols:\n            v = row.get(c, np.nan)\n            try:\n                v = float(v)\n            except Exception:\n                v = np.nan\n            if np.isnan(v):\n                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        # 处理类别特征\n        cats = []\n        for c in self.cat_cols:\n            v = str(row.get(c, 'NA'))\n            idx = self.cat_maps[c].get(v, 0)  # 未知类别用0\n            cats.append(idx)\n        return np.array(nums, dtype=np.float32), np.array(cats, dtype=np.int64)\n\n    def transform_df_numeric(self, df: pd.DataFrame) -> np.ndarray:\n        \"\"\"批量转换数值特征为数组\"\"\"\n        arr = np.zeros((len(df), len(self.num_cols)), dtype=np.float32)\n        for i, (_, row) in enumerate(df.iterrows()):\n            nums, _ = self.transform_row(row)\n            if len(nums)>0:\n                arr[i] = nums\n        return arr\n\n# -------------------- Dataset --------------------\n\nclass PastureDataset(Dataset):\n    \"\"\"牧场数据集：加载图像和表格特征，返回模型输入\"\"\"\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\n    def __len__(self):\n        return len(self.df)\n\n    def load_image(self, path: str):\n        \"\"\"加载图像并转换为RGB格式\"\"\"\n        return Image.open(path).convert('RGB')\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        img_path = row.get('image_path', f\"{row['image_id']}.jpg\")\n        full_path = os.path.join(self.img_dir, img_path)\n        img = self.load_image(full_path)\n        if self.transforms is not None:\n            img = self.transforms(img)\n        # 处理表格特征\n        tab_num = None\n        if self.tab_processor is not None:\n            nums, _ = self.tab_processor.transform_row(row)\n            tab_num = torch.from_numpy(nums)\n        # 测试集返回图像ID，训练集返回目标值\n        if self.is_test:\n            return img, tab_num, row['image_id']\n        targets = row[CFG.target_cols].values.astype(np.float32)\n        return img, tab_num, torch.from_numpy(targets)\n\n# -------------------- Transforms --------------------\n\ntrain_transforms = T.Compose([\n    T.Resize((CFG.img_size, CFG.img_size)),\n    T.RandomHorizontalFlip(p=0.5),\n    T.RandomVerticalFlip(p=0.15),\n    T.RandomApply([T.ColorJitter(0.12,0.12,0.12,0.04)], p=0.6),\n    T.RandomRotation(8),\n    T.ToTensor(),\n    T.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])  # ImageNet标准化\n])\n\nvalid_transforms = T.Compose([\n    T.Resize((CFG.img_size, CFG.img_size)),\n    T.ToTensor(),\n    T.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])\n])\n\n# -------------------- Model --------------------\n\nclass TabularEncoder(nn.Module):\n    \"\"\"表格特征编码器\"\"\"\n    def __init__(self, num_in:int):\n        super().__init__()\n        if num_in > 0:\n            self.net = nn.Sequential(\n                nn.Linear(num_in, 128),\n                nn.ReLU(),\n                nn.BatchNorm1d(128),\n                nn.Dropout(0.1),\n                nn.Linear(128, 128),\n                nn.ReLU()\n            )\n        else:\n            self.net = None  # 无表格特征时不使用\n\n    def forward(self, x):\n        if self.net is None:\n            return None\n        return self.net(x)\n\nclass MultimodalModel(nn.Module):\n    \"\"\"多模态模型：融合图像特征和表格特征\"\"\"\n    def __init__(self, backbone_name=CFG.backbone, pretrained=True, num_tab=1, n_targets=5, head_dim=512, dropout=0.3):\n        super().__init__()\n        # 图像特征提取器（使用预训练backbone）\n        self.backbone = timm.create_model(backbone_name, pretrained=pretrained, num_classes=0, global_pool='avg')\n        feat_dim = self.backbone.num_features  # 图像特征维度\n        # 表格特征编码器\n        self.tab_enc = TabularEncoder(num_tab) if num_tab > 0 else None\n        # 融合特征维度\n        if self.tab_enc is not None:\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=None):\n        f = self.backbone(img)  # 图像特征\n        if self.tab_enc is not None and tab is not None:\n            tabf = self.tab_enc(tab)  # 表格特征\n            f = torch.cat([f, tabf], dim=1)  # 特征融合\n        out = self.head(f)  # 预测输出\n        return out\n\n# -------------------- EMA (修复设备同步问题) --------------------\n\nclass ModelEMA:\n    \"\"\"指数移动平均模型，确保与主模型在同一设备\"\"\"\n    def __init__(self, model: nn.Module, decay=CFG.ema_decay):\n        # 克隆模型并移动到目标设备\n        self.ema = self._clone_model(model).to(CFG.device)\n        self.decay = decay\n        self.num_updates = 0\n        # 冻结EMA模型参数\n        for p in self.ema.parameters():\n            p.requires_grad_(False)\n\n    def _clone_model(self, model):\n        \"\"\"克隆模型并复制权重\"\"\"\n        m = type(model)(\n            backbone_name=CFG.backbone, \n            pretrained=False, \n            num_tab=len(CFG.tabular_cols), \n            n_targets=len(CFG.target_cols)\n        )\n        m.load_state_dict(model.state_dict())\n        return m\n\n    def update(self, model):\n        \"\"\"更新EMA模型权重，确保所有张量在同一设备\"\"\"\n        self.num_updates += 1\n        decay = min(self.decay, (1 + self.num_updates) / (10 + self.num_updates))\n        msd = model.state_dict()  # 主模型权重\n        # 同步权重到EMA模型（强制设备一致）\n        for k, v in self.ema.state_dict().items():\n            if v.dtype.is_floating_point:\n                # 确保主模型权重张量在EMA模型相同设备上\n                v.copy_(v * decay + msd[k].detach().to(CFG.device) * (1. - decay))\n\n    def state_dict(self):\n        return self.ema.state_dict()\n\n# -------------------- Training & Validation --------------------\n\ndef train_one_epoch(model, loader, optimizer, loss_fn, device, scaler: GradScaler, ema: Optional[ModelEMA]=None):\n    \"\"\"训练一个epoch\"\"\"\n    model.train()\n    running_loss = 0.0\n    pbar = tqdm(loader, leave=False)\n    for imgs, tabs, targets in pbar:\n        # 确保所有数据在目标设备上\n        imgs = imgs.to(device, non_blocking=True)\n        tabs = tabs.to(device, non_blocking=True) if tabs is not None else None\n        targets = targets.to(device, non_blocking=True)\n        \n        optimizer.zero_grad()\n        with autocast():  # 混合精度训练\n            preds = model(imgs, tabs)\n            loss = loss_fn(preds, targets)\n        \n        scaler.scale(loss).backward()  # 缩放损失以避免梯度下溢\n        scaler.step(optimizer)\n        scaler.update()\n        \n        if ema is not None:\n            ema.update(model)  # 更新EMA模型\n        \n        running_loss += loss.item() * imgs.size(0)\n        pbar.set_description(f\"tr_loss {running_loss/((pbar.n+1)*CFG.batch_size):.4f}\")\n    \n    return running_loss / len(loader.dataset)\n\n\ndef valid_epoch(model, loader, device):\n    \"\"\"验证模型性能\"\"\"\n    model.eval()\n    preds_list = []\n    targs_list = []\n    with torch.no_grad():  # 关闭梯度计算\n        for imgs, tabs, targets in tqdm(loader, leave=False):\n            imgs = imgs.to(device, non_blocking=True)\n            tabs = tabs.to(device, non_blocking=True) if tabs is not None else None\n            p = model(imgs, tabs).cpu().numpy()  # 预测结果移回CPU\n            preds_list.append(p)\n            targs_list.append(targets.numpy())\n    \n    preds = np.vstack(preds_list)\n    targs = np.vstack(targs_list)\n    score = weighted_r2_strict(targs, preds)  # 计算加权R²\n    return score, preds, targs\n\n# -------------------- Runner (CV) --------------------\n\ndef prepare_dataframes():\n    \"\"\"准备训练数据和元数据\"\"\"\n    # 读取长表训练数据并转换为宽表\n    long_train = pd.read_csv(CFG.train_long_csv)\n    wide = long_to_wide(long_train)\n    \n    # 读取元数据（包含图像路径、NDVI等）\n    meta_df = None\n    if os.path.exists(CFG.test_meta_csv):\n        try:\n            meta_candidate = pd.read_csv(CFG.test_meta_csv)\n            if 'image_id' in meta_candidate.columns and 'image_path' in meta_candidate.columns:\n                meta_df = meta_candidate[['image_id','image_path']].drop_duplicates()\n        except Exception:\n            meta_df = None\n    \n    # 从训练集中提取元数据（图像路径、NDVI、站点ID）\n    train_csv_all = pd.read_csv(CFG.train_long_csv)\n    possible_meta_cols = ['image_path','ndvi','site_id']\n    has_meta_cols = set(possible_meta_cols).issubset(set(train_csv_all.columns))\n    if has_meta_cols:\n        meta_train = train_csv_all[['image_id','image_path','ndvi','site_id']].drop_duplicates()\n    else:\n        # 若无元数据，创建默认值\n        meta_train = wide[['image_id']].copy()\n        meta_train['image_path'] = meta_train['image_id'].astype(str) + '.jpg'\n        meta_train['ndvi'] = 0.0\n        meta_train['site_id'] = np.arange(len(meta_train))\n    \n    # 合并宽表目标值和元数据\n    df = wide.merge(meta_train, on='image_id', how='left')\n    # 确保必要列存在\n    if 'image_path' not in df.columns:\n        df['image_path'] = df['image_id'].astype(str) + '.jpg'\n    if 'ndvi' not in df.columns:\n        df['ndvi'] = 0.0\n    if 'site_id' not in df.columns:\n        df['site_id'] = np.arange(len(df))\n    return df\n\n\ndef run_cv(df: pd.DataFrame):\n    \"\"\"运行K折交叉验证\"\"\"\n    device = CFG.device\n    # 初始化表格处理器\n    tab_proc = TabularProcessor(num_cols=CFG.tabular_cols, cat_cols=[])\n    tab_proc.fit(df)\n\n    # 按站点分组的K折交叉验证（避免数据泄露）\n    groups = df['site_id'].values\n    gkf = GroupKFold(n_splits=CFG.n_folds)\n\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\"--- 第 {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        # 对目标值进行对数转换（改善分布）\n        if CFG.log_transform:\n            train_df_tr = train_df.copy()\n            train_df_tr[CFG.target_cols] = np.log1p(train_df_tr[CFG.target_cols].values)\n        else:\n            train_df_tr = train_df.copy()\n\n        # 创建数据集和数据加载器\n        train_ds = PastureDataset(\n            train_df_tr, CFG.img_dir_train, \n            tab_processor=tab_proc, \n            transforms=train_transforms, \n            is_test=False\n        )\n        val_ds = PastureDataset(\n            val_df, CFG.img_dir_train, \n            tab_processor=tab_proc, \n            transforms=valid_transforms, \n            is_test=False\n        )\n\n        train_loader = DataLoader(\n            train_ds, batch_size=CFG.batch_size, \n            shuffle=True, num_workers=CFG.num_workers, \n            pin_memory=True, drop_last=True\n        )\n        val_loader = DataLoader(\n            val_ds, batch_size=CFG.batch_size, \n            shuffle=False, num_workers=CFG.num_workers, \n            pin_memory=True\n        )\n\n        # 初始化模型、优化器、学习率调度器\n        model = MultimodalModel(\n            backbone_name=CFG.backbone, \n            pretrained=True, \n            num_tab=len(tab_proc.num_cols), \n            n_targets=len(CFG.target_cols)\n        ).to(device)\n        optimizer = torch.optim.AdamW(\n            model.parameters(), \n            lr=CFG.lr, \n            weight_decay=1e-5  # L2正则化\n        )\n        scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(\n            optimizer, T_max=CFG.epochs\n        )\n        scaler = GradScaler()  # 混合精度训练缩放器\n        ema = ModelEMA(model, decay=CFG.ema_decay)  # EMA模型\n\n        loss_fn = nn.MSELoss()  # 均方误差损失\n        best_score = -999\n        best_path = None\n\n        # 训练循环\n        for epoch in range(1, CFG.epochs+1):\n            tr_loss = train_one_epoch(model, train_loader, optimizer, loss_fn, device, scaler, ema=ema)\n            scheduler.step()  # 更新学习率\n\n            # 验证主模型\n            val_score_model, preds_model_trans, _ = valid_epoch(model, val_loader, device)\n            if CFG.log_transform:\n                preds_model = np.expm1(preds_model_trans)  # 逆转对数转换\n            else:\n                preds_model = preds_model_trans\n            val_score_model = weighted_r2_strict(val_df[CFG.target_cols].values.astype(np.float32), preds_model)\n\n            # 验证EMA模型\n            ema.ema.to(device)\n            ema_preds = []\n            with torch.no_grad():\n                for imgs, tabs, targets in val_loader:\n                    imgs = imgs.to(device)\n                    tabs = tabs.to(device) if tabs is not None else None\n                    p = ema.ema(imgs, tabs).cpu().numpy()\n                    ema_preds.append(p)\n            ema_preds = np.vstack(ema_preds)\n            if CFG.log_transform:\n                ema_preds_inv = np.expm1(ema_preds)\n            else:\n                ema_preds_inv = ema_preds\n            ema_score = weighted_r2_strict(val_df[CFG.target_cols].values.astype(np.float32), ema_preds_inv)\n\n            print(f\"Epoch {epoch} | 训练损失: {tr_loss:.4f} | 模型R²: {val_score_model:.5f} | EMA R²: {ema_score:.5f}\")\n\n            # 保存最佳EMA模型\n            if ema_score > best_score:\n                best_score = ema_score\n                best_path = os.path.join(CFG.exp_dir, f'model_fold{fold}_ema.pth')\n                torch.save(ema.state_dict(), best_path)\n\n        print(f\"第 {fold} 折完成，最佳EMA R²: {best_score:.5f}\")\n\n        # 加载最佳模型并生成OOF预测\n        state = torch.load(best_path, map_location=device)\n        inf_model = MultimodalModel(\n            backbone_name=CFG.backbone, \n            pretrained=False, \n            num_tab=len(tab_proc.num_cols), \n            n_targets=len(CFG.target_cols)\n        ).to(device)\n        inf_model.load_state_dict(state)\n        inf_model.eval()\n\n        preds_list = []\n        with torch.no_grad():\n            for imgs, tabs, _ in DataLoader(val_ds, batch_size=CFG.batch_size, shuffle=False, num_workers=CFG.num_workers):\n                imgs = imgs.to(device)\n                tabs = tabs.to(device) if tabs is not None else None\n                p = inf_model(imgs, tabs).cpu().numpy()\n                preds_list.append(p)\n        preds = np.vstack(preds_list)\n        if CFG.log_transform:\n            preds = np.expm1(preds)\n        oof_preds[val_idx] = preds\n\n        # 清理内存\n        del model, inf_model, ema, train_loader, val_loader\n        gc.collect()\n        torch.cuda.empty_cache()\n\n    # 计算整体OOF分数\n    overall = weighted_r2_strict(df[CFG.target_cols].values.astype(np.float32), oof_preds)\n    print(f'所有折叠OOF加权R²: {overall:.5f}')\n    np.save(os.path.join(CFG.exp_dir, 'oof_preds.npy'), oof_preds)\n    return overall\n\n# -------------------- TTA Predict --------------------\n\ndef tta_inference(model_path: str, test_meta_csv: str, out_csv: str):\n    \"\"\"带测试时增强的推理函数\"\"\"\n    device = CFG.device\n    meta = pd.read_csv(test_meta_csv)\n    if 'image_path' not in meta.columns:\n        meta['image_path'] = meta['image_id'].astype(str) + '.jpg'\n    \n    # 初始化表格处理器（使用训练集统计量）\n    tab_proc = TabularProcessor(num_cols=CFG.tabular_cols)\n    train_long = pd.read_csv(CFG.train_long_csv)\n    wide_train = long_to_wide(train_long)\n    if os.path.exists(CFG.train_long_csv):\n        try:\n            train_all = pd.read_csv(CFG.train_long_csv)\n            if set(['image_id','ndvi','site_id']).issubset(set(train_all.columns)):\n                meta_train = train_all[['image_id','ndvi','site_id']].drop_duplicates()\n                wide_train = wide_train.merge(meta_train, on='image_id', how='left')\n        except Exception:\n            pass\n    tab_proc.fit(wide_train)\n\n    # 加载模型\n    model = MultimodalModel(\n        backbone_name=CFG.backbone, \n        pretrained=False, \n        num_tab=len(tab_proc.num_cols), \n        n_targets=len(CFG.target_cols)\n    ).to(device)\n    state = torch.load(model_path, map_location=device)\n    model.load_state_dict(state)\n    model.eval()\n\n    # 创建测试集数据集和加载器\n    ds = PastureDataset(\n        meta, CFG.img_dir_test, \n        tab_processor=tab_proc, \n        transforms=valid_transforms, \n        is_test=True\n    )\n    loader = DataLoader(\n        ds, batch_size=CFG.batch_size, \n        shuffle=False, num_workers=CFG.num_workers\n    )\n\n    # 推理（带TTA）\n    preds_list = []\n    with torch.no_grad():\n        for imgs, tabs, image_ids in tqdm(loader):\n            imgs = imgs.to(device)\n            tabs = tabs.to(device) if tabs is not None else None\n            # 原始图像预测\n            p1 = model(imgs, tabs).cpu().numpy()\n            if CFG.tta:\n                # 水平翻转增强\n                imgs_flipped = torch.flip(imgs, dims=[3])\n                p2 = model(imgs_flipped, tabs).cpu().numpy()\n                p = (p1 + p2) / 2.0  # 平均两个预测\n            else:\n                p = p1\n            preds_list.append(p)\n    \n    preds = np.vstack(preds_list)\n    if CFG.log_transform:\n        preds = np.expm1(preds)  # 逆转对数转换\n\n    # 生成提交文件\n    rows = []\n    for i, r in meta.reset_index(drop=True).iterrows():\n        image_id = r['image_id']\n        for j, t in enumerate(CFG.target_cols):\n            rows.append({'sample_id': f'{image_id}__{t}', 'target': float(preds[i,j])})\n    sub = pd.DataFrame(rows)\n    sub.to_csv(out_csv, index=False)\n    print(f'提交文件已保存至: {out_csv}')\n\n# -------------------- Main --------------------\n\nif __name__ == '__main__':\n    print(f'使用设备: {CFG.device}')\n    df = prepare_dataframes()\n    print(f'数据准备完成，形状: {df.shape}')\n    overall = run_cv(df)\n    print(f'OOF分数: {overall:.5f}')\n\n    # 选择所有折叠的最佳模型进行集成推理\n    model_files = sorted(Path(CFG.exp_dir).glob('model_fold*_ema.pth'))\n    if len(model_files) == 0:\n        print('未找到模型文件，无法进行推理')\n    else:\n        # 使用最后一个折叠的模型进行推理\n        model_path = str(model_files[-1])\n        out_csv = '/kaggle/working/submission.csv'\n        tta_inference(model_path, CFG.test_meta_csv, out_csv)\n        print('完成，提交文件已保存')","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}