{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":112509,"databundleVersionId":14254895,"sourceType":"competition"},{"sourceId":14383543,"sourceType":"datasetVersion","datasetId":9185919},{"sourceId":268942,"sourceType":"modelInstanceVersion","modelInstanceId":230141,"modelId":251887}],"dockerImageVersionId":31236,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# enhanced_biomass_system.py\n# 高级生物量预测系统 - 融合多种SOTA技术\n# 1. 双流架构 (左右图像分离处理)\n# 2. 多模型集成 (Swin Transformer + ConvNeXt + 传统ML)\n# 3. 语义特征工程 (SigLIP 提取草地相关概念特征)\n# 4. 高级后处理 (物理约束 + 优化算法)\n# 5. Kaggle 兼容性优化 (离线模式 + 显存管理)\n\nimport os\nimport gc\nimport math\nimport random\nimport warnings\nimport joblib  # 用于保存和加载 ML 模型\nfrom copy import deepcopy\nimport numpy as np\nimport pandas as pd\nfrom tqdm import tqdm\nfrom PIL import Image\nimport cv2\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.optim.lr_scheduler import CosineAnnealingWarmRestarts\nfrom torch.cuda.amp import autocast, GradScaler\n\nimport timm\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nfrom sklearn.model_selection import GroupKFold\nfrom sklearn.decomposition import PCA\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.cross_decomposition import PLSRegression\nfrom sklearn.mixture import GaussianMixture\nfrom sklearn.base import BaseEstimator, TransformerMixin\nfrom sklearn.ensemble import GradientBoostingRegressor\n\n# 强制禁用 TensorFlow，防止 Transformers 自动加载它导致 CUDA 冲突\nimport os\nos.environ[\"USE_TF\"] = \"0\"\nos.environ[\"TF_CPP_MIN_LOG_LEVEL\"] = \"3\"\n\nfrom transformers import AutoModel, AutoTokenizer, AutoImageProcessor\n\nwarnings.filterwarnings('ignore')\n\n# ====================================================\n# 配置 (Configuration)\n# ====================================================\nclass CFG:\n    DATA_PATH = '/kaggle/input/csiro-biomass'\n    train_path = os.path.join(DATA_PATH, 'train.csv')\n    test_path = os.path.join(DATA_PATH, 'test.csv')\n    TRAIN_IMG_DIR = os.path.join(DATA_PATH, 'train')\n    TEST_IMG_DIR = os.path.join(DATA_PATH, 'test')\n    output_dir = './'\n    \n    # SigLIP 模型路径 (自动搜索列表)\n    # 脚本会自动尝试以下路径，只要添加了其中任意一个数据集即可\n    siglip_paths = [\n        './siglip_model',                                                           # 本地运行路径\n        '/kaggle/input/google-siglip-so400m-patch14-384/transformers/default/1', # 用户指定的路径\n        '/kaggle/input/siglip-so400m-patch14-384',                               # 通用路径\n        '/kaggle/input/siglip-model/siglip-so400m-patch14-384'                     # 另一种常见路径\n    ]\n    siglip_path = siglip_paths[0] # 默认值，会被自动检测覆盖\n    \n    # Swin Transformer 权重路径 (自动搜索列表)\n    # 离线运行时，请在 Kaggle 上添加 'swinv2_tiny_window8_256.pth' 相关数据集\n    swin_paths = [\n        './swinv2_tiny_window8_256.pth',\n        '/kaggle/input/swinv2-tiny-window8-256/swinv2_tiny_window8_256.pth',\n        '/kaggle/input/swin-transformer-v2-weights/swinv2_tiny_window8_256.pth'\n    ]\n    \n    debug = False\n    train = True\n    inference = True\n    \n    # 多模型策略开关\n    use_dual_stream = True      # 使用双流架构 (左右图分开特征提取再融合)\n    use_ensemble = True         # 使用模型集成 (CNN + ML)\n    use_semantic_features = True # 使用 SigLIP 提取语义特征\n    \n    seed = 42\n    \n    # 主模型: Swin Transformer V2 Tiny\n    # 选择 Tiny 版本是为了适应 Kaggle P100 GPU 的 16GB 显存限制\n    # 输入尺寸设置为 256 以平衡性能和显存\n    model_name = 'swinv2_tiny_window8_256' \n    \n    img_size = 256  # 图像分辨率\n    \n    batch_size = 8  # 批次大小\n    accum_iter = 1  # 梯度累积步数 (如果显存不足可增加此值并减小 batch_size)\n    epochs = 15     # 训练轮数\n    lr = 5e-4       # 学习率\n    weight_decay = 1e-4\n    num_workers = 2\n    n_fold = 5      # 交叉验证折数\n    \n    target_cols = ['Dry_Green_g', 'Dry_Dead_g', 'Dry_Clover_g', 'GDM_g', 'Dry_Total_g']\n    target_weights = [0.1, 0.1, 0.1, 0.2, 0.5] # 损失函数权重，强调 Total Biomass\n    \n    # Tiling (切片) 参数 - 用于推理时处理高分辨率细节\n    use_tiling = True\n    tile_size = 256\n    tile_overlap = 32\n    \n    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n\nos.makedirs(CFG.output_dir, exist_ok=True)\n\n# ====================================================\n# 工具函数 (Utils)\n# ====================================================\ndef seed_everything(seed=42):\n    random.seed(seed)\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n\nseed_everything(CFG.seed)\n\ndef post_process_biomass(df_preds: pd.DataFrame) -> pd.DataFrame:\n    \"\"\"\n    物理约束后处理\n    确保预测值满足生物量组成的物理关系:\n    1. GDM (Green Dry Matter) = Green + Clover\n    2. Total = GDM + Dead\n    \"\"\"\n    ordered_cols = [\"Dry_Green_g\", \"Dry_Clover_g\", \"Dry_Dead_g\", \"GDM_g\", \"Dry_Total_g\"]\n    \n    if not all(col in df_preds.columns for col in ordered_cols):\n        return df_preds\n    \n    # 方法: 矩阵投影 (Projection)\n    # 将预测向量投影到满足约束条件的子空间上\n    Y = df_preds[ordered_cols].values.T\n    \n    # 约束矩阵 C (Cx = 0)\n    # C1: Green + Clover - GDM = 0\n    # C2: GDM + Dead - Total = 0\n    C = np.array([\n        [1, 1, 0, -1, 0], \n        [0, 0, 1, 1, -1]\n    ], dtype=float)\n    C_T = C.T\n    \n    try:\n        inv_CCt = np.linalg.inv(C @ C_T)\n    except np.linalg.LinAlgError:\n        inv_CCt = np.linalg.pinv(C @ C_T)\n    \n    # 投影矩阵 P = I - C^T(CC^T)^-1 C\n    P = np.eye(5) - C_T @ inv_CCt @ C\n    Y_reconciled = P @ Y\n    \n    # 确保非负\n    Y_reconciled = Y_reconciled.T.clip(min=0)\n    \n    df_out = df_preds.copy()\n    df_out[ordered_cols] = Y_reconciled\n    \n    # 简单的逻辑修正\n    df_out['GDM_g'] = df_out['Dry_Green_g'] + df_out['Dry_Clover_g']\n    df_out['Dry_Total_g'] = df_out['GDM_g'] + df_out['Dry_Dead_g']\n    \n    return df_out\n\ndef split_image_adaptive(image: np.ndarray, patch_size: int = 256, overlap: int = 32):\n    \"\"\"\n    自适应切片 (Adaptive Tiling)\n    将大图切分成多个 patch，并计算每个 patch 的高斯权重\n    推理时，中心区域的预测权重高于边缘区域\n    \"\"\"\n    h, w, c = image.shape\n    \n    # 如果图像小于 patch_size，进行填充\n    if h < patch_size or w < patch_size:\n        pad_h = max(0, patch_size - h)\n        pad_w = max(0, patch_size - w)\n        image = np.pad(image, ((0, pad_h), (0, pad_w), (0, 0)), mode='reflect')\n        h, w, c = image.shape\n    \n    stride = patch_size - overlap\n    patches = []\n    weights = []\n    \n    center_y, center_x = h // 2, w // 2\n    \n    for y in range(0, h - patch_size + 1, stride):\n        for x in range(0, w - patch_size + 1, stride):\n            patch = image[y:y+patch_size, x:x+patch_size, :]\n            patches.append(patch)\n            \n            # 计算到图像中心的距离 - 中心区域权重更高\n            patch_center_y = y + patch_size // 2\n            patch_center_x = x + patch_size // 2\n            dist = np.sqrt((patch_center_y - center_y)**2 + (patch_center_x - center_x)**2)\n            weight = np.exp(-dist / (h + w) * 4)  # 高斯权重 decay\n            weights.append(weight)\n    \n    # 归一化权重\n    weights = np.array(weights)\n    weights = weights / (weights.sum() + 1e-8)\n    \n    return patches, weights\n\n# ====================================================\n# 语义特征提取器 (SigLIP)\n# ====================================================\ndef get_siglip_model(model_path=None):\n    \"\"\"\n    加载 SigLIP 模型\n    支持自动路径检测和离线加载 (local_files_only=True)\n    \"\"\"\n    paths_to_try = []\n    if model_path:\n        paths_to_try.append(model_path)\n    \n    if hasattr(CFG, 'siglip_paths'):\n        paths_to_try.extend(CFG.siglip_paths)\n    elif hasattr(CFG, 'siglip_path'):\n        paths_to_try.append(CFG.siglip_path)\n        \n    # 去重\n    seen = set()\n    unique_paths = []\n    for p in paths_to_try:\n        if p and p not in seen:\n            unique_paths.append(p)\n            seen.add(p)\n            \n    for path in unique_paths:\n        if not os.path.exists(path):\n            continue\n            \n        try:\n            print(f\"Trying to load SigLIP from: {path}\")\n            # 关键：local_files_only=True 确保不联网\n            model = AutoModel.from_pretrained(path, local_files_only=True)\n            tokenizer = AutoTokenizer.from_pretrained(path, local_files_only=True)\n            processor = AutoImageProcessor.from_pretrained(path, local_files_only=True)\n            print(f\"Successfully loaded SigLIP from: {path}\")\n            return model.eval().to(CFG.device), tokenizer, processor\n        except Exception as e:\n            print(f\"Failed to load from {path}: {e}\")\n            continue\n            \n    print(f\"Warning: Failed to load SigLIP. Semantic features will be skipped.\")\n    return None, None, None\n\ndef extract_semantic_features(model, processor, tokenizer, df, device):\n    \"\"\"\n    批量提取 SigLIP 语义特征\n    \n    原理：\n    1. 定义一系列与生物量相关的文本概念 (如 \"green pasture\", \"dead grass\", \"bare soil\")\n    2. 使用 SigLIP 提取图像的 embedding\n    3. 计算图像 embedding 与 文本概念 embedding 的相似度\n    4. 将这些相似度作为额外的语义特征输入到 ML 模型中\n    \"\"\"\n    if model is None:\n        return None\n\n    print(\"Extracting Semantic Features...\")\n    model.eval()\n    model.to(device)\n    \n    image_paths = df['image_path'].values\n    \n    # 定义概念组\n    concept_groups = {\n        \"bare\": [\"bare soil\", \"dirt ground\", \"sparse vegetation\", \"exposed earth\"],\n        \"sparse\": [\"low density pasture\", \"thin grass\", \"short clipped grass\"],\n        \"medium\": [\"average pasture cover\", \"medium height grass\", \"grazed pasture\"],\n        \"dense\": [\"dense tall pasture\", \"thick grassy volume\", \"high biomass\", \"overgrown vegetation\"],\n        \"green\": [\"lush green vibrant pasture\", \"photosynthesizing leaves\", \"fresh growth\"],\n        \"dead\": [\"dry brown dead grass\", \"yellow straw\", \"senesced material\", \"standing hay\"],\n        \"clover\": [\"white clover\", \"trifolium repens\", \"broadleaf legume\", \"clover flowers\"],\n        \"grass\": [\"ryegrass\", \"blade-like leaves\", \"fescue\", \"grassy sward\"],\n        \"weeds\": [\"broadleaf weeds\", \"thistles\", \"non-pasture vegetation\"]\n    }\n    \n    # 预计算文本概念向量 (只需计算一次)\n    concept_vectors = {}\n    with torch.no_grad():\n        for name, prompts in concept_groups.items():\n            inputs = tokenizer(prompts, padding=\"max_length\", return_tensors=\"pt\").to(device)\n            if hasattr(model, \"get_text_features\"):\n                emb = model.get_text_features(**inputs)\n            else:\n                out = model(**inputs)\n                if hasattr(out, \"pooler_output\"):\n                    emb = out.pooler_output\n                else:\n                    emb = out.last_hidden_state.mean(dim=1)\n            \n            # 归一化并取平均\n            emb = emb / (emb.norm(p=2, dim=-1, keepdim=True) + 1e-10)\n            concept_vectors[name] = emb.mean(dim=0)\n            \n    # 批量处理图像\n    batch_size = 16\n    all_scores = []\n    siglip_size = 384 # SigLIP 默认输入大小\n    \n    for i in tqdm(range(0, len(df), batch_size), desc=\"SigLIP Features\"):\n        batch_paths = image_paths[i:i+batch_size]\n        batch_images = []\n        \n        for path in batch_paths:\n            full_path = os.path.join(CFG.DATA_PATH, path)\n            try:\n                image = Image.open(full_path).convert(\"RGB\")\n                batch_images.append(image)\n            except:\n                batch_images.append(Image.new(\"RGB\", (siglip_size, siglip_size)))\n        \n        try:\n            inputs = processor(images=batch_images, return_tensors=\"pt\").to(device)\n            with torch.no_grad():\n                if hasattr(model, \"get_image_features\"):\n                    img_emb = model.get_image_features(**inputs)\n                else:\n                    out = model(**inputs)\n                    if hasattr(out, \"pooler_output\"):\n                        img_emb = out.pooler_output\n                    else:\n                        img_emb = out.last_hidden_state.mean(dim=1)\n            \n            img_emb = img_emb / (img_emb.norm(p=2, dim=-1, keepdim=True) + 1e-10)\n            \n            # 计算相似度分数\n            batch_scores = {}\n            for name, vec in concept_vectors.items():\n                batch_scores[name] = torch.matmul(img_emb, vec).cpu().numpy()\n                \n            df_batch = pd.DataFrame(batch_scores)\n            \n            # 构造衍生特征\n            df_batch['ratio_greenness'] = df_batch['green'] / (df_batch['green'] + df_batch['dead'] + 1e-6)\n            df_batch['ratio_clover'] = df_batch['clover'] / (df_batch['clover'] + df_batch['grass'] + 1e-6)\n            df_batch['ratio_cover'] = (df_batch['dense'] + df_batch['medium']) / (df_batch['bare'] + df_batch['sparse'] + 1e-6)\n            df_batch['max_density'] = df_batch[['bare', 'sparse', 'medium', 'dense']].max(axis=1)\n            \n            all_scores.append(df_batch.values)\n            \n        except Exception as e:\n            print(f\"Error in batch {i}: {e}\")\n            all_scores.append(np.zeros((len(batch_paths), 13))) # 13 features\n            \n    # 移回 CPU 并清理显存\n    model.cpu()\n    del model\n    torch.cuda.empty_cache()\n    \n    if all_scores:\n        return np.vstack(all_scores)\n    return None\n\n# ====================================================\n# 监督嵌入引擎 (Supervised Embedding Engine)\n# ====================================================\nclass SupervisedEmbeddingEngine(BaseEstimator, TransformerMixin):\n    \"\"\"\n    用于 ML Stacking 的特征转换引擎\n    结合 PCA (无监督), PLS (监督), GMM (分布) 提取强力特征\n    \"\"\"\n    def __init__(self, n_pca=0.98, n_pls=8, n_gmm=5, random_state=42):\n        self.n_pca = n_pca\n        self.n_pls = n_pls\n        self.n_gmm = n_gmm\n        self.random_state = random_state\n        self.scaler = StandardScaler()\n        self.pca = PCA(n_components=n_pca, random_state=random_state)\n        self.pls = PLSRegression(n_components=n_pls, scale=False)\n        self.gmm = GaussianMixture(n_components=n_gmm, covariance_type='diag', random_state=random_state)\n        self.pls_fitted_ = False\n\n    def fit(self, X, y=None):\n        X_scaled = self.scaler.fit_transform(X)\n        self.pca.fit(X_scaled)\n        self.gmm.fit(X_scaled)\n        \n        if y is not None:\n            # 对于多目标，使用均值作为指导\n            if len(y.shape) > 1:\n                 y_proxy = y.mean(axis=1)\n            else:\n                y_proxy = y\n                \n            try:\n                self.pls.fit(X_scaled, y_proxy)\n                self.pls_fitted_ = True\n            except Exception:\n                self.pls_fitted_ = False\n        return self\n\n    def transform(self, X):\n        X_scaled = self.scaler.transform(X)\n        pca_feats = self.pca.transform(X_scaled)\n        gmm_feats = self.gmm.predict_proba(X_scaled)\n        \n        feats = [pca_feats, gmm_feats]\n        \n        if self.pls_fitted_:\n            pls_feats = self.pls.transform(X_scaled)\n            feats.append(pls_feats)\n            \n        return np.hstack(feats)\n\n# ====================================================\n# 数据处理与加载\n# ====================================================\ndef load_and_pivot_data(train_path, test_path):\n    \"\"\"加载原始CSV并转换为宽格式 (Wide Format)\"\"\"\n    train_df = pd.read_csv(train_path)\n    test_df = pd.read_csv(test_path)\n    \n    # 解析 sample_id 得到 image_id 和 target_type\n    if 'sample_id' in train_df.columns:\n        train_df[['image_id', 'target_type']] = train_df['sample_id'].str.split('__', n=1, expand=True)\n    if 'sample_id' in test_df.columns:\n        test_df[['image_id', 'target_type']] = test_df['sample_id'].str.split('__', n=1, expand=True)\n    \n    meta_cols = [c for c in ['image_id', 'image_path', 'Sampling_Date', 'State', 'Species'] if c in train_df.columns]\n    if 'target' not in train_df.columns:\n        raise KeyError(\"train.csv 缺少 target 列，无法训练。\")\n    if 'target_name' not in train_df.columns:\n        raise KeyError(\"train.csv 缺少 target_name 列，无法透视成宽表。\")\n    \n    df_pivot = train_df.pivot_table(index=meta_cols, columns='target_name', values='target').reset_index()\n    df_pivot.columns.name = None\n    \n    test_meta_cols = [c for c in ['image_id', 'image_path', 'Sampling_Date', 'State', 'Species'] if c in test_df.columns]\n    if 'target' in test_df.columns and 'target_name' in test_df.columns:\n        test_pivot = test_df.pivot_table(index=test_meta_cols, columns='target_name', values='target').reset_index()\n        test_pivot.columns.name = None\n    else:\n        if not test_meta_cols:\n            raise KeyError(\"test.csv 缺少 image_id/image_path 等必要列，无法推理。\")\n        test_pivot = test_df[test_meta_cols].drop_duplicates().reset_index(drop=True)\n    \n    if CFG.debug:\n        df_pivot = df_pivot.head(50)\n        test_pivot = test_pivot.head(20)\n    \n    return df_pivot, test_pivot\n\nclass DualStreamDataset(Dataset):\n    \"\"\"\n    双流数据集\n    将全景图分割为左右两部分，分别输入模型的两个分支\n    \"\"\"\n    def __init__(self, df, transform=None, mode='train', return_raw=False):\n        self.df = df\n        self.transform = transform\n        self.mode = mode\n        self.return_raw = return_raw\n        self.image_paths = df['image_path'].values\n        \n        if self.mode == 'train':\n            self.labels = df[CFG.target_cols].values\n    \n    def __len__(self):\n        return len(self.df)\n    \n    def __getitem__(self, idx):\n        img_path = os.path.join(CFG.DATA_PATH, self.image_paths[idx])\n        \n        try:\n            image = cv2.imread(img_path)\n            image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        except:\n            # 容错处理\n            image = np.zeros((1000, 2000, 3), dtype=np.uint8)\n        \n        # 左右分割\n        h, w = image.shape[:2]\n        mid = w // 2\n        left_img = image[:, :mid]\n        right_img = image[:, mid:]\n        \n        if self.return_raw:\n            return left_img, right_img, self.image_paths[idx]\n        \n        if self.transform:\n            left_aug = self.transform(image=left_img)\n            right_aug = self.transform(image=right_img)\n            left_img = left_aug['image']\n            right_img = right_aug['image']\n        \n        if self.mode == 'train':\n            label = torch.tensor(self.labels[idx], dtype=torch.float32)\n            return left_img, right_img, label\n        else:\n            return left_img, right_img\n\ndef get_transforms(data='train'):\n    if data == 'train':\n        return A.Compose([\n            A.RandomResizedCrop(size=(CFG.img_size, CFG.img_size), scale=(0.6, 1.0)),\n            A.HorizontalFlip(p=0.5),\n            A.VerticalFlip(p=0.5),\n            A.RandomRotate90(p=0.5),\n            A.OneOf([\n                A.GaussianBlur(blur_limit=3, p=1.0),\n                A.MedianBlur(blur_limit=3, p=1.0),\n            ], p=0.3),\n            A.OneOf([\n                A.RandomBrightnessContrast(p=1.0),\n                A.HueSaturationValue(hue_shift_limit=10, sat_shift_limit=20, p=1.0),\n                A.RandomGamma(p=1.0),\n            ], p=0.5),\n            A.CoarseDropout(max_holes=8, max_height=32, max_width=32, p=0.3),\n            A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n            ToTensorV2(),\n        ])\n    else:\n        return A.Compose([\n            A.Resize(height=CFG.img_size, width=CFG.img_size),\n            A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n            ToTensorV2(),\n        ])\n\n# ====================================================\n# 模型定义 (CNN)\n# ====================================================\nclass CrossGate(nn.Module):\n    \"\"\"交叉门控机制 - 允许左右图像特征互相交流\"\"\"\n    def __init__(self, dim):\n        super().__init__()\n        self.gate_left = nn.Sequential(nn.Linear(dim, dim), nn.Sigmoid())\n        self.gate_right = nn.Sequential(nn.Linear(dim, dim), nn.Sigmoid())\n    \n    def forward(self, feat_left, feat_right):\n        gate_l = self.gate_left(feat_right)\n        gate_r = self.gate_right(feat_left)\n        return feat_left * gate_l, feat_right * gate_r\n\nclass DualStreamBiomassModel(nn.Module):\n    def __init__(self, model_name=CFG.model_name, pretrained=True, dropout=0.3):\n        super().__init__()\n        \n        # 离线模式支持：尝试寻找本地权重\n        local_weight_path = None\n        if pretrained:\n            for path in CFG.swin_paths:\n                if os.path.exists(path):\n                    print(f\"Found local backbone weights: {path}\")\n                    local_weight_path = path\n                    pretrained = False # 切换为离线加载模式 (timm 不下载)\n                    break\n            \n            if pretrained:\n                # 即使没找到本地权重，如果是在 Kaggle 离线环境，也必须设为 False，否则 timm 会尝试下载并报错\n                import socket\n                try:\n                    # 简单测试网络连接\n                    socket.create_connection((\"www.google.com\", 80), timeout=1)\n                    print(\"No local weights found, downloading from Internet...\")\n                except OSError:\n                    print(\"No local weights found and Offline Mode detected. Initializing with random weights!\")\n                    print(\"WARNING: Model performance will be severely degraded without pretrained weights.\")\n                    pretrained = False\n\n        # 骨干网络 (Shared Weights)\n        # 注意：如果 pretrained=False，则模型随机初始化\n        self.backbone = timm.create_model(model_name, pretrained=pretrained, num_classes=0, global_pool='avg')\n        \n        # 加载本地权重\n        if local_weight_path:\n            try:\n                checkpoint = torch.load(local_weight_path, map_location='cpu')\n                if 'model' in checkpoint:\n                    state_dict = checkpoint['model']\n                elif 'state_dict' in checkpoint:\n                    state_dict = checkpoint['state_dict']\n                else:\n                    state_dict = checkpoint\n                \n                # 【新增】键名映射：Hugging Face -> timm\n                def convert_key_base(key):\n                    # 处理可能的前缀\n                    if key.startswith('backbone.'): key = key[9:]\n                    elif key.startswith('model.'): key = key[6:]\n                    \n                    key = key.replace('embeddings.patch_embeddings.projection.', 'patch_embed.proj.')\n                    key = key.replace('embeddings.norm.', 'patch_embed.norm.')\n                    key = key.replace('encoder.layers.', 'layers.')\n                    key = key.replace('.attention.self.', '.attn.')\n                    key = key.replace('.continuous_position_bias_mlp.', '.cpb_mlp.')\n                    key = key.replace('.attention.output.dense.', '.attn.proj.')\n                    key = key.replace('.layernorm_before.', '.norm1.')\n                    key = key.replace('.layernorm_after.', '.norm2.')\n                    key = key.replace('.intermediate.dense.', '.mlp.fc1.')\n                    key = key.replace('.output.dense.', '.mlp.fc2.')\n                    key = key.replace('layernorm.', 'norm.')\n                    return key\n                \n                # 1. 重命名所有键，并分离 Q/K/V\n                renamed_state_dict = {}\n                qkv_buffer = {} # key_prefix -> {'q': val, 'k': val, 'v': val}\n\n                for k, v in state_dict.items():\n                    new_key = convert_key_base(k)\n                    \n                    # 识别并缓存 Q/K/V Weight\n                    if '.query.weight' in new_key:\n                        prefix = new_key.replace('.query.weight', '.qkv.weight')\n                        if prefix not in qkv_buffer: qkv_buffer[prefix] = {}\n                        qkv_buffer[prefix]['q'] = v\n                        continue\n                    if '.key.weight' in new_key:\n                        prefix = new_key.replace('.key.weight', '.qkv.weight')\n                        if prefix not in qkv_buffer: qkv_buffer[prefix] = {}\n                        qkv_buffer[prefix]['k'] = v\n                        continue\n                    if '.value.weight' in new_key:\n                        prefix = new_key.replace('.value.weight', '.qkv.weight')\n                        if prefix not in qkv_buffer: qkv_buffer[prefix] = {}\n                        qkv_buffer[prefix]['v'] = v\n                        continue\n                    \n                    # 识别并缓存 Q/K/V Bias\n                    if '.query.bias' in new_key:\n                        prefix = new_key.replace('.query.bias', '.qkv.bias')\n                        if prefix not in qkv_buffer: qkv_buffer[prefix] = {}\n                        qkv_buffer[prefix]['q'] = v\n                        continue\n                    if '.key.bias' in new_key:\n                        prefix = new_key.replace('.key.bias', '.qkv.bias')\n                        if prefix not in qkv_buffer: qkv_buffer[prefix] = {}\n                        qkv_buffer[prefix]['k'] = v\n                        continue\n                    if '.value.bias' in new_key:\n                        prefix = new_key.replace('.value.bias', '.qkv.bias')\n                        if prefix not in qkv_buffer: qkv_buffer[prefix] = {}\n                        qkv_buffer[prefix]['v'] = v\n                        continue\n                    \n                    # 【新增】转置下采样层权重 (Hugging Face -> timm)\n                    # HF: (H*W, C) -> timm: (C, H*W) for linear layers sometimes, but here it's about Linear shape\n                    # 错误信息显示：copying a param with shape [384, 768] (HF) -> current model [192, 384] (timm) ?? Wait.\n                    # 让我们看具体数值：\n                    # layers.1.downsample.reduction.weight: \n                    #   HF: [384, 768] (Out=384, In=768) ? No, HF Linear is (Out, In). \n                    #   timm: [192, 384] (Out=192, In=384) ??\n                    # \n                    # 仔细看：swinv2-tiny-window8-256\n                    # Stage 1 (layers.1) downsample:\n                    #   Input dim = 96 (Stage 0) * ? No.\n                    #   Swin Tiny dims: 96, 192, 384, 768.\n                    #   Layer 0: 96\n                    #   Layer 1: 192 (input=96, output=192). Downsample: 96*4 -> 192?\n                    #   Layer 2: 384\n                    #   Layer 3: 768\n                    #\n                    # 报错信息：\n                    # layers.1.downsample.reduction.weight: HF [384, 768] vs timm [192, 384]\n                    # 这意味着 HF 权重的维度是 timm 的两倍？或者层级错位了？\n                    # \n                    # 可能性：我们加载的权重是 Swin Small/Base，而不是 Swin Tiny？\n                    # HF Swin Tiny: embed_dim=96.\n                    # HF Swin Base: embed_dim=128.\n                    #\n                    # 让我们再次检查报错：\n                    # layers.1.downsample.reduction.weight\n                    #   Loaded (HF): [384, 768] -> 这看起来像是 Layer 2 的权重 (384->768) 或者 Base 模型？\n                    #   Model (timm): [192, 384] -> 这是一个标准的 Swin Tiny Layer 1 Downsample (384 input dim after merge? No.)\n                    #   \n                    #   Swin V1/V2 Downsample (Patch Merging):\n                    #   Input: H x W x C\n                    #   Output: H/2 x W/2 x 2C\n                    #   Implementation: concat 4 patches -> Linear(4C, 2C)\n                    #\n                    #   Timm Swin Tiny:\n                    #   Layer 0: C=96\n                    #   Layer 1: Input 96. PatchMerge: 96*4 = 384 -> Linear(384, 192). Weight: [192, 384].\n                    #   (符合 timm 模型期待: [192, 384])\n                    #\n                    #   Loaded Checkpoint (HF): [384, 768]\n                    #   这说明 Linear(768, 384)。这是 768->384。\n                    #   这对应 Layer 2 (192*4=768 -> 384) 的操作。\n                    #   \n                    #   这意味着：HF 的 layers.1 其实对应 timm 的 layers.2 ？\n                    #   或者 HF 的 layers 从 0 开始计数，但包含 PatchEmbed 为 layer 0？\n                    #\n                    #   HF Swin Model Structure:\n                    #   embeddings (PatchEmbed)\n                    #   encoder.layers.0 (SwinStage)\n                    #   encoder.layers.1 (SwinStage)\n                    #   encoder.layers.2 (SwinStage)\n                    #   encoder.layers.3 (SwinStage)\n                    #\n                    #   Timm Swin Model Structure:\n                    #   patch_embed (PatchEmbed)\n                    #   layers.0 (BasicLayer)\n                    #   layers.1 (BasicLayer)\n                    #   layers.2 (BasicLayer)\n                    #   layers.3 (BasicLayer)\n                    #\n                    #   如果在 HF 中，downsample 位于 layer 的**末尾**，而在 timm 中位于**开始**？\n                    #   Timm Swin Transformer V2:\n                    #   layers[i] contains blocks + downsample (optional).\n                    #   downsample is applied **after** blocks in Timm? No, usually before or after depends on implementation.\n                    #   In `timm.models.swin_transformer_v2`:\n                    #   `self.downsample = PatchMerging(...)` if downsample else None.\n                    #   Forward: x = self.downsample(x) ... (Wait, usually downsample is between stages).\n                    #\n                    #   **关键差异**：\n                    #   HF: encoder.layers.i 包含 blocks + downsample (at the end).\n                    #       Layer i downsample transforms dim_i -> dim_{i+1}.\n                    #       So Layer 0 downsample: 96 -> 192. (Weight: 384->192)\n                    #       Layer 1 downsample: 192 -> 384. (Weight: 768->384)\n                    #\n                    #   Timm: layers.i 包含 blocks + downsample.\n                    #       In Timm SwinV2, downsample is at the **end** of the layer?\n                    #       Let's check mismatch again.\n                    #       Mismatch: layers.1.downsample.weight\n                    #       HF (Loaded): [384, 768] (Input 768, Output 384) -> This is 192*4->384. This matches Layer 1 -> Layer 2 transition.\n                    #       Timm (Expected): [192, 384] (Input 384, Output 192) -> This is 96*4->192. This matches Layer 0 -> Layer 1 transition.\n                    #\n                    #   **结论**：\n                    #   HF 的 `layers.1.downsample` 对应 Timm 的 `layers.1.downsample` ?\n                    #   No. \n                    #   HF Layer 0 output is 96. Downsample -> 192.\n                    #   HF Layer 1 output is 192. Downsample -> 384.\n                    #\n                    #   Timm Layer 0 output is 96. Downsample -> 192.\n                    #   Wait, why does Timm expect [192, 384] at layers.1.downsample?\n                    #   Because in Timm, `layers.0` has downsample? \n                    #   Usually Stage 0 (96) -> Stage 1 (192).\n                    #   In Timm, `layers[0]` is Stage 0. `layers[0].downsample` converts 96->192.\n                    #   In HF, `layers[0]` is Stage 0. `layers[0].downsample` converts 96->192.\n                    #\n                    #   Wait, let's look at the mismatch again carefully.\n                    #   `layers.1.downsample...`: HF has [384, 768]. Timm expects [192, 384].\n                    #   HF [384, 768] is clearly larger (Input 768 = 192*4). This belongs to 192->384 transition.\n                    #   Timm [192, 384] is smaller (Input 384 = 96*4). This belongs to 96->192 transition.\n                    #\n                    #   This implies:\n                    #   HF `layers.1.downsample` is the transition 192->384.\n                    #   Timm `layers.1.downsample` is the transition 96->192.\n                    #\n                    #   So, HF `layers.i.downsample` corresponds to Timm `layers.i+1.downsample`? No, indices are shifted?\n                    #   \n                    #   Actually, in many implementations:\n                    #   Stage 0: 96.\n                    #   Stage 1: 192.\n                    #   Stage 2: 384.\n                    #   Stage 3: 768.\n                    #\n                    #   Transitions happen between stages.\n                    #   Timm: `layers[0]` includes blocks(96) + downsample(96->192).\n                    #   Timm: `layers[1]` includes blocks(192) + downsample(192->384).\n                    #\n                    #   Wait, if Timm `layers[1]` expects [192, 384], that means `layers[1]` IS DOING 96->192 transition?\n                    #   That would mean `layers[0]` is just blocks(96) without downsample?\n                    #   Or `layers[0]` downsample is NULL?\n                    #\n                    #   Let's check Timm SwinV2 source code logic (mental model):\n                    #   layers = [BasicLayer(...), BasicLayer(...), ...]\n                    #   If `downsample` is not None, it is applied at the END of the layer (usually).\n                    #   \n                    #   But if Timm expects [192, 384] at `layers.1`, it means `layers.1` has a Linear(384, 192).\n                    #   Input 384 = 96*4. Output 192.\n                    #   So `layers.1` in Timm is taking 96-dim input (merged to 384) and outputting 192.\n                    #   This means `layers.1` is the START of Stage 1 (192 dim).\n                    #   And the downsample is part of `layers.1` (at the beginning)?\n                    #   \n                    #   YES. In Timm Swin V2, downsample is often at the **start** of the stage (except first stage).\n                    #   `layers[0]`: Stage 0 (96). No downsample at start (patch embed did it).\n                    #   `layers[1]`: Stage 1 (192). Has downsample at start (96->192).\n                    #   `layers[2]`: Stage 2 (384). Has downsample at start (192->384).\n                    #\n                    #   In HF:\n                    #   `layers[0]`: Stage 0. Has downsample at END (96->192).\n                    #   `layers[1]`: Stage 1. Has downsample at END (192->384).\n                    #\n                    #   **Mapping Fix**:\n                    #   HF `layers.0.downsample` -> Timm `layers.1.downsample`\n                    #   HF `layers.1.downsample` -> Timm `layers.2.downsample`\n                    #   HF `layers.2.downsample` -> Timm `layers.3.downsample`\n                    #\n                    #   Let's verify the mismatch again with this hypothesis:\n                    #   Mismatch: `layers.1.downsample`\n                    #   Loaded (HF layer 1): [384, 768] (192->384). Matches Timm `layers.2.downsample`.\n                    #   Current Model (Timm layer 1): [192, 384] (96->192). Matches HF `layers.0.downsample`.\n                    #\n                    #   So we are loading HF `layers.1` into Timm `layers.1`, but Timm `layers.1` needs HF `layers.0`'s downsample.\n                    #\n                    #   **Correction Logic**:\n                    #   If key contains `downsample`:\n                    #       If `layers.0.downsample` (HF) -> `layers.1.downsample` (Timm).\n                    #       If `layers.1.downsample` (HF) -> `layers.2.downsample` (Timm).\n                    #       If `layers.2.downsample` (HF) -> `layers.3.downsample` (Timm).\n                    #\n                    #   Wait, does Timm `layers.0` have downsample?\n                    #   Timm `layers.0` corresponds to Stage 0 (96). It typically does NOT have downsample (PatchEmbed handles raw->96).\n                    #   So Timm `layers.0.downsample` does not exist.\n                    #\n                    #   Does HF `layers.3` have downsample?\n                    #   Stage 3 is the last stage. Usually no downsample after it.\n                    #   So HF `layers.3.downsample` should not exist.\n                    #\n                    #   So the mapping is:\n                    #   Timm `layers.1.downsample` <== HF `layers.0.downsample`\n                    #   Timm `layers.2.downsample` <== HF `layers.1.downsample`\n                    #   Timm `layers.3.downsample` <== HF `layers.2.downsample`\n                    \n                    if 'downsample' in new_key:\n                        # Parse layer index\n                        # format: layers.N.downsample...\n                        parts = new_key.split('.')\n                        if parts[0] == 'layers' and parts[1].isdigit():\n                            layer_idx = int(parts[1])\n                            # Shift layer index by +1 for downsample keys\n                            new_layer_idx = layer_idx + 1\n                            parts[1] = str(new_layer_idx)\n                            new_key = '.'.join(parts)\n                    \n                    # 其他键直接保留\n                    if 'head' in new_key or 'classifier' in new_key:\n                        continue\n                    renamed_state_dict[new_key] = v\n                \n                # 2. 合并 Q/K/V 并放入 state_dict\n                for prefix, items in qkv_buffer.items():\n                    if 'q' in items and 'k' in items and 'v' in items:\n                        # 权重合并: cat([q, k, v], dim=0)\n                        qkv_tensor = torch.cat([items['q'], items['k'], items['v']], dim=0)\n                        renamed_state_dict[prefix] = qkv_tensor\n                \n                state_dict = renamed_state_dict\n\n                msg = self.backbone.load_state_dict(state_dict, strict=False)\n                print(f\"Local backbone weights loaded. Missing: {len(msg.missing_keys)}, Unexpected: {len(msg.unexpected_keys)}\")\n            except Exception as e:\n                print(f\"Warning: Failed to load local weights from {local_weight_path}: {e}\")\n\n        in_features = self.backbone.num_features\n        \n        # 交叉门控\n        self.cross_gate = CrossGate(in_features)\n        \n        # 特征融合\n        self.fusion = nn.Sequential(\n            nn.Linear(in_features * 2, in_features),\n            nn.LayerNorm(in_features),\n            nn.ReLU(inplace=True),\n            nn.Dropout(dropout)\n        )\n        \n        # 多头预测 (每个目标一个头，或分组)\n        def make_head():\n            return nn.Sequential(\n                nn.Linear(in_features, 384),\n                nn.LayerNorm(384),\n                nn.ReLU(inplace=True),\n                nn.Dropout(dropout),\n                nn.Linear(384, 128),\n                nn.ReLU(inplace=True),\n                nn.Dropout(dropout * 0.5),\n                nn.Linear(128, 1),\n                nn.Softplus(beta=1.0) # 保证输出非负\n            )\n        \n        self.head_green = make_head()\n        self.head_clover = make_head()\n        self.head_dead = make_head()\n        \n        # 辅助头 (用于 Stack 训练时的辅助监督)\n        self.aux_head = nn.Sequential(\n            nn.Linear(in_features, 256),\n            nn.ReLU(inplace=True),\n            nn.Dropout(dropout),\n            nn.Linear(256, len(CFG.target_cols)),\n            nn.Softplus(beta=1.0)\n        ) if CFG.use_ensemble else None\n    \n    def forward(self, left_img, right_img):\n        feat_left = self.backbone(left_img)\n        feat_right = self.backbone(right_img)\n        \n        feat_left, feat_right = self.cross_gate(feat_left, feat_right)\n        \n        feat_combined = torch.cat([feat_left, feat_right], dim=1)\n        feat_fused = self.fusion(feat_combined)\n        \n        green = self.head_green(feat_fused)\n        clover = self.head_clover(feat_fused)\n        dead = self.head_dead(feat_fused)\n        \n        gdm = green + clover\n        total = gdm + dead\n        \n        preds = torch.cat([green, dead, clover, gdm, total], dim=1)\n        \n        aux_preds = None\n        if self.aux_head is not None and self.training:\n            aux_preds = self.aux_head(feat_fused)\n        \n        return preds, aux_preds\n    \n    def forward_features(self, left_img, right_img):\n        \"\"\"仅提取特征，用于 Tiling 推理\"\"\"\n        feat_left = self.backbone(left_img)\n        feat_right = self.backbone(right_img)\n        return feat_left, feat_right\n    \n    def predict_from_features(self, feat_left, feat_right):\n        \"\"\"从特征预测\"\"\"\n        feat_left, feat_right = self.cross_gate(feat_left, feat_right)\n        feat_combined = torch.cat([feat_left, feat_right], dim=1)\n        feat_fused = self.fusion(feat_combined)\n        \n        green = self.head_green(feat_fused)\n        clover = self.head_clover(feat_fused)\n        dead = self.head_dead(feat_fused)\n        \n        gdm = green + clover\n        total = gdm + dead\n        \n        return torch.cat([green, dead, clover, gdm, total], dim=1)\n\n# ====================================================\n# 损失函数 & 评估指标\n# ====================================================\nclass CombinedLoss(nn.Module):\n    \"\"\"组合损失: 平滑 L1 + 辅助 MSE + 物理约束惩罚\"\"\"\n    def __init__(self, weights, aux_weight=0.3, constraint_weight=0.1):\n        super().__init__()\n        self.weights = torch.tensor(weights).to(CFG.device)\n        self.aux_weight = aux_weight\n        self.constraint_weight = constraint_weight\n        self.smooth_l1 = nn.SmoothL1Loss(reduction='none')\n        self.mse = nn.MSELoss(reduction='none')\n    \n    def forward(self, preds, targets, aux_preds=None):\n        main_loss = (self.smooth_l1(preds, targets) * self.weights).mean()\n        total_loss = main_loss\n        \n        if aux_preds is not None:\n            aux_loss = (self.mse(aux_preds, targets) * self.weights).mean()\n            total_loss += self.aux_weight * aux_loss\n        \n        # 物理约束损失: 惩罚不满足 total = green + dead + clover 的情况\n        green, dead, clover, gdm, total_pred = preds.T\n        constraint_loss = (torch.abs(gdm - (green + clover)) + \n                           torch.abs(total_pred - (green + dead + clover))).mean()\n        \n        total_loss += self.constraint_weight * constraint_loss\n        return total_loss\n\ndef get_weighted_r2_score(y_true, y_pred):\n    y_true = np.array(y_true)\n    y_pred = np.array(y_pred)\n    weights = np.array(CFG.target_weights)\n    \n    W = np.tile(weights, (y_true.shape[0], 1))\n    rss = np.sum(W * (y_true - y_pred)**2)\n    \n    global_mean = np.average(y_true.flatten(), weights=W.flatten())\n    tss = np.sum(W * (y_true - global_mean)**2)\n    \n    return 1 - (rss / (tss + 1e-8))\n\n# ====================================================\n# 训练与推理流程\n# ====================================================\ndef train_fn(train_loader, model, criterion, optimizer, scheduler, device):\n    model.train()\n    scaler = GradScaler()\n    losses = []\n    \n    optimizer.zero_grad()\n    bar = tqdm(train_loader, desc='Train')\n    for step, (left_imgs, right_imgs, labels) in enumerate(bar):\n        left_imgs, right_imgs, labels = left_imgs.to(device), right_imgs.to(device), labels.to(device)\n        \n        with autocast():\n            preds, aux_preds = model(left_imgs, right_imgs)\n            loss = criterion(preds, labels, aux_preds) / CFG.accum_iter\n        \n        scaler.scale(loss).backward()\n        \n        if (step + 1) % CFG.accum_iter == 0:\n            scaler.step(optimizer)\n            scaler.update()\n            optimizer.zero_grad()\n        \n        losses.append(loss.item() * CFG.accum_iter)\n        bar.set_postfix(loss=np.mean(losses))\n    \n    if len(train_loader) % CFG.accum_iter != 0:\n        scaler.step(optimizer)\n        scaler.update()\n        optimizer.zero_grad()\n        \n    if scheduler: scheduler.step()\n    return np.mean(losses)\n\ndef valid_fn(valid_loader, model, criterion, device):\n    model.eval()\n    losses = []\n    preds_all = []\n    labels_all = []\n    \n    bar = tqdm(valid_loader, desc='Valid')\n    with torch.no_grad():\n        for left_imgs, right_imgs, labels in bar:\n            left_imgs, right_imgs, labels = left_imgs.to(device), right_imgs.to(device), labels.to(device)\n            \n            # 兼容模型返回元组 (preds, aux_preds)\n            output = model(left_imgs, right_imgs)\n            if isinstance(output, tuple):\n                preds = output[0]\n            else:\n                preds = output\n                \n            loss = criterion(preds, labels, None)\n            \n            losses.append(loss.item())\n            preds_all.append(preds.cpu().numpy())\n            labels_all.append(labels.cpu().numpy())\n            bar.set_postfix(loss=np.mean(losses))\n    \n    preds_all = np.concatenate(preds_all)\n    labels_all = np.concatenate(labels_all)\n    score = get_weighted_r2_score(labels_all, np.maximum(preds_all, 0))\n    return np.mean(losses), score, preds_all, labels_all\n\ndef inference_with_tiling(model, left_img, right_img, device):\n    \"\"\"Tiling 推理\"\"\"\n    model.eval()\n    \n    # 确保输入是 (H, W, C) 格式的 numpy 数组 (uint8 或 float)\n    # 如果输入是 (C, H, W) (例如来自 ToTensorV2), 则转置\n    if left_img.shape[0] == 3 and left_img.ndim == 3: \n        left_img = np.transpose(left_img, (1, 2, 0))\n        right_img = np.transpose(right_img, (1, 2, 0))\n\n    # 此时 left_img 应该是 (H, W, C)\n    \n    left_patches, left_weights = split_image_adaptive(left_img, CFG.tile_size, CFG.tile_overlap)\n    right_patches, right_weights = split_image_adaptive(right_img, CFG.tile_size, CFG.tile_overlap)\n    \n    transform = get_transforms('valid')\n    # patches 已经是 numpy (H, W, C)\n    # transform 会进行 Normalize 和 ToTensorV2\n    left_tensors = [transform(image=p)['image'] for p in left_patches]\n    right_tensors = [transform(image=p)['image'] for p in right_patches]\n    \n    left_feats, right_feats = [], []\n    batch_size = 8\n    \n    with torch.no_grad():\n        for i in range(0, len(left_tensors), batch_size):\n            # 这里的 stack 需要所有 patch 大小一致 (tile_size)，这是保证的\n            lb = torch.stack(left_tensors[i:i+batch_size]).to(device)\n            rb = torch.stack(right_tensors[i:i+batch_size]).to(device)\n            fl, fr = model.forward_features(lb, rb)\n            left_feats.append(fl)\n            right_feats.append(fr)\n    \n    if not left_feats: # 防御性编程：如果没有 patch (图片太小?)\n         return np.zeros(5)\n\n    left_feats = torch.cat(left_feats, dim=0)\n    right_feats = torch.cat(right_feats, dim=0)\n    \n    # 加权聚合\n    # 确保权重为 float32，避免 Double vs Float 错误\n    weights_tensor_left = torch.tensor(left_weights, device=device, dtype=torch.float32).unsqueeze(1)\n    weights_tensor_right = torch.tensor(right_weights, device=device, dtype=torch.float32).unsqueeze(1)\n    \n    feat_left_avg = (left_feats * weights_tensor_left).sum(dim=0, keepdim=True)\n    feat_right_avg = (right_feats * weights_tensor_right).sum(dim=0, keepdim=True)\n    \n    return model.predict_from_features(feat_left_avg, feat_right_avg).detach().cpu().numpy()\n\ndef inference_fn(model, test_dataset, device, use_tiling=True):\n    model.eval()\n    preds = []\n    \n    # 如果使用 Tiling，我们需要处理原始分辨率图像 (大小不一)，不能使用默认 collate stack\n    if use_tiling:\n        def collate_fn(batch):\n            return batch[0] # batch_size=1, 直接返回 (left, right) 元组\n        loader = DataLoader(test_dataset, batch_size=1, shuffle=False, num_workers=CFG.num_workers, collate_fn=collate_fn)\n    else:\n        loader = DataLoader(test_dataset, batch_size=1, shuffle=False, num_workers=CFG.num_workers)\n    \n    for batch in tqdm(loader, desc='Inference'):\n        if use_tiling:\n            # batch 是 (left_img, right_img) 的 numpy 数组 (H, W, C)\n            # 注意：DualStreamDataset(return_raw=False, transform=None) 会返回 numpy 数组\n            l_img, r_img = batch\n            preds.append(inference_with_tiling(model, l_img, r_img, device))\n        else:\n            if isinstance(batch, (tuple, list)) and len(batch) == 3:\n                l_imgs, r_imgs, _ = batch\n            else:\n                l_imgs, r_imgs = batch\n            l_imgs, r_imgs = l_imgs.to(device), r_imgs.to(device)\n            with torch.no_grad():\n                # 兼容模型返回元组\n                output = model(l_imgs, r_imgs)\n                if isinstance(output, tuple):\n                    pred = output[0]\n                else:\n                    pred = output\n                preds.append(pred.cpu().numpy())\n    \n    return np.concatenate(preds)\n\n# ====================================================\n# 主程序 (Main)\n# ====================================================\ndef main():\n    print(\"Starting Biomass Prediction Pipeline...\")\n    df_train, df_test = load_and_pivot_data(CFG.train_path, CFG.test_path)\n    \n    # 1. 特征工程: SigLIP 语义特征提取 (一次性提取以节省显存)\n    # --------------------------------------------------------\n    siglip_model, siglip_tokenizer, siglip_processor = get_siglip_model()\n    \n    sem_feats_train = None\n    sem_feats_test = None\n    \n    if siglip_model is not None:\n        if CFG.train:\n            sem_feats_train = extract_semantic_features(siglip_model, siglip_processor, siglip_tokenizer, df_train, CFG.device)\n        if CFG.inference:\n            sem_feats_test = extract_semantic_features(siglip_model, siglip_processor, siglip_tokenizer, df_test, CFG.device)\n            \n        # 释放 SigLIP 显存\n        del siglip_model, siglip_tokenizer, siglip_processor\n        torch.cuda.empty_cache()\n        gc.collect()\n        print(\"SigLIP features extracted and model memory released.\")\n    else:\n        print(\"Skipping Semantic Features (Model not found).\")\n\n    # 2. 训练阶段 (CNN + ML Ensemble)\n    # --------------------------------------------------------\n    if CFG.train:\n        gkf = GroupKFold(n_splits=CFG.n_fold)\n        oof_preds = np.zeros((len(df_train), 5))\n        \n        for fold, (train_idx, val_idx) in enumerate(gkf.split(df_train, df_train[CFG.target_cols], df_train['Sampling_Date'])):\n            print(f\"\\n{'='*20} Fold {fold+1}/{CFG.n_fold} {'='*20}\")\n            \n            # 准备数据\n            train_ds = DualStreamDataset(df_train.iloc[train_idx], transform=get_transforms('train'), mode='train')\n            valid_ds = DualStreamDataset(df_train.iloc[val_idx], transform=get_transforms('valid'), mode='train')\n            \n            train_loader = DataLoader(train_ds, batch_size=CFG.batch_size, shuffle=True, num_workers=CFG.num_workers, drop_last=True)\n            valid_loader = DataLoader(valid_ds, batch_size=CFG.batch_size, shuffle=False, num_workers=CFG.num_workers)\n            \n            # 训练 CNN\n            model = DualStreamBiomassModel(CFG.model_name, pretrained=True).to(CFG.device)\n            optimizer = optim.AdamW(model.parameters(), lr=CFG.lr, weight_decay=CFG.weight_decay)\n            scheduler = CosineAnnealingWarmRestarts(optimizer, T_0=CFG.epochs, T_mult=1, eta_min=1e-6)\n            criterion = CombinedLoss(CFG.target_weights)\n            \n            best_score = -np.inf\n            best_model_path = os.path.join(CFG.output_dir, f'model_fold{fold}.pth')\n            \n            for epoch in range(CFG.epochs):\n                train_loss = train_fn(train_loader, model, criterion, optimizer, scheduler, CFG.device)\n                val_loss, val_score, _, _ = valid_fn(valid_loader, model, criterion, CFG.device)\n                print(f\"Epoch {epoch+1}: Train Loss {train_loss:.4f}, Val Score {val_score:.4f}\")\n                \n                if val_score > best_score:\n                    best_score = val_score\n                    torch.save(model.state_dict(), best_model_path)\n            \n            # 加载最佳 CNN\n            model.load_state_dict(torch.load(best_model_path))\n            \n            # 训练 ML Stacking (如果可用)\n            _, _, val_cnn_preds, _ = valid_fn(valid_loader, model, criterion, CFG.device)\n            \n            if CFG.use_ensemble and sem_feats_train is not None:\n                print(\"Training ML Ensemble (Stacking)...\")\n                X_sem_tr = sem_feats_train[train_idx]\n                X_sem_val = sem_feats_train[val_idx]\n                y_tr = df_train.iloc[train_idx][CFG.target_cols].values\n                \n                ml_preds_val = np.zeros_like(val_cnn_preds)\n                ml_models = []\n                \n                for i, col in enumerate(CFG.target_cols):\n                    # Engine: PCA/PLS/GMM 特征变换\n                    engine = SupervisedEmbeddingEngine(n_pca=0.98, n_pls=8, n_gmm=5)\n                    # GBDT: 强力回归器\n                    gbdt = GradientBoostingRegressor(n_estimators=100, learning_rate=0.05, max_depth=5, random_state=CFG.seed)\n                    \n                    try:\n                        X_tr_eng = engine.fit_transform(X_sem_tr, y_tr[:, i])\n                        X_val_eng = engine.transform(X_sem_val)\n                        gbdt.fit(X_tr_eng, y_tr[:, i])\n                        ml_preds_val[:, i] = gbdt.predict(X_val_eng)\n                    except Exception as e:\n                        print(f\"ML Error {col}: {e}\")\n                        ml_preds_val[:, i] = val_cnn_preds[:, i] # Fallback\n                        \n                    ml_models.append((engine, gbdt))\n                \n                # 保存 ML 模型\n                joblib.dump(ml_models, os.path.join(CFG.output_dir, f'ml_models_fold{fold}.pkl'))\n                \n                # 融合预测 (0.5 CNN + 0.5 ML)\n                final_preds = 0.5 * val_cnn_preds + 0.5 * ml_preds_val\n                oof_preds[val_idx] = final_preds\n            else:\n                oof_preds[val_idx] = val_cnn_preds\n            \n            del model, optimizer, scheduler, train_loader, valid_loader\n            torch.cuda.empty_cache()\n            gc.collect()\n\n        # 保存 OOF 结果\n        df_oof = df_train.copy()\n        df_oof[CFG.target_cols] = oof_preds\n        df_oof = post_process_biomass(df_oof)\n        df_oof.to_csv(os.path.join(CFG.output_dir, 'oof.csv'), index=False)\n        print(f\"OOF Score: {get_weighted_r2_score(df_train[CFG.target_cols].values, oof_preds):.4f}\")\n\n    # 3. 推理阶段 (Inference)\n    # --------------------------------------------------------\n    if CFG.inference:\n        print(\"Starting Inference...\")\n        \n        # 如果使用 Tiling，我们希望输入原始分辨率图像，不进行 Resize 和 Normalize\n        # 预处理将在 tiling 后的 patches 上进行\n        if CFG.use_tiling:\n            test_transform = None\n            print(\"Using Tiling: Loading raw images (no resize/normalize)\")\n        else:\n            test_transform = get_transforms('valid')\n            \n        test_ds = DualStreamDataset(df_test, transform=test_transform, mode='test')\n        fold_preds = []\n        \n        for fold in range(CFG.n_fold):\n            # CNN 推理\n            model_path = os.path.join(CFG.output_dir, f'model_fold{fold}.pth')\n            if not os.path.exists(model_path): continue\n            \n            model = DualStreamBiomassModel(CFG.model_name, pretrained=False).to(CFG.device)\n            model.load_state_dict(torch.load(model_path, map_location=CFG.device))\n            \n            cnn_pred = inference_fn(model, test_ds, CFG.device, use_tiling=CFG.use_tiling)\n            \n            # ML Ensemble 推理\n            ml_path = os.path.join(CFG.output_dir, f'ml_models_fold{fold}.pkl')\n            if CFG.use_ensemble and sem_feats_test is not None and os.path.exists(ml_path):\n                ml_models = joblib.load(ml_path)\n                ml_pred = np.zeros_like(cnn_pred)\n                \n                for i in range(len(CFG.target_cols)):\n                    engine, gbdt = ml_models[i]\n                    X_test_eng = engine.transform(sem_feats_test)\n                    ml_pred[:, i] = gbdt.predict(X_test_eng)\n                \n                # 融合\n                pred = 0.5 * cnn_pred + 0.5 * ml_pred\n            else:\n                pred = cnn_pred\n                \n            fold_preds.append(pred)\n            del model\n            torch.cuda.empty_cache()\n            gc.collect()\n        \n        # 平均并生成提交\n        avg_preds = np.mean(fold_preds, axis=0)\n        df_sub = df_test.copy()\n        df_sub[CFG.target_cols] = avg_preds\n        df_sub = post_process_biomass(df_sub)\n        \n        # 格式化\n        sub_rows = []\n        for i, row in df_sub.iterrows():\n            img_id = row['image_id']\n            for col in CFG.target_cols:\n                sub_rows.append({\n                    'sample_id': f\"{img_id}__{col}\",\n                    'target': row[col]\n                })\n        \n        submission = pd.DataFrame(sub_rows)\n        submission.to_csv('submission.csv', index=False)\n        print(\"Submission saved to submission.csv\")\n\nif __name__ == '__main__':\n    main()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-04T05:58:52.646984Z","iopub.execute_input":"2026-01-04T05:58:52.647832Z","iopub.status.idle":"2026-01-04T06:17:56.202598Z","shell.execute_reply.started":"2026-01-04T05:58:52.647793Z","shell.execute_reply":"2026-01-04T06:17:56.201909Z"}},"outputs":[],"execution_count":null}]}