{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceType":"competition","sourceId":112509,"databundleVersionId":14254895},{"sourceType":"modelInstanceVersion","sourceId":993,"databundleVersionId":4905387,"modelInstanceId":847,"modelId":101},{"sourceType":"modelInstanceVersion","sourceId":6095,"databundleVersionId":7429351,"modelInstanceId":4632,"modelId":2801}],"dockerImageVersionId":31329,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ============================================================\n# SOLUCIÓN 3 - ResNet50 + SVR (LOCAL)\n# Incluye: Preprocesamiento, Aumento de Datos,\n#          Extracción de Características, Regresión,\n#          Validación y Calibración\n# ============================================================\n\n# Para manipulación de datos\nimport os\nimport joblib\nimport pandas as pd\nimport numpy as np\nfrom tqdm import tqdm\n\n# Para el aumento de datos\nimport albumentations as A\nimport cv2\n\n# Para el modelo de aprendizaje automático\n# MultiOutputRegressor envuelve SVR para soportar múltiples variables objetivo,\n# ya que SVR de sklearn solo acepta una variable objetivo a la vez.\nfrom sklearn.svm import SVR\nfrom sklearn.multioutput import MultiOutputRegressor\nfrom sklearn.metrics import mean_squared_error, r2_score\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.preprocessing import StandardScaler\n\n# Para visión por computador\nimport tensorflow as tf\nimport keras_cv\n\n\n# ===============================\n# RUTAS\n# ===============================\n\nBASE_PATH = \"/kaggle/input/competitions/csiro-biomass\"\n\nTRAIN_CSV_PATH = os.path.join(BASE_PATH, \"train.csv\")\nTEST_CSV_PATH  = os.path.join(BASE_PATH, \"test.csv\")\n\nTRAIN_IMG_DIR  = BASE_PATH\nTEST_IMG_DIR   = BASE_PATH\n\nSAVE_DIR = \"saved_model_resnet_svr\"\nos.makedirs(SAVE_DIR, exist_ok=True)\n\n\n# ===============================\n# CONFIGURACIÓN GENERAL\n# ===============================\n\nIMG_SIZE    = 224       # Tamaño de entrada esperado por ResNet50\nRANDOM_SEED = 999       # Semilla para reproducibilidad\nVAL_SIZE    = 0.2       # 20 % de los datos se reservan para validación\n\n\n# ===============================\n# CARGA DE DATOS\n# ===============================\n\ntrain_df = pd.read_csv(TRAIN_CSV_PATH)\ntest_df  = pd.read_csv(TEST_CSV_PATH)\n\n# Convertir de formato largo a ancho para tener una fila por imagen\n# Cada columna corresponde a una variable objetivo (Dry_Clover_g, Dry_Dead_g, etc.)\ntrain_wide_df = train_df.pivot(\n    index=\"image_path\",\n    columns=\"target_name\",\n    values=\"target\"\n).reset_index()\n\nTARGET_COLS = [c for c in train_wide_df.columns if c != \"image_path\"]\n\nprint(\"Targets:\", TARGET_COLS)\nprint(f\"Imágenes de train: {len(train_wide_df)}\")\nprint(f\"Imágenes de test:  {len(test_df['image_path'].unique())}\")\n\n\n# ===============================\n# MÓDULO DE AUMENTO DE DATOS\n# ===============================\n# Se definen transformaciones geométricas y de color sencillas que se\n# aplican aleatoriamente a cada imagen SOLO durante el entrenamiento.\n# El objetivo es aumentar artificialmente la variabilidad del conjunto\n# de entrenamiento para que el modelo generalice mejor.\n\ndef get_train_transforms():\n    \"\"\"\n    Devuelve un pipeline de albumentations con transformaciones simples:\n      - HorizontalFlip : volteo horizontal con probabilidad 0.5\n      - VerticalFlip   : volteo vertical con probabilidad 0.5\n      - RandomRotate90 : rotación aleatoria de 90° con probabilidad 0.5\n      - RandomBrightnessContrast : variación leve de brillo y contraste\n    \"\"\"\n    return A.Compose([\n        A.HorizontalFlip(p=0.5),\n        A.VerticalFlip(p=0.5),\n        A.RandomRotate90(p=0.5),\n        A.RandomBrightnessContrast(\n            brightness_limit=0.2,\n            contrast_limit=0.2,\n            p=0.5\n        ),\n        A.Resize(IMG_SIZE, IMG_SIZE),\n    ], seed=RANDOM_SEED)\n\n\n# Instanciar el pipeline de aumento una sola vez\ntrain_augment = get_train_transforms()\n\n\n# ===============================\n# CARGA DEL MODELO ResNet50V2\n# ===============================\n# Se carga ResNet50V2 usando keras_cv desde una ruta local en Kaggle,\n# lo que permite funcionar con internet desactivado.\n#\n# from_preset con una ruta local lee los pesos directamente del disco\n# sin necesitar conexión externa. La ruta apunta al modelo descargado\n# previamente en el dataset de Kaggle.\n#\n# include_rescaling=True (por defecto en from_preset) indica que el modelo\n# aplica internamente el reescalado de píxeles, por lo que las imágenes\n# deben pasarse en rango [0, 255] sin preprocesamiento adicional.\n#\n# GlobalAveragePooling2D reduce la salida espacial del backbone\n# a un vector de 2048 dimensiones por imagen.\n\nMODEL_PATH = \"/kaggle/input/models/keras/resnetv2/keras/resnet50_v2/2\"\n\nbase_net = keras_cv.models.ResNetV2Backbone.from_preset(MODEL_PATH)\nbase_net.trainable = False   # Los pesos preentrenados NO se actualizan\n\n# Construir el extractor: backbone + pooling global\ninputs  = tf.keras.Input(shape=(IMG_SIZE, IMG_SIZE, 3))\nx       = base_net(inputs, training=False)\noutputs = tf.keras.layers.GlobalAveragePooling2D()(x)\n\nfeature_extractor = tf.keras.Model(inputs=inputs, outputs=outputs)\n\nprint(f\"Modelo cargado. Forma del vector de características: {feature_extractor.output_shape}\")\n# Debería imprimir: (None, 2048)\n\n\n# ===============================\n# FUNCIONES DE CARGA Y EXTRACCIÓN\n# ===============================\n\ndef load_image_as_array(img_path):\n    \"\"\"\n    Carga una imagen desde disco con OpenCV y la convierte a RGB.\n    \"\"\"\n    img = cv2.imread(img_path)\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    img = cv2.resize(img, (IMG_SIZE, IMG_SIZE))\n    return img\n\n\ndef extract_features(img_array):\n    \"\"\"\n    Recibe un array HxWx3 uint8 y extrae su vector de características.\n\n    Parámetros\n    ----------\n    img_array : np.ndarray\n        Imagen en formato HxWx3 uint8.\n\n    Retorna\n    -------\n    numpy.ndarray\n        Vector 1-D de 2048 dimensiones con las características extraídas.\n    \"\"\"\n    # keras_cv con from_preset incluye rescaling interno (include_rescaling=True),\n    # por lo tanto las imágenes se pasan en rango [0, 255] sin preprocesamiento\n    # adicional. No se usa preprocess_input de keras.applications aquí.\n    x = np.expand_dims(img_array.astype(np.float32), axis=0)  # (1, 224, 224, 3)\n    features = feature_extractor(x, training=False)\n    return features.numpy().flatten()   # vector 1-D de 2048 dimensiones\n\n\n# ===============================\n# DIVISIÓN ENTRENAMIENTO / VALIDACIÓN\n# ===============================\n# Se separa el 20 % de las imágenes como conjunto de validación ANTES\n# de extraer características, para evitar cualquier fuga de información.\n\ntrain_split_df, val_split_df = train_test_split(\n    train_wide_df,\n    test_size=VAL_SIZE,\n    random_state=RANDOM_SEED\n)\n\nprint(f\"Imágenes de entrenamiento : {len(train_split_df)}\")\nprint(f\"Imágenes de validación    : {len(val_split_df)}\")\n\n\n# ===============================\n# EXTRACCIÓN DE CARACTERÍSTICAS - ENTRENAMIENTO\n# ===============================\n# Por cada imagen se generan dos muestras: la original y una versión aumentada.\n# Esto duplica el conjunto de entrenamiento de 285 a 570 muestras.\n#\n# Se usa UNA sola copia aumentada por imagen para añadir variabilidad\n# sin generar una correlación excesiva entre muestras del mismo original.\n\nprint(\"\\nExtrayendo features de TRAIN (original + 1 aumentada por imagen)...\")\n\ntrain_imgs, train_labels = [], []\n\nfor _, row in tqdm(train_split_df.iterrows(), total=len(train_split_df)):\n    full_path = os.path.join(TRAIN_IMG_DIR, row[\"image_path\"])\n    img_array = load_image_as_array(full_path)\n    label     = row[TARGET_COLS].values.astype(np.float32)\n\n    # Imagen original\n    train_imgs.append(img_array)\n    train_labels.append(label)\n\n    # Una única versión aumentada con las transformaciones definidas\n    aug_img = train_augment(image=img_array)[\"image\"]\n    train_imgs.append(aug_img)\n    train_labels.append(label)\n\nprint(f\"Extrayendo características de {len(train_imgs)} imágenes...\")\ntrain_features = []\n\nfor img in tqdm(train_imgs):\n    train_features.append(extract_features(img))\n\ntrain_features = np.array(train_features)   # shape: (570, 2048)\ntrain_labels   = np.array(train_labels)\n\nprint(f\"→ {len(train_split_df)} imágenes × 2 = {train_features.shape[0]} ejemplos totales\")\nprint(f\"Forma del vector de características: {train_features.shape}\")\n\n\n# ===============================\n# EXTRACCIÓN DE CARACTERÍSTICAS - VALIDACIÓN\n# ===============================\n# Para validación NO se aplica aumento de datos: se evalúa el modelo\n# en imágenes tal cual llegan, sin modificaciones artificiales.\n\nprint(\"\\nExtrayendo features del conjunto de VALIDACIÓN...\")\n\nval_imgs, val_labels = [], []\n\nfor _, row in tqdm(val_split_df.iterrows(), total=len(val_split_df)):\n    full_path = os.path.join(TRAIN_IMG_DIR, row[\"image_path\"])\n    img_array = load_image_as_array(full_path)\n\n    val_imgs.append(img_array)\n    val_labels.append(row[TARGET_COLS].values.astype(np.float32))\n\nval_features = []\nfor img in tqdm(val_imgs):\n    val_features.append(extract_features(img))\n\nval_features = np.array(val_features)   # shape: (72, 2048)\nval_labels   = np.array(val_labels)\n\nprint(f\"Matriz de características de validación: {val_features.shape}\")\n\n\n# ===============================\n# NORMALIZACIÓN\n# ===============================\n# El escalado es un requisito crítico para SVR, NO opcional.\n#\n# ¿Por qué?\n#   SVR calcula distancias entre puntos en el espacio de características\n#   para construir el hiperplano de separación. Si las características tienen\n#   escalas muy diferentes, las de mayor magnitud dominarán el cálculo de\n#   distancias y el modelo ignorará efectivamente las de menor magnitud,\n#   sin importar cuán informativas sean.\n#\n#   StandardScaler estandariza cada característica a media=0 y desviación=1,\n#   garantizando que todas contribuyan por igual al cálculo de distancias.\n#\n# REGLA: el scaler se ajusta (fit) SOLO con datos de entrenamiento y luego\n# se aplica (transform) a validación y test para evitar data leakage.\n\nscaler  = StandardScaler()\nX_train = scaler.fit_transform(train_features)   # ajusta y transforma\nX_val   = scaler.transform(val_features)          # solo transforma\ny_train = train_labels\ny_val   = val_labels\n\n\n# ===============================\n# EXTRACCIÓN DE CARACTERÍSTICAS - TEST\n# ===============================\n# Al igual que en validación, NO se aplica aumento de datos en test.\n# Se extrae un único vector de características por imagen original.\n\nunique_paths = test_df[\"image_path\"].unique()\nprint(f\"\\nExtrayendo features de TEST...\")\n\ntest_imgs = []\nfor img_path in tqdm(unique_paths):\n    full_path = os.path.join(TEST_IMG_DIR, img_path)\n    test_imgs.append(load_image_as_array(full_path))\n\ntest_features = []\nfor img in tqdm(test_imgs):\n    test_features.append(extract_features(img))\n\ntest_features = np.array(test_features)   # shape: (n_test, 2048)\nX_test        = scaler.transform(test_features)\n\n\n# ===============================\n# ENTRENAMIENTO DEL MODELO - SVR\n# ===============================\n# SVR (Support Vector Regression) busca ajustar una función que prediga\n# el valor objetivo cometiendo un error máximo de epsilon. Solo las muestras\n# fuera de ese margen de tolerancia contribuyen a la función de pérdida,\n# lo que hace al modelo robusto ante outliers.\n#\n# Parámetros clave:\n#   - kernel : 'rbf' (Radial Basis Function) proyecta los datos a un espacio\n#              de alta dimensión donde la relación puede ser lineal, permitiendo\n#              capturar patrones no lineales entre características y biomasa.\n#   - C      : penalización por errores fuera del margen. Un C alto permite\n#              menos errores pero puede sobreajustar; un C bajo es más tolerante.\n#   - epsilon: define el margen de tolerancia dentro del cual no se penalizan\n#              los errores. Muestras dentro del tubo epsilon son ignoradas.\n#\n# MultiOutputRegressor es necesario porque SVR solo acepta una variable\n# objetivo a la vez. Este wrapper entrena un SVR independiente por cada\n# columna de y, de forma transparente al resto del código.\n\nprint(\"\\nEntrenando SVR con MultiOutputRegressor...\")\n\nmodel = MultiOutputRegressor(\n    SVR(\n        kernel=\"rbf\",   # kernel radial para capturar relaciones no lineales\n        C=1.0,          # penalización por errores fuera del margen epsilon\n        epsilon=0.1     # margen de tolerancia: errores menores no se penalizan\n    ),\n    n_jobs=-1   # entrenar los 5 SVR (uno por target) en paralelo\n)\n\nmodel.fit(X_train, y_train)\nprint(\"Entrenamiento completado.\")\n\n\n# ============================================================\n# MÓDULO DE VALIDACIÓN Y CALIBRACIÓN\n# ============================================================\n# Se evalúa el rendimiento del modelo comparando las predicciones\n# generadas contra los valores reales del conjunto de validación.\n#\n# Métricas utilizadas:\n#   - MSE  (Mean Squared Error / Error Cuadrático Medio):\n#       Promedio de los errores al cuadrado. Penaliza fuertemente\n#       los errores grandes. Unidad: (g/m²)²\n#\n#   - RMSE (Root Mean Squared Error / Raíz del ECM):\n#       Raíz cuadrada del MSE. Está en la misma unidad que la variable\n#       objetivo (g/m²), por lo que es más interpretable que el MSE.\n#\n#   - R²  (Coeficiente de Determinación):\n#       Indica qué proporción de la varianza de los datos es explicada\n#       por el modelo. Va de 0 a 1; un valor cercano a 1 indica un\n#       mejor ajuste.\n#\n#   - R² ponderado (métrica oficial de la competición):\n#       Cada variable objetivo tiene un peso distinto según su importancia.\n#       Dry_Total_g tiene el mayor peso (0.5) por ser la biomasa total,\n#       mientras que las fracciones individuales pesan menos (0.1 cada una).\n# ============================================================\n\n# Pesos oficiales de la competición\nTARGET_WEIGHTS = {\n    \"Dry_Green_g\":  0.1,\n    \"Dry_Dead_g\":   0.1,\n    \"Dry_Clover_g\": 0.1,\n    \"GDM_g\":        0.2,\n    \"Dry_Total_g\":  0.5\n}\n\ndef weighted_r2_score(y_true, y_pred, weights):\n    \"\"\"\n    Calcula el R² ponderado definido por la competición:\n    R²_w = 1 - (sum(w*(y - ŷ)²) / sum(w*(y - ȳ_w)²))\n    donde ȳ_w = sum(w * y) / sum(w)\n    \"\"\"\n    y_true = np.asarray(y_true).ravel()\n    y_pred = np.asarray(y_pred).ravel()\n    w      = np.asarray(weights).ravel()\n\n    # Media ponderada de los valores reales\n    y_mean_w = np.sum(w * y_true) / np.sum(w)\n\n    # Suma de cuadrados ponderada de los residuos y de la variación total\n    ss_res = np.sum(w * (y_true - y_pred) ** 2)\n    ss_tot = np.sum(w * (y_true - y_mean_w) ** 2)\n\n    # Si no hay variación (ss_tot == 0) se evita división por cero\n    if ss_tot == 0:\n        return float('nan')\n    return 1 - ss_res / ss_tot\n\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"MÓDULO DE VALIDACIÓN Y CALIBRACIÓN\")\nprint(\"=\" * 60)\n\nresults = []\n# Vectores para acumular todas las observaciones y pesos\nall_y_true  = []\nall_y_pred  = []\nall_weights = []\n\n# Generar predicciones sobre el conjunto de validación\n# y_pred tiene shape (72, 5): una fila por imagen, una columna por target\ny_pred = model.predict(X_val)\n\nfor i, col in enumerate(TARGET_COLS):\n\n    y_true_col = y_val[:, i]\n    y_pred_col = y_pred[:, i]\n\n    # Aplicar clipping a cero, igual que en la inferencia final\n    y_pred_col = np.clip(y_pred_col, 0, None)\n\n    # --- MSE: promedio de (real - predicho)² ---\n    mse  = mean_squared_error(y_true_col, y_pred_col)\n\n    # --- RMSE: raíz del MSE, misma unidad que la biomasa (g/m²) ---\n    rmse = np.sqrt(mse)\n\n    # --- R²: proporción de varianza explicada por el modelo ---\n    r2   = r2_score(y_true_col, y_pred_col)\n\n    results.append({\"Variable objetivo\": col, \"MSE\": mse, \"RMSE\": rmse, \"R²\": r2})\n    print(f\"  {col:<20}  MSE={mse:>10.4f}  RMSE={rmse:>8.4f} g/m²  R²={r2:>6.4f}\")\n\n    # Acumular para el R² ponderado global\n    all_y_true.append(y_true_col)\n    all_y_pred.append(y_pred_col)\n    all_weights.append(np.full_like(y_true_col, TARGET_WEIGHTS[col]))\n\n# Concatenar todos los targets en un solo vector\nall_y_true  = np.concatenate(all_y_true)\nall_y_pred  = np.concatenate(all_y_pred)\nall_weights = np.concatenate(all_weights)\n\n# Calcular R² ponderado global (métrica oficial de la competición)\nr2_weighted = weighted_r2_score(all_y_true, all_y_pred, all_weights)\n\n# Resumen global\nresults_df = pd.DataFrame(results)\nmean_mse  = results_df[\"MSE\"].mean()\nmean_rmse = results_df[\"RMSE\"].mean()\nmean_r2   = results_df[\"R²\"].mean()\n\nprint(\"-\" * 60)\nprint(f\"  {'PROMEDIO GLOBAL (R² simple)':<30}  MSE={mean_mse:>10.4f}  RMSE={mean_rmse:>8.4f} g/m²  R²={mean_r2:>6.4f}\")\nprint(f\"  {'R² PONDERADO (métrica oficial)':<30}  R²={r2_weighted:>6.4f}\")\nprint(\"=\" * 60 + \"\\n\")\n\n\n# ===============================\n# PREDICCIÓN Y ENVÍO\n# ===============================\n\nprint(\"Generando predicciones finales...\")\n\n# El modelo predice las 5 variables objetivo simultáneamente\ntest_predictions = model.predict(X_test)\n\n# Clamp a 0 para evitar predicciones negativas (biomasa no puede ser negativa)\ntest_predictions = np.clip(test_predictions, 0, None)\n\n# Armar DataFrame ancho con las predicciones\npredictions_df = pd.DataFrame(test_predictions, columns=TARGET_COLS)\npredictions_df.insert(0, \"image_path\", unique_paths)\n\n# Convertir de formato ancho a largo (long format requerido para la entrega)\npredictions_long_df = predictions_df.melt(\n    id_vars=[\"image_path\"],\n    value_vars=TARGET_COLS,\n    var_name=\"target_name\",\n    value_name=\"target\"\n)\n\n# Unir con test_df para recuperar el sample_id original\nsubmission_df = pd.merge(\n    test_df[[\"sample_id\", \"image_path\", \"target_name\"]],\n    predictions_long_df,\n    on=[\"image_path\", \"target_name\"]\n)[[\"sample_id\", \"target\"]].dropna()\n\nsubmission_df.to_csv(\"submission.csv\", index=False)\nprint(\"Archivo de entrega creado: submission_resnet_svr.csv\")\nprint(submission_df.head())\n\n\n# ===============================\n# GUARDAR MODELO Y ARTEFACTOS\n# ===============================\n\n# Guardar el modelo MultiOutputRegressor (contiene los 5 SVR internos)\njoblib.dump(model, os.path.join(SAVE_DIR, \"svr_model.pkl\"))\n\n# Guardar el scaler para poder transformar nuevas imágenes en inferencia\njoblib.dump(scaler, os.path.join(SAVE_DIR, \"scaler.pkl\"))\n\n# Guardar los nombres de los targets\njoblib.dump(TARGET_COLS, os.path.join(SAVE_DIR, \"target_cols.pkl\"))\n\n# Guardar los features de entrenamiento (opcional, para análisis futuros)\nnp.save(os.path.join(SAVE_DIR, \"train_features.npy\"), train_features)\n\nprint(f\"Modelo y artefactos guardados en: {SAVE_DIR}\")","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-04-26T01:25:19.215786Z","iopub.execute_input":"2026-04-26T01:25:19.216541Z","iopub.status.idle":"2026-04-26T01:27:36.694962Z","shell.execute_reply.started":"2026-04-26T01:25:19.216511Z","shell.execute_reply":"2026-04-26T01:27:36.694115Z"}},"outputs":[],"execution_count":null}]}