{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"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":"gpu","dataSources":[{"sourceId":112509,"databundleVersionId":14254895,"sourceType":"competition"},{"sourceId":14308106,"sourceType":"datasetVersion","datasetId":9127832}],"dockerImageVersionId":31236,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# CSIRO Image2Biomass — Baseline A (Two-Stream, Three-Head)\n\n本Notebook用于在Kaggle 9h限制内训练可提交的Baseline：\n- 两流共享骨干（convnext_tiny），输入为2000×1000图像左右裁切后的两半，缩放至640。\n- 三头回归：预测`Dry_Total_g, GDM_g, Dry_Green_g`；`Dry_Clover_g = max(0, GDM - Green)`，`Dry_Dead_g = max(0, Total - GDM)`。\n- 损失：SmoothL1按(0.5, 0.2, 0.1)加权。\n- 划分：GroupKFold按`Sampling_Date`分组，防止泄漏；同一图像的5行同折。\n- 推理：H/V Flip TTA + 折数平均；生成`submission.csv`。\n\n","metadata":{}},{"cell_type":"code","source":"# %% Run switches (set before running all)\nRUN_A_TRAIN = True       # 训练A版（三头）\nRUN_A_INFER = True       # 用A版进行TTA推理并生成submission\nRUN_OOF_ANALYSIS = True # 训练后再打开以跑OOF与分组R²\n\n# 可选：B版（五输出一致性）与B+Tab（多模态融合，仅训练/OOF用）\nRUN_B_TRAIN = False\nRUN_BTAB_TRAIN = False\n\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% Imports & Config\nfrom pathlib import Path\nimport os\nimport math\nimport random\nimport gc\n\nimport numpy as np\nimport pandas as pd\nfrom tqdm import tqdm\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import DataLoader\nimport timm\nimport sys\nsys.path.insert(0, '/kaggle/input/csiro-biomass-prehandle')\n\nfrom src.metrics import weighted_r2_from_long_df, DEFAULT_WEIGHTS\nfrom src.splits import add_groupkfold_by_date\nfrom src.data import (\n    read_train_long,\n    read_test_long,\n    long_to_wide_targets,\n    select_three_targets,\n    build_train_transforms,\n    build_valid_transforms,\n    TwoStreamTrainDataset,\n)\n\nclass CFG:\n    BASE_PATH = Path('/kaggle/input/csiro-biomass')  # Kaggle dataset input\n    TRAIN_IMG_DIR = 'train'\n    TEST_IMG_DIR = 'test'\n    IMG_SIZE = 640\n    MODEL_NAME = 'convnext_tiny'\n    BATCH_SIZE = 8\n    NUM_WORKERS = 2\n    N_FOLDS = 5\n    EPOCHS_FROZEN = 4\n    EPOCHS_UNFROZEN = 6\n    LR_HEAD = 1e-4\n    LR_BACKBONE = 1e-5\n    WEIGHTS = {\n        'Dry_Total_g': 0.5,\n        'GDM_g': 0.2,\n        'Dry_Green_g': 0.1,\n    }\n    DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n    OUT_DIR = Path('./models')\n    SUBMISSION_FILE = 'submission.csv'\n\nCFG.OUT_DIR.mkdir(parents=True, exist_ok=True)\nprint('Device:', CFG.DEVICE)\n\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% Load data & folds\ntrain_long = read_train_long(CFG.BASE_PATH)\nprint('Train long rows:', len(train_long))\n\n# Assign folds at image level, then merge back to long df\ndf_image, train_long = add_groupkfold_by_date(train_long, n_splits=CFG.N_FOLDS,\n                                              date_col='Sampling_Date', id_col='sample_id')\nprint('Fold distribution (image-level):')\nprint(df_image['fold'].value_counts().sort_index())\n\n# Build wide table and select 3 targets for A-baseline\ntrain_wide = long_to_wide_targets(train_long)\ntrain_three = select_three_targets(train_wide)\nprint('Train wide rows (unique images with 5 targets):', len(train_wide))\nprint('Train three-target rows:', len(train_three))\n\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% Model definition (Two-stream, three-head)\nclass TwoStreamThreeHeadModel(nn.Module):\n    def __init__(self, model_name: str, pretrained: bool = True):\n        super().__init__()\n        weights_path = '/kaggle/input/csiro-biomass-prehandle/src/convnext_tiny_weights/models--timm--convnext_tiny.in12k_ft_in1k/blobs/1abc6ff66688819cab2f7f8b1d9e14f7830c947ad663bd3c1f6681c0d31fcce6'\n        self.backbone = timm.create_model(model_name, pretrained=False, num_classes=0, global_pool='avg')\n        state_dict = torch.load(weights_path, map_location='cuda')\n        # 处理 Hugging Face 格式（如果有 'model' 键则提取）\n        if 'model' in state_dict:\n            state_dict = state_dict['model']\n        self.backbone.load_state_dict(state_dict, strict=False)\n        feat = self.backbone.num_features\n        combined = feat * 2\n        def head():\n            return nn.Sequential(\n                nn.Linear(combined, combined // 2),\n                nn.ReLU(inplace=True),\n                nn.Dropout(0.3),\n                nn.Linear(combined // 2, 1),\n            )\n        self.head_total = head()\n        self.head_gdm = head()\n        self.head_green = head()\n    def forward(self, img_left: torch.Tensor, img_right: torch.Tensor):\n        f_left = self.backbone(img_left)\n        f_right = self.backbone(img_right)\n        x = torch.cat([f_left, f_right], dim=1)\n        total = self.head_total(x)\n        gdm = self.head_gdm(x)\n        green = self.head_green(x)\n        return total.squeeze(1), gdm.squeeze(1), green.squeeze(1)\n\nclass WeightedSmoothL1(nn.Module):\n    def __init__(self, weights: dict):\n        super().__init__()\n        self.w_total = float(weights['Dry_Total_g'])\n        self.w_gdm = float(weights['GDM_g'])\n        self.w_green = float(weights['Dry_Green_g'])\n        self.loss = nn.SmoothL1Loss()\n    def forward(self, pred_total, pred_gdm, pred_green, tgt_total, tgt_gdm, tgt_green):\n        l_total = self.loss(pred_total, tgt_total)\n        l_gdm = self.loss(pred_gdm, tgt_gdm)\n        l_green = self.loss(pred_green, tgt_green)\n        return self.w_total*l_total + self.w_gdm*l_gdm + self.w_green*l_green\n\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% Training utilities\n\ndef set_seed(seed: int = 42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n\n\ndef freeze_backbone(model: TwoStreamThreeHeadModel):\n    for p in model.backbone.parameters():\n        p.requires_grad = False\n\ndef unfreeze_backbone(model: TwoStreamThreeHeadModel):\n    for p in model.backbone.parameters():\n        p.requires_grad = True\n\n\ndef evaluate_fold(model: TwoStreamThreeHeadModel, loader: DataLoader, device: torch.device) -> float:\n    model.eval()\n    preds_total, preds_gdm, preds_green = [], [], []\n    tg_total, tg_gdm, tg_green = [], [], []\n    with torch.no_grad():\n        for left, right, targets in loader:\n            left = left.to(device)\n            right = right.to(device)\n            total, gdm, green = model(left, right)\n            preds_total.append(total.detach().cpu().numpy())\n            preds_gdm.append(gdm.detach().cpu().numpy())\n            preds_green.append(green.detach().cpu().numpy())\n            t = targets.numpy()\n            tg_total.append(t[:, 0])\n            tg_gdm.append(t[:, 1])\n            tg_green.append(t[:, 2])\n    # stack\n    preds_total = np.concatenate(preds_total)\n    preds_gdm = np.concatenate(preds_gdm)\n    preds_green = np.concatenate(preds_green)\n    tg_total = np.concatenate(tg_total)\n    tg_gdm = np.concatenate(tg_gdm)\n    tg_green = np.concatenate(tg_green)\n\n    # derive clover/dead and build long df for R2\n    def build_long(true_total, true_gdm, true_green, pred_total, pred_gdm, pred_green):\n        true_clover = np.maximum(0.0, true_gdm - true_green)\n        true_dead = np.maximum(0.0, true_total - true_gdm)\n        pred_clover = np.maximum(0.0, pred_gdm - pred_green)\n        pred_dead = np.maximum(0.0, pred_total - pred_gdm)\n        # long df\n        data = []\n        names = ['Dry_Green_g', 'Dry_Dead_g', 'Dry_Clover_g', 'GDM_g', 'Dry_Total_g']\n        true_wide = [true_green, true_dead, true_clover, true_gdm, true_total]\n        pred_wide = [pred_green, pred_dead, pred_clover, pred_gdm, pred_total]\n        for n, tv, pv in zip(names, true_wide, pred_wide):\n            for t, p in zip(tv, pv):\n                data.append({'target_name': n, 'target': float(t), 'pred': float(p)})\n        return pd.DataFrame(data)\n\n    df_long = build_long(tg_total, tg_gdm, tg_green, preds_total, preds_gdm, preds_green)\n    score = weighted_r2_from_long_df(df_long)\n    return float(score)\n\n\ndef train_one_fold(fold: int, train_three: pd.DataFrame, df_image: pd.DataFrame) -> str:\n    set_seed(42 + fold)\n    # split indices by fold\n    img_to_fold = df_image.set_index('sample_id')['fold']\n    train_three = train_three.copy()\n    train_three['fold'] = train_three['sample_id'].map(img_to_fold)\n    train_idx = train_three['fold'] != fold\n    valid_idx = train_three['fold'] == fold\n\n    train_df = train_three.loc[train_idx].reset_index(drop=True)\n    valid_df = train_three.loc[valid_idx].reset_index(drop=True)\n\n    train_ds = TwoStreamTrainDataset(train_df, base_path=CFG.BASE_PATH, img_dir=CFG.TRAIN_IMG_DIR,\n                                     transforms=build_train_transforms(CFG.IMG_SIZE))\n    valid_ds = TwoStreamTrainDataset(valid_df, base_path=CFG.BASE_PATH, img_dir=CFG.TRAIN_IMG_DIR,\n                                     transforms=build_valid_transforms(CFG.IMG_SIZE))\n\n    train_loader = DataLoader(train_ds, batch_size=CFG.BATCH_SIZE, shuffle=True,\n                              num_workers=CFG.NUM_WORKERS, pin_memory=True)\n    valid_loader = DataLoader(valid_ds, batch_size=CFG.BATCH_SIZE, shuffle=False,\n                              num_workers=CFG.NUM_WORKERS, pin_memory=True)\n\n    model = TwoStreamThreeHeadModel(CFG.MODEL_NAME, pretrained=True).to(CFG.DEVICE)\n    criterion = WeightedSmoothL1(CFG.WEIGHTS)\n\n    # Stage 1: freeze backbone\n    freeze_backbone(model)\n    optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=CFG.LR_HEAD, weight_decay=1e-4)\n\n    best_score = -1e9\n    best_path = CFG.OUT_DIR / f'best_fold{fold}.pth'\n\n    for epoch in range(1, CFG.EPOCHS_FROZEN + 1):\n        model.train()\n        for left, right, targets in tqdm(train_loader, desc=f'Fold {fold} | Stage1 E{epoch}', leave=False):\n            left, right = left.to(CFG.DEVICE), right.to(CFG.DEVICE)\n            targets = targets.to(CFG.DEVICE)\n            optimizer.zero_grad(set_to_none=True)\n            p_total, p_gdm, p_green = model(left, right)\n            loss = criterion(p_total, p_gdm, p_green, targets[:,0], targets[:,1], targets[:,2])\n            loss.backward()\n            optimizer.step()\n        # eval\n        score = evaluate_fold(model, valid_loader, CFG.DEVICE)\n        if score > best_score:\n            best_score = score\n            torch.save(model.state_dict(), best_path)\n        tqdm.write(f'[Stage1][Fold {fold}] R2={score:.5f} (best {best_score:.5f})')\n\n    # Stage 2: unfreeze with low LR\n    unfreeze_backbone(model)\n    optimizer = torch.optim.AdamW(model.parameters(), lr=CFG.LR_BACKBONE, weight_decay=1e-4)\n\n    for epoch in range(1, CFG.EPOCHS_UNFROZEN + 1):\n        model.train()\n        for left, right, targets in tqdm(train_loader, desc=f'Fold {fold} | Stage2 E{epoch}', leave=False):\n            left, right = left.to(CFG.DEVICE), right.to(CFG.DEVICE)\n            targets = targets.to(CFG.DEVICE)\n            optimizer.zero_grad(set_to_none=True)\n            p_total, p_gdm, p_green = model(left, right)\n            loss = criterion(p_total, p_gdm, p_green, targets[:,0], targets[:,1], targets[:,2])\n            loss.backward()\n            optimizer.step()\n        score = evaluate_fold(model, valid_loader, CFG.DEVICE)\n        if score > best_score:\n            best_score = score\n            torch.save(model.state_dict(), best_path)\n        tqdm.write(f'[Stage2][Fold {fold}] R2={score:.5f} (best {best_score:.5f})')\n\n    del model, train_loader, valid_loader\n    gc.collect(); torch.cuda.empty_cache()\n    return str(best_path)\n\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% Orchestrate K-Fold training\nbest_paths = []\nfor fold in range(CFG.N_FOLDS):\n    path = train_one_fold(fold, train_three, df_image)\n    best_paths.append(path)\nprint('Best checkpoints:', best_paths)\n\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% Inference with TTA on test & build submission (A-baseline)\nfrom albumentations import Compose, Resize, Normalize, HorizontalFlip, VerticalFlip\nfrom albumentations.pytorch import ToTensorV2\nimport cv2\nclass TwoStreamTestDataset(torch.utils.data.Dataset):\n    def __init__(self, unique_df: pd.DataFrame, base_path: Path, img_dir: str, transform: Compose):\n        self.df = unique_df.reset_index(drop=True)\n        self.base = base_path\n        self.img_dir = img_dir\n        self.transform = transform\n    def __len__(self):\n        return len(self.df)\n    def __getitem__(self, idx: int):\n        rel = self.df.loc[idx, 'image_path']\n        path = (self.base / self.img_dir / Path(rel).name).as_posix()\n        img = cv2.imread(path)\n        if img is None:\n            raise FileNotFoundError(path)\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        h, w, _ = img.shape\n        mid = w // 2\n        left = img[:, :mid]\n        right = img[:, mid:]\n        left = self.transform(image=left)['image']\n        right = self.transform(image=right)['image']\n        return left, right\n\n\ndef get_tta_transforms():\n    base = [Normalize(mean=(0.485,0.456,0.406), std=(0.229,0.224,0.225)), ToTensorV2()]\n    original = Compose([Resize(CFG.IMG_SIZE, CFG.IMG_SIZE), *base])\n    hflip = Compose([HorizontalFlip(p=1.0), Resize(CFG.IMG_SIZE, CFG.IMG_SIZE), *base])\n    vflip = Compose([VerticalFlip(p=1.0), Resize(CFG.IMG_SIZE, CFG.IMG_SIZE), *base])\n    return [original, hflip, vflip]\n\n\ndef predict_test(best_paths: list[str]):\n    # read test long and unique images\n    test_long = read_test_long(CFG.BASE_PATH)\n    test_unique = test_long.drop_duplicates(subset=['image_path']).reset_index(drop=True)\n\n    # load models\n    models = []\n    for p in best_paths:\n        m = TwoStreamThreeHeadModel(CFG.MODEL_NAME, pretrained=False)\n        state = torch.load(p, map_location=CFG.DEVICE)\n        # remove possible 'module.' prefix\n        if any(k.startswith('module.') for k in state.keys()):\n            from collections import OrderedDict\n            new_state = OrderedDict((k.replace('module.',''), v) for k, v in state.items())\n            state = new_state\n        m.load_state_dict(state)\n        m.eval(); m.to(CFG.DEVICE)\n        models.append(m)\n\n    # TTA loop\n    tta = get_tta_transforms()\n    preds_total, preds_gdm, preds_green = [], [], []\n\n    for t in tta:\n        ds = TwoStreamTestDataset(test_unique, base_path=CFG.BASE_PATH, img_dir=CFG.TEST_IMG_DIR, transform=t)\n        dl = DataLoader(ds, batch_size=1, shuffle=False, num_workers=CFG.NUM_WORKERS)\n        with torch.no_grad():\n            view_total, view_gdm, view_green = [], [], []\n            for left, right in tqdm(dl, desc='TTA view', leave=False):\n                left, right = left.to(CFG.DEVICE), right.to(CFG.DEVICE)\n                # ensemble over folds\n                out_total, out_gdm, out_green = [], [], []\n                for m in models:\n                    p_total, p_gdm, p_green = m(left, right)\n                    out_total.append(p_total.detach().cpu().numpy())\n                    out_gdm.append(p_gdm.detach().cpu().numpy())\n                    out_green.append(p_green.detach().cpu().numpy())\n                view_total.append(np.mean(np.stack(out_total), axis=0))\n                view_gdm.append(np.mean(np.stack(out_gdm), axis=0))\n                view_green.append(np.mean(np.stack(out_green), axis=0))\n        preds_total.append(np.concatenate(view_total).flatten())\n        preds_gdm.append(np.concatenate(view_gdm).flatten())\n        preds_green.append(np.concatenate(view_green).flatten())\n\n    total = np.mean(np.stack(preds_total), axis=0)\n    gdm = np.mean(np.stack(preds_gdm), axis=0)\n    green = np.mean(np.stack(preds_green), axis=0)\n    clover = np.maximum(0.0, gdm - green)\n    dead = np.maximum(0.0, total - gdm)\n\n    # build long submission\n    preds_wide = pd.DataFrame({\n        'image_path': test_unique['image_path'],\n        'Dry_Green_g': green,\n        'Dry_Dead_g': dead,\n        'Dry_Clover_g': clover,\n        'GDM_g': gdm,\n        'Dry_Total_g': total,\n    })\n    long_pred = preds_wide.melt(id_vars=['image_path'], value_vars=list(DEFAULT_WEIGHTS.keys()),\n                                var_name='target_name', value_name='target')\n    submission = test_long[['sample_id', 'image_path', 'target_name']].merge(long_pred, on=['image_path','target_name'], how='left')\n    submission = submission[['sample_id','target']]\n    submission.to_csv(CFG.SUBMISSION_FILE, index=False)\n    print('Saved:', CFG.SUBMISSION_FILE)\n\n# Uncomment to run after training\n# predict_test(best_paths)\n\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% Generate submission after training\n# 运行本单元以生成 Kaggle 要求的 submission.csv\npredict_test(best_paths)\nprint('Submission ready ->', CFG.SUBMISSION_FILE)\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## EDA：目标分布、相关性与增广预览\n","metadata":{}},{"cell_type":"code","source":"# %% EDA helpers\nimport matplotlib.pyplot as plt\nimport seaborn as sns\n\n# 目标分布（长表）\nfig, axes = plt.subplots(1, 3, figsize=(15,4))\nlong_for_eda = train_long[['sample_id','target_name','target']].copy()\nfor ax, name in zip(axes, ['Dry_Total_g','GDM_g','Dry_Green_g']):\n    sns.histplot(long_for_eda[long_for_eda['target_name']==name]['target'], bins=30, ax=ax)\n    ax.set_title(f'{name} distribution')\nplt.tight_layout(); plt.show()\n\n# 相关性（宽表5目标）\nplt.figure(figsize=(6,5))\ncols = ['Dry_Green_g','Dry_Dead_g','Dry_Clover_g','GDM_g','Dry_Total_g']\nsns.heatmap(train_wide[cols].corr(), annot=True, cmap='Blues', fmt='.2f')\nplt.title('Target correlation (train wide)'); plt.show()\n\n# 增广预览（随机1张，左右裁切+训练增广）\nexample = train_three.sample(1, random_state=0).iloc[0]\nleft_img, right_img = cv2.imread((CFG.BASE_PATH/CFG.TRAIN_IMG_DIR/Path(example['image_path']).name).as_posix()), None\nif left_img is not None:\n    img = cv2.cvtColor(left_img, cv2.COLOR_BGR2RGB)\n    h,w,_ = img.shape\n    mid = w//2\n    l = img[:, :mid]\n    r = img[:, mid:]\n    aug = build_train_transforms(CFG.IMG_SIZE)\n    l_aug = aug(image=l)['image'].permute(1,2,0).cpu().numpy()\n    r_aug = aug(image=r)['image'].permute(1,2,0).cpu().numpy()\n    fig, axes = plt.subplots(1,2, figsize=(8,4))\n    axes[0].imshow(l_aug)\n    axes[0].set_title('Left aug'); axes[0].axis('off')\n    axes[1].imshow(r_aug)\n    axes[1].set_title('Right aug'); axes[1].axis('off')\n    plt.tight_layout(); plt.show()\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 误差分析：OOF预测与分组R²（State / 月份 / 目标）\n","metadata":{}},{"cell_type":"code","source":"# %% OOF prediction & grouped R2 (run after training)\nfrom collections import OrderedDict\n\ndef month_from_date(s: str) -> int:\n    try:\n        return pd.to_datetime(s).month\n    except Exception:\n        return -1\n\n\ndef oof_predict(best_paths: list[str]) -> pd.DataFrame:\n    img_to_fold = df_image.set_index('sample_id')['fold']\n    tmp = train_three.copy()\n    tmp['fold'] = tmp['sample_id'].map(img_to_fold)\n\n    parts = []\n    for fold in range(CFG.N_FOLDS):\n        valid_df = tmp[tmp['fold']==fold].reset_index(drop=True)\n        ds = TwoStreamTrainDataset(valid_df, base_path=CFG.BASE_PATH, img_dir=CFG.TRAIN_IMG_DIR,\n                                   transforms=build_valid_transforms(CFG.IMG_SIZE))\n        dl = DataLoader(ds, batch_size=CFG.BATCH_SIZE, shuffle=False, num_workers=CFG.NUM_WORKERS)\n        # load model\n        model = TwoStreamThreeHeadModel(CFG.MODEL_NAME, pretrained=False)\n        state = torch.load(best_paths[fold], map_location=CFG.DEVICE)\n        if any(k.startswith('module.') for k in state.keys()):\n            state = OrderedDict((k.replace('module.',''), v) for k, v in state.items())\n        model.load_state_dict(state)\n        model.eval(); model.to(CFG.DEVICE)\n\n        preds_t, preds_g, preds_gr = [], [], []\n        with torch.no_grad():\n            for left, right, _ in tqdm(dl, desc=f'OOF fold {fold}', leave=False):\n                left, right = left.to(CFG.DEVICE), right.to(CFG.DEVICE)\n                pt, pg, pgr = model(left, right)\n                preds_t.append(pt.detach().cpu().numpy())\n                preds_g.append(pg.detach().cpu().numpy())\n                preds_gr.append(pgr.detach().cpu().numpy())\n        total = np.concatenate(preds_t)\n        gdm = np.concatenate(preds_g)\n        green = np.concatenate(preds_gr)\n        clover = np.maximum(0.0, gdm - green)\n        dead = np.maximum(0.0, total - gdm)\n        pred_wide = pd.DataFrame({\n            'sample_id': valid_df['sample_id'].values,\n            'Dry_Green_g': green,\n            'Dry_Dead_g': dead,\n            'Dry_Clover_g': clover,\n            'GDM_g': gdm,\n            'Dry_Total_g': total,\n        })\n        long_pred = pred_wide.melt(id_vars=['sample_id'], value_vars=list(DEFAULT_WEIGHTS.keys()),\n                                   var_name='target_name', value_name='pred')\n        gt = train_long[['sample_id','target_name','target','State','Sampling_Date']]\n        part = gt.merge(long_pred, on=['sample_id','target_name'], how='inner')\n        parts.append(part)\n    return pd.concat(parts, axis=0, ignore_index=True)\n\n# Example usage (uncomment after training):\n# oof_df = oof_predict(best_paths)\n# print('OOF overall weighted R2:', weighted_r2_from_long_df(oof_df))\n# oof_df['Month'] = oof_df['Sampling_Date'].map(month_from_date)\n# for col in ['target_name','State','Month']:\n#     print(f'\\nGroup by {col}:')\n#     for key, grp in oof_df.groupby(col):\n#         try:\n#             print(key, weighted_r2_from_long_df(grp))\n#         except Exception:\n#             pass\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## B版：一致性参数化（五输出，仅图像）\n- 思路：直接预测三个非负基变量：green_base、clover_base、dead_base，经`softplus`确保非负；\n  - `Dry_Green_g = green_base`\n  - `Dry_Clover_g = clover_base`\n  - `GDM_g = green_base + clover_base`\n  - `Dry_Dead_g = dead_base`\n  - `Dry_Total_g = GDM_g + dead_base`\n- 优点：天然满足非负与可加约束，避免A版“相减+截零”的边界误差。\n- 训练：对五项按官方权重（0.5/0.2/0.1/0.1/0.1）使用SmoothL1加权总损失。\n\n","metadata":{}},{"cell_type":"code","source":"# %% Consistent five-output model\nclass ConsistentFiveOutputModel(nn.Module):\n    def __init__(self, model_name: str, pretrained: bool = True):\n        super().__init__()\n        self.backbone = timm.create_model(model_name, pretrained=pretrained, num_classes=0, global_pool='avg')\n        feat = self.backbone.num_features\n        combined = feat * 2\n        self.head = nn.Sequential(\n            nn.Linear(combined, combined // 2),\n            nn.ReLU(inplace=True),\n            nn.Dropout(0.3),\n            nn.Linear(combined // 2, 3),  # green_base, clover_base, dead_base\n        )\n        self.softplus = nn.Softplus()\n    def forward(self, left: torch.Tensor, right: torch.Tensor):\n        f_left = self.backbone(left)\n        f_right = self.backbone(right)\n        x = torch.cat([f_left, f_right], dim=1)\n        base = self.softplus(self.head(x))  # (B,3) non-negative\n        green_base = base[:, 0]\n        clover_base = base[:, 1]\n        dead_base = base[:, 2]\n        green = green_base\n        clover = clover_base\n        gdm = green_base + clover_base\n        dead = dead_base\n        total = gdm + dead_base\n        # return five heads as tuple (in fixed order)\n        return green, dead, clover, gdm, total\n\nclass WeightedSmoothL1Five(nn.Module):\n    def __init__(self, weights: dict):\n        super().__init__()\n        self.w = {\n            'Dry_Green_g': float(weights.get('Dry_Green_g', 0.1)),\n            'Dry_Dead_g': float(weights.get('Dry_Dead_g', 0.1)),\n            'Dry_Clover_g': float(weights.get('Dry_Clover_g', 0.1)),\n            'GDM_g': float(weights.get('GDM_g', 0.2)),\n            'Dry_Total_g': float(weights.get('Dry_Total_g', 0.5)),\n        }\n        self.loss = nn.SmoothL1Loss()\n    def forward(self, pred_tuple, tgt_tuple):\n        p_green, p_dead, p_clover, p_gdm, p_total = pred_tuple\n        t_green, t_dead, t_clover, t_gdm, t_total = tgt_tuple\n        return (\n            self.w['Dry_Green_g'] * self.loss(p_green, t_green)\n            + self.w['Dry_Dead_g'] * self.loss(p_dead, t_dead)\n            + self.w['Dry_Clover_g'] * self.loss(p_clover, t_clover)\n            + self.w['GDM_g'] * self.loss(p_gdm, t_gdm)\n            + self.w['Dry_Total_g'] * self.loss(p_total, t_total)\n        )\n\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% Train/Eval utilities for B-version\n\ndef evaluate_fold_B(model: ConsistentFiveOutputModel, loader: DataLoader, device: torch.device) -> float:\n    model.eval()\n    preds = {k: [] for k in ['Dry_Green_g','Dry_Dead_g','Dry_Clover_g','GDM_g','Dry_Total_g']}\n    trues = {k: [] for k in ['Dry_Green_g','Dry_Dead_g','Dry_Clover_g','GDM_g','Dry_Total_g']}\n    with torch.no_grad():\n        for left, right, targets in loader:\n            left, right = left.to(device), right.to(device)\n            p_green, p_dead, p_clover, p_gdm, p_total = model(left, right)\n            preds['Dry_Green_g'].append(p_green.detach().cpu().numpy())\n            preds['Dry_Dead_g'].append(p_dead.detach().cpu().numpy())\n            preds['Dry_Clover_g'].append(p_clover.detach().cpu().numpy())\n            preds['GDM_g'].append(p_gdm.detach().cpu().numpy())\n            preds['Dry_Total_g'].append(p_total.detach().cpu().numpy())\n            # build true tuple from train_wide\n            t_total, t_gdm, t_green = targets[:,0].numpy(), targets[:,1].numpy(), targets[:,2].numpy()\n            t_clover = np.maximum(0.0, t_gdm - t_green)\n            t_dead = np.maximum(0.0, t_total - t_gdm)\n            trues['Dry_Green_g'].append(t_green)\n            trues['Dry_Dead_g'].append(t_dead)\n            trues['Dry_Clover_g'].append(t_clover)\n            trues['GDM_g'].append(t_gdm)\n            trues['Dry_Total_g'].append(t_total)\n    # concat and build long df\n    data = []\n    for name in preds:\n        pv = np.concatenate(preds[name]).flatten()\n        tv = np.concatenate(trues[name]).flatten()\n        for t, p in zip(tv, pv):\n            data.append({'target_name': name, 'target': float(t), 'pred': float(p)})\n    df_long = pd.DataFrame(data)\n    return float(weighted_r2_from_long_df(df_long))\n\n\ndef train_one_fold_B(fold: int, train_three: pd.DataFrame, df_image: pd.DataFrame) -> str:\n    set_seed(1024 + fold)\n    img_to_fold = df_image.set_index('sample_id')['fold']\n    tmp = train_three.copy(); tmp['fold'] = tmp['sample_id'].map(img_to_fold)\n    train_df = tmp[tmp['fold'] != fold].reset_index(drop=True)\n    valid_df = tmp[tmp['fold'] == fold].reset_index(drop=True)\n    train_ds = TwoStreamTrainDataset(train_df, base_path=CFG.BASE_PATH, img_dir=CFG.TRAIN_IMG_DIR,\n                                     transforms=build_train_transforms(CFG.IMG_SIZE))\n    valid_ds = TwoStreamTrainDataset(valid_df, base_path=CFG.BASE_PATH, img_dir=CFG.TRAIN_IMG_DIR,\n                                     transforms=build_valid_transforms(CFG.IMG_SIZE))\n    train_loader = DataLoader(train_ds, batch_size=CFG.BATCH_SIZE, shuffle=True,\n                              num_workers=CFG.NUM_WORKERS, pin_memory=True)\n    valid_loader = DataLoader(valid_ds, batch_size=CFG.BATCH_SIZE, shuffle=False,\n                              num_workers=CFG.NUM_WORKERS, pin_memory=True)\n\n    model = ConsistentFiveOutputModel(CFG.MODEL_NAME, pretrained=True).to(CFG.DEVICE)\n    criterion = WeightedSmoothL1Five(DEFAULT_WEIGHTS)\n\n    # Stage 1: freeze\n    for p in model.backbone.parameters():\n        p.requires_grad = False\n    optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=CFG.LR_HEAD, weight_decay=1e-4)\n\n    best, best_path = -1e9, CFG.OUT_DIR / f'best_B_fold{fold}.pth'\n    for epoch in range(1, CFG.EPOCHS_FROZEN + 1):\n        model.train()\n        for left, right, targets in tqdm(train_loader, desc=f'[B] Fold {fold} | Stage1 E{epoch}', leave=False):\n            left, right = left.to(CFG.DEVICE), right.to(CFG.DEVICE)\n            t_total, t_gdm, t_green = targets[:,0], targets[:,1], targets[:,2]\n            t_clover = torch.clamp(t_gdm - t_green, min=0.0)\n            t_dead = torch.clamp(t_total - t_gdm, min=0.0)\n            optimizer.zero_grad(set_to_none=True)\n            pred_tuple = model(left, right)\n            tgt_tuple = (t_green, t_dead, t_clover, t_gdm, t_total)\n            loss = criterion(pred_tuple, tgt_tuple)\n            loss.backward(); optimizer.step()\n        score = evaluate_fold_B(model, valid_loader, CFG.DEVICE)\n        if score > best:\n            best = score; torch.save(model.state_dict(), best_path)\n        tqdm.write(f'[B-Stage1][Fold {fold}] R2={score:.5f} (best {best:.5f})')\n\n    # Stage 2: unfreeze\n    for p in model.backbone.parameters():\n        p.requires_grad = True\n    optimizer = torch.optim.AdamW(model.parameters(), lr=CFG.LR_BACKBONE, weight_decay=1e-4)\n\n    for epoch in range(1, CFG.EPOCHS_UNFROZEN + 1):\n        model.train()\n        for left, right, targets in tqdm(train_loader, desc=f'[B] Fold {fold} | Stage2 E{epoch}', leave=False):\n            left, right = left.to(CFG.DEVICE), right.to(CFG.DEVICE)\n            t_total, t_gdm, t_green = targets[:,0], targets[:,1], targets[:,2]\n            t_clover = torch.clamp(t_gdm - t_green, min=0.0)\n            t_dead = torch.clamp(t_total - t_gdm, min=0.0)\n            optimizer.zero_grad(set_to_none=True)\n            pred_tuple = model(left, right)\n            tgt_tuple = (t_green, t_dead, t_clover, t_gdm, t_total)\n            loss = criterion(pred_tuple, tgt_tuple)\n            loss.backward(); optimizer.step()\n        score = evaluate_fold_B(model, valid_loader, CFG.DEVICE)\n        if score > best:\n            best = score; torch.save(model.state_dict(), best_path)\n        tqdm.write(f'[B-Stage2][Fold {fold}] R2={score:.5f} (best {best:.5f})')\n\n    del model, train_loader, valid_loader\n    gc.collect(); torch.cuda.empty_cache()\n    return str(best_path)\n\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% Orchestrate B-version training (optional)\n# best_paths_B = []\n# for fold in range(CFG.N_FOLDS):\n#     p = train_one_fold_B(fold, train_three, df_image)\n#     best_paths_B.append(p)\n# print('Best checkpoints (B):', best_paths_B)\n\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## B版：多模态融合（图像 + 表格塔）\n说明：测试集 `test.csv` 不含 `NDVI/Height/State/Species/Date`，因此表格塔仅用于训练与OOF分析；线上推理默认关闭表格塔（或用均值填充作为占位）。\n","metadata":{}},{"cell_type":"code","source":"# %% Tabular pipeline (fit on train, transform train/valid)\nfrom typing import List, Dict, Tuple\n\nclass TabularPipeline:\n    def __init__(self, species_topk: int = 30):\n        self.num_cols = ['Pre_GSHH_NDVI','Height_Ave_cm']\n        self.state_cats: List[str] = []\n        self.species_vocab: List[str] = []\n        self.species_topk = species_topk\n        self.means: Dict[str, float] = {}\n        self.stds: Dict[str, float] = {}\n\n    @staticmethod\n    def _month(s: str) -> int:\n        try:\n            return pd.to_datetime(s).month\n        except Exception:\n            return -1\n\n    def fit(self, df: pd.DataFrame):\n        # numeric stats\n        for c in self.num_cols:\n            v = pd.to_numeric(df[c], errors='coerce')\n            self.means[c] = float(np.nanmean(v))\n            self.stds[c] = float(np.nanstd(v) + 1e-6)\n        # states\n        self.state_cats = sorted(df['State'].astype(str).dropna().unique().tolist())\n        # species vocab\n        tokens = []\n        for s in df['Species'].astype(str).fillna(''):\n            tokens.extend([t for t in s.split('_') if t])\n        vc = pd.Series(tokens).value_counts()\n        self.species_vocab = vc.head(self.species_topk).index.tolist()\n        return self\n\n    def transform(self, df: pd.DataFrame) -> np.ndarray:\n        rows = []\n        for _, r in df.iterrows():\n            vec = []\n            # numeric z-score\n            for c in self.num_cols:\n                x = pd.to_numeric(r.get(c, np.nan), errors='coerce')\n                x = self.means[c] if pd.isna(x) else float(x)\n                z = (x - self.means[c]) / self.stds[c]\n                vec.append(z)\n            # month sin/cos\n            m = self._month(str(r.get('Sampling_Date', '')))\n            if m == -1:\n                vec += [0.0, 0.0]\n            else:\n                vec += [math.sin(2*math.pi*m/12.0), math.cos(2*math.pi*m/12.0)]\n            # state one-hot in fixed order\n            st = str(r.get('State',''))\n            for cat in self.state_cats:\n                vec.append(1.0 if st == cat else 0.0)\n            # species bow in fixed vocab\n            sp = str(r.get('Species',''))\n            toks = set([t for t in sp.split('_') if t])\n            for tok in self.species_vocab:\n                vec.append(1.0 if tok in toks else 0.0)\n            rows.append(vec)\n        return np.asarray(rows, dtype=np.float32)\n\n# Build pipeline on training data (wide/three have same rows ordering for meta)\ntab_pipe = TabularPipeline(species_topk=30).fit(train_wide)\nX_tab_all = tab_pipe.transform(train_wide)\n# 对齐到 train_three 的行顺序（通过 sample_id merge）\nmeta = train_wide[['sample_id']].copy(); meta['__idx'] = np.arange(len(meta))\nidx_map = train_three[['sample_id']].merge(meta, on='sample_id', how='left')['__idx'].values\nX_tab_three = X_tab_all[idx_map]\nX_tab_three.shape\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% Dataset with tabular features\nclass TwoStreamTrainDatasetWithTab(TwoStreamTrainDataset):\n    def __init__(self, df_wide_three: pd.DataFrame, base_path, img_dir, transforms, X_tab: np.ndarray):\n        super().__init__(df_wide_three, base_path, img_dir, transforms)\n        assert len(df_wide_three) == len(X_tab)\n        self.X_tab = X_tab.astype(np.float32)\n    def __getitem__(self, idx: int):\n        left, right, targets = super().__getitem__(idx)\n        tab = torch.from_numpy(self.X_tab[idx])\n        return left, right, tab, targets\n\n# Simple Tabular encoder\nclass TabularEncoder(nn.Module):\n    def __init__(self, in_dim: int, hidden: int = 64, out_dim: int = 64):\n        super().__init__()\n        self.net = nn.Sequential(\n            nn.Linear(in_dim, hidden), nn.ReLU(inplace=True), nn.Dropout(0.2),\n            nn.Linear(hidden, out_dim), nn.ReLU(inplace=True),\n        )\n    def forward(self, x):\n        return self.net(x)\n\n# Fused consistent five-output model\nclass ConsistentFiveOutputFused(nn.Module):\n    def __init__(self, model_name: str, tab_in_dim: int, pretrained: bool = True, tab_latent: int = 64):\n        super().__init__()\n        self.backbone = timm.create_model(model_name, pretrained=pretrained, num_classes=0, global_pool='avg')\n        feat = self.backbone.num_features\n        self.tab = TabularEncoder(tab_in_dim, hidden=64, out_dim=tab_latent)\n        combined = feat * 2 + tab_latent\n        self.head = nn.Sequential(\n            nn.Linear(combined, combined // 2),\n            nn.ReLU(inplace=True),\n            nn.Dropout(0.3),\n            nn.Linear(combined // 2, 3),\n        )\n        self.softplus = nn.Softplus()\n    def forward(self, left, right, tab):\n        f_left = self.backbone(left)\n        f_right = self.backbone(right)\n        f_tab = self.tab(tab)\n        x = torch.cat([f_left, f_right, f_tab], dim=1)\n        base = self.softplus(self.head(x))\n        green_base, clover_base, dead_base = base[:,0], base[:,1], base[:,2]\n        green = green_base\n        clover = clover_base\n        gdm = green_base + clover_base\n        dead = dead_base\n        total = gdm + dead_base\n        return green, dead, clover, gdm, total\n\n# Train one fold (fused)\ndef train_one_fold_B_fused(fold: int, train_three: pd.DataFrame, df_image: pd.DataFrame, X_tab_three: np.ndarray) -> str:\n    set_seed(2024 + fold)\n    img_to_fold = df_image.set_index('sample_id')['fold']\n    tmp = train_three.copy(); tmp['fold'] = tmp['sample_id'].map(img_to_fold)\n\n    train_df = tmp[tmp['fold'] != fold].reset_index(drop=True)\n    valid_df = tmp[tmp['fold'] == fold].reset_index(drop=True)\n\n    X_tab_train = X_tab_three[tmp['fold'] != fold]\n    X_tab_valid = X_tab_three[tmp['fold'] == fold]\n\n    train_ds = TwoStreamTrainDatasetWithTab(train_df, CFG.BASE_PATH, CFG.TRAIN_IMG_DIR,\n                                            build_train_transforms(CFG.IMG_SIZE), X_tab_train)\n    valid_ds = TwoStreamTrainDatasetWithTab(valid_df, CFG.BASE_PATH, CFG.TRAIN_IMG_DIR,\n                                            build_valid_transforms(CFG.IMG_SIZE), X_tab_valid)\n\n    train_loader = DataLoader(train_ds, batch_size=CFG.BATCH_SIZE, shuffle=True,\n                              num_workers=CFG.NUM_WORKERS, pin_memory=True)\n    valid_loader = DataLoader(valid_ds, batch_size=CFG.BATCH_SIZE, shuffle=False,\n                              num_workers=CFG.NUM_WORKERS, pin_memory=True)\n\n    tab_in_dim = X_tab_three.shape[1]\n    model = ConsistentFiveOutputFused(CFG.MODEL_NAME, tab_in_dim, pretrained=True).to(CFG.DEVICE)\n    criterion = WeightedSmoothL1Five(DEFAULT_WEIGHTS)\n\n    # Stage 1\n    for p in model.backbone.parameters():\n        p.requires_grad = False\n    optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=CFG.LR_HEAD, weight_decay=1e-4)\n\n    best, best_path = -1e9, CFG.OUT_DIR / f'best_Bf_fold{fold}.pth'\n    for epoch in range(1, CFG.EPOCHS_FROZEN + 1):\n        model.train()\n        for left, right, tab, targets in tqdm(train_loader, desc=f'[B+Tab] Fold {fold} | Stage1 E{epoch}', leave=False):\n            left, right, tab = left.to(CFG.DEVICE), right.to(CFG.DEVICE), tab.to(CFG.DEVICE)\n            t_total, t_gdm, t_green = targets[:,0].to(CFG.DEVICE), targets[:,1].to(CFG.DEVICE), targets[:,2].to(CFG.DEVICE)\n            t_clover = torch.clamp(t_gdm - t_green, min=0.0)\n            t_dead = torch.clamp(t_total - t_gdm, min=0.0)\n            optimizer.zero_grad(set_to_none=True)\n            pred_tuple = model(left, right, tab)\n            tgt_tuple = (t_green, t_dead, t_clover, t_gdm, t_total)\n            loss = criterion(pred_tuple, tgt_tuple)\n            loss.backward(); optimizer.step()\n        # eval\n        model.eval()\n        with torch.no_grad():\n            preds, trues = [], []\n            for left, right, tab, targets in valid_loader:\n                left, right, tab = left.to(CFG.DEVICE), right.to(CFG.DEVICE), tab.to(CFG.DEVICE)\n                p = model(left, right, tab)\n                preds.append([t.detach().cpu().numpy() for t in p])\n                t_total, t_gdm, t_green = targets[:,0].numpy(), targets[:,1].numpy(), targets[:,2].numpy()\n                t_clover = np.maximum(0.0, t_gdm - t_green)\n                t_dead = np.maximum(0.0, t_total - t_gdm)\n                trues.append([t_green, t_dead, t_clover, t_gdm, t_total])\n            # build long df\n            P = [np.concatenate([b[i] for b in preds]).flatten() for i in range(5)]\n            T = [np.concatenate([b[i] for b in trues]).flatten() for i in range(5)]\n            names = ['Dry_Green_g','Dry_Dead_g','Dry_Clover_g','GDM_g','Dry_Total_g']\n            data = []\n            for n, tv, pv in zip(names, T, P):\n                for t, q in zip(tv, pv):\n                    data.append({'target_name': n, 'target': float(t), 'pred': float(q)})\n            score = weighted_r2_from_long_df(pd.DataFrame(data))\n        if score > best:\n            best = score; torch.save(model.state_dict(), best_path)\n        tqdm.write(f'[B+Tab-Stage1][Fold {fold}] R2={score:.5f} (best {best:.5f})')\n\n    # Stage 2\n    for p in model.backbone.parameters():\n        p.requires_grad = True\n    optimizer = torch.optim.AdamW(model.parameters(), lr=CFG.LR_BACKBONE, weight_decay=1e-4)\n\n    for epoch in range(1, CFG.EPOCHS_UNFROZEN + 1):\n        model.train()\n        for left, right, tab, targets in tqdm(train_loader, desc=f'[B+Tab] Fold {fold} | Stage2 E{epoch}', leave=False):\n            left, right, tab = left.to(CFG.DEVICE), right.to(CFG.DEVICE), tab.to(CFG.DEVICE)\n            t_total, t_gdm, t_green = targets[:,0].to(CFG.DEVICE), targets[:,1].to(CFG.DEVICE), targets[:,2].to(CFG.DEVICE)\n            t_clover = torch.clamp(t_gdm - t_green, min=0.0)\n            t_dead = torch.clamp(t_total - t_gdm, min=0.0)\n            optimizer.zero_grad(set_to_none=True)\n            pred_tuple = model(left, right, tab)\n            tgt_tuple = (t_green, t_dead, t_clover, t_gdm, t_total)\n            loss = criterion(pred_tuple, tgt_tuple)\n            loss.backward(); optimizer.step()\n        # eval\n        model.eval()\n        with torch.no_grad():\n            preds, trues = [], []\n            for left, right, tab, targets in valid_loader:\n                left, right, tab = left.to(CFG.DEVICE), right.to(CFG.DEVICE), tab.to(CFG.DEVICE)\n                p = model(left, right, tab)\n                preds.append([t.detach().cpu().numpy() for t in p])\n                t_total, t_gdm, t_green = targets[:,0].numpy(), targets[:,1].numpy(), targets[:,2].numpy()\n                t_clover = np.maximum(0.0, t_gdm - t_green)\n                t_dead = np.maximum(0.0, t_total - t_gdm)\n                trues.append([t_green, t_dead, t_clover, t_gdm, t_total])\n            P = [np.concatenate([b[i] for b in preds]).flatten() for i in range(5)]\n            T = [np.concatenate([b[i] for b in trues]).flatten() for i in range(5)]\n            names = ['Dry_Green_g','Dry_Dead_g','Dry_Clover_g','GDM_g','Dry_Total_g']\n            data = []\n            for n, tv, pv in zip(names, T, P):\n                for t, q in zip(tv, pv):\n                    data.append({'target_name': n, 'target': float(t), 'pred': float(q)})\n            score = weighted_r2_from_long_df(pd.DataFrame(data))\n        if score > best:\n            best = score; torch.save(model.state_dict(), best_path)\n        tqdm.write(f'[B+Tab-Stage2][Fold {fold}] R2={score:.5f} (best {best:.5f})')\n\n    del model, train_loader, valid_loader\n    gc.collect(); torch.cuda.empty_cache()\n    return str(best_path)\n\n# Orchestrate (optional)\n# best_paths_Bf = []\n# for fold in range(CFG.N_FOLDS):\n#     best_paths_Bf.append(train_one_fold_B_fused(fold, train_three, df_image, X_tab_three))\n# print('Best checkpoints (B+Tab):', best_paths_Bf)\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"","metadata":{}},{"cell_type":"markdown","source":"","metadata":{}},{"cell_type":"code","source":"","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% OOF prediction & grouped R2\nfrom datetime import datetime\n\ndef month_from_date(s: str) -> int:\n    try:\n        return pd.to_datetime(s).month\n    except Exception:\n        return -1\n\n\ndef oof_predict(best_paths: list[str], train_three: pd.DataFrame, df_image: pd.DataFrame) -> pd.DataFrame:\n    img_to_fold = df_image.set_index('sample_id')['fold']\n    train_three = train_three.copy()\n    train_three['fold'] = train_three['sample_id'].map(img_to_fold)\n\n    rows = []\n    for fold in range(CFG.N_FOLDS):\n        valid_df = train_three[train_three['fold'] == fold].reset_index(drop=True)\n        ds = TwoStreamTrainDataset(valid_df, base_path=CFG.BASE_PATH, img_dir=CFG.TRAIN_IMG_DIR,\n                                   transforms=build_valid_transforms(CFG.IMG_SIZE))\n        dl = DataLoader(ds, batch_size=CFG.BATCH_SIZE, shuffle=False, num_workers=CFG.NUM_WORKERS)\n        # load model\n        m = TwoStreamThreeHeadModel(CFG.MODEL_NAME, pretrained=False)\n        state = torch.load(best_paths[fold], map_location=CFG.DEVICE)\n        # remove possible 'module.' prefix\n        if any(k.startswith('module.') for k in state.keys()):\n            from collections import OrderedDict\n            state = OrderedDict((k.replace('module.',''), v) for k, v in state.items())\n        m.load_state_dict(state)\n        m.eval(); m.to(CFG.DEVICE)\n        # predict\n        preds_total, preds_gdm, preds_green = [], [], []\n        with torch.no_grad():\n            for left, right, targets in tqdm(dl, desc=f'OOF fold {fold}', leave=False):\n                left, right = left.to(CFG.DEVICE), right.to(CFG.DEVICE)\n                pt, pg, pr = m(left, right)\n                preds_total.append(pt.detach().cpu().numpy())\n                preds_gdm.append(pg.detach().cpu().numpy())\n                preds_green.append(pr.detach().cpu().numpy())\n        total = np.concatenate(preds_total)\n        gdm = np.concatenate(preds_gdm)\n        green = np.concatenate(preds_green)\n        clover = np.maximum(0.0, gdm - green)\n        dead = np.maximum(0.0, total - gdm)\n        pred_wide = pd.DataFrame({\n            'sample_id': valid_df['sample_id'].values,\n            'Dry_Green_g': green,\n            'Dry_Dead_g': dead,\n            'Dry_Clover_g': clover,\n            'GDM_g': gdm,\n            'Dry_Total_g': total,\n        })\n        # unpivot to long for metric\n        long_pred = pred_wide.melt(id_vars=['sample_id'], value_vars=list(DEFAULT_WEIGHTS.keys()),\n                                   var_name='target_name', value_name='pred')\n        # merge gt + meta for analysis\n        gt_long = train_long[['sample_id','target_name','target','State','Sampling_Date']]\n        part = gt_long.merge(long_pred, on=['sample_id','target_name'], how='inner')\n        rows.append(part)\n    return pd.concat(rows, axis=0, ignore_index=True)\n\n# 运行OOF并计算分组R²（注意：需先完成训练得到best_paths）\n# oof_df = oof_predict(best_paths, train_three, df_image)\n# overall = weighted_r2_from_long_df(oof_df)\n# print('OOF weighted R2 (overall):', overall)\n# oof_df['Month'] = oof_df['Sampling_Date'].map(month_from_date)\n# for col in ['target_name','State','Month']:\n#     print(f'\\nGroup by {col}:')\n#     for key, grp in oof_df.groupby(col):\n#         try:\n#             print(key, weighted_r2_from_long_df(grp))\n#         except Exception:\n#             pass\n","metadata":{},"outputs":[],"execution_count":null}]}