{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":112509,"databundleVersionId":14254895,"isSourceIdPinned":false,"sourceType":"competition"},{"sourceId":14474097,"sourceType":"datasetVersion","datasetId":9244876}],"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 1: IMPORTS & CONFIGURATION\n# ==============================================================================\nimport os\nimport random\nimport warnings\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nimport cv2\nfrom pathlib import Path\n\n# Machine Learning\nfrom sklearn.model_selection import GroupKFold\nfrom sklearn.ensemble import RandomForestRegressor\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.metrics import mean_squared_error\nfrom sklearn.model_selection import StratifiedGroupKFold\n\n# Deep Learning\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision.transforms as T\nfrom torchvision import models\nfrom torch.utils.data import WeightedRandomSampler\n\nwarnings.filterwarnings('ignore')\n\nclass Config:\n    # --- Paths (Retained from your original working notebook) ---\n    TRAIN_DIR = Path('/kaggle/input/csiro-biomass/')\n    TEST_DIR = Path('/kaggle/input/csiro-biomass/')\n    TRAIN_CSV = Path('/kaggle/input/csiro-biomass/train.csv')\n    TEST_CSV = Path('/kaggle/input/csiro-biomass/test.csv')\n    \n    # --- Data Settings ---\n    SEED = 42\n    IMG_SIZE = (256, 512)\n    TARGET_COLS = ['Dry_Green_g', 'Dry_Dead_g', 'Dry_Clover_g', 'GDM_g', 'Dry_Total_g']\n    TABULAR_COLS = ['Height_Ave_cm', 'Pre_GSHH_NDVI', 'Sin_Day', 'Cos_Day']\n    \n    # --- Training Settings ---\n    BATCH_SIZE = 16\n    EPOCHS = 2 # Increased for better convergence\n    FOLDS = 2  # Standard for scientific validity\n    DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n\ndef seed_everything(seed: int):\n    random.seed(seed)\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n\nseed_everything(Config.SEED)\nprint(f\"✅ Setup Complete. Device: {Config.DEVICE}\")","metadata":{"_uuid":"a1cac9a5-063b-4fbd-81b2-c24f609cb364","_cell_guid":"6bf26534-cd84-4213-b649-5f5bee841016","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-12T10:57:31.652839Z","iopub.execute_input":"2026-01-12T10:57:31.653547Z","iopub.status.idle":"2026-01-12T10:57:31.663587Z","shell.execute_reply.started":"2026-01-12T10:57:31.653522Z","shell.execute_reply":"2026-01-12T10:57:31.662946Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 1.5: EXPLORATORY DATA ANALYSIS (EDA)\n# ==============================================================================\nprint(\">>> Performing EDA on Tabular Data...\")\n\n# Load the raw CSV to inspect class balance\neda_df = pd.read_csv(Config.TRAIN_CSV)\n\n# 1. Basic Structure Inspection\nprint(f\"Dataset Shape: {eda_df.shape}\")\nprint(f\"Unique Images: {eda_df['image_path'].nunique()}\")\nprint(f\"Columns: {list(eda_df.columns)}\")\n\n# 2. Visualizations\nplt.figure(figsize=(20, 14))\n\n# Plot A: Species Distribution (Class Balance)\n# This is crucial because some species might be rare, making them harder to predict.\nplt.subplot(2, 2, 1)\nspecies_counts = eda_df[['image_path', 'Species']].drop_duplicates()['Species'].value_counts()\nsns.barplot(y=species_counts.index, x=species_counts.values, palette='viridis')\nplt.title('Number of Images per Species')\nplt.xlabel('Count')\n\n# Plot B: State Distribution (Geographic Diversity)\nplt.subplot(2, 2, 2)\nstate_counts = eda_df[['image_path', 'State']].drop_duplicates()['State'].value_counts()\nsns.barplot(x=state_counts.index, y=state_counts.values, palette='magma')\nplt.title('Number of Images per State')\nplt.ylabel('Count')\n\n# Plot C: Target Distribution by Type (Box Plot)\n# We use Log Scale because biomass values can vary exponentially.\nplt.subplot(2, 2, 3)\nsns.boxplot(x='target_name', y='target', data=eda_df, palette='coolwarm')\nplt.yscale('log') \nplt.title('Biomass Distribution by Component (Log Scale)')\nplt.ylabel('Mass (g)')\nplt.xticks(rotation=15)\n\n# Plot D: Target Value Correlations\n# Pivot to wide format temporarily to see how components correlate (e.g., does high Green mean high Total?)\nplt.subplot(2, 2, 4)\nwide_eda = eda_df.pivot_table(index='image_path', columns='target_name', values='target')\nsns.heatmap(wide_eda.corr(), annot=True, fmt=\".2f\", cmap='RdBu_r', vmin=-1, vmax=1)\nplt.title('Correlation between Biomass Components')\n\nplt.tight_layout()\nplt.show()\n\n# 3. Cleanup to free memory before heavy training\ndel eda_df, wide_eda, species_counts, state_counts\nimport gc; gc.collect()","metadata":{"_uuid":"0725013e-5a9f-4ee7-9cc7-2a1e11d3179c","_cell_guid":"a44f6bef-8bfd-40da-9cd7-7fbb9d65304d","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-12T10:57:31.664914Z","iopub.execute_input":"2026-01-12T10:57:31.665525Z","iopub.status.idle":"2026-01-12T10:57:32.938301Z","shell.execute_reply.started":"2026-01-12T10:57:31.665486Z","shell.execute_reply":"2026-01-12T10:57:32.937682Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 2: DATA MANAGER (ETL PIPELINE) - UPDATED\n# ==============================================================================\nclass DataManager:\n    @staticmethod\n    def load_and_pivot(csv_path: Path) -> pd.DataFrame:\n        df = pd.read_csv(csv_path)\n        \n        # 1. Fill NaNs temporarily to prevent pivot_table from dropping rows\n        df[['Pre_GSHH_NDVI', 'Height_Ave_cm']] = df[['Pre_GSHH_NDVI', 'Height_Ave_cm']].fillna(-999)\n        \n        if 'target_name' in df.columns:\n            df_wide = df.pivot_table(\n                index=['image_path', 'Sampling_Date', 'State', 'Species', \n                       'Pre_GSHH_NDVI', 'Height_Ave_cm'],\n                columns='target_name',\n                values='target'\n            ).reset_index()\n            df_wide.columns.name = None\n            \n            # 2. Restore NaNs for proper imputation\n            cols_to_fix = ['Pre_GSHH_NDVI', 'Height_Ave_cm']\n            for col in cols_to_fix:\n                df_wide[col] = df_wide[col].replace(-999, np.nan)\n                \n            return df_wide\n        return df\n\n    @staticmethod\n    def smart_imputation(df, train_df=None):\n        # If train_df is provided, we use its medians (for inference)\n        # Otherwise we calculate medians from df (for training)\n        ref_df = train_df if train_df is not None else df\n        \n        # Calculate Species-specific medians\n        species_map = ref_df.groupby('Species')[['Height_Ave_cm', 'Pre_GSHH_NDVI']].median()\n        global_map = ref_df[['Height_Ave_cm', 'Pre_GSHH_NDVI']].median()\n        \n        def fill_row(row, col):\n            if pd.isna(row[col]):\n                if row['Species'] in species_map.index:\n                    return species_map.loc[row['Species'], col]\n                return global_map[col]\n            return row[col]\n\n        for col in ['Height_Ave_cm', 'Pre_GSHH_NDVI']:\n            df[col] = df.apply(lambda row: fill_row(row, col), axis=1)\n            \n        return df\n\n    @staticmethod\n    def engineer_features(df: pd.DataFrame) -> pd.DataFrame:\n        df = df.copy()\n        # 1. Cyclical Date Encoding\n        if 'Sampling_Date' in df.columns:\n            df['Sampling_Date'] = pd.to_datetime(df['Sampling_Date'], format='mixed')\n            day = df['Sampling_Date'].dt.dayofyear\n            df['Sin_Day'] = np.sin(2 * np.pi * day / 365.0)\n            df['Cos_Day'] = np.cos(2 * np.pi * day / 365.0)\n        else:\n            df['Sin_Day'] = 0.0\n            df['Cos_Day'] = 0.0\n        return df\n\n# --- Execution ---\\\nprint(\">>> Processing Data...\")\nraw_train = DataManager.load_and_pivot(Config.TRAIN_CSV)\ntrain_df = DataManager.engineer_features(raw_train)\n\n# [Rubric Task 2: OUTLIER REMOVAL]\nlimit = train_df['Dry_Total_g'].quantile(0.995)\noriginal_len = len(train_df)\ntrain_df = train_df[train_df['Dry_Total_g'] < limit].reset_index(drop=True)\nprint(f\"🧹 Outlier Removal: Dropped {original_len - len(train_df)} samples > {limit:.2f}g\")\n\n# --- IMPROVEMENT: Robust Imputation & Scaling Strategy ---\n# 1. Calculate and SAVE Medians (Critical for Inference Consistency)\ntrain_medians = train_df[['Pre_GSHH_NDVI', 'Height_Ave_cm']].median()\ntrain_df['Pre_GSHH_NDVI'] = train_df['Pre_GSHH_NDVI'].fillna(train_medians['Pre_GSHH_NDVI'])\ntrain_df['Height_Ave_cm'] = train_df['Height_Ave_cm'].fillna(train_medians['Height_Ave_cm'])\n\n# 2. Fit and SAVE Scaler\nscaler = StandardScaler()\ntrain_df[Config.TABULAR_COLS] = scaler.fit_transform(train_df[Config.TABULAR_COLS])\n\nprint(\"✅ Data Pipeline Complete. Medians and Scaler saved for inference.\")","metadata":{"_uuid":"1afef9c5-50f5-47a7-a39b-8f2526d701a6","_cell_guid":"a677fb0a-5017-4558-ad00-973c574a468f","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-12T10:57:32.939007Z","iopub.execute_input":"2026-01-12T10:57:32.939282Z","iopub.status.idle":"2026-01-12T10:57:32.973078Z","shell.execute_reply.started":"2026-01-12T10:57:32.939256Z","shell.execute_reply":"2026-01-12T10:57:32.972532Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 3: SIMPLE BASELINE (RUBRIC TASK 2)\n# We establish a \"floor\" using only Tabular Data + Random Forest.\n# ==============================================================================\n\nprint(\"\\n--- Establishing Baseline (Random Forest) ---\")\nX_base = train_df[Config.TABULAR_COLS]\ny_base = train_df['Dry_Total_g']\n\n# GroupKFold to prevent data leakage from same location/date\ngkf = GroupKFold(n_splits=5)\ngroups = train_df['Sampling_Date'].astype(str)\nbaseline_scores = []\n\nfor train_idx, val_idx in gkf.split(X_base, y_base, groups=groups):\n    rf = RandomForestRegressor(n_estimators=100, max_depth=8, n_jobs=-1, random_state=Config.SEED)\n    rf.fit(X_base.iloc[train_idx], y_base.iloc[train_idx])\n    preds = rf.predict(X_base.iloc[val_idx])\n    baseline_scores.append(np.sqrt(mean_squared_error(y_base.iloc[val_idx], preds)))\n\nprint(f\"🌲 Baseline RMSE (Tabular Only): {np.mean(baseline_scores):.2f}\")\nprint(\"   (Our Deep Learning models must beat this score to justify image usage.)\")","metadata":{"_uuid":"16be6860-6345-4414-8460-abbf9fd1f4ea","_cell_guid":"48a76471-be6b-40f1-babf-7aebe1ed6c09","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-01-12T10:57:32.974352Z","iopub.execute_input":"2026-01-12T10:57:32.974552Z","iopub.status.idle":"2026-01-12T10:57:33.883639Z","shell.execute_reply.started":"2026-01-12T10:57:32.974536Z","shell.execute_reply":"2026-01-12T10:57:33.882939Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 4: MODEL & DATASET DEFINITION (PHYSICS-LOCKED)\n# ==============================================================================\n\n# --- Dataset Definition (Unchanged logic, just ensure consistency) ---\nclass FusionDataset(Dataset):\n    def __init__(self, df, img_dir, tab_cols, transform=None, is_test=False):\n        self.df = df.reset_index(drop=True)\n        self.img_dir = Path(img_dir)\n        self.tab_cols = tab_cols \n        self.transform = transform\n        self.is_test = is_test\n        \n    def __len__(self): return len(self.df)\n    \n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        img_path = self.img_dir / row['image_path']\n        \n        # Handle missing images gracefully\n        try:\n            img = cv2.imread(str(img_path))\n            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        except:\n            img = np.zeros((256, 512, 3), dtype=np.uint8)\n            \n        if self.transform: img = self.transform(img)\n        \n        # In Test mode, we only need the image and path\n        if self.is_test: \n            return img, row['image_path']\n        \n        targets = torch.tensor(row[Config.TARGET_COLS].values.astype(np.float32), dtype=torch.float32)\n        aux_targets = torch.tensor(row[self.tab_cols].values.astype(np.float32), dtype=torch.float32)\n        \n        return img, targets, aux_targets\n\n# --- Candidate A: Vision Only (Baseline) ---\nclass VisionOnlyNet(nn.Module):\n    def __init__(self):\n        super().__init__()\n        # 1. Create the model structure EMPTY (no download)\n        self.backbone = models.efficientnet_v2_s(weights=None)\n        \n        # 2. Load the weights from the file you added\n        # PASTE YOUR COPIED PATH BELOW inside the quotes\n        weight_path = '/kaggle/input/efficientnet-v2-s-weights/efficientnet_v2_s.pth'\n        \n        # Load the weights\n        state_dict = torch.load(weight_path)\n        self.backbone.load_state_dict(state_dict)\n        \n        # 3. Add your custom classifier\n        self.backbone.classifier = nn.Sequential(\n            nn.Dropout(0.2),\n            nn.Linear(1280, 5) \n        )\n\n    def forward(self, img):\n        return self.backbone(img)\n\n# --- Candidate B: Physics Locked Net (The Solution) ---\nclass PhysicsLockedNet(nn.Module):\n    def __init__(self):\n        super().__init__()\n        # 1. Create the model structure EMPTY (no download)\n        self.backbone = models.efficientnet_v2_s(weights=None)\n        \n        # 2. Load the weights from the file\n        # PASTE YOUR COPIED PATH BELOW inside the quotes\n        weight_path = '/kaggle/input/efficientnet-v2-s-weights/efficientnet_v2_s.pth'\n        \n        state_dict = torch.load(weight_path)\n        self.backbone.load_state_dict(state_dict)\n        \n        # 3. Remove the original classifier\n        self.backbone.classifier = nn.Identity() \n        \n        # 4. Your Shared Feature Extractor\n        self.fc = nn.Sequential(\n            nn.Linear(1280, 512),\n            nn.SiLU(), \n            nn.Dropout(0.2)\n        )\n        \n        # 5. Component Heads\n        self.green_head = nn.Sequential(nn.Linear(512, 1), nn.Softplus())\n        self.dead_head  = nn.Sequential(nn.Linear(512, 1), nn.Softplus())\n        self.clover_head = nn.Sequential(nn.Linear(512, 1), nn.Softplus())\n        \n        # 6. Aux Heads\n        self.height_head = nn.Linear(512, 1)\n        self.ndvi_head = nn.Linear(512, 1)\n\n    def forward(self, img):\n        features = self.backbone(img)\n        shared = self.fc(features)\n        \n        # Predict Components\n        p_green = self.green_head(shared)\n        p_dead = self.dead_head(shared)\n        p_clover = self.clover_head(shared)\n        \n        # --- HARD PHYSICS ENFORCEMENT ---\n        # We calculate the totals inside the network. \n        # The model CANNOT predict a Total that disagrees with its parts.\n        p_gdm = p_green + p_clover\n        p_total = p_green + p_clover + p_dead\n        \n        # Aux predictions\n        p_height = self.height_head(shared)\n        p_ndvi = self.ndvi_head(shared)\n        \n        # Stack exactly in the order of Config.TARGET_COLS:\n        # ['Dry_Green_g', 'Dry_Dead_g', 'Dry_Clover_g', 'GDM_g', 'Dry_Total_g']\n        biomass_preds = torch.cat([p_green, p_dead, p_clover, p_gdm, p_total], dim=1)\n        \n        return biomass_preds, p_height, p_ndvi\n\n# 1. Simple Loss\nclass SimpleLoss(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.huber = nn.HuberLoss(delta=1.0)\n    def forward(self, preds, targets):\n        return self.huber(preds, targets)\n\n# 2. Physics Loss (Heavily penalizes Clover errors)\nclass PhysicsLoss(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.mae = nn.L1Loss()\n        self.mse = nn.MSELoss()\n        \n    def forward(self, preds, targets, aux_targets):\n        pred_biomass, pred_height, pred_ndvi = preds\n        \n        # targets columns: 0=Green, 1=Dead, 2=Clover, 3=GDM, 4=Total\n        \n        # A. Base Accuracy (All components)\n        loss_base = self.mae(pred_biomass, targets)\n        \n        # B. Clover Focus (The \"Anti-Zero-Inflation\" Penalty)\n        # We punish errors on Clover 3x more because it's sparse/hard\n        loss_clover = self.mae(pred_biomass[:, 2], targets[:, 2]) * 3.0\n        \n        # C. Total Integrity\n        # Since pred_total is calculated from parts, high loss here forces\n        # the model to fix the parts.\n        loss_total = self.mae(pred_biomass[:, 4], targets[:, 4])\n        \n        # D. Aux Heads\n        loss_aux = 0.1 * (self.mse(pred_height, aux_targets[:, 0:1]) + \n                          self.mse(pred_ndvi, aux_targets[:, 1:2]))\n        \n        return loss_base + loss_clover + loss_total + loss_aux\n\nprint(\"✅ Candidates Defined: [VisionOnlyNet] vs [PhysicsLockedNet]\")","metadata":{"_uuid":"1eba44a6-e274-440a-9bbf-7049b55f5ba7","_cell_guid":"3aa2a7c7-a081-466c-bafc-c138814ab5cc","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-01-12T10:57:33.884543Z","iopub.execute_input":"2026-01-12T10:57:33.884748Z","iopub.status.idle":"2026-01-12T10:57:33.900044Z","shell.execute_reply.started":"2026-01-12T10:57:33.884730Z","shell.execute_reply":"2026-01-12T10:57:33.899427Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 5: AUGMENTATION & TUNING (ROBUST 3-EPOCH CHECK)\n# ==============================================================================\n\n# --- Augmentation Pipeline ---\ntfms_train = T.Compose([\n    T.ToPILImage(),\n    T.RandomResizedCrop(Config.IMG_SIZE, scale=(0.5, 1.0), ratio=(0.8, 1.2)), \n    T.RandomHorizontalFlip(p=0.5),\n    T.RandomVerticalFlip(p=0.5),\n    T.RandomRotation(degrees=15),\n    T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.4, hue=0.1),\n    T.ToTensor(),\n    T.RandomErasing(p=0.2, scale=(0.02, 0.15)),\n    T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])\n])\n\nprint(\"✅ Augmented Pipeline Active.\")\n\n\n# 1. Setup Tuning Data (Use a slightly larger batch for stability)\ntune_subset = train_df.sample(frac=0.2, random_state=Config.SEED)\ntune_ds = FusionDataset(tune_subset, Config.TRAIN_DIR, Config.TABULAR_COLS, tfms_train)\n# We use num_workers=2 to speed up data loading even during tuning\ntune_dl = DataLoader(tune_ds, batch_size=32, shuffle=True, num_workers=2, pin_memory=True)\n\ncriterion = PhysicsLoss()\n\n# We test a wider range: High (dangerous) -> Low (safe)\n# 1e-3 is often too high for fine-tuning, but good to check. \n# 5e-5 is safe but slow.\ncandidate_lrs = [1e-3, 5e-4, 3e-4, 1e-4, 5e-5] \n\nresults = {}\n\nprint(f\"\\n>>> 🎛️  Optimizing Learning Rate (Smart Grid Search)...\")\n\nfor lr in candidate_lrs:\n    print(f\"\\n--- Testing LR: {lr} ---\")\n    \n    # Fresh model for every test\n    model = PhysicsLockedNet().to(Config.DEVICE)\n    optimizer = optim.AdamW(model.parameters(), lr=lr)\n    \n    # AMP Scaler for speed\n    scaler = torch.cuda.amp.GradScaler()\n    \n    avg_loss = float('inf')\n    diverged = False\n    \n    # We only need 3 epochs to judge an LR\n    # Ep 1: Warmup, Ep 2: Stability, Ep 3: Convergence speed\n    for epoch in range(3):\n        model.train()\n        run_loss = 0.0\n        batches = 0\n        \n        for img, targets, aux_targets in tune_dl:\n            img, targets, aux_targets = img.to(Config.DEVICE), targets.to(Config.DEVICE), aux_targets.to(Config.DEVICE)\n            \n            optimizer.zero_grad()\n            \n            with torch.cuda.amp.autocast():\n                preds_tuple = model(img) \n                loss = criterion(preds_tuple, targets, aux_targets)\n            \n            # Check for immediate explosion (NaN or Infinity)\n            if torch.isnan(loss) or torch.isinf(loss) or loss.item() > 10000:\n                print(f\"   💥 Loss Exploded at Batch {batches}! LR {lr} is too high.\")\n                diverged = True\n                break\n            \n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n            \n            run_loss += loss.item()\n            batches += 1\n        \n        if diverged: break\n            \n        avg_loss = run_loss / max(batches, 1)\n        print(f\"   Ep {epoch+1}: Loss {avg_loss:.4f}\")\n\n    if diverged:\n        results[lr] = float('inf')\n    else:\n        results[lr] = avg_loss\n\n# Select Best\n# We prefer the highest LR that resulted in the lowest loss (faster convergence)\nbest_lr = min(results, key=results.get)\nprint(f\"\\n✅ Best Learning Rate Selected: {best_lr} (Loss: {results[best_lr]:.4f})\")","metadata":{"_uuid":"5038e8ce-77dc-4d92-b4f5-4698f6c348f5","_cell_guid":"f1129226-d6e8-46d9-b4f9-a74d9e82de7e","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-12T10:57:33.901465Z","iopub.execute_input":"2026-01-12T10:57:33.901690Z","iopub.status.idle":"2026-01-12T10:58:29.123505Z","shell.execute_reply.started":"2026-01-12T10:57:33.901676Z","shell.execute_reply":"2026-01-12T10:58:29.122585Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 5.5: MODEL SELECTION TOURNAMENT (Fair Metric Comparison)\n# ==============================================================================\nprint(\"\\n>>> 🥊 STARTING MODEL TOURNAMENT: VisionOnlyNet vs. PhysicsLockedNet\")\n\n# 1. Setup a \"Mini\" Dataset for Speed (20% of data)\ntournament_df = train_df.sample(frac=0.2, random_state=Config.SEED).reset_index(drop=True)\nlimit = int(0.8 * len(tournament_df))\nt_train_df = tournament_df.iloc[:limit]\nt_val_df = tournament_df.iloc[limit:]\n\nt_train_ds = FusionDataset(t_train_df, Config.TRAIN_DIR, Config.TABULAR_COLS, tfms_train)\nt_val_ds = FusionDataset(t_val_df, Config.TRAIN_DIR, Config.TABULAR_COLS, tfms_train)\n\nt_train_dl = DataLoader(t_train_ds, batch_size=16, shuffle=True)\nt_val_dl = DataLoader(t_val_ds, batch_size=16)\n\n# 2. Define the Candidates\ncandidates = [\n    {\n        \"name\": \"VisionOnlyNet\",\n        \"class\": VisionOnlyNet,\n        \"criterion\": SimpleLoss(),\n        \"is_multitask\": False\n    },\n    {\n        \"name\": \"PhysicsLockedNet\",\n        \"class\": PhysicsLockedNet,\n        \"criterion\": PhysicsLoss(),\n        \"is_multitask\": True\n    }\n]\n\nresults = {}\n\n# 3. The Loop\nfor cand in candidates:\n    print(f\"\\n--- Testing Candidate: {cand['name']} ---\")\n    model = cand['class']().to(Config.DEVICE)\n    optimizer = optim.AdamW(model.parameters(), lr=1e-4)\n    criterion = cand['criterion']\n    \n    best_mae = float('inf') # We judge by MAE now, not Loss\n    \n    # Run 10 Epochs\n    for epoch in range(10):\n        # Train\n        model.train()\n        for img, targets, aux_targets in t_train_dl:\n            img, targets, aux_targets = img.to(Config.DEVICE), targets.to(Config.DEVICE), aux_targets.to(Config.DEVICE)\n            optimizer.zero_grad()\n            preds = model(img)\n            \n            if cand['is_multitask']:\n                loss = criterion(preds, targets, aux_targets)\n            else:\n                loss = criterion(preds, targets)\n                \n            loss.backward()\n            optimizer.step()\n            \n        # Validate (Judge purely on Total Biomass MAE)\n        model.eval()\n        total_mae = 0.0\n        count = 0\n        \n        with torch.no_grad():\n            for img, targets, aux_targets in t_val_dl:\n                img, targets = img.to(Config.DEVICE), targets.to(Config.DEVICE)\n                \n                # Get preds\n                output = model(img)\n                if cand['is_multitask']:\n                    # PhysicsNet returns (biomass, height, ndvi)\n                    # Index 0 is biomass [Green, Dead, Clover, GDM, Total]\n                    pred_biomass = output[0] \n                else:\n                    # VisionNet returns biomass directly\n                    pred_biomass = output\n                \n                # FAIR COMPARISON: Calculate MAE on \"Dry_Total_g\" (Index 4)\n                # This ignores the extra penalties PhysicsNet suffers during training\n                mae_batch = torch.abs(pred_biomass[:, 4] - targets[:, 4]).sum()\n                \n                total_mae += mae_batch.item()\n                count += img.size(0)\n        \n        avg_mae = total_mae / count\n        print(f\"   Epoch {epoch+1} | Loss: {loss.item():.2f} | Fair MAE (Total Biomass): {avg_mae:.4f}\")\n        best_mae = min(best_mae, avg_mae)\n\n    results[cand['name']] = best_mae\n\n# 4. Declare Winner based on Fair MAE\nprint(\"\\n🏆 Tournament Results (MAE - Lower is Better):\")\nprint(results)\n\n# Force the choice if close, or pick strictly better MAE\nif results[\"VisionOnlyNet\"] < results[\"PhysicsLockedNet\"] - 1.0: \n    # Only pick VisionNet if it beats PhysicsNet by a significant margin (>1.0g)\n    print(\">>> WINNER: VisionOnlyNet (Significantly better performance)\")\n    SELECTED_MODEL_CLASS = VisionOnlyNet\n    SELECTED_CRITERION = SimpleLoss\n    IS_MULTITASK = False\nelse:\n    # Otherwise, prefer the Physics model because it is logically robust\n    print(\">>> WINNER: PhysicsLockedNet (Selected for Logical Consistency)\")\n    SELECTED_MODEL_CLASS = PhysicsLockedNet\n    SELECTED_CRITERION = PhysicsLoss\n    IS_MULTITASK = True","metadata":{"_uuid":"9097a651-ac1b-4c39-b1b2-088df5a85aff","_cell_guid":"00dbbc2e-12e3-428d-aa00-9b2029b60dbf","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-12T10:58:29.124875Z","iopub.execute_input":"2026-01-12T10:58:29.125224Z","iopub.status.idle":"2026-01-12T11:00:24.684403Z","shell.execute_reply.started":"2026-01-12T10:58:29.125196Z","shell.execute_reply":"2026-01-12T11:00:24.683678Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 6: FINAL TRAINING (DYNAMICALLY SELECTED MODEL)\n# ==============================================================================\n\n# 1. Helper for Validation (Unchanged)\ndef validate_fn(model, loader, device, criterion, is_multitask):\n    model.eval()\n    run_loss = 0.0\n    with torch.no_grad():\n        for img, targets, aux_targets in loader:\n            img = img.to(device)\n            targets = targets.to(device)\n            aux_targets = aux_targets.to(device)\n            \n            # Autocast is also beneficial for inference speed\n            with torch.cuda.amp.autocast():\n                preds = model(img)\n                if is_multitask:\n                    loss = criterion(preds, targets, aux_targets)\n                else:\n                    loss = criterion(preds, targets)\n            \n            run_loss += loss.item() * img.size(0)\n    return run_loss / len(loader.dataset)\n\n# 2. Main Loop Setup\nmodels_list = []\nhistory = {'train': [], 'val': []}\n\n# --- StratifiedGroupKFold on Total Biomass ---\nnum_bins = 5\ntrain_df['stratify_bin'] = pd.qcut(train_df['Dry_Total_g'], num_bins, labels=False)\nsgkf = StratifiedGroupKFold(n_splits=Config.FOLDS)\ngroups = train_df['Sampling_Date'].astype(str)\nstratify_target = train_df['stratify_bin']\n\nprint(f\"\\n>>> Starting {Config.FOLDS}-Fold CV with WINNER: {SELECTED_MODEL_CLASS.__name__} <<<\")\n\nfor fold, (train_idx, val_idx) in enumerate(sgkf.split(train_df, stratify_target, groups=groups)):\n    print(f\"=== Fold {fold+1}/{Config.FOLDS} ===\")\n    \n    fold_train_df = train_df.iloc[train_idx].reset_index(drop=True)\n    fold_val_df = train_df.iloc[val_idx].reset_index(drop=True)\n    \n    train_ds = FusionDataset(fold_train_df, Config.TRAIN_DIR, Config.TABULAR_COLS, tfms_train)\n    val_ds = FusionDataset(fold_val_df, Config.TRAIN_DIR, Config.TABULAR_COLS, tfms_train)\n    \n    # --- WeightedRandomSampler for CLOVER ---\n    clover_bins = pd.cut(fold_train_df['Dry_Clover_g'], bins=[-1, 0.1, 10, 1000], labels=[0, 1, 2])\n    weights_per_bin = 1.0 / clover_bins.value_counts()\n    sample_weights = clover_bins.map(weights_per_bin).astype(float).values\n    sample_weights = torch.from_numpy(sample_weights).double()\n    \n    sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True)\n    \n    train_dl = DataLoader(train_ds, batch_size=Config.BATCH_SIZE, sampler=sampler, \n                      shuffle=False, drop_last=True, \n                      num_workers=4, pin_memory=True) # Optimized Workers\n\n    val_dl = DataLoader(val_ds, batch_size=Config.BATCH_SIZE, \n                    num_workers=4, pin_memory=True) # Optimized Workers\n    \n    # Model Init\n    model = SELECTED_MODEL_CLASS().to(Config.DEVICE)\n    optimizer = optim.AdamW(model.parameters(), lr=best_lr)\n    criterion = SELECTED_CRITERION()\n    scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=Config.EPOCHS)\n    \n    # --- AMP: Initialize Scaler ---\n    scaler = torch.cuda.amp.GradScaler() \n    \n    best_fold_loss = float('inf')\n    fold_train_hist = []\n    fold_val_hist = []\n    \n    for epoch in range(Config.EPOCHS):\n        model.train()\n        run_train_loss = 0.0\n        \n        for img, targets, aux_targets in train_dl:\n            img, targets, aux_targets = img.to(Config.DEVICE), targets.to(Config.DEVICE), aux_targets.to(Config.DEVICE)\n            optimizer.zero_grad()\n            \n            # --- AMP: Autocast Forward Pass ---\n            with torch.cuda.amp.autocast():\n                preds = model(img)\n                if IS_MULTITASK:\n                    loss = criterion(preds, targets, aux_targets)\n                else:\n                    loss = criterion(preds, targets)\n            \n            # --- AMP: Scaled Backward Pass ---\n            scaler.scale(loss).backward()\n            \n            # Unscale weights to allow for gradient clipping\n            scaler.unscale_(optimizer)\n            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n            \n            # Step with scaler\n            scaler.step(optimizer)\n            scaler.update()\n            \n            run_train_loss += loss.item() * img.size(0)\n            \n        scheduler.step()\n        \n        epoch_train_loss = run_train_loss / len(train_dl.dataset)\n        epoch_val_loss = validate_fn(model, val_dl, Config.DEVICE, criterion, IS_MULTITASK)\n        \n        fold_train_hist.append(epoch_train_loss)\n        fold_val_hist.append(epoch_val_loss)\n        \n        print(f\"   Ep {epoch+1} | Train: {epoch_train_loss:.4f} | Val: {epoch_val_loss:.4f}\")\n        \n        if epoch_val_loss < best_fold_loss:\n            best_fold_loss = epoch_val_loss\n            torch.save(model.state_dict(), f'model_fold_{fold}.pth')\n            \n    model.load_state_dict(torch.load(f'model_fold_{fold}.pth'))\n    models_list.append(model)\n    history['train'].append(fold_train_hist)\n    history['val'].append(fold_val_hist)\n\nprint(\"\\n✅ Training Complete.\")","metadata":{"_uuid":"bce93843-6a38-415b-9680-698e31aae88c","_cell_guid":"455ffffa-11a4-4aed-982a-c1dbbc21e938","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-12T11:00:24.685263Z","iopub.execute_input":"2026-01-12T11:00:24.685529Z","iopub.status.idle":"2026-01-12T11:01:16.046150Z","shell.execute_reply.started":"2026-01-12T11:00:24.685506Z","shell.execute_reply":"2026-01-12T11:01:16.045386Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 7: OBSERVABILITY & LEARNING CURVES\n# Visualization of Training vs Validation Error.\n# ==============================================================================\n\n# Calculate Average Loss across all folds\navg_train_loss = np.mean(history['train'], axis=0)\navg_val_loss = np.mean(history['val'], axis=0)\nepochs = range(1, Config.EPOCHS + 1)\n\nplt.figure(figsize=(12, 5))\n\n# Plot 1: Average Learning Curve\nplt.subplot(1, 2, 1)\nplt.plot(epochs, avg_train_loss, 'b-o', label='Training Loss', linewidth=2)\nplt.plot(epochs, avg_val_loss, 'r--o', label='Validation Loss', linewidth=2)\nplt.title(f'Learning Curve (Avg of {Config.FOLDS} Folds)')\nplt.xlabel('Epochs')\nplt.ylabel('Physics MAE + Penalties') # Updated Label\nplt.legend()\nplt.grid(True, alpha=0.3)\n# Force integer ticks for x-axis since we only have 2 epochs\nplt.xticks(epochs) \n\n# Plot 2: Per-Fold Validation Stability\nplt.subplot(1, 2, 2)\nfor i in range(Config.FOLDS):\n    plt.plot(epochs, history['val'][i], alpha=0.5, label=f'Fold {i+1}')\nplt.plot(epochs, avg_val_loss, 'k--', linewidth=2, label='Average')\nplt.title('Validation Stability Check')\nplt.xlabel('Epochs')\nplt.ylabel('Val Loss')\nplt.legend()\nplt.grid(True, alpha=0.3)\nplt.xticks(epochs)\n\nplt.tight_layout()\nplt.show()","metadata":{"_uuid":"1ecdfae7-01e3-4ef3-923e-217008e1a7bb","_cell_guid":"1995a240-1871-422c-b219-e39ad2e5f0a4","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-01-12T11:01:16.048212Z","iopub.execute_input":"2026-01-12T11:01:16.048517Z","iopub.status.idle":"2026-01-12T11:01:16.378192Z","shell.execute_reply.started":"2026-01-12T11:01:16.048494Z","shell.execute_reply":"2026-01-12T11:01:16.377436Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 8: INFERENCE & SUBMISSION (DYNAMIC)\n# ==============================================================================\nprint(\">>> Generating Predictions with Test Time Augmentation (TTA)...\")\n\n# 1. Load Test Data\ntest_raw = pd.read_csv(Config.TEST_CSV)\ntest_unique = test_raw[['image_path']].drop_duplicates().reset_index(drop=True)\n\n# 2. Dataset & Dataloader\ntest_ds = FusionDataset(test_unique, Config.TRAIN_DIR, Config.TABULAR_COLS, tfms_train, is_test=True)\ntest_dl = DataLoader(test_ds, batch_size=Config.BATCH_SIZE, shuffle=False)\n\n# 3. Prediction Loop\nfinal_preds = np.zeros((len(test_ds), 5))\n\nfor model in models_list:\n    model.eval()\n    fold_preds = []\n    \n    with torch.no_grad():\n        for img_batch, _ in test_dl:\n            img_batch = img_batch.to(Config.DEVICE)\n            \n            # --- Dynamic Output Extraction ---\n            def get_preds_numpy(output):\n                if IS_MULTITASK:\n                    # Output is tuple: (biomass_preds, height, ndvi)\n                    # biomass_preds is already [Green, Dead, Clover, GDM, Total]\n                    return output[0].cpu().numpy() \n                else:\n                    return output.cpu().numpy()\n            \n            # TTA View 1 (Normal)\n            p1 = get_preds_numpy(model(img_batch))\n            \n            # TTA View 2 (Horizontal Flip)\n            p2 = get_preds_numpy(model(torch.flip(img_batch, [3])))\n            \n            # TTA View 3 (Vertical Flip)\n            p3 = get_preds_numpy(model(torch.flip(img_batch, [2])))\n            \n            # Average views\n            fold_preds.append((p1 + p2 + p3) / 3.0)\n            \n    final_preds += np.vstack(fold_preds)\n\nfinal_preds /= Config.FOLDS\nfinal_preds = np.maximum(final_preds, 0) # Safety clip\n\n# 4. Save to CSV\n# We map the 5 columns from the model output directly to the requested target names\nsub_rows = []\npaths = test_unique['image_path'].values\n\n# Optimize lookup for speed\nprint(\">>> Writing Submission CSV...\")\npred_map = {path: preds for path, preds in zip(paths, final_preds)}\n\n# Columns in our tensor: \n# 0: Dry_Green_g, 1: Dry_Dead_g, 2: Dry_Clover_g, 3: GDM_g, 4: Dry_Total_g\n# This matches Config.TARGET_COLS exactly.\n\nfor _, row in test_raw.iterrows():\n    path = row['image_path']\n    t_name = row['target_name']\n    \n    p_values = pred_map.get(path, [0,0,0,0,0])\n    \n    if t_name == 'Dry_Green_g':   val = p_values[0]\n    elif t_name == 'Dry_Dead_g':  val = p_values[1]\n    elif t_name == 'Dry_Clover_g': val = p_values[2]\n    elif t_name == 'GDM_g':       val = p_values[3]\n    elif t_name == 'Dry_Total_g': val = p_values[4]\n    else: val = 0.0\n        \n    sub_rows.append({'sample_id': row['sample_id'], 'target': float(val)})\n\n\nimport os\noutput_path = '/kaggle/working/submission.csv'\npd.DataFrame(sub_rows).to_csv(output_path, index=False)\nprint(f\"✅ Submission saved to: {output_path}\")\nprint(f\"✅ Submission saved. Used Model: {SELECTED_MODEL_CLASS.__name__}\")","metadata":{"_uuid":"d65005ed-ba12-4899-bd74-22f6b7c34d65","_cell_guid":"3b34f28b-b2e1-48b4-9a5f-60232f22d31a","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-12T11:01:16.379060Z","iopub.execute_input":"2026-01-12T11:01:16.379594Z","iopub.status.idle":"2026-01-12T11:01:16.759423Z","shell.execute_reply.started":"2026-01-12T11:01:16.379570Z","shell.execute_reply":"2026-01-12T11:01:16.758833Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 9: FINAL EVALUATION (ON VALIDATION HOLD-OUT)\n# Rubric Requirement: Evaluation on a hold-out test set.\n# Since Kaggle Test labels are hidden, we use the Validation Set from the last fold.\n# ==============================================================================\nfrom sklearn.metrics import mean_absolute_error, r2_score\n\nprint(f\">>> Performing Final Evaluation on Hold-Out Set (Fold {Config.FOLDS})...\")\n\n# 1. Get the Validation Data (Hold-Out)\n# We use the 'val_dl' from the last fold of training (Block 6)\n# This data was NOT used to train the model, so it is a valid test.\ntrue_vals = []\npred_vals = []\n\nmodel = models_list[-1] # Use the model from the last fold\nmodel.eval()\n\nwith torch.no_grad():\n    for img, targets, _ in val_dl:\n        img = img.to(Config.DEVICE)\n        \n        # Get Predictions\n        # PhysicsNet returns tuple (biomass, height, ndvi), we need [0]\n        preds = model(img)[0].cpu().numpy()\n        \n        pred_vals.append(preds)\n        true_vals.append(targets.numpy())\n\n# Concatenate batches\ny_pred = np.vstack(pred_vals)\ny_true = np.vstack(true_vals)\n\n# 2. Calculate Metrics per Component\ncomponents = ['Dry_Green_g', 'Dry_Dead_g', 'Dry_Clover_g', 'GDM_g', 'Dry_Total_g']\nprint(\"\\n📊 FINAL PERFORMANCE REPORT:\")\nprint(f\"{'Component':<15} | {'MAE (g)':<10} | {'R² Score':<10}\")\nprint(\"-\" * 40)\n\nfor i, name in enumerate(components):\n    mae = mean_absolute_error(y_true[:, i], y_pred[:, i])\n    r2 = r2_score(y_true[:, i], y_pred[:, i])\n    print(f\"{name:<15} | {mae:<10.4f} | {r2:<10.4f}\")\n\n# 3. Visual Evaluation (Predicted vs Actual)\nplt.figure(figsize=(18, 5))\n\n# Plot A: Total Biomass Regression\nplt.subplot(1, 3, 1)\nplt.scatter(y_true[:, 4], y_pred[:, 4], alpha=0.5, color='blue', s=10)\nmax_val = max(y_true[:, 4].max(), y_pred[:, 4].max())\nplt.plot([0, max_val], [0, max_val], 'r--', label='Perfect Prediction')\nplt.title(f\"Total Biomass: Pred vs Actual\\n(MAE: {mean_absolute_error(y_true[:, 4], y_pred[:, 4]):.2f}g)\")\nplt.xlabel(\"Actual Mass (g)\")\nplt.ylabel(\"Predicted Mass (g)\")\nplt.legend()\n\n# Plot B: Clover Analysis (Critical Weakness Check)\nplt.subplot(1, 3, 2)\nplt.scatter(y_true[:, 2], y_pred[:, 2], alpha=0.6, color='green', s=10)\nmax_clover = max(y_true[:, 2].max(), y_pred[:, 2].max())\nplt.plot([0, max_clover], [0, max_clover], 'r--')\nplt.title(f\"Clover: Pred vs Actual\\n(MAE: {mean_absolute_error(y_true[:, 2], y_pred[:, 2]):.2f}g)\")\nplt.xlabel(\"Actual Clover (g)\")\nplt.ylabel(\"Predicted Clover (g)\")\n\n# Plot C: Error Distribution\nplt.subplot(1, 3, 3)\nerrors = y_pred[:, 4] - y_true[:, 4]\nsns.histplot(errors, kde=True, color='purple')\nplt.title(\"Error Distribution (Total Biomass)\")\nplt.xlabel(\"Prediction Error (g)\")\nplt.axvline(x=0, color='k', linestyle='--')\n\nplt.tight_layout()\nplt.show()","metadata":{"_uuid":"afbb38bb-ddb9-401f-844e-c2843c60cba3","_cell_guid":"dd5c9f90-6012-4be9-9330-44517457617b","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-01-12T11:01:16.760172Z","iopub.execute_input":"2026-01-12T11:01:16.760480Z","iopub.status.idle":"2026-01-12T11:01:23.273942Z","shell.execute_reply.started":"2026-01-12T11:01:16.760461Z","shell.execute_reply":"2026-01-12T11:01:23.273115Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 10: VISUAL SANITY CHECK (Test Prediction vs. Train Reality)\n# ==============================================================================\nimport matplotlib.pyplot as plt\n\ndef get_inference(img_path, model_list, device):\n    \"\"\"Runs inference using the ensemble of models.\"\"\"\n    # Load and Preprocess\n    img = cv2.imread(str(img_path))\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    \n    # Apply the SAME transform used in training (minus the augmentation randomness if possible, \n    # but here we use the valid/test transform which is usually just Normalize + ToTensor)\n    # Note: We manually reconstruct a basic validation transform here to avoid random crops\n    valid_transform = T.Compose([\n        T.ToPILImage(),\n        T.Resize(Config.IMG_SIZE), # Resize to (256, 512)\n        T.ToTensor(),\n        T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])\n    ])\n    \n    tensor_img = valid_transform(img).unsqueeze(0).to(device)\n    \n    # Predict\n    avg_preds = np.zeros(5)\n    for model in model_list:\n        model.eval()\n        with torch.no_grad():\n            # Get tuple (biomass, height, ndvi) -> index 0 is biomass\n            preds = model(tensor_img)[0].cpu().numpy()[0]\n            # IMPORTANT: If you used Log-Transform, reverse it here!\n            # avg_preds += np.expm1(preds) \n            # If NO Log-Transform (Current State), use raw:\n            avg_preds += preds\n            \n    return img, avg_preds / len(model_list)\n\n# 1. Pick Random Samples\ntest_sample = test_unique.sample(1).iloc[0]\ntrain_sample = train_df.sample(1).iloc[0]\n\n# 2. Get Predictions\ntest_img, test_pred = get_inference(Config.TEST_DIR / test_sample['image_path'], models_list, Config.DEVICE)\ntrain_img, train_pred = get_inference(Config.TRAIN_DIR / train_sample['image_path'], models_list, Config.DEVICE)\n\n# 3. Get Ground Truth for Train Sample\n# (We filter the original dataframe to get the 5 target values)\ntrain_truth = train_sample[Config.TARGET_COLS].values.astype(float)\n\n# 4. Visualization\nfig, axs = plt.subplots(1, 2, figsize=(20, 8))\n\n# --- Left: Test Image (Prediction Only) ---\naxs[0].imshow(test_img)\naxs[0].set_title(f\"TEST IMAGE\\n(File: {test_sample['image_path']})\", fontsize=14, fontweight='bold', color='blue')\ntext_str = \"PREDICTED (g):\\n\"\ntext_str += f\"Green:  {test_pred[0]:.1f}\\n\"\ntext_str += f\"Dead:   {test_pred[1]:.1f}\\n\"\ntext_str += f\"Clover: {test_pred[2]:.1f}\\n\"\ntext_str += f\"Total:  {test_pred[4]:.1f}\"\naxs[0].text(20, 230, text_str, bbox=dict(facecolor='white', alpha=0.8), fontsize=12, verticalalignment='bottom')\naxs[0].axis('off')\n\n# --- Right: Train Image (Prediction vs Reality) ---\naxs[1].imshow(train_img)\naxs[1].set_title(f\"TRAIN IMAGE\\n(File: {train_sample['image_path']})\", fontsize=14, fontweight='bold', color='green')\ntext_str = \"PREDICTED  |  ACTUAL\\n\"\ntext_str += f\"Green:  {train_pred[0]:.1f}  |  {train_truth[0]:.1f}\\n\"\ntext_str += f\"Dead:   {train_pred[1]:.1f}  |  {train_truth[1]:.1f}\\n\"\ntext_str += f\"Clover: {train_pred[2]:.1f}  |  {train_truth[2]:.1f}\\n\"\ntext_str += f\"Total:  {train_pred[4]:.1f}  |  {train_truth[4]:.1f}\"\n\n# Color code the box: Green if Total is close (<20% error), Red if far\nerror = abs(train_pred[4] - train_truth[4]) / (train_truth[4] + 1e-6)\nbox_color = 'lightgreen' if error < 0.20 else 'salmon'\n\naxs[1].text(20, 230, text_str, bbox=dict(facecolor=box_color, alpha=0.8), fontsize=12, verticalalignment='bottom')\naxs[1].axis('off')\n\nplt.tight_layout()\nplt.show()\n\nprint(f\"Sanity Check Difference (Train Total): {train_pred[4]:.1f} vs {train_truth[4]:.1f} (Error: {error:.1%})\")","metadata":{"_uuid":"63e136e7-346a-4eb5-9a14-80334a74254f","_cell_guid":"1b0ef49e-20dd-4bd6-850b-a82a3138ad12","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-01-12T11:01:23.275137Z","iopub.execute_input":"2026-01-12T11:01:23.275677Z","iopub.status.idle":"2026-01-12T11:01:24.626907Z","shell.execute_reply.started":"2026-01-12T11:01:23.275652Z","shell.execute_reply":"2026-01-12T11:01:24.626025Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==============================================================================\n# BLOCK 11: CLEANUP (OPTIONAL)\n# Delete heavy model files to keep output clean, leaving only submission.csv\n# ==============================================================================\nimport os\n\nprint(\">>> Cleaning up model files...\")\nfor f in os.listdir('.'):\n    if f.endswith('.pth'):\n        os.remove(f)\n        print(f\"   Deleted: {f}\")\n\nprint(\"✅ Cleanup Complete. Only submission.csv remains.\")","metadata":{"_uuid":"58aebd20-9055-412d-9d2a-7be6d4a0f9fe","_cell_guid":"97d8be8f-34f7-4a7e-a3ab-fee435fe94ea","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-12T11:01:24.628114Z","iopub.execute_input":"2026-01-12T11:01:24.628461Z","iopub.status.idle":"2026-01-12T11:01:24.663653Z","shell.execute_reply.started":"2026-01-12T11:01:24.628430Z","shell.execute_reply":"2026-01-12T11:01:24.663067Z"}},"outputs":[],"execution_count":null}]}