{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"pygments_lexer":"ipython3","nbconvert_exporter":"python","version":"3.6.4","file_extension":".py","codemirror_mode":{"name":"ipython","version":3},"name":"python","mimetype":"text/x-python"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":112509,"databundleVersionId":14254895,"isSourceIdPinned":false,"sourceType":"competition"},{"sourceId":14556186,"sourceType":"datasetVersion","datasetId":9296530},{"sourceId":733293,"sourceType":"modelInstanceVersion","isSourceIdPinned":false,"modelInstanceId":536849,"modelId":550283},{"sourceId":4535,"sourceType":"modelInstanceVersion","isSourceIdPinned":true,"modelInstanceId":3327,"modelId":986}],"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import os\nimport sys\n\n# 必须在导入 transformers/tensorflow 之前设置\nos.environ[\"USE_TF\"] = \"0\"\nos.environ[\"PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION\"] = \"python\"\n\nimport gc\nimport random\nimport warnings\nimport numpy as np\nimport pandas as pd\nfrom tqdm import tqdm\nfrom PIL import Image\nfrom pathlib import Path\nimport cv2  # Para segmentación visual\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision.transforms as T\nfrom transformers import AutoModel\n\n# Models\nimport lightgbm as lgb\nimport xgboost as xgb\n# import optuna  # 移除 Optuna\nfrom sklearn.linear_model import Ridge, LinearRegression\nfrom sklearn.cross_decomposition import PLSRegression\nfrom sklearn.preprocessing import StandardScaler, RobustScaler\nfrom sklearn.model_selection import KFold, RepeatedKFold, StratifiedKFold\nfrom sklearn.multioutput import MultiOutputRegressor\nfrom sklearn.base import BaseEstimator, RegressorMixin, clone\n\n# 屏蔽警告\nwarnings.filterwarnings('ignore')\ntry:\n    DEFAULT_DATA_PATH = '/kaggle/input/csiro-biomass/'\nexcept (ImportError, AttributeError):\n    from setup_data import csiro_biomass_path\n    DEFAULT_DATA_PATH = csiro_biomass_path\n\nif not os.path.exists(DEFAULT_DATA_PATH):\n    DEFAULT_DATA_PATH = '/root/.cache/kagglehub/competitions/csiro-biomass'\n# ====================================================\n# 配置 (Configuration)\n# ====================================================\nclass CFG:\n    DATA_PATH = DEFAULT_DATA_PATH\n    train_path = os.path.join(DATA_PATH, 'train.csv')\n    test_path = os.path.join(DATA_PATH, 'test.csv')\n    \n    # DINOv2 模型路径 (支持 Kaggle 离线路径)\n    dinov2_paths = [\n        '/kaggle/input/dinov3/transformers/default/4'                   # 备选路径\n    ]\n    \n    # 增大图片分辨率至 518 (DINOv2 官方推荐的高分辨率设置)\n    img_size = 1024 \n    # 分辨率增大后，显存占用增加，需减小 Batch Size\n    batch_size = 8\n    \n    # Letterbox resize target size (para preservar relación de aspecto)\n    # Puede ser int (cuadrado) o tuple (h, w) para rectángulo\n    # Ejemplo: 512 (cuadrado) o (1000, 2000) para rectángulo\n    # target_size = (1008, 2016)  # Por defecto cuadrado, igual a img_size\n    \n    seed = 42\n    n_fold = 5\n    \n    # 7-Head Multi-Task: 5 biomass targets + 2 auxiliary (NDVI, Height)\n    # Auxiliares ayudan al modelo a aprender features que correlacionan con biomasa:\n    # - NDVI → \"verdor/salud\" → útil para Dry_Green_g, GDM_g\n    # - Height → \"densidad/volumen\" → útil para Dry_Total_g\n    train_target_cols = ['Dry_Green_g', 'Dry_Dead_g', 'Dry_Clover_g', 'GDM_g', 'Dry_Total_g',\n                         'Pre_GSHH_NDVI', 'Height_Ave_cm']  # 7 targets\n    # Pesos: auxiliares con peso bajo (0.05) para no dominar el loss\n    train_target_weights = [0.09, 0.09, 0.09, 0.18, 0.45, 0.05, 0.05]\n    \n    # Targets finales para submission (SIN auxiliares)\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]  # Original para evaluación\n    \n    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n    \n    # Species Classifier Config (DINOv2 - 87% accuracy)\n    use_species_features = True\n    species_ckpt_path = '/kaggle/input/cnn-clasificacion'  # Directorio con model_fold_classifier_*.pth\n    \n    FULL_TRAIN = False\n\n    # DEBUG: Set to True to predict on train.csv instead of test.csv\n    DEBUG_MODE = False  # ⚠️ Set to False for actual submission!\n    \n    # GeM Pooling Config\n    use_gem_pooling = False  # Usar GeM Pooling en lugar de solo Average Pooling\n    gem_p = 3.0  # Parámetro p de GeM (p→1 ≈ avg, p→∞ ≈ max)\n\n# ====================================================\n# 工具函数\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\nseed_everything(CFG.seed)\n\ndef weighted_r2_metric(y_true, y_pred, verbose=False):\n    \"\"\"比赛评估指标: 加权 R2\"\"\"\n    weights = np.array(CFG.target_weights)\n    if y_true.ndim == 1: y_true = y_true.reshape(-1, 1)\n    if y_pred.ndim == 1: y_pred = y_pred.reshape(-1, 1)\n    \n    # R² por target individual\n    if verbose:\n        print(\"\\n=== R² por Target ===\")\n        for i, col in enumerate(CFG.target_cols):\n            ss_res_i = np.sum((y_true[:, i] - y_pred[:, i])**2)\n            ss_tot_i = np.sum((y_true[:, i] - np.mean(y_true[:, i]))**2)\n            r2_i = 1 - (ss_res_i / (ss_tot_i + 1e-8))\n            print(f\"  {col}: R²={r2_i:.6f} (weight={weights[i]})\")\n    \n    ss_res = np.sum(weights * np.sum((y_true - y_pred)**2, axis=0))\n    global_mean = np.average(np.mean(y_true, axis=0), weights=weights)\n    ss_tot = np.sum(weights * np.sum((y_true - global_mean)**2, axis=0))\n    return 1 - (ss_res / (ss_tot + 1e-8))\n\ndef post_process_biomass(df_preds):\n    \"\"\"物理约束后处理\"\"\"\n    ordered_cols = [\"Dry_Green_g\", \"Dry_Clover_g\", \"Dry_Dead_g\", \"GDM_g\", \"Dry_Total_g\"]\n    if not all(col in df_preds.columns for col in ordered_cols):\n        return df_preds\n    \n    Y = df_preds[ordered_cols].values.T\n    C = np.array([[1, 1, 0, -1, 0], [0, 0, 1, 1, -1]], dtype=float)\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 = np.eye(5) - C.T @ inv_CCt @ C\n    Y_reconciled = (P @ Y).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    return df_out\n\n# Especies SIN clover (donde Dry_Clover_g siempre debe ser 0)\nSPECIES_WITHOUT_CLOVER = ['Lucerne', 'Ryegrass', 'Fescue', 'Phalaris']\n\n# Lista completa de especies (ORDEN VERIFICADO desde train.csv unique())\n# Este orden coincide EXACTAMENTE con el clasificador entrenado\nSPECIES_LIST = [\n    'Ryegrass_Clover',  # 0 - tiene clover\n    'Lucerne',  # 1 - NO tiene clover\n    'SubcloverDalkeith',  # 2 - tiene clover\n    'Ryegrass',  # 3 - NO tiene clover\n    'Phalaris_Clover',  # 4 - tiene clover\n    'SubcloverLosa',  # 5 - tiene clover\n    'Clover',  # 6 - tiene clover\n    'Fescue_CrumbWeed',  # 7 - NO tiene clover\n    'Phalaris_Ryegrass_Clover',  # 8 - tiene clover\n    'Phalaris',  # 9 - NO tiene clover\n    'WhiteClover',  # 10 - tiene clover\n    'Fescue',  # 11 - NO tiene clover\n    'Phalaris_Clover_Ryegrass_Barleygrass_Bromegrass',  # 12 - tiene clover (corregido orden)\n    'Phalaris_BarleyGrass_SilverGrass_SpearGrass_Clover_Capeweed',  # 13 - tiene clover\n    'Mixed',  # 14 - asumimos puede tener clover\n]\n\ndef apply_species_gating(df_preds, species_probs, confidence_threshold=0.90):\n    \"\"\"\n    Gating basado en especies: fuerza targets a 0 cuando la especie predicha\n    lo requiere (verificado 100% en train.csv).\n    \n    Reglas de gating:\n    1. Especies sin clover (Lucerne, Phalaris, Fescue) → Dry_Clover_g = 0\n    2. Subclovers (SubcloverDalkeith, SubcloverLosa) → Dry_Green_g = 0, Dry_Dead_g = 0\n    \"\"\"\n    df_out = df_preds.copy()\n    \n    # Obtener clase predicha y su probabilidad\n    predicted_class = np.argmax(species_probs, axis=1)\n    max_prob = np.max(species_probs, axis=1)\n    is_confident = max_prob > confidence_threshold\n    \n    # === GATING 1: Clover=0 para especies sin clover ===\n    NO_CLOVER_SPECIES = [1, 9, 11]  # Lucerne, Phalaris, Fescue\n    is_non_clover = np.isin(predicted_class, NO_CLOVER_SPECIES)\n    clover_mask = is_non_clover & is_confident\n    \n    if clover_mask.any():\n        n_forced = clover_mask.sum()\n        species_counts = {SPECIES_LIST[i]: (predicted_class[clover_mask] == i).sum() \n                         for i in NO_CLOVER_SPECIES if (predicted_class[clover_mask] == i).any()}\n        old_values = df_out.loc[clover_mask, 'Dry_Clover_g']\n        print(f\"[Clover Gating] {n_forced} samples → Clover=0 (conf > {confidence_threshold})\")\n        print(f\"  Breakdown: {species_counts}\")\n        print(f\"  Before: Mean={old_values.mean():.4f}, Max={old_values.max():.4f}\")\n        df_out.loc[clover_mask, 'Dry_Clover_g'] = 0.0\n    \n    # === GATING 2: Green=0 y Dead=0 para Subclovers ===\n    # SubcloverDalkeith (2) y SubcloverLosa (5) tienen Green=0 y Dead=0 en 100% de muestras\n    SUBCLOVER_SPECIES = [2, 5]  # SubcloverDalkeith, SubcloverLosa\n    is_subclover = np.isin(predicted_class, SUBCLOVER_SPECIES)\n    subclover_mask = is_subclover & is_confident\n    \n    if subclover_mask.any():\n        n_forced = subclover_mask.sum()\n        species_counts = {SPECIES_LIST[i]: (predicted_class[subclover_mask] == i).sum() \n                         for i in SUBCLOVER_SPECIES if (predicted_class[subclover_mask] == i).any()}\n        \n        old_green = df_out.loc[subclover_mask, 'Dry_Green_g']\n        old_dead = df_out.loc[subclover_mask, 'Dry_Dead_g']\n        print(f\"[Subclover Gating] {n_forced} samples → Green=0, Dead=0 (conf > {confidence_threshold})\")\n        print(f\"  Breakdown: {species_counts}\")\n        print(f\"  Green before: Mean={old_green.mean():.4f}, Max={old_green.max():.4f}\")\n        print(f\"  Dead before: Mean={old_dead.mean():.4f}, Max={old_dead.max():.4f}\")\n        \n        df_out.loc[subclover_mask, 'Dry_Green_g'] = 0.0\n        df_out.loc[subclover_mask, 'Dry_Dead_g'] = 0.0\n    \n    return df_out\n\n\n# ====================================================\n# 数据加载与处理\n# ====================================================\ndef load_data():\n    train_df = pd.read_csv(CFG.train_path)\n    \n    # Switch data source based on DEBUG_MODE\n    test_path = CFG.train_path if CFG.DEBUG_MODE else CFG.test_path\n    \n    if CFG.DEBUG_MODE:\n        print(\"⚠️ DEBUG MODE ENABLED: Using FILTERED train.csv as test data\")\n        # IDs de muestra para verificar: Lucerne (0 clover), Fescue (0 clover), Ryegrass (0 clover), Clover (>0)\n        debug_ids = [\n            \"ID1012260530\", # Lucerne\n            \"ID1119761112\", # Fescue\n            \"ID1028611175\", # Ryegrass\n            \"ID1053972079\", # Clover\n            \"ID1011485656\"  # Ryegrass_Clover\n        ]\n        # Filtrar raw dataframe antes de cualquier procesamiento\n        raw_df = pd.read_csv(test_path)\n        \n        # Extraer ID para filtrar (asumiendo formato IDxxxx_Target)\n        if 'sample_id' in raw_df.columns:\n            # Crear columna temporar de ID base\n            raw_df['base_id'] = raw_df['sample_id'].str.split('__').str[0]\n            test_df = raw_df[raw_df['base_id'].isin(debug_ids)].copy()\n            test_df = test_df.drop(columns=['base_id'])\n        else:\n            test_df = raw_df # Fallback\n            \n        print(f\"   -> Debug subset size: {len(test_df)}\")\n    else:\n        test_df = pd.read_csv(test_path)\n    \n    def parse_ids(df):\n        if 'sample_id' in df.columns:\n            df[['image_id', 'target_type']] = df['sample_id'].str.split('__', n=1, expand=True)\n        return df\n\n    train_df = parse_ids(train_df)\n    test_df = parse_ids(test_df)\n    \n    # IMPORTANTE: Incluir Sampling_Date y State para StratifiedGroupKFold\n    meta_cols = ['image_id', 'image_path', 'Species', 'Pre_GSHH_NDVI', 'Height_Ave_cm', 'Sampling_Date', 'State']\n    train_pivot = train_df.pivot_table(index=meta_cols, columns='target_name', values='target').reset_index()\n    train_pivot.columns.name = None\n    \n    # Asegurar que test también tenga las columnas necesarias (Species no siempre está en test real, \n    # pero en DEBUG_MODE sí. Si es test real, 'Species' no existe, así que debemos manejarlo)\n    \n    if 'Species' in test_df.columns:\n         test_pivot = test_df[meta_cols].drop_duplicates().reset_index(drop=True)\n    else:\n         # Test real no tiene Species (o tal vez sí en sample_submission?), \n         # pero aqui solo nos importa train para el OOF\n         test_meta = ['image_id', 'image_path']\n         test_pivot = test_df[test_meta].drop_duplicates().reset_index(drop=True)\n    \n    return train_pivot, test_pivot\n\nclass BiomassDataset(Dataset):\n    def __init__(self, df, transform=None):\n        self.image_paths = df['image_path'].values\n        self.transform = transform\n        \n    def __len__(self):\n        return len(self.image_paths)\n    \n    def __getitem__(self, idx):\n        path = os.path.join(CFG.DATA_PATH, self.image_paths[idx])\n        try:\n            image = Image.open(path).convert(\"RGB\")\n        except:\n            image = Image.new(\"RGB\", (256, 256))\n            \n        if self.transform:\n            image = self.transform(image)\n        return image\n\n# ====================================================\n# Clasificador de Especies (DINOv2 Two-Stream - 87% accuracy)\n# ====================================================\nclass DINOv2SpeciesClassifier(nn.Module):\n    \"\"\"\n    DINOv2 Species Classifier - Two-stream para imagen estéreo.\n    Procesa left/right por separado y concatena features.\n    \"\"\"\n    def __init__(\n        self,\n        num_species=15,\n        model_path=\"/kaggle/input/dinov2/pytorch/large/1\",\n        freeze_backbone=True,\n        hidden_dim=512,\n        dropout=0.1,\n    ):\n        super().__init__()\n        self.backbone = AutoModel.from_pretrained(model_path)\n        embed_dim = self.backbone.config.hidden_size  # 1024 for large\n\n        # Head: concat left+right → hidden → output\n        self.head = nn.Sequential(\n            nn.Linear(embed_dim * 2, hidden_dim),\n            nn.GELU(),\n            nn.Dropout(dropout),\n            nn.Linear(hidden_dim, num_species),\n        )\n\n        if freeze_backbone:\n            for p in self.backbone.parameters():\n                p.requires_grad = False\n\n    def forward(self, x_left, x_right):\n        # Extract CLS token from each side\n        outputs_l = self.backbone(pixel_values=x_left)\n        cls_l = outputs_l.last_hidden_state[:, 0]\n\n        outputs_r = self.backbone(pixel_values=x_right)\n        cls_r = outputs_r.last_hidden_state[:, 0]\n\n        # Combine and classify\n        combined = torch.cat([cls_l, cls_r], dim=1)\n        return self.head(combined)\n\n\nclass SpeciesDatasetStereo(Dataset):\n    \"\"\"Dataset para clasificación de especies con imagen estéreo (left/right)\"\"\"\n    def __init__(self, df, transform=None, input_res=224):\n        self.image_paths = df['image_path'].values\n        self.transform = transform\n        self.input_res = input_res\n        \n    def __len__(self):\n        return len(self.image_paths)\n    \n    def split_stereo(self, img):\n        \"\"\"Divide imagen rectangular en left/right squares\"\"\"\n        H, W = img.shape[:2]\n        left = img[:, :H, :]\n        right = img[:, W-H:, :]\n        return left, right\n    \n    def __getitem__(self, idx):\n        path = os.path.join(CFG.DATA_PATH, self.image_paths[idx])\n        img = np.array(Image.open(path).convert(\"RGB\"))\n        \n        # Split into left and right\n        left, right = self.split_stereo(img)\n        \n        # Resize\n        left = Image.fromarray(left).resize((self.input_res, self.input_res))\n        right = Image.fromarray(right).resize((self.input_res, self.input_res))\n        \n        # Transform\n        if self.transform:\n            left = self.transform(left)\n            right = self.transform(right)\n        \n        return left, right\n\n\ndef load_species_classifier(checkpoint_path, device='cuda', num_species=15):\n    \"\"\"\n    Carga checkpoints de DINOv2SpeciesClassifier (5-fold ensemble).\n    Returns: lista de modelos en eval mode\n    \"\"\"\n    import glob\n    \n    # Si es un solo archivo\n    if checkpoint_path.endswith('.pth'):\n        print(f\"Loading single species classifier from {checkpoint_path}...\")\n        model = DINOv2SpeciesClassifier(num_species=num_species, freeze_backbone=True)\n        model.load_state_dict(torch.load(checkpoint_path, map_location=device))\n        model.to(device)\n        model.eval()\n        return [model]\n    \n    # Si es directorio, cargar todos los folds\n    pattern = os.path.join(checkpoint_path, 'model_fold_classifier_*.pth')\n    fold_paths = sorted(glob.glob(pattern))\n    \n    if not fold_paths:\n        raise FileNotFoundError(f\"No checkpoints found in {checkpoint_path}\")\n    \n    models = []\n    for path in fold_paths:\n        print(f\"Loading species classifier from {path}...\")\n        model = DINOv2SpeciesClassifier(num_species=num_species, freeze_backbone=True)\n        model.load_state_dict(torch.load(path, map_location=device))\n        model.to(device)\n        model.eval()\n        models.append(model)\n    \n    print(f\"✅ Loaded {len(models)} species classifier models\")\n    return models\n\n\ndef extract_species_features(df, species_models, device='cuda', silent=False):\n    \"\"\"\n    Extrae probabilidades de especies usando ensemble de modelos.\n    Returns: numpy array [num_images, 15]\n    \"\"\"\n    if not silent:\n        print(f\"Extracting species features for {len(df)} images...\")\n    \n    # Transform (224x224)\n    transform = T.Compose([\n        T.ToTensor(),\n        T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])\n    ])\n    \n    dataset = SpeciesDatasetStereo(df, transform=transform, input_res=224)\n    loader = DataLoader(dataset, batch_size=CFG.batch_size, shuffle=False, num_workers=2)\n    \n    all_probs = []\n    with torch.no_grad():\n        for left, right in tqdm(loader, desc=\"Species features\"):\n            left, right = left.to(device), right.to(device)\n            \n            # Ensemble: promedio de logits de todos los modelos\n            fold_logits = []\n            for model in species_models:\n                logits = model(left, right)\n                fold_logits.append(logits)\n            \n            avg_logits = torch.stack(fold_logits).mean(dim=0)\n            probs = torch.softmax(avg_logits, dim=1).cpu().numpy()\n            all_probs.append(probs)\n    \n    return np.concatenate(all_probs)\n\n\n# ====================================================\n# 特征提取 (DINOv2)\n# ====================================================\ndef get_dinov2_model():\n    if AutoModel is not None:\n        for path in CFG.dinov2_paths:\n            if os.path.exists(path):\n                print(f\"Loading DINOv2 (Transformers) from: {path}\")\n                try:\n                    model = AutoModel.from_pretrained(path, local_files_only=True)\n                    return model.to(CFG.device).eval()\n                except Exception as e:\n                    print(f\"Failed to load from {path}: {e}\")\n\n    print(\"Trying to load via torch.hub (facebookresearch/dinov2)...\")\n    try:\n        torch.hub.set_dir('/kaggle/working/torch_hub_cache')\n        model = torch.hub.load('facebookresearch/dinov2', 'dinov2_vitl14', pretrained=True)\n        print(\"Successfully loaded DINOv2 via torch.hub!\")\n        return model.to(CFG.device).eval()\n    except Exception as e:\n        print(f\"torch.hub load failed: {e}\")\n        \n    if AutoModel is not None:\n        print(\"Warning: Local DINOv2 not found. Trying to download...\")\n        try:\n            model = AutoModel.from_pretrained('facebook/dinov2-large')\n            return model.to(CFG.device).eval()\n        except Exception as e:\n            print(f\"Transformers download failed: {e}\")\n\n    raise RuntimeError(\"Could not load DINOv2 model.\")\n\n# ====================================================\n# GeM Pooling (Generalized Mean Pooling)\n# ====================================================\nclass GeMPooling(nn.Module):\n    \"\"\"\n    Generalized Mean Pooling (GeM)\n    Paper: https://arxiv.org/abs/1711.02512\n    \n    GeM es una generalización de Average Pooling (p=1) y Max Pooling (p→∞).\n    Permite aprender un pooling intermedio que puede ser más efectivo.\n    \n    Args:\n        p: Parámetro de potencia (default=3.0)\n           - p=1: Equivalente a Average Pooling\n           - p→∞: Equivalente a Max Pooling\n           - p=3.0: Valor típico que funciona bien en la práctica\n        eps: Valor pequeño para estabilidad numérica\n    \"\"\"\n    def __init__(self, p=3.0, eps=1e-6):\n        super().__init__()\n        self.p = nn.Parameter(torch.ones(1) * p)\n        self.eps = eps\n    \n    def forward(self, x):\n        \"\"\"\n        Args:\n            x: Tensor de forma [batch, seq_len, dim] (patch tokens)\n        \n        Returns:\n            Tensor de forma [batch, dim] (pooled features)\n        \"\"\"\n        # Clamp para evitar valores negativos o cero\n        x = x.clamp(min=self.eps)\n        # Elevar a la potencia p\n        x = x.pow(self.p)\n        # Promediar sobre la dimensión de secuencia\n        x = x.mean(dim=1)  # [batch, dim]\n        # Raíz p-ésima para obtener GeM\n        x = x.pow(1.0 / self.p)\n        return x\n\n# ====================================================\n# Letterbox Resize Transform (Para imágenes rectangulares)\n# ====================================================\nclass LetterboxResize(nn.Module):\n    \"\"\"\n    Letterbox resize que mantiene la relación de aspecto original.\n    Preserva la geometría real de la imagen añadiendo padding en lugar de estirar.\n    \n    Ideal para imágenes panorámicas (ej. 2000x1000) que deben convertirse a cuadrados\n    sin distorsionar la densidad de píxeles del pasto/vegetación.\n    \"\"\"\n    def __init__(self, target_size, fill_color=(0, 0, 0)):\n        \"\"\"\n        Args:\n            target_size: int (cuadrado) o tuple (h, w) -> Ejemplo: (1000, 2000)\n            fill_color: tuple (R, G, B) para el padding (negro por defecto)\n        \"\"\"\n        super().__init__()\n        if isinstance(target_size, int):\n            self.target_h = self.target_w = target_size\n        else:\n            self.target_h, self.target_w = target_size  # Nota: PyTorch suele usar (h, w)\n            \n        self.fill_color = fill_color\n\n    def forward(self, img):\n        \"\"\"\n        Args:\n            img: PIL Image\n        \n        Returns:\n            PIL Image con letterboxing aplicado\n        \"\"\"\n        # img: PIL Image\n        orig_w, orig_h = img.size\n        \n        # 1. Calcular el factor de escala 'min' para que quepa entera\n        scale = min(self.target_w / orig_w, self.target_h / orig_h)\n        \n        # 2. Nuevas dimensiones redimensionadas\n        new_w = int(orig_w * scale)\n        new_h = int(orig_h * scale)\n        \n        # 3. Resize de alta calidad usando BICUBIC (balanceado para ML)\n        resized = img.resize((new_w, new_h), Image.Resampling.BICUBIC)\n        \n        # 4. Crear lienzo (canvas) con padding\n        new_img = Image.new(\"RGB\", (self.target_w, self.target_h), self.fill_color)\n        \n        # 5. Pegar centrado\n        paste_x = (self.target_w - new_w) // 2\n        paste_y = (self.target_h - new_h) // 2\n        new_img.paste(resized, (paste_x, paste_y))\n        \n        return new_img\n\ndef extract_features(df, model):\n    print(f\"Extracting features for {len(df)} images...\")\n    if CFG.use_gem_pooling:\n        print(f\"Using GeM Pooling (p={CFG.gem_p})\")\n    \n    mean = [0.485, 0.456, 0.406]\n    std = [0.229, 0.224, 0.225]\n    \n    transforms_list = [\n        # 使用 Resize 保持宽高比并 Crop，或者直接 Resize 到目标尺寸 (这里采用 Resize+CenterCrop 以匹配预训练习惯)\n        T.Compose([T.Resize(CFG.img_size), T.CenterCrop(CFG.img_size), T.ToTensor(), T.Normalize(mean, std)]),\n        T.Compose([T.Resize(CFG.img_size), T.CenterCrop(CFG.img_size), T.RandomHorizontalFlip(p=1.0), T.ToTensor(), T.Normalize(mean, std)]),\n        T.Compose([T.Resize(CFG.img_size), T.CenterCrop(CFG.img_size), T.RandomVerticalFlip(p=1.0), T.ToTensor(), T.Normalize(mean, std)])\n    ]\n    \n    # Inicializar GeM Pooling si se usa\n    gem_pool = None\n    if CFG.use_gem_pooling:\n        gem_pool = GeMPooling(p=CFG.gem_p).to(CFG.device)\n        gem_pool.eval()  # Modo evaluación (no entrenamiento)\n    \n    is_transformer_model = hasattr(model, 'last_hidden_state') or hasattr(model, 'embeddings')\n    feats_tta = []\n    \n    for i, transform in enumerate(transforms_list):\n        ds = BiomassDataset(df, transform=transform)\n        ld = DataLoader(ds, batch_size=CFG.batch_size, shuffle=False, num_workers=2)\n        \n        feats_epoch = []\n        with torch.no_grad():\n            for imgs in tqdm(ld, desc=f\"TTA Round {i+1}\"):\n                imgs = imgs.to(CFG.device)\n                \n                if is_transformer_model:\n                    outputs = model(imgs)\n                    last_hidden = outputs.last_hidden_state\n                    cls_token = last_hidden[:, 0, :]\n                    patch_tokens = last_hidden[:, 1:, :]\n                    \n                    # Usar GeM Pooling si está habilitado\n                    if CFG.use_gem_pooling and gem_pool is not None:\n                        gem_pooled = gem_pool(patch_tokens)  # [batch, dim]\n                        avg_pool = patch_tokens.mean(dim=1)\n                        max_pool = patch_tokens.max(dim=1)[0]\n                        # Combinar: CLS + GeM + Avg + Max (features más ricas)\n                        emb = torch.cat([cls_token, gem_pooled, avg_pool, max_pool], dim=1)\n                    else:\n                        # Original: CLS + Avg + Max\n                        avg_pool = patch_tokens.mean(dim=1)\n                        max_pool = patch_tokens.max(dim=1)[0]\n                        emb = torch.cat([cls_token, avg_pool, max_pool], dim=1)\n                else:\n                    outputs = model.forward_features(imgs)\n                    if isinstance(outputs, dict):\n                        cls_token = outputs['x_norm_clstoken']\n                        patch_tokens = outputs['x_norm_patchtokens']\n                        \n                        # Usar GeM Pooling si está habilitado\n                        if CFG.use_gem_pooling and gem_pool is not None:\n                            gem_pooled = gem_pool(patch_tokens)\n                            avg_pool = patch_tokens.mean(dim=1)\n                            max_pool = patch_tokens.max(dim=1)[0]\n                            emb = torch.cat([cls_token, gem_pooled, avg_pool, max_pool], dim=1)\n                        else:\n                            avg_pool = patch_tokens.mean(dim=1)\n                            max_pool = patch_tokens.max(dim=1)[0]\n                            emb = torch.cat([cls_token, avg_pool, max_pool], dim=1)\n                    else:\n                        if outputs.dim() == 3:\n                            cls_token = outputs[:, 0, :]\n                            patch_tokens = outputs[:, 1:, :]\n                            \n                            # Usar GeM Pooling si está habilitado\n                            if CFG.use_gem_pooling and gem_pool is not None:\n                                gem_pooled = gem_pool(patch_tokens)\n                                avg_pool = patch_tokens.mean(dim=1)\n                                max_pool = patch_tokens.max(dim=1)[0]\n                                emb = torch.cat([cls_token, gem_pooled, avg_pool, max_pool], dim=1)\n                            else:\n                                avg_pool = patch_tokens.mean(dim=1)\n                                max_pool = patch_tokens.max(dim=1)[0]\n                                emb = torch.cat([cls_token, avg_pool, max_pool], dim=1)\n                        else:\n                            emb = outputs\n                        \n                feats_epoch.append(emb.cpu().numpy())\n        feats_tta.append(np.concatenate(feats_epoch))\n    \n    # Weighted average en TTA (HUVR-style)\n    # Original tiene más peso que las transformadas\n    # Pesos: [Original, HorizontalFlip, VerticalFlip]\n    n_transforms = len(transforms_list)\n    if n_transforms == 3:\n        # 3 transformaciones: Original, H-Flip, V-Flip\n        weights = np.array([0.50, 0.25, 0.25])  # Original tiene más peso\n    elif n_transforms == 5:\n        # 5 transformaciones: Original, H-Flip, V-Flip, Rotate, ColorJitter\n        weights = np.array([0.40, 0.20, 0.20, 0.10, 0.10])\n    else:\n        # Fallback: más peso al primero (original)\n        weights = np.ones(n_transforms)\n        weights[0] = 2.0  # Original tiene doble peso\n        weights = weights / weights.sum()\n    \n    # Normalizar pesos para que sumen 1.0\n    weights = weights / weights.sum()\n    \n    print(f\"  TTA weighted average: {len(transforms_list)} transforms, weights={weights}\")\n    final_feats = np.average(feats_tta, axis=0, weights=weights)\n    feat_cols = [f\"feat_{i}\" for i in range(final_feats.shape[1])]\n    \n    if CFG.use_gem_pooling:\n        # Con GeM: CLS + GeM + Avg + Max = 4 * dim\n        # Sin GeM: CLS + Avg + Max = 3 * dim\n        dim_per_component = final_feats.shape[1] // 4\n        dim_without_gem = dim_per_component * 3\n        print(f\"Feature dimension: {final_feats.shape[1]} (with GeM: CLS+GeM+Avg+Max)\")\n        print(f\"  Without GeM would be: {dim_without_gem} (CLS+Avg+Max)\")\n    \n    return pd.DataFrame(final_feats, columns=feat_cols)\n# ====================================================\n# Feature Normalization (LayerNorm style from HUVR)\n# ====================================================\nclass FeatureLayerNorm:\n    \"\"\"\n    LayerNorm-based feature normalizer (inspired by HUVR)\n    Uses PyTorch's LayerNorm with affine=False (no learnable parameters).\n    This matches HUVR's approach: BatchNorm1d(fc_dim, affine=False, eps=1e-6)\n    LayerNorm normalizes over the feature dimension, making it\n    more stable for inference and independent of batch statistics.\n    \"\"\"\n    def __init__(self, feature_dim, eps=1e-6):\n        \"\"\"\n        Args:\n            feature_dim: Dimension of features (number of features per sample)\n            eps: Small value for numerical stability\n        \"\"\"\n        self.feature_dim = feature_dim\n        self.eps = eps\n        # PyTorch LayerNorm without learnable parameters (affine=False)\n        # This matches HUVR's BatchNorm1d(affine=False)\n        self.layer_norm = nn.LayerNorm(feature_dim, eps=eps, elementwise_affine=False)\n        self.fitted = False\n    \n    def fit(self, X):\n        \"\"\"\n        Fit the normalizer (no-op for LayerNorm, but kept for sklearn compatibility).\n        \n        Args:\n            X: Training features [N, D]\n        \"\"\"\n        if X.shape[1] != self.feature_dim:\n            raise ValueError(f\"Feature dimension mismatch: expected {self.feature_dim}, got {X.shape[1]}\")\n        \n        # LayerNorm doesn't need fitting (no learnable parameters with affine=False)\n        # But we set fitted=True for compatibility\n        self.fitted = True\n        return self\n    \n    def transform(self, X):\n        \"\"\"\n        Normalize features using PyTorch LayerNorm (no learnable parameters).\n        \n        Args:\n            X: Features to normalize [N, D]\n        \n        Returns:\n            Normalized features [N, D]\n        \"\"\"\n        if not self.fitted:\n            raise ValueError(\"Normalizer must be fitted before transform\")\n        \n        if X.shape[1] != self.feature_dim:\n            raise ValueError(f\"Feature dimension mismatch: expected {self.feature_dim}, got {X.shape[1]}\")\n        \n        # Convert to tensor, normalize, convert back\n        X_tensor = torch.FloatTensor(X)\n        # LayerNorm normalizes over last dimension (feature dimension)\n        X_normalized = self.layer_norm(X_tensor)\n        return X_normalized.numpy()\n    \n    def fit_transform(self, X):\n        \"\"\"Fit and transform in one step.\"\"\"\n        return self.fit(X).transform(X)\n\n# ====================================================\n# Stacking Ensemble\n# ====================================================\nclass StackingTrainer:\n    def __init__(self, n_folds=5, seed=42, lgbm_params=None, xgb_params=None):\n        self.n_folds = n_folds\n        self.seed = seed\n        self.models = {}\n        self.lgbm_params = lgbm_params\n        self.xgb_params = xgb_params\n        \n    def get_base_models(self):\n        \"\"\"定义基模型 (Level 1)\"\"\"\n        # 1. LightGBM\n        if self.lgbm_params is None:\n            self.lgbm_params = {\n                'n_estimators': 300,\n                'learning_rate': 0.05,\n                'num_leaves': 31,\n                'random_state': self.seed,\n                'n_jobs': -1,\n                'verbose': -1\n            }\n            if torch.cuda.is_available():\n                 self.lgbm_params.update({'device': 'gpu'})\n                 \n        lgbm = lgb.LGBMRegressor(**self.lgbm_params)\n        \n        # 2. XGBoost (GPU Accelerated)\n        if self.xgb_params is None:\n             self.xgb_params = {\n                'n_estimators': 300,\n                'learning_rate': 0.05,\n                'max_depth': 6,\n                'subsample': 0.8,\n                'colsample_bytree': 0.8,\n                'objective': 'reg:squarederror',\n                'n_jobs': -1,\n                'random_state': self.seed,\n                'tree_method': 'hist',\n                'device': 'cuda' if torch.cuda.is_available() else 'cpu'\n            }\n        xgb_model = xgb.XGBRegressor(**self.xgb_params)\n        \n        # 3. Ridge Regression (线性模型，抗过拟合)\n        ridge = Ridge(alpha=1.0, random_state=self.seed)\n        \n        # 4. PLS Regression (El nuevo experto para features de alta dimensión)\n        # PLS es excelente para features de DINOv2 (3840 features) con pocas muestras\n        # n_components=10 es un buen punto de partida para ~300 muestras\n        pls = PLSRegression(n_components=10, scale=True)\n        \n        return {\n            'lgbm': MultiOutputRegressor(lgbm),\n            'xgb': MultiOutputRegressor(xgb_model),\n            'ridge': MultiOutputRegressor(ridge),\n            'pls': MultiOutputRegressor(pls)  # 4to modelo base\n        }\n\n    def train_predict(self, X, y, X_test, groups=None, stratify=None):\n        \"\"\"训练基模型并生成 Out-of-Fold 预测和测试集预测\n        \n        Args:\n            groups: array-like, grupo para cada muestra (Sampling_Date)\n            stratify: array-like, variable para estratificar (State)\n        \"\"\"\n        # Usar StratifiedGroupKFold si tenemos grupos y estratificación\n        if groups is not None and stratify is not None:\n            from sklearn.model_selection import StratifiedGroupKFold\n            kf = StratifiedGroupKFold(n_splits=self.n_folds, shuffle=True, random_state=self.seed)\n            split_args = (X, stratify, groups)\n            print(f\"Using StratifiedGroupKFold (groups={len(np.unique(groups))} unique dates, stratify by State)\")\n        else:\n            kf = KFold(n_splits=self.n_folds, shuffle=True, random_state=self.seed)\n            split_args = (X, y)\n            print(\"Using simple KFold\")\n        \n        base_models = self.get_base_models()\n        self.models = base_models\n        \n        # Initialize OOF and Test preds\n        oof_preds = {name: np.zeros(y.shape) for name in base_models}\n        test_preds = {name: np.zeros((X_test.shape[0], y.shape[1])) for name in base_models}\n        \n        # --- FULL TRAIN MODE ---\n        if hasattr(CFG, 'FULL_TRAIN') and CFG.FULL_TRAIN:\n            print(\"FULL TRAIN MODE ACTIVATED: Training on 100% of data\")\n            for name, model in base_models.items():\n                print(f\"Training {name} on full dataset...\")\n                model.fit(X, y)\n                test_preds[name] = np.clip(model.predict(X_test), 0, None)\n                # OOF dummy\n                oof_preds[name] = np.clip(model.predict(X), 0, None)\n            return oof_preds, test_preds\n            \n        print(\"Training Base Models...\")\n        \n        for fold, (train_idx, val_idx) in enumerate(kf.split(*split_args)):\n            print(f\"  Fold {fold+1}/{self.n_folds} (train: {len(train_idx)}, val: {len(val_idx)})\")\n            X_tr, X_val = X[train_idx], X[val_idx]\n            y_tr, y_val = y[train_idx], y[val_idx]\n            \n            for name, model in base_models.items():\n                model_fold = clone(model)\n                model_fold.fit(X_tr, y_tr)\n                \n                val_pred = model_fold.predict(X_val)\n                val_pred = np.clip(val_pred, 0, None)\n                oof_preds[name][val_idx] = val_pred\n                \n                test_pred = model_fold.predict(X_test)\n                test_pred = np.clip(test_pred, 0, None)\n                test_preds[name] += test_pred / self.n_folds\n                \n        return oof_preds, test_preds\n\n    def fit_stacking(self, oof_preds, y_true, test_preds_dict):\n        \"\"\"训练元模型 (Level 2) - 使用简单的加权或 Ridge\"\"\"\n        print(\"\\nTraining Meta Model (Stacking with Chained Regression)...\")\n        \n        final_oof = np.zeros_like(y_true)\n        final_test_pred = np.zeros_like(list(test_preds_dict.values())[0])\n        \n        n_targets = y_true.shape[1]\n        \n        # ====================================================\n        # CHAINED REGRESSION: Predecir aux primero, luego usar para biomasa\n        # Orden: [0:Green, 1:Dead, 2:Clover, 3:GDM, 4:Total, 5:NDVI, 6:Height]\n        # Paso 1: Predecir NDVI (5) y Height (6) primero (sin chain)\n        # Paso 2: Usar NDVI/Height predichos como features para biomasa (0-4)\n        # ====================================================\n        \n        # Indices de targets auxiliares (NDVI, Height) y biomasa\n        aux_indices = [5, 6] if n_targets > 5 else []\n        biomass_indices = list(range(min(5, n_targets)))\n        \n        # Almacenar predicciones auxiliares para usar como features\n        aux_oof_preds = {}\n        aux_test_preds = {}\n        \n        # === PASO 1: Predecir targets auxiliares (sin chain) ===\n        print(\"  [Chain Step 1] Predicting auxiliary targets (NDVI, Height)...\")\n        for i in aux_indices:\n            X_meta_train = np.column_stack([\n                oof_preds['lgbm'][:, i],\n                oof_preds['xgb'][:, i],\n                oof_preds['ridge'][:, i],\n                oof_preds['pls'][:, i]  # 4to modelo base: PLS\n            ])\n            \n            X_meta_test = np.column_stack([\n                test_preds_dict['lgbm'][:, i],\n                test_preds_dict['xgb'][:, i],\n                test_preds_dict['ridge'][:, i],\n                test_preds_dict['pls'][:, i]  # 4to modelo base: PLS\n            ])\n            \n            y_meta_train = y_true[:, i]\n            \n            meta_model = Ridge(alpha=10.0, random_state=self.seed)\n            meta_model.fit(X_meta_train, y_meta_train)\n            \n            final_oof[:, i] = meta_model.predict(X_meta_train)\n            final_test_pred[:, i] = meta_model.predict(X_meta_test)\n            \n            # Guardar para usar en chain\n            aux_name = ['NDVI', 'Height'][i - 5] if i >= 5 else f'Aux{i}'\n            aux_oof_preds[i] = final_oof[:, i]\n            aux_test_preds[i] = final_test_pred[:, i]\n            \n            print(f\"    Target {i} ({aux_name}) -> LGBM: {meta_model.coef_[0]:.3f}, XGB: {meta_model.coef_[1]:.3f}, Ridge: {meta_model.coef_[2]:.3f}, PLS: {meta_model.coef_[3]:.3f}\")\n        \n        # === PASO 2: Predecir biomasa usando aux predichos como features ===\n        print(\"  [Chain Step 2] Predicting biomass targets (with aux predictions as features)...\")\n        for i in biomass_indices:\n            # Features base: predicciones de los 4 modelos (LGBM, XGB, Ridge, PLS)\n            X_meta_train = np.column_stack([\n                oof_preds['lgbm'][:, i],\n                oof_preds['xgb'][:, i],\n                oof_preds['ridge'][:, i],\n                oof_preds['pls'][:, i]  # 4to modelo base: PLS\n            ])\n            \n            X_meta_test = np.column_stack([\n                test_preds_dict['lgbm'][:, i],\n                test_preds_dict['xgb'][:, i],\n                test_preds_dict['ridge'][:, i],\n                test_preds_dict['pls'][:, i]  # 4to modelo base: PLS\n            ])\n            \n            # CHAIN: Agregar predicciones auxiliares como features adicionales\n            if aux_indices:\n                aux_features_train = np.column_stack([aux_oof_preds[j] for j in aux_indices])\n                aux_features_test = np.column_stack([aux_test_preds[j] for j in aux_indices])\n                \n                # Verificar alineación de filas antes de hstack\n                assert X_meta_train.shape[0] == aux_features_train.shape[0], \\\n                    f\"Row mismatch train: {X_meta_train.shape[0]} vs {aux_features_train.shape[0]}\"\n                assert X_meta_test.shape[0] == aux_features_test.shape[0], \\\n                    f\"Row mismatch test: {X_meta_test.shape[0]} vs {aux_features_test.shape[0]}\"\n                \n                X_meta_train = np.hstack([X_meta_train, aux_features_train])\n                X_meta_test = np.hstack([X_meta_test, aux_features_test])\n            \n            y_meta_train = y_true[:, i]\n            \n            meta_model = Ridge(alpha=10.0, random_state=self.seed)\n            meta_model.fit(X_meta_train, y_meta_train)\n            \n            final_oof[:, i] = meta_model.predict(X_meta_train)\n            final_test_pred[:, i] = meta_model.predict(X_meta_test)\n            \n            # Log weights (ahora incluye coeficientes para PLS y aux features)\n            target_name = CFG.train_target_cols[i] if hasattr(CFG, 'train_target_cols') and i < len(CFG.train_target_cols) else f'Target{i}'\n            coefs = meta_model.coef_\n            n_base_models = 4  # LGBM, XGB, Ridge, PLS\n            n_aux = len(aux_indices) if aux_indices else 0\n            \n            if len(coefs) > n_base_models:\n                # Tiene aux features\n                aux_str = \", \".join([f\"+{['NDVI', 'Height'][j]}: {coefs[n_base_models+j]:.3f}\" for j in range(n_aux)])\n                print(f\"    {target_name} -> LGBM: {coefs[0]:.3f}, XGB: {coefs[1]:.3f}, Ridge: {coefs[2]:.3f}, PLS: {coefs[3]:.3f}, {aux_str}\")\n            else:\n                # Solo modelos base\n                print(f\"    {target_name} -> LGBM: {coefs[0]:.3f}, XGB: {coefs[1]:.3f}, Ridge: {coefs[2]:.3f}, PLS: {coefs[3]:.3f}\")\n            \n        final_oof = np.clip(final_oof, 0, None)\n        final_test_pred = np.clip(final_test_pred, 0, None)\n        \n        return final_oof, final_test_pred\n\n# ====================================================\n# 主流程\n# ====================================================\ndef main():\n    print(\"Initializing Stacking Pipeline (LGBM + XGB + Ridge + PLS)...\")\n    \n    # 1. 数据准备\n    train_df, test_df = load_data()\n    print(f\"Train shape: {train_df.shape}, Test shape: {test_df.shape}\")\n    \n    # 2. 特征提取 - DINOv2\n    model = get_dinov2_model()\n    X_train_dinov2 = extract_features(train_df, model)\n    X_test_dinov2 = extract_features(test_df, model)\n    \n    del model\n    torch.cuda.empty_cache()\n    gc.collect()\n    \n    # 3. (OPCIONAL) Species Classifier - Usado para GATING (train y test)\n    train_species_probs = None\n    test_species_probs = None\n    if CFG.use_species_features and os.path.exists(CFG.species_ckpt_path):\n        species_models = load_species_classifier(CFG.species_ckpt_path, device=CFG.device)\n        \n        # Extraer probabilidades de especies para GATING\n        train_species_probs = extract_species_features(train_df, species_models, device=CFG.device)\n        test_species_probs = extract_species_features(test_df, species_models, device=CFG.device)\n        print(f\"Species probs: Train {train_species_probs.shape}, Test {test_species_probs.shape}\")\n        \n        del species_models\n        torch.cuda.empty_cache()\n        gc.collect()\n    \n    # Features para regresión: SOLO DINOv2\n    X_train = X_train_dinov2.values\n    X_test = X_test_dinov2.values\n    print(f\"DINOv2-only features: Train {X_train.shape}, Test {X_test.shape}\")\n    \n    # 4. 预处理\n    print(\"Preprocessing features...\")\n    \n    # Step 1: LayerNorm normalization (HUVR-style, before scaler)\n    # Uses PyTorch LayerNorm with elementwise_affine=False (no learnable parameters)\n    # This matches HUVR's BatchNorm1d(affine=False, eps=1e-6)\n    print(\"  Applying LayerNorm normalization (no learnable parameters, HUVR-style)...\")\n    layer_norm = FeatureLayerNorm(feature_dim=X_train.shape[1], eps=1e-6)\n    X_train = layer_norm.fit_transform(X_train)\n    X_test = layer_norm.transform(X_test)\n    print(f\"  LayerNorm applied: feature_dim={X_train.shape[1]}, eps=1e-6, affine=False\")\n    \n    # Step 2: RobustScaler (after LayerNorm)\n    print(\"  Applying RobustScaler...\")\n    scaler = RobustScaler()\n    X_train = scaler.fit_transform(X_train)\n    X_test = scaler.transform(X_test)\n    \n    Y_train = train_df[CFG.train_target_cols].values\n    \n    # Extraer grupos y estratificación para StratifiedGroupKFold\n    groups = train_df['Sampling_Date'].values if 'Sampling_Date' in train_df.columns else None\n    stratify = train_df['State'].values if 'State' in train_df.columns else None\n    \n    # 4. Stacking 训练\n    trainer = StackingTrainer(n_folds=CFG.n_fold, seed=CFG.seed)\n    \n    # Step 1: Base Models (Level 1) con StratifiedGroupKFold\n    oof_preds, test_preds = trainer.train_predict(\n        X_train, Y_train, X_test,\n        groups=groups,\n        stratify=stratify\n    )\n    \n    # Step 2: Meta Model (Level 2) - Ya no necesita pasarlas aparte\n    final_oof_all, final_test_pred_all = trainer.fit_stacking(\n        oof_preds, Y_train, test_preds\n    )\n    \n    # === SEPARAR TARGETS: 5 biomasa + 2 auxiliares ===\n    n_biomass_targets = len(CFG.target_cols)  # 5\n    \n    # Biomasa para evaluación y submission\n    final_oof = final_oof_all[:, :n_biomass_targets]\n    final_test_pred = final_test_pred_all[:, :n_biomass_targets]\n    \n    # Auxiliares para análisis (NDVI, Height predichos)\n    if final_oof_all.shape[1] > n_biomass_targets:\n        aux_oof = final_oof_all[:, n_biomass_targets:]\n        aux_test = final_test_pred_all[:, n_biomass_targets:]\n        aux_names = CFG.train_target_cols[n_biomass_targets:]  # ['Pre_GSHH_NDVI', 'Height_Ave_cm']\n        print(f\"\\n=== Auxiliary Predictions (Multi-Task Learning) ===\")\n        for i, name in enumerate(aux_names):\n            oof_mean = aux_oof[:, i].mean()\n            oof_std = aux_oof[:, i].std()\n            true_mean = Y_train[:, n_biomass_targets + i].mean()\n            true_std = Y_train[:, n_biomass_targets + i].std()\n            print(f\"  {name}: Pred mean={oof_mean:.4f}±{oof_std:.4f}, True mean={true_mean:.4f}±{true_std:.4f}\")\n    \n    # Y_train para evaluación: solo biomasa\n    Y_train_biomass = Y_train[:, :n_biomass_targets]\n    \n    # Aplicar gating al OOF para score preciso\n    if train_species_probs is not None:\n        df_oof = train_df.copy()\n        df_oof[CFG.target_cols] = final_oof\n        df_oof = apply_species_gating(df_oof, train_species_probs, confidence_threshold=0.10)\n        final_oof = df_oof[CFG.target_cols].values\n    \n    # 评估 (solo con targets de biomasa)\n    score = weighted_r2_metric(Y_train_biomass, final_oof, verbose=True)\n    print(f\"\\nStacking OOF Score (with gating): {score:.4f}\")\n\n    \n    # 5. 后处理与提交\n    print(\"Generating submission...\")\n    df_sub = test_df.copy()\n    df_sub[CFG.target_cols] = final_test_pred\n    \n    # 1. Post-process: reconciliación de restricciones\n    df_sub = post_process_biomass(df_sub)\n    \n    # 2. Gating: forzar Clover=0 para especies sin clover (DESPUÉS de post_process)\n    if test_species_probs is not None:\n        df_sub = apply_species_gating(df_sub, test_species_probs, confidence_threshold=0.05)\n    \n    # 3. Recalcular GDM y Total DESPUÉS del gating para mantener consistencia\n    df_sub['GDM_g'] = df_sub['Dry_Green_g'] + df_sub['Dry_Clover_g']\n    df_sub['Dry_Total_g'] = df_sub['GDM_g'] + df_sub['Dry_Dead_g']\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(\"Saved to submission.csv\")\n\nif __name__ == '__main__':\n    main()","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}