{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":112509,"databundleVersionId":14254895,"sourceType":"competition"}],"dockerImageVersionId":31153,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"### 🚀 Inference & Submission Notebook Link\n\n**This training process led to the final model, which is used for predictions in a separate inference notebook.**\n\n[**Go to the Inference Notebook here**](https://www.kaggle.com/code/tasmim/lb-0-54-csiro-image2biomass-prediction-infer?scriptVersionId=272147088)\n","metadata":{}},{"cell_type":"code","source":"# ============================================================================\n# CSIRO Image2Biomass Prediction - Complete End-to-End Pipeline\n# ============================================================================\n# This pipeline predicts 5 biomass components from pasture images:\n# - Dry_Green_g, Dry_Dead_g, Dry_Clover_g, GDM_g, Dry_Total_g\n# ============================================================================\n\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nimport timm\nfrom sklearn.model_selection import StratifiedKFold\nfrom sklearn.preprocessing import StandardScaler\nimport cv2\nfrom tqdm import tqdm\nimport warnings\nwarnings.filterwarnings('ignore')\n\n# ============================================================================\n# CONFIGURATION\n# ============================================================================\nclass CFG:\n    # Paths\n    train_csv = '/kaggle/input/csiro-biomass/train.csv'\n    test_csv = '/kaggle/input/csiro-biomass/test.csv'\n    train_dir = '/kaggle/input/csiro-biomass/train'\n    test_dir = '/kaggle/input/csiro-biomass/test/'\n    \n    # Model\n    model_name = 'tf_efficientnetv2_m'  # EfficientNetV2-M for better performance\n    img_size = 512  # Higher resolution for detail\n    pretrained = True\n    \n    # Training\n    n_folds = 5\n    seed = 42\n    epochs = 50\n    batch_size = 16\n    num_workers = 4\n    lr = 3e-4  # Increased learning rate\n    weight_decay = 1e-5\n    warmup_epochs = 2  # Add warmup\n    \n    # Augmentation\n    use_tta = True\n    tta_steps = 5\n    \n    # Targets\n    targets = ['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]  # From evaluation criteria\n    \n    # Target scaling (CRITICAL FIX)\n    use_target_scaling = True  # Scale targets to reasonable range\n    \n    # Device\n    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n\n# Set random seeds for reproducibility\ndef set_seed(seed):\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n\nset_seed(CFG.seed)\n\n# ============================================================================\n# DATA PREPROCESSING\n# ============================================================================\ndef prepare_data(train_csv_path):\n    \"\"\"\n    Prepare training data by pivoting from long to wide format.\n    Each image has 5 rows (one per target), we combine them into 1 row.\n    \"\"\"\n    df = pd.read_csv(train_csv_path)\n    \n    # The CSV is already in long format with one row per (image, target) pair\n    # We need to pivot so each image becomes one row with all 5 targets as columns\n    \n    # First, get the unique identifier for each image (excluding target columns)\n    # Extract just the image ID from sample_id\n    df['image_id'] = df['sample_id'].str.split('__').str[0] if '__' in df['sample_id'].iloc[0] else df['sample_id']\n    \n    # Group by image and get metadata (should be same for all targets of same image)\n    metadata_cols = ['image_path', 'Sampling_Date', 'State', 'Species', 'Pre_GSHH_NDVI', 'Height_Ave_cm']\n    \n    # Pivot to wide format\n    df_pivot = df.pivot_table(\n        index=['image_id'] + metadata_cols,\n        columns='target_name',\n        values='target',\n        aggfunc='first'  # Use first value if duplicates exist\n    ).reset_index()\n    \n    # Ensure all 5 target columns exist and fill any NaN with 0\n    for target in CFG.targets:\n        if target not in df_pivot.columns:\n            df_pivot[target] = 0.0\n        else:\n            df_pivot[target] = df_pivot[target].fillna(0.0)\n    \n    # Create stratification bins based on total biomass\n    # This ensures balanced folds across biomass ranges\n    # Use robust binning to handle edge cases\n    try:\n        df_pivot['biomass_bin'] = pd.qcut(\n            df_pivot['Dry_Total_g'], \n            q=10, \n            labels=False, \n            duplicates='drop'\n        )\n    except ValueError:\n        # If qcut fails, use cut with equal-width bins\n        df_pivot['biomass_bin'] = pd.cut(\n            df_pivot['Dry_Total_g'], \n            bins=10, \n            labels=False\n        )\n    \n    # Fill any remaining NaN in biomass_bin with a default value\n    df_pivot['biomass_bin'] = df_pivot['biomass_bin'].fillna(0).astype(int)\n    \n    print(f\"Prepared {len(df_pivot)} unique images\")\n    print(f\"Target columns: {CFG.targets}\")\n    print(f\"Sample biomass statistics:\")\n    for target in CFG.targets:\n        print(f\"  {target}: mean={df_pivot[target].mean():.2f}, std={df_pivot[target].std():.2f}\")\n    \n    return df_pivot\n\n# ============================================================================\n# DATASET CLASS\n# ============================================================================\nclass BiomassDataset(Dataset):\n    \"\"\"\n    Custom dataset for loading pasture images and metadata.\n    Returns: image tensor, tabular features, and target values\n    \"\"\"\n    def __init__(self, df, img_dir, transform=None, is_test=False, scaler=None):\n        self.df = df.reset_index(drop=True)\n        self.img_dir = img_dir\n        self.transform = transform\n        self.is_test = is_test\n        \n        # Prepare tabular features (NDVI and Height)\n        tabular_data = df[['Pre_GSHH_NDVI', 'Height_Ave_cm']].fillna(0).values\n        \n        if not is_test:\n            if scaler is None:\n                self.scaler = StandardScaler()\n                self.tabular_features = self.scaler.fit_transform(tabular_data)\n            else:\n                self.scaler = scaler\n                self.tabular_features = self.scaler.transform(tabular_data)\n        else:\n            if scaler is not None:\n                self.scaler = scaler\n                self.tabular_features = self.scaler.transform(tabular_data)\n            else:\n                self.tabular_features = tabular_data\n    \n    def __len__(self):\n        return len(self.df)\n    \n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        \n        # Load image\n        img_path = f\"{self.img_dir}/{row['image_path'].split('/')[-1]}\"\n        image = cv2.imread(img_path)\n        \n        if image is None:\n            raise ValueError(f\"Failed to load image: {img_path}\")\n            \n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        \n        # Apply augmentations\n        if self.transform:\n            augmented = self.transform(image=image)\n            image = augmented['image']\n        \n        # Get tabular features\n        tabular = torch.tensor(self.tabular_features[idx], dtype=torch.float32)\n        \n        if self.is_test:\n            return image, tabular\n        else:\n            # Get all 5 target values\n            targets = torch.tensor([\n                row['Dry_Green_g'],\n                row['Dry_Dead_g'],\n                row['Dry_Clover_g'],\n                row['GDM_g'],\n                row['Dry_Total_g']\n            ], dtype=torch.float32)\n            \n            return image, tabular, targets\n\n# ============================================================================\n# AUGMENTATION STRATEGIES\n# ============================================================================\ndef get_train_transforms():\n    \"\"\"\n    Strong augmentation for training to improve generalization.\n    Includes geometric, color, and quality transforms.\n    \"\"\"\n    return A.Compose([\n        A.Resize(CFG.img_size, CFG.img_size),\n        A.HorizontalFlip(p=0.5),\n        A.VerticalFlip(p=0.5),\n        A.RandomRotate90(p=0.5),\n        A.ShiftScaleRotate(shift_limit=0.1, scale_limit=0.2, rotate_limit=15, p=0.5),\n        \n        # Color augmentations (important for varying lighting conditions)\n        A.OneOf([\n            A.HueSaturationValue(hue_shift_limit=20, sat_shift_limit=30, val_shift_limit=20, p=1),\n            A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=1),\n            A.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1, p=1),\n        ], p=0.7),\n        \n        # Quality degradation (simulate camera variations)\n        A.OneOf([\n            A.GaussNoise(var_limit=(10.0, 50.0), p=1),\n            A.GaussianBlur(blur_limit=(3, 7), p=1),\n            A.MotionBlur(blur_limit=5, p=1),\n        ], p=0.3),\n        \n        A.CoarseDropout(max_holes=8, max_height=32, max_width=32, p=0.3),\n        A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n        ToTensorV2(),\n    ])\n\ndef get_valid_transforms():\n    \"\"\"Simple transforms for validation (no augmentation)\"\"\"\n    return A.Compose([\n        A.Resize(CFG.img_size, CFG.img_size),\n        A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n        ToTensorV2(),\n    ])\n\n# ============================================================================\n# MODEL ARCHITECTURE\n# ============================================================================\nclass BiomassModel(nn.Module):\n    \"\"\"\n    Multi-modal model combining:\n    1. EfficientNet for image features\n    2. MLP for tabular features (NDVI, Height)\n    3. Fusion layer combining both modalities\n    4. 5 output heads (one per target)\n    \"\"\"\n    def __init__(self, model_name, pretrained=True):\n        super(BiomassModel, self).__init__()\n        \n        # Image encoder (EfficientNet)\n        self.backbone = timm.create_model(\n            model_name, \n            pretrained=pretrained,\n            num_classes=0,  # Remove classification head\n            global_pool='avg'\n        )\n        \n        # Get feature dimension from backbone\n        with torch.no_grad():\n            dummy_input = torch.randn(1, 3, CFG.img_size, CFG.img_size)\n            img_features = self.backbone(dummy_input).shape[1]\n        \n        # Tabular feature encoder (for NDVI and Height)\n        self.tabular_encoder = nn.Sequential(\n            nn.Linear(2, 64),\n            nn.BatchNorm1d(64),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(64, 128),\n            nn.BatchNorm1d(128),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n        )\n        \n        # Fusion layer\n        fusion_dim = img_features + 128\n        self.fusion = nn.Sequential(\n            nn.Linear(fusion_dim, 512),\n            nn.BatchNorm1d(512),\n            nn.ReLU(),\n            nn.Dropout(0.4),\n            nn.Linear(512, 256),\n            nn.BatchNorm1d(256),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n        )\n        \n        # Output heads (5 separate heads for better learning)\n        self.head_green = nn.Linear(256, 1)\n        self.head_dead = nn.Linear(256, 1)\n        self.head_clover = nn.Linear(256, 1)\n        self.head_gdm = nn.Linear(256, 1)\n        self.head_total = nn.Linear(256, 1)\n    \n    def forward(self, image, tabular):\n        # Extract image features\n        img_features = self.backbone(image)\n        \n        # Extract tabular features\n        tab_features = self.tabular_encoder(tabular)\n        \n        # Concatenate features\n        combined = torch.cat([img_features, tab_features], dim=1)\n        \n        # Fusion\n        fused = self.fusion(combined)\n        \n        # Predict all 5 targets\n        out_green = self.head_green(fused)\n        out_dead = self.head_dead(fused)\n        out_clover = self.head_clover(fused)\n        out_gdm = self.head_gdm(fused)\n        out_total = self.head_total(fused)\n        \n        # Stack outputs [batch_size, 5]\n        outputs = torch.cat([out_green, out_dead, out_clover, out_gdm, out_total], dim=1)\n        \n        return outputs\n\n# ============================================================================\n# LOSS FUNCTION\n# ============================================================================\nclass WeightedMSELoss(nn.Module):\n    \"\"\"\n    Weighted MSE loss matching the competition metric.\n    Each target has a different weight in final score.\n    \"\"\"\n    def __init__(self, weights):\n        super(WeightedMSELoss, self).__init__()\n        self.weights = torch.tensor(weights, dtype=torch.float32)\n    \n    def forward(self, predictions, targets):\n        self.weights = self.weights.to(predictions.device)\n        \n        # MSE for each target\n        mse_per_target = (predictions - targets) ** 2\n        \n        # Apply weights\n        weighted_mse = mse_per_target * self.weights.unsqueeze(0)\n        \n        # Return mean loss\n        return weighted_mse.mean()\n\n# ============================================================================\n# METRIC CALCULATION (R² Score)\n# ============================================================================\ndef calculate_r2_score(y_true, y_pred):\n    \"\"\"\n    Calculate R² (coefficient of determination) for model evaluation.\n    R² = 1 - (SS_res / SS_tot)\n    \"\"\"\n    ss_res = np.sum((y_true - y_pred) ** 2)\n    ss_tot = np.sum((y_true - y_true.mean()) ** 2)\n    \n    if ss_tot == 0:\n        return 0.0\n    \n    r2 = 1 - (ss_res / ss_tot)\n    return r2\n\ndef calculate_weighted_r2(y_true, y_pred, weights):\n    \"\"\"\n    Calculate weighted R² score across all 5 targets.\n    This matches the competition evaluation metric.\n    \"\"\"\n    scores = []\n    for i in range(5):\n        r2 = calculate_r2_score(y_true[:, i], y_pred[:, i])\n        scores.append(r2)\n    \n    weighted_score = sum(s * w for s, w in zip(scores, weights))\n    return weighted_score, scores\n\n# ============================================================================\n# TRAINING FUNCTION\n# ============================================================================\ndef train_epoch(model, loader, optimizer, criterion, device, scaler):\n    \"\"\"Train for one epoch\"\"\"\n    model.train()\n    running_loss = 0.0\n    \n    pbar = tqdm(loader, desc='Training')\n    for batch_idx, (images, tabular, targets) in enumerate(pbar):\n        images = images.to(device)\n        tabular = tabular.to(device)\n        targets = targets.to(device)\n        \n        optimizer.zero_grad()\n        \n        # Mixed precision training for speed\n        with torch.cuda.amp.autocast(enabled=True):\n            outputs = model(images, tabular)\n            loss = criterion(outputs, targets)\n        \n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n        \n        running_loss += loss.item()\n        pbar.set_postfix({'loss': running_loss / (pbar.n + 1)})\n        \n        # Debug: Print first batch predictions\n        if batch_idx == 0:\n            print(f\"\\n  Sample predictions: {outputs[0].detach().cpu().numpy()}\")\n            print(f\"  Sample targets:     {targets[0].cpu().numpy()}\")\n    \n    return running_loss / len(loader)\n\n# ============================================================================\n# VALIDATION FUNCTION\n# ============================================================================\ndef validate_epoch(model, loader, criterion, device):\n    \"\"\"Validate and calculate R² score\"\"\"\n    model.eval()\n    running_loss = 0.0\n    all_preds = []\n    all_targets = []\n    \n    with torch.no_grad():\n        for images, tabular, targets in tqdm(loader, desc='Validation'):\n            images = images.to(device)\n            tabular = tabular.to(device)\n            targets = targets.to(device)\n            \n            outputs = model(images, tabular)\n            loss = criterion(outputs, targets)\n            \n            running_loss += loss.item()\n            all_preds.append(outputs.cpu().numpy())\n            all_targets.append(targets.cpu().numpy())\n    \n    all_preds = np.vstack(all_preds)\n    all_targets = np.vstack(all_targets)\n    \n    # Calculate R² scores\n    weighted_r2, individual_r2 = calculate_weighted_r2(\n        all_targets, all_preds, CFG.target_weights\n    )\n    \n    return running_loss / len(loader), weighted_r2, individual_r2, all_preds, all_targets\n\n# ============================================================================\n# TRAINING LOOP (K-FOLD CROSS-VALIDATION)\n# ============================================================================\ndef train_kfold(df, fold):\n    \"\"\"Train a single fold\"\"\"\n    print(f\"\\n{'='*50}\")\n    print(f\"Training Fold {fold + 1}/{CFG.n_folds}\")\n    print(f\"{'='*50}\")\n    \n    # Split data\n    train_df = df[df['fold'] != fold].copy()\n    valid_df = df[df['fold'] == fold].copy()\n    \n    print(f\"Train size: {len(train_df)}, Valid size: {len(valid_df)}\")\n    \n    # CRITICAL: Check target distribution\n    print(f\"\\nTarget statistics (training set):\")\n    for target in CFG.targets:\n        print(f\"  {target}: mean={train_df[target].mean():.2f}, std={train_df[target].std():.2f}, \"\n              f\"min={train_df[target].min():.2f}, max={train_df[target].max():.2f}\")\n    \n    # Create target scaler if enabled\n    target_scaler = None\n    if CFG.use_target_scaling:\n        target_scaler = StandardScaler()\n        target_values = train_df[CFG.targets].values\n        target_scaler.fit(target_values)\n        \n        # Scale targets in dataframes\n        train_df[CFG.targets] = target_scaler.transform(train_df[CFG.targets].values)\n        valid_df[CFG.targets] = target_scaler.transform(valid_df[CFG.targets].values)\n        print(\"\\n✓ Targets scaled to zero mean and unit variance\")\n    \n    # Create datasets with shared scaler\n    train_dataset = BiomassDataset(\n        train_df, CFG.train_dir, transform=get_train_transforms()\n    )\n    valid_dataset = BiomassDataset(\n        valid_df, CFG.train_dir, transform=get_valid_transforms(),\n        scaler=train_dataset.scaler  # Use same scaler for validation\n    )\n    \n    # Create dataloaders\n    train_loader = DataLoader(\n        train_dataset, batch_size=CFG.batch_size, \n        shuffle=True, num_workers=CFG.num_workers, pin_memory=True\n    )\n    valid_loader = DataLoader(\n        valid_dataset, batch_size=CFG.batch_size * 2,\n        shuffle=False, num_workers=CFG.num_workers, pin_memory=True\n    )\n    \n    # Initialize model, loss, optimizer\n    model = BiomassModel(CFG.model_name, CFG.pretrained).to(CFG.device)\n    criterion = WeightedMSELoss(CFG.target_weights)\n    optimizer = optim.AdamW(model.parameters(), lr=CFG.lr, weight_decay=CFG.weight_decay)\n    \n    # Learning rate scheduler with warmup\n    def lr_lambda(epoch):\n        if epoch < CFG.warmup_epochs:\n            return (epoch + 1) / CFG.warmup_epochs\n        return 1.0\n    \n    warmup_scheduler = optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)\n    main_scheduler = optim.lr_scheduler.CosineAnnealingWarmRestarts(\n        optimizer, T_0=10, T_mult=2, eta_min=1e-6\n    )\n    \n    scaler = torch.cuda.amp.GradScaler()\n    \n    best_score = -np.inf\n    patience_counter = 0\n    patience = 13\n    \n    for epoch in range(CFG.epochs):\n        print(f\"\\nEpoch {epoch + 1}/{CFG.epochs}\")\n        print(f\"Learning rate: {optimizer.param_groups[0]['lr']:.6f}\")\n        \n        # Train\n        train_loss = train_epoch(model, train_loader, optimizer, criterion, CFG.device, scaler)\n        \n        # Validate\n        valid_loss, weighted_r2, individual_r2, all_preds, all_targets = validate_epoch(\n            model, valid_loader, criterion, CFG.device\n        )\n        \n        # Scale predictions back if needed\n        if target_scaler is not None:\n            all_preds_original = target_scaler.inverse_transform(all_preds)\n            all_targets_original = target_scaler.inverse_transform(all_targets)\n            \n            # Recalculate R² on original scale\n            weighted_r2_original, individual_r2_original = calculate_weighted_r2(\n                all_targets_original, all_preds_original, CFG.target_weights\n            )\n            \n            print(f\"Train Loss: {train_loss:.4f} | Valid Loss: {valid_loss:.4f}\")\n            print(f\"Weighted R² (scaled): {weighted_r2:.4f}\")\n            print(f\"Weighted R² (original): {weighted_r2_original:.4f}\")\n            print(f\"Individual R² (original): {individual_r2_original}\")\n            \n            # Use original scale for model selection\n            score_to_use = weighted_r2_original\n        else:\n            print(f\"Train Loss: {train_loss:.4f} | Valid Loss: {valid_loss:.4f}\")\n            print(f\"Weighted R²: {weighted_r2:.4f}\")\n            print(f\"Individual R²: {individual_r2}\")\n            score_to_use = weighted_r2\n        \n        # Update scheduler\n        if epoch < CFG.warmup_epochs:\n            warmup_scheduler.step()\n        else:\n            main_scheduler.step()\n        \n        # Save best model\n        if score_to_use > best_score:\n            best_score = score_to_use\n            # Save model and scalers\n            checkpoint = {\n                'model_state_dict': model.state_dict(),\n                'tabular_scaler': train_dataset.scaler,\n                'target_scaler': target_scaler\n            }\n            torch.save(checkpoint, f'best_model_fold{fold}.pth')\n            print(f\"✓ Saved best model (R²: {best_score:.4f})\")\n            patience_counter = 0\n        else:\n            patience_counter += 1\n        \n        # Early stopping\n        if patience_counter >= patience:\n            print(f\"Early stopping at epoch {epoch + 1}\")\n            break\n    \n    return best_score\n\n\n# ============================================================================\n# MAIN EXECUTION\n# ============================================================================\ndef main():\n    # 1. Prepare data\n    print(\"Loading and preparing data...\")\n    print(f\"Reading from: {CFG.train_csv}\")\n    \n    # First, let's check the format of the CSV\n    df_raw = pd.read_csv(CFG.train_csv)\n    print(f\"\\nRaw data shape: {df_raw.shape}\")\n    print(f\"Columns: {df_raw.columns.tolist()}\")\n    print(f\"\\nFirst few rows:\")\n    print(df_raw.head(10))\n    \n    # Check if data needs pivoting\n    if 'target_name' in df_raw.columns and 'target' in df_raw.columns:\n        print(\"\\n✓ Data is in long format, will pivot...\")\n        df = prepare_data(CFG.train_csv)\n    else:\n        print(\"\\n✓ Data appears to be in wide format already\")\n        df = df_raw.copy()\n        # Ensure biomass_bin exists\n        try:\n            df['biomass_bin'] = pd.qcut(df['Dry_Total_g'], q=10, labels=False, duplicates='drop')\n        except:\n            df['biomass_bin'] = pd.cut(df['Dry_Total_g'], bins=10, labels=False)\n        df['biomass_bin'] = df['biomass_bin'].fillna(0).astype(int)\n    \n    print(f\"\\n✓ Final processed data shape: {df.shape}\")\n    print(f\"✓ Checking for NaN values in targets:\")\n    for target in CFG.targets:\n        nan_count = df[target].isna().sum()\n        print(f\"  {target}: {nan_count} NaN values\")\n    \n    # 2. Create folds\n    print(\"\\nCreating cross-validation folds...\")\n    skf = StratifiedKFold(n_splits=CFG.n_folds, shuffle=True, random_state=CFG.seed)\n    df['fold'] = -1\n    \n    # Ensure no NaN in biomass_bin before splitting\n    assert df['biomass_bin'].isna().sum() == 0, \"NaN found in biomass_bin!\"\n    \n    for fold, (_, val_idx) in enumerate(skf.split(df, df['biomass_bin'])):\n        df.loc[val_idx, 'fold'] = fold\n    \n    print(\"✓ Fold distribution:\")\n    print(df['fold'].value_counts().sort_index())\n    \n    # 3. Train all folds\n    fold_scores = []\n    for fold in range(CFG.n_folds):\n        score = train_kfold(df, fold)\n        fold_scores.append(score)\n    \n    print(f\"\\n{'='*50}\")\n    print(f\"Cross-Validation Results:\")\n    print(f\"{'='*50}\")\n    for i, score in enumerate(fold_scores):\n        print(f\"Fold {i+1}: {score:.4f}\")\n    print(f\"Mean CV Score: {np.mean(fold_scores):.4f} ± {np.std(fold_scores):.4f}\")\n\n\nif __name__ == '__main__':\n    main()\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-30T01:35:21.707436Z","iopub.execute_input":"2025-10-30T01:35:21.707749Z","iopub.status.idle":"2025-10-30T02:29:31.438643Z","shell.execute_reply.started":"2025-10-30T01:35:21.707722Z","shell.execute_reply":"2025-10-30T02:29:31.437512Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}