{"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 + Regresión Lineal (LOCAL)\n# Incluye: Preprocesamiento, Extracción de Características,\n#          Regresió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 modelo de aprendizaje automático\n# MultiOutputRegressor envuelve LinearRegression para soportar múltiples\n# variables objetivo de forma explícita, aunque LinearRegression de sklearn\n# ya acepta matrices Y de múltiples columnas de forma nativa.\n# Se usa MultiOutputRegressor por consistencia y flexibilidad con el pipeline.\nfrom sklearn.linear_model import LinearRegression\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 cv2\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_lr\"\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# 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\nprint(\"\\nExtrayendo features de TRAIN...\")\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    train_imgs.append(img_array)\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: (n_train, 2048)\ntrain_labels   = np.array(train_labels)\n\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: (n_val, 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 recomendable para Regresión Lineal cuando las características\n# provienen de diferentes distribuciones, aunque no es tan crítico como en SVR.\n#\n# ¿Por qué escalar de todas formas?\n#   Los vectores de características extraídos por ResNet50V2 pueden tener\n#   magnitudes muy variables entre dimensiones. StandardScaler estandariza\n#   cada característica a media=0 y desviación=1, lo que estabiliza el\n#   proceso de estimación de coeficientes (especialmente útil si se añade\n#   regularización en el futuro, como Ridge o Lasso).\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\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 - REGRESIÓN LINEAL\n# ===============================\n# LinearRegression ajusta un modelo de la forma ŷ = Xβ + b minimizando\n# la suma de los cuadrados de los residuos (OLS - Ordinary Least Squares).\n# Al no tener hiperparámetros de regularización, el ajuste es directo y\n# computacionalmente muy eficiente, incluso con vectores de 2048 dimensiones.\n#\n# Parámetros relevantes:\n#   - fit_intercept : incluye el término de sesgo b en el modelo (por defecto True).\n#   - n_jobs        : paraleliza el ajuste cuando se usa con MultiOutputRegressor.\n#\n# MultiOutputRegressor ajusta un regresor independiente por cada variable\n# objetivo. En el caso de LinearRegression esto es equivalente a resolver\n# cada columna de Y de forma separada, lo que es matemáticamente idéntico\n# a la regresión lineal multivariada.\n\nprint(\"\\nEntrenando Regresión Lineal con MultiOutputRegressor...\")\n\nmodel = MultiOutputRegressor(\n    LinearRegression(fit_intercept=True),   # incluye término de sesgo\n    n_jobs=-1   # ajustar los 5 regresores (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 (n_val, 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)\n# La Regresión Lineal no impone restricciones de dominio, por lo que puede\n# producir valores negativos en zonas del espacio de características poco\n# representadas en el entrenamiento.\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.csv\")\nprint(submission_df.head())\n\n\n# ===============================\n# GUARDAR MODELO Y ARTEFACTOS\n# ===============================\n\n# Guardar el modelo MultiOutputRegressor (contiene los 5 regresores lineales internos)\njoblib.dump(model, os.path.join(SAVE_DIR, \"lr_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-26T21:07:27.869045Z","iopub.execute_input":"2026-04-26T21:07:27.869526Z","iopub.status.idle":"2026-04-26T21:08:53.724977Z","shell.execute_reply.started":"2026-04-26T21:07:27.869495Z","shell.execute_reply":"2026-04-26T21:08:53.724095Z"}},"outputs":[],"execution_count":null}]}