Este sitio web aborda el análisis de sólidos suspendidos totales (SST) en cuerpos de agua mediante el uso de imágenes satelitales y técnicas de aprendizaje automático. Presenta una introducción teórica sobre la importancia de los SST como indicador ambiental y luego desarrolla una parte práctica con Python, donde se integran datos espectrales y mediciones reales para aplicar modelos de regresión. Se evalúa el desempeño de estos modelos y se exploran relaciones entre bandas espectrales y SST, proponiendo mejoras metodológicas para estudios futuros.
Palabras clave
GISTAQ, UTN, FRRe, Quarto
1 Sólidos suspendidos totales, por Vera Geneyer (https://github.com/VeraGeneyer)
Los sólidos suspendidos totales (TSM): es la cantidad de materia en suspensión en el agua, que incluye plancton, minerales, arena, y microorganismos. Se determinan como el residuo no filtrable de una muestra de agua. Niveles altos (TSM) pueden reducir la transparencia del agua, limitar la luz y y transportar sustancias tóxicas, afectando la vida acuática y la calidad del agua. Este parámetro, medido mediante sensores remotos, nos da información sobre el estado físico del cuerpo de agua y están relacionados con factores como la humedad, temperatura y entre otros, que es vital para detectar riesgos al ecosistema y cumplir con las normas ambientales.
1.1 Métodos tradicionales
Tabla 1: Características principales de algoritmos tradicionales para la estimación de sólidos suspendidos.
De acuerdo a un estudio que analizó 48 cuerpos de agua, la estimación de TSM se hizo en su mayoría por modelos lineales, siendo la banda B8A la más frecuente [3].
1.2 Métodos de aprendizaje automático
El aprendizaje automático (ML) es una rama de la inteligencia artificial cuyo objetivo es desarrollar algoritmos capaces de resolver problemas mediante el análisis de datos y la creación de funciones que describen el comportamiento de fenómenos monitoreados [4]. Los modelos de aprendizaje automático más utilizados y mencionados por los investigadores para predecir la concentración de SST son:
Bosque Aleatorio (RF) y Refuerzo Adaptativo (AdB), modelos que se destacan por su robustez ante datos complejos y ruidosos. Estos algoritmos construyen múltiples árboles de decisión que analizan las relaciones entre características como el uso del suelo o el volumen de escorrentía y los niveles de SST [5].
Redes Neuronales Artificiales (ANN), copian las redes neuronales biológicas y aprenden patrones complejos en grandes volúmenes de datos, como los niveles de SST en distintas condiciones ambientales [5],
k-Nearest Neighbors (kNN), en sus variantes de ponderación uniforme y variable, que estima el SST en función de la cercanía en características de nuevos puntos de muestreo con datos históricos [5].
El aprendizaje automático es esencial para mejorar la precisión y rapidez en el análisis de la calidad del agua, proporcionando un monitoreo más eficiente y menos costoso en comparación con los métodos tradicionales, especialmente en áreas de difícil acceso o con datos limitados.
Tabla 2: Características principales de algoritmos de aprendizaje automático para la estimación de sólidos suspendidos.
Modelo de machine learning
Software
Agua
Datos
Métricas
Referencias
Bagging y Random Forest
Programa R
Bahía
Muestreo
Prueba de normalidad multivalente Mardia-tests y Royston
Regresión lineal, LASSO, regresión de vectores de soporte (SVR), K vecinos más cercanos (KNN), bosque aleatorio (RF) y redes neuronales artificiales (ANN).
Se detalla el procedimiento técnico que implementé para procesar información ambiental georreferenciada con el objetivo de analizar el comportamiento del parámetro sólidos suspendidos (sol_sus) en una región específica (pixel 3x3). Para esto, utilicé el lenguaje Python y la biblioteca pandas, que resulta particularmente eficiente para el manejo de estructuras tabulares.
2.1.1 Carga de datos
Primero importo la biblioteca pandas, una herramienta en Python que se utiliza para manejar datos en formato tabular (como hojas de cálculo o CSVs). Se le da el alias pd por convención, para simplificar el código.
Luego cargo dos archivos CSV con la función pd.read_csv(), la cual convierte dichos archivos en objetos del tipo DataFrame, que representan tablas en memoria, que son estructuras de datos similares a tablas (parecida a una hoja de Excel). Los conjuntos de datos cargados fueron:
lab_df: contiene datos de laboratorio, incluyendo el parámetro de interés sol_sus.
Verifico la carga correcta mostrando las primeras filas con la función .head(). Es útil para ver rápidamente cómo es la estructura del archivo: qué columnas hay, qué tipo de datos, si se cargó bien.
import pandas as pd # pandas es la biblioteca para manejar datos tabulares# Cargar los archivos de datosgis_df = pd.read_csv('datos/base_de_datos_gis.csv')lab_df = pd.read_csv('datos/base_de_datos_lab.csv')# Ver las primeras filas para asegurarse de que se cargaron biengis_df.head(), lab_df.head()print("Primeras filas de gis_df:")display(gis_df.head())print("\nPrimeras filas de lab_df:")display(lab_df.head())
Primeras filas de gis_df:
fecha
punto
pixel
banda
reflect
longitud
latitud
0
2023-05-11
1
1x1
B01
0.161744
-58.868047
-27.464687
1
2023-05-11
1
1x1
B02
0.170400
-58.868047
-27.464687
2
2023-05-11
1
1x1
B03
0.208800
-58.868047
-27.464687
3
2023-05-11
1
1x1
B04
0.251000
-58.868047
-27.464687
4
2023-05-11
1
1x1
B05
0.257656
-58.868047
-27.464687
Primeras filas de lab_df:
fecha
longitud
latitud
param
valor
0
2023-05-11
-58.868047
-27.464687
ph
6.8
1
2023-05-11
-58.868047
-27.464687
cond
141.1
2
2023-05-11
-58.868047
-27.464687
sol_sus
198.0
3
2023-05-11
-58.868047
-27.464687
turb
185.5
4
2023-05-11
-58.866729
-27.466303
ph
6.8
📄 Nota técnica
pd.read_csv() carga los archivos en estructuras llamadas dataframes, que funcionan como tablas.
head() te muestra las primeras 5 filas para ver cómo están los datos.
display() permite mostrar las tablas con formato más visual (en HTML).
2.1.2 Filtrar el parámetro ‘sol_sus’
En el conjunto de datos del laboratorio lab_df, hay múltiples parámetros medidos (como pH, turbidez, etc.). En este caso, me interesa trabajar solamente con los datos de sólidos suspendidos, identificado como "sol_sus" en la columna param. Este filtrado selectivo lo realicé para limitar el análisis al fenómeno físico-químico de interés.
Filtré el DataFrame para quedarme solo con esas filas, y renombré la columna valor como sol_sus para que sea más claro en los siguientes pasos.
# Filtrar solo las filas donde el parámetro es 'sol_sus'sol_sus_df = lab_df[lab_df["param"] =="sol_sus"]# Renombrar la columna 'valor' a 'sol_sus' para que tenga sentido en el mergesol_sus_df = sol_sus_df.rename(columns={"valor": "sol_sus"})# Mostrar para confirmarsol_sus_df.head()print("Primeras filas de sol_sus_df:")display(sol_sus_df.head())
Primeras filas de sol_sus_df:
fecha
longitud
latitud
param
sol_sus
2
2023-05-11
-58.868047
-27.464687
sol_sus
198.0
6
2023-05-11
-58.866729
-27.466303
sol_sus
150.0
10
2023-05-11
-58.864889
-27.468056
sol_sus
101.0
14
2023-05-11
-58.863367
-27.469240
sol_sus
95.0
18
2023-05-11
-58.862454
-27.470561
sol_sus
69.0
📄 Nota técnica
lab_df[lab_df["param"] == "sol_sus"] filtra las filas cuyo valor en la columna "param" sea "sol_sus".
.rename(columns={"valor": "sol_sus"}) cambia el nombre de la columna "valor" a "sol_sus".
2.1.3 Transformar la columna banda en columnas individuales
En este paso, convierto los valores únicos de la columna banda (como B01, B02, etc.) en nombres de columnas. Cada nueva columna contendrá los valores del parámetro reflect correspondientes a esa banda en particular. Esta operación se realiza antes de unir con los valores de sol_sus, ya que el valor de reflectancia depende de la banda, mientras que sol_sus es un dato independiente que se asignará luego por punto, fecha y ubicación.
# Pivotear la tabla para que cada banda sea una columnagis_pivot = gis_df.pivot_table( index=['fecha', 'punto', 'pixel', 'latitud', 'longitud'], columns='banda', values='reflect').reset_index()# Eliminar el nombre del índice de columnas generado por el pivotgis_pivot.columns.name =Noneprint("Primeras filas de gis_pivot:")display(gis_pivot.head())
Primeras filas de gis_pivot:
fecha
punto
pixel
latitud
longitud
B01
B02
B03
B04
B05
B06
B07
B08
B11
B12
B8A
0
2023-05-11
1
1x1
-27.464687
-58.868047
0.161744
0.170400
0.208800
0.251000
0.257656
0.202175
0.207500
0.194000
0.118625
0.114738
0.173069
1
2023-05-11
1
3x3
-27.464687
-58.868047
0.161290
0.171256
0.206644
0.250911
0.258058
0.202170
0.207490
0.193722
0.118892
0.115462
0.173137
2
2023-05-11
2
1x1
-27.466303
-58.866729
0.149925
0.170600
0.202100
0.243200
0.247169
0.186631
0.187512
0.175800
0.114100
0.111031
0.159263
3
2023-05-11
2
3x3
-27.466303
-58.866729
0.149906
0.170378
0.202278
0.242689
0.247115
0.186739
0.188027
0.175544
0.113992
0.111210
0.159373
4
2023-05-11
3
1x1
-27.468056
-58.864889
0.149725
0.166200
0.197100
0.223800
0.218175
0.159169
0.163344
0.150700
0.114263
0.113006
0.139662
📄 Nota técnica
pivot_table() reorganiza el DataFrame convirtiendo los valores de una columna (banda) en columnas individuales.
index=[...] define las columnas que se mantendrán como claves (se repetirán por fila).
columns='banda' indica qué columna queremos transformar en nombres de columnas.
values='reflect' especifica qué valor colocar en cada celda de la nueva tabla.
reset_index() convierte los índices jerárquicos en columnas normales para facilitar el análisis.
columns.name = None quita la etiqueta “banda” que se agregaría al encabezado por defecto.
2.1.4 Combinar datos geoespaciales y de laboratorio
Una vez que las bandas han sido transformadas en columnas, combino esta tabla con los valores de sólidos suspendidos (sol_sus) provenientes del laboratorio. La combinación se hace usando las columnas fecha, latitud y longitud, que permiten identificar los datos correspondientes a un mismo punto geográfico y temporal.
# Realizar el merge por ubicación y fechadf_merged = pd.merge( gis_pivot, sol_sus_df[['fecha', 'latitud', 'longitud', 'sol_sus']], on=['fecha', 'latitud', 'longitud'], how='inner')print("Primeras filas del DataFrame combinado:")display(df_merged.head())
Primeras filas del DataFrame combinado:
fecha
punto
pixel
latitud
longitud
B01
B02
B03
B04
B05
B06
B07
B08
B11
B12
B8A
sol_sus
0
2023-05-11
1
1x1
-27.464687
-58.868047
0.161744
0.170400
0.208800
0.251000
0.257656
0.202175
0.207500
0.194000
0.118625
0.114738
0.173069
198.0
1
2023-05-11
1
3x3
-27.464687
-58.868047
0.161290
0.171256
0.206644
0.250911
0.258058
0.202170
0.207490
0.193722
0.118892
0.115462
0.173137
198.0
2
2023-05-11
2
1x1
-27.466303
-58.866729
0.149925
0.170600
0.202100
0.243200
0.247169
0.186631
0.187512
0.175800
0.114100
0.111031
0.159263
150.0
3
2023-05-11
2
3x3
-27.466303
-58.866729
0.149906
0.170378
0.202278
0.242689
0.247115
0.186739
0.188027
0.175544
0.113992
0.111210
0.159373
150.0
4
2023-05-11
3
1x1
-27.468056
-58.864889
0.149725
0.166200
0.197100
0.223800
0.218175
0.159169
0.163344
0.150700
0.114263
0.113006
0.139662
101.0
📄 Nota técnica
pd.merge() permite combinar dos DataFrames en uno nuevo, uniendo filas que coincidan en las columnas especificadas.
on=["latitud", "longitud"] indica que la combinación debe hacerse usando esas columnas como claves.
how="inner" especifica el tipo de combinación:
"inner": solo conserva las filas donde hay coincidencia en ambos DataFrames.
Otras opciones:
"left": conserva todas las filas del primer DataFrame.
"right": conserva todas las filas del segundo.
"outer": conserva todo, incluso si no hay coincidencia.
2.1.5 Filtrado espacial por pixel
Luego de combinar los datos, aplico un filtrado adicional al DataFrame sobre la columna pixel para conservar únicamente las filas correspondientes al área geográfica designada como "3x3". Este paso reduce el dominio de análisis y permite concentrarse en una región de estudio concreta.
# Filtrar solo los datos del pixel 3x3df_pixel_3x3 = df_merged[df_merged["pixel"] =="3x3"]print("Primeras filas del pixel 3x3:")display(df_pixel_3x3.head())
Primeras filas del pixel 3x3:
fecha
punto
pixel
latitud
longitud
B01
B02
B03
B04
B05
B06
B07
B08
B11
B12
B8A
sol_sus
1
2023-05-11
1
3x3
-27.464687
-58.868047
0.161290
0.171256
0.206644
0.250911
0.258058
0.202170
0.207490
0.193722
0.118892
0.115462
0.173137
198.0
3
2023-05-11
2
3x3
-27.466303
-58.866729
0.149906
0.170378
0.202278
0.242689
0.247115
0.186739
0.188027
0.175544
0.113992
0.111210
0.159373
150.0
5
2023-05-11
3
3x3
-27.468056
-58.864889
0.149718
0.167256
0.197322
0.223511
0.218608
0.159292
0.163228
0.151544
0.114241
0.112931
0.139783
101.0
7
2023-05-11
4
3x3
-27.469240
-58.863367
0.148441
0.163289
0.188733
0.196167
0.183854
0.139388
0.140017
0.135400
0.115602
0.113097
0.128222
95.0
9
2023-05-11
5
3x3
-27.470561
-58.862454
0.145838
0.157733
0.178767
0.173911
0.162600
0.132140
0.133229
0.126656
0.117069
0.113992
0.124548
69.0
📄 Nota técnica
Usa filtrado booleano (DataFrame[condición]), que es la forma estándar en pandas para seleccionar subconjuntos de datos.
df_pixel_3x3 = df_combinado[df_combinado["pixel"] == "3x3"] selecciona ese subconjunto. Filtra las filas cuyo valor en la columna "pixel" es igual a "3x3".
2.1.6 Guardar el archivo final
Finalmente, guardo el resultado como un nuevo archivo .csv dentro de la carpeta datos.
Por último, exporto el resultado a un nuevo archivo en formato .csv, mediante la función to_csv() de pandas, con el parámetro index=False para evitar que la columna de índice se incluya en el archivo de salida que pandas crea por defecto. Esto me permite utilizarlo después para visualización o análisis posterior.
# Guardar el archivo CSV dentro de la carpeta "datos"df_pixel_3x3.to_csv('datos/datos_sol_sus_pixel_3x3.csv', index=False)
📄 Nota técnica
to_csv() guarda los datos en formato CSV.
index=False evita que se guarde el índice numérico del DataFrame como una columna adicional en el CSV.
2.2 Procesamiento de datos (ACOLITE)
A continuación teniendo la misma lógica de procesamiento que aplique antes, pero usando el nuevo archivo base_de_datos_gis_acolite.csv.
En este análisis aplico un modelo de regresión lineal simple para estudiar la relación entre la reflectancia y los sólidos suspendidos, utilizando datos experimentales. La regresión lineal es una técnica fundamental del aprendizaje automático supervisado que nos permite predecir un valor continuo basado en una o más variables independientes. A lo largo de este documento, se explican paso a paso las acciones realizadas y los conceptos clave para comprender y replicar este análisis.
2.3.1 Importar librerías
En este paso, cargo las bibliotecas necesarias para procesar datos, ajustar modelos de regresión, evaluar su desempeño y visualizar los resultados.
import pandas as pdfrom sklearn.model_selection import train_test_splitfrom sklearn.linear_model import LinearRegressionfrom sklearn.metrics import mean_squared_error, r2_scoreimport matplotlib.pyplot as plt
📄 Nota técnica
pandas se utiliza para manejar datos en forma de tablas (DataFrames), especialmente útiles al trabajar con archivos .csv.
train_test_split permite dividir los datos en subconjuntos de entrenamiento y prueba, lo cual es esencial para evaluar el desempeño de un modelo sin sobreajustarlo.
LinearRegression representa un modelo lineal que se ajusta a los datos minimizando el error cuadrático entre las predicciones y los valores reales.
mean_squared_error y r2_score son métricas de evaluación: el primero mide el promedio de los errores al cuadrado, mientras que el segundo indica qué tan bien el modelo explica la variabilidad de los datos.
matplotlib.pyplot se utiliza para crear gráficos. Permite visualizar los datos y los resultados del modelo.
2.3.2 Cargar datos desde un CSV
Importo el archivo .csv con los datos experimentales. Se visualizan las primeras filas para verificar que los datos se han cargado correctamente.
# Cargar el CSVdatos = pd.read_csv('datos/datos_sol_sus_acolite.csv')# Mostrar las primeras filas para verificardatos.head()print("Primeras filas de datos:")display(datos.head())
Primeras filas de datos:
fecha
punto
latitud
longitud
B01
B02
B03
B04
B05
B06
B07
B08
B11
B12
B8A
sol_sus
0
2023-05-11
1
-27.464687
-58.868047
0.041367
0.046669
0.085630
0.128799
0.128704
0.091748
0.098365
0.083253
0.002521
0.000000
0.066612
198.0
1
2023-05-11
2
-27.466303
-58.866729
0.032235
0.047916
0.084191
0.123898
0.122481
0.079922
0.082403
0.069021
0.001669
0.000000
0.055924
150.0
2
2023-05-11
3
-27.468056
-58.864889
0.030956
0.044084
0.078444
0.104498
0.095737
0.052162
0.057475
0.044354
0.000209
0.000174
0.034767
101.0
3
2023-05-11
4
-27.469240
-58.863367
0.029646
0.040407
0.070384
0.078357
0.063937
0.032452
0.033994
0.028441
0.001279
0.000000
0.022597
95.0
4
2023-05-11
5
-27.470561
-58.862454
0.026737
0.034968
0.060701
0.056602
0.044067
0.024809
0.026684
0.019209
0.001879
0.000000
0.017930
69.0
📄 Nota técnica
pd.read_csv carga datos desde un archivo .csv y los convierte en un DataFrame de Pandas. Esta estructura tabular permite filtrar, seleccionar y transformar fácilmente los datos.
datos.head() permite ver las primeras 5 filas del DataFrame para tener una vista preliminar de los datos cargados.
2.3.3 Seleccionar variables y dividir en conjuntos
Selecciono las variables relevantes: B01 como variable independiente y sol_sus como variable dependiente. Luego divido el conjunto en dos subconjuntos: uno para entrenar el modelo y otro para probarlo, lo cual sirve para evaluar su capacidad de generalización.
# Selección de variablesX = datos[["B01"]]y = datos["sol_sus"]# División en entrenamiento y validaciónX_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False)
📄 Nota técnica
Se selecciona una columna como variable independiente (X) y otra como variable dependiente (y). Es importante usar doble corchete al seleccionar una sola columna como X para mantener la estructura de tabla.
train_test_split divide el conjunto de datos en entrenamiento y prueba. Esto permite entrenar el modelo en un subconjunto y evaluar su capacidad de generalización con otro.
El parámetro test_size=0.2 indica que el 20% de los datos se usan para prueba. shuffle=False mantiene el orden original de los datos, útil cuando los datos están organizados temporalmente o espacialmente.
2.3.4 Entrenar modelo de regresión lineal
En este paso se entrena un modelo de regresión lineal usando los datos de entrenamiento. El modelo aprende la relación matemática entre la reflectancia y los sólidos suspendidos.
LinearRegression().fit() ajusta un modelo lineal a los datos de entrenamiento. Internamente calcula la pendiente e intercepto que minimizan la diferencia entre las predicciones y los valores reales.
2.3.5 Evaluar desempeño del modelo
Una vez entrenado el modelo, evaluo su desempeño usando métricas estadísticas. Estas nos permiten cuantificar qué tan bien el modelo predice los valores de sólidos suspendidos a partir de la reflectancia en los datos de prueba.
El error cuadrático medio es: 1846.79
El coeficiente de determinación (R²) es: -0.252
📄 Nota técnica
predict() genera predicciones del modelo usando los datos de prueba. Estas predicciones se comparan con los valores reales para evaluar el desempeño.
mean_squared_error calcula el promedio de los errores al cuadrado. Cuanto menor sea este valor, mejor se ajusta el modelo.
r2_score mide qué proporción de la variabilidad en los datos es explicada por el modelo. Un valor cercano a 1 indica una buena predicción.
2.3.6 Visualizar el modelo
Finalmente, se visualiza gráficamente la relación entre reflectancia y sólidos suspendidos, tanto en el conjunto de entrenamiento como en el de prueba. Esto ayuda a interpretar de forma visual cómo se ajusta el modelo a los datos reales.
plt.subplots crea una figura con uno o más ejes para dibujar. Permite organizar varios gráficos en una misma figura.
plot() traza una línea continua. Se usa para mostrar la línea de regresión generada por el modelo.
scatter() traza puntos individuales. Se usa para mostrar los datos reales y compararlos con la línea del modelo.
set() configura etiquetas de ejes y títulos de los subgráficos.
legend() muestra una leyenda que identifica cada elemento del gráfico.
fig.suptitle() agrega un título general a la figura completa.
plt.show() es necesario para visualizar los gráficos al renderizar el documento.
2.4 Análisis de regresión por banda
Con el objetivo de profundizar el análisis, se evalúa la relación entre los sólidos suspendidos y cada una de las bandas espectrales disponibles de forma individual. Para ello, se entrena un modelo de regresión lineal simple por cada banda, utilizando los mismos datos experimentales. Este enfoque permite comparar el desempeño predictivo de cada banda por separado mediante métricas como el coeficiente de determinación R², su versión ajustada y el error cuadrático medio (RMSE).
Código
import pandas as pdimport numpy as npfrom sklearn.model_selection import train_test_splitfrom sklearn.linear_model import LinearRegressionfrom sklearn.metrics import mean_squared_errorimport matplotlib.pyplot as pltimport math# Cargar datosdatos = pd.read_csv('datos/datos_sol_sus_acolite.csv')# Detectar columnas de bandasbandas = [col for col in datos.columns if col.startswith("B")]# Lista para guardar resultadosresultados = []# Parámetros para organización de gráficosn_bandas =len(bandas)ncols =3# Número de columnas de la grillanrows = math.ceil(n_bandas / ncols) # Calculamos cuántas filas se necesitan# Crear figurafig, axs = plt.subplots(nrows=nrows, ncols=ncols, figsize=(12, 4* nrows))axs = axs.flatten() # Asegura que podamos indexarlos como una listafor i, banda inenumerate(bandas):# Variables X = datos[[banda]] y = datos["sol_sus"]# División entrenamiento/prueba X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False)# Ajuste del modelo modelo = LinearRegression().fit(X_train, y_train) y_train_pred = modelo.predict(X_train)# Métricas r2 = modelo.score(X_train, y_train) n =len(y_train) p = X_train.shape[1] r2_adj =1- (1- r2) * (n -1) / (n - p -1) rmse = np.sqrt(mean_squared_error(y_train, y_train_pred)) resultados.append({"Banda": banda,"R²": round(r2, 4),"R²_ajustado": round(r2_adj, 4),"RMSE": round(rmse, 4) })# Gráfico de entrenamiento ax = axs[i] ax.scatter(X_train, y_train, alpha=0.6, color="#9D50A6", label="Entrenamiento") ax.plot(X_train, y_train_pred, color="#17A77E", linewidth=1.8, label="Modelo") ax.set_title(f'{banda}\nR²={r2:.2f}', fontsize=10) ax.set_xlabel('Reflectancia', fontsize=8) ax.set_ylabel('Sol_Sus', fontsize=8) ax.tick_params(axis='both', which='major', labelsize=8) ax.legend(fontsize=7) ax.grid(True)# Eliminar ejes sobrantesfor j inrange(i +1, len(axs)): fig.delaxes(axs[j])plt.suptitle("Regresiones lineales por banda (entrenamiento)", fontsize=14)plt.tight_layout(rect=[0, 0, 1, 0.96])plt.show()# Tabla resumendf_resultados = pd.DataFrame(resultados).sort_values("R²", ascending=False).reset_index(drop=True)print("Tabla resumen de métricas por banda:")display(df_resultados.style.hide(axis="index"))
Tabla resumen de métricas por banda:
Banda
R²
R²_ajustado
RMSE
B05
0.182800
0.167100
33.457100
B06
0.179600
0.163800
33.523000
B07
0.173300
0.157400
33.650800
B08
0.146500
0.130100
34.191400
B8A
0.134600
0.118000
34.429300
B04
0.093900
0.076400
35.230300
B12
0.037900
0.019400
36.302600
B11
0.033300
0.014700
36.389200
B01
0.007800
-0.011200
36.864800
B03
0.005000
-0.014100
36.916700
B02
0.001400
-0.017800
36.984700
Conclusiones del análisis por banda
Al aplicar regresión lineal simple a cada banda espectral por separado, se observó que ninguna logra predecir con precisión los sólidos suspendidos. Las bandas B05, B06 y B07 fueron las que mejor se desempeñaron, pero con un R² apenas superior al 18 %, lo que indica que explican muy poca variabilidad. Además, los errores (RMSE) siguen siendo altos en relación con los valores reales.
Esto sugiere que la relación entre reflectancia y sólidos suspendidos no es lineal en escala natural, y que sería necesario probar modelos más complejos. En próximos pasos, pienso aplicar transformaciones logarítmicas y combinaciones de bandas para mejorar el ajuste.
2.5 Análisis de regresión por banda aplicando logaritmo a las variables
En esta etapa del análisis, aplico una transformación logarítmica natural a las variables de reflectancia y sólidos suspendidos antes de ajustar los modelos de regresión lineal. Esta transformación es útil para:
Estabilizar la varianza y reducir la heterocedasticidad.
Linealizar relaciones no lineales entre variables.
Evitar que valores extremos influyan excesivamente en el modelo.
El procedimiento es similar al análisis anterior, pero antes de entrenar el modelo se aplica log(x) a las columnas correspondientes. Para evitar problemas con ceros en los datos, estos se reemplazan por NaN y se eliminan del análisis.
import pandas as pdimport numpy as npimport matplotlib.pyplot as pltfrom sklearn.model_selection import train_test_splitfrom sklearn.linear_model import LinearRegressionfrom sklearn.metrics import mean_squared_error, r2_scoreimport math# 1. Cargar datos datos = pd.read_csv("datos/datos_sol_sus_acolite.csv")bandas = [c for c in datos.columns if c.startswith("B")]# 2. Filtrar positivos (requisito para log) mask_pos = (datos["sol_sus"] >0) & (datos[bandas] >0).all(axis=1)datos_log = datos.loc[mask_pos].copy()# 3.Transformar a log-natural (para entrenar) datos_log["log_sol_sus"] = np.log(datos_log["sol_sus"])for b in bandas: datos_log[f"log_{b}"] = np.log(datos_log[b])log_bandas = [f"log_{b}"for b in bandas]# 4. Split train / test (test se descarta aquí) X_total = datos_log[log_bandas]y_total = datos_log["log_sol_sus"]X_train, _, y_train, _ = train_test_split( X_total, y_total, test_size=0.2, shuffle=False)# 5. Funciones de métricas def metrics_log(model, X, y):"""Métricas sobre los datos en log (las que ya tenías).""" y_pred = model.predict(X) rmse = np.sqrt(mean_squared_error(y, y_pred)) r2 = r2_score(y, y_pred) n, p = X.shape r2_adj =1- ((1- r2) * (n -1)) / (n - p -1)return rmse, r2, r2_adjdef metrics_original(model, X, y):""" Idem, pero después de volver a la escala original: sol_sus_pred = exp(ŷ_log) sol_sus_true = exp(y_log) """ y_pred_log = model.predict(X).ravel() y_pred_orig = np.exp(y_pred_log) y_true_orig = np.exp(y.values) rmse = np.sqrt(mean_squared_error(y_true_orig, y_pred_orig)) r2 = r2_score(y_true_orig, y_pred_orig) n, p = X.shape r2_adj =1- ((1- r2) * (n -1)) / (n - p -1)return rmse, r2, r2_adj# 6. Entrenar un modelo por banda y registrar métricas resultados = [] # para logresultados_nolog = [] # para escala originallineas_log = {} # (x, ŷ_log) para graficar en log-loglineas_nolog = {} # (x_original, ŷ_original) para graficar sin logfor b, lb inzip(bandas, log_bandas): Xtr = X_train[[lb]] reg = LinearRegression().fit(Xtr, y_train)# ── Métricas en log rmse_l, r2_l, r2a_l = metrics_log(reg, Xtr, y_train) resultados.append({"Banda": b, "RMSE_log": rmse_l,"R²_log": r2_l, "R²aj_log": r2a_l})# ── Métricas en original rmse_o, r2_o, r2a_o = metrics_original(reg, Xtr, y_train) resultados_nolog.append({"Banda": b, "RMSE": rmse_o,"R²": r2_o, "R²aj": r2a_o})# ── Recta ordenada para ambas escalas order = np.argsort(Xtr.values.ravel()) x_log_ord = Xtr.values.ravel()[order] # log(B) y_pred_log = reg.predict(Xtr).ravel()[order] # log(sol_suŝ) lineas_log[b] = (x_log_ord, y_pred_log)# Para volver a la escala original: x_orig_ord = np.exp(x_log_ord) # B y_pred_orig = np.exp(y_pred_log) # sol_suŝ lineas_nolog[b] = (x_orig_ord, y_pred_orig)# 7. DataFrames con métricasres_df_log = (pd.DataFrame(resultados) .round(3) .sort_values("RMSE_log") .reset_index(drop=True))res_df_nolog = (pd.DataFrame(resultados_nolog) .round(3) .sort_values("RMSE") .reset_index(drop=True))# 8.Gráficos *sin* log (escala original)n_cols =3n_rows = math.ceil(len(bandas) / n_cols)fig, axes = plt.subplots(n_rows, n_cols, figsize=(15, 4* n_rows))axes = axes.flatten()for idx, b inenumerate(bandas): ax = axes[idx]# Datos originales de entrenamiento (sin log): x_orig = np.exp(X_train[f"log_{b}"]) y_orig = np.exp(y_train) ax.scatter(x_orig, y_orig, alpha=0.6, color="#9D50A6", label="Entrenamiento")# Curva modelo en original: ax.plot(*lineas_nolog[b], lw=3, color="#17A77E", label="Modelo") ax.set(xlabel=b, ylabel="sol_sus", title=f"Banda {b} (escala original)") ax.legend() ax.grid(True)# Si sobran ejes, quitarlosfor j inrange(len(bandas), len(axes)): fig.delaxes(axes[j])plt.tight_layout()plt.show()# 9. Mostrar tablas de métricasprint("\nMétricas en **escala log-log** (ordenadas por RMSE_log):\n")print(res_df_log.to_string(index=False))print("\nMétricas en **escala original** (ordenadas por RMSE):\n")print(res_df_nolog.to_string(index=False))
La transformación log(x) se usa frecuentemente cuando la relación entre variables es multiplicativa o cuando hay asimetría en la distribución.
Es fundamental reemplazar ceros por NaN antes de aplicar el logaritmo, ya que log(0) no está definido.
Las métricas obtenidas (R² y RMSE) se interpretan en la escala logarítmica y no son directamente comparables con las obtenidas en la escala original.
El R² ajustado penaliza la complejidad del modelo y ayuda a evaluar si el ajuste mejora más allá de lo que se esperaría por azar.
Conclusión del ajuste log–log por banda
Aplicar la transformación logarítmica mejoró notablemente el ajuste respecto al análisis en escala natural. Las bandas B06, B07 y B05 mostraron los mejores resultados, con R²_log cercanos a 0.30 y una reducción del error (RMSE) tanto en escala logarítmica como en la original.
Aun así, el desempeño sigue siendo limitado si se usa cada banda por separado. Esto sugiere que para mejorar las predicciones es necesario combinar bandas, aplicar modelos más complejos o probar con índices espectrales derivados.
2.6 Selección de bandas con AIC y desempeño individual
Para evaluar qué bandas individuales son más relevantes para predecir los sólidos suspendidos, se ajustó un modelo de regresión lineal simple en escala logarítmica para cada banda. Además de calcular el coeficiente de determinación (R²) y el error cuadrático medio (RMSE), se calcula ahora el AIC (Criterio de Información de Akaike), que penaliza la complejidad del modelo. Esto ayuda a decidir si la incorporación de una banda es realmente valiosa o si se está agregando complejidad innecesaria al modelo.
Código
# 0. Libreríasimport pandas as pdimport numpy as npfrom sklearn.model_selection import train_test_splitfrom sklearn.linear_model import LinearRegressionfrom sklearn.metrics import mean_squared_error, r2_score# 1. Cargar datos y detectar bandasdatos = pd.read_csv("datos/datos_sol_sus_acolite.csv")bandas = [c for c in datos.columns if c.startswith("B")]# 2. Filtrar casos con valores positivos (requisito de log)mask_pos = (datos["sol_sus"] >0) & (datos[bandas] >0).all(axis=1)datos_log = datos.loc[mask_pos].copy()# 3. Transformar a log-naturaldatos_log["log_sol_sus"] = np.log(datos_log["sol_sus"])for b in bandas: datos_log[f"log_{b}"] = np.log(datos_log[b])log_bandas = [f"log_{b}"for b in bandas]# 4. Partición entrenamiento / test X_total = datos_log[log_bandas]y_total = datos_log["log_sol_sus"]X_train, _, y_train, _ = train_test_split( X_total, y_total, test_size=0.20, shuffle=False)# 5. Funciones auxiliares (AIC + métricas)def aic_gauss(model, X, y):""" AIC para regresión lineal con ruido ~ N(0, σ²) AIC = n · ln(RSS/n) + 2k """ n =len(y) k = X.shape[1] +1# predictores + intercepto rss = np.sum((y - model.predict(X)) **2)return n * np.log(rss / n) +2* kdef metrics_log(model, X, y): y_pred = model.predict(X) rmse = np.sqrt(mean_squared_error(y, y_pred)) r2 = r2_score(y, y_pred) n, p = X.shape r2_adj =1- ((1- r2) * (n -1)) / (n - p -1)return rmse, r2, r2_adj# 6. Ajuste univariable banda-a-banda + AICresultados = []for b in bandas: lb =f"log_{b}" Xtr = X_train[[lb]] reg = LinearRegression().fit(Xtr, y_train) rmse_log, r2_log, r2a_log = metrics_log(reg, Xtr, y_train) aic_val = aic_gauss(reg, Xtr, y_train) resultados.append({"Banda" : b,"RMSE_log" : rmse_log,"R²_log" : r2_log,"R²aj_log" : r2a_log,"AIC" : aic_val })# 7. Tabla ordenada por AICdf_log = (pd.DataFrame(resultados) .round(3) .sort_values("AIC") .reset_index(drop=True))print("\nAIC y métricas en escala log-log (ordenadas por AIC):\n")print(df_log.to_string(index=False))
Los resultados del AIC coinciden con las métricas de desempeño: las bandas B06 y B07 son las más relevantes para predecir sólidos suspendidos, mostrando los valores más bajos de AIC (-69.2 y -69.1 respectivamente), así como los mejores valores de R²_log (~0.30) y RMSE_log (~0.32).
Las demás bandas presentan AIC menos favorables, lo que indica que, individualmente, aportan menos información útil y no justifican la complejidad del modelo.
Por lo tanto, para modelos simples basados en una sola banda, B06 y B07 son las candidatas preferentes. Sin embargo, para un mejor desempeño se recomienda evaluar modelos que combinen varias bandas.
2.6.1 Incorporar validación cruzada en la selección de modelo
En esta etapa incorporo una validación cruzada (CV) para evaluar la robustez y generalización del modelo univariable basado en cada banda espectral. La validación cruzada consiste en dividir el conjunto de entrenamiento en varios “folds” o particiones, entrenar el modelo en todas menos una, y validar en la partición restante, repitiendo este proceso rotando las particiones. Esto ayuda a obtener métricas de desempeño más confiables y evitar sobreajuste al conjunto de entrenamiento.
El flujo completo que implemento es: - Dividir el conjunto completo en un 80 % para entrenamiento y un 20 % para prueba final (hold-out). - En el 80 % de entrenamiento, aplicar validación cruzada K-Fold (5 folds) para evaluar RMSE y coeficiente de determinación ajustado (R² ajustado) de cada banda. - Calcular también el AIC para cada modelo univariable, utilizando el conjunto completo de entrenamiento (80 %) para penalizar complejidad. - Ordenar las bandas por AIC y, en caso de empate, desempatar usando el promedio de RMSE de la validación cruzada. - Finalmente, con la banda ganadora entrenar el modelo sobre todo el 80 % y evaluar su desempeño en el conjunto de prueba (hold-out 20 %).
Esta estrategia permite seleccionar la banda más relevante no solo por la bondad del ajuste, sino también considerando la estabilidad y capacidad predictiva en datos no vistos, ofreciendo una selección más robusta y realista para modelos futuros.
Código
# ─────────────────────────────────────────────────────────────────────# 0. LIBRERÍAS# ─────────────────────────────────────────────────────────────────────import pandas as pdimport numpy as npfrom sklearn.linear_model import LinearRegressionfrom sklearn.model_selection import train_test_split, KFoldfrom sklearn.metrics import mean_squared_error, r2_scorefrom sklearn.utils import resample# ─────────────────────────────────────────────────────────────────────# 1. CARGAR DATOS Y DETECTAR BANDAS# ─────────────────────────────────────────────────────────────────────datos = pd.read_csv("datos/datos_sol_sus_acolite.csv")bandas = [c for c in datos.columns if c.startswith("B")]# ─────────────────────────────────────────────────────────────────────# 2. FILTRAR CASOS > 0 (requisito de log)# ─────────────────────────────────────────────────────────────────────mask_pos = (datos["sol_sus"] >0) & (datos[bandas] >0).all(axis=1)datos_log = datos.loc[mask_pos].copy()# ─────────────────────────────────────────────────────────────────────# 3. TRANSFORMACIÓN LOGARÍTMICA# ─────────────────────────────────────────────────────────────────────datos_log["log_sol_sus"] = np.log(datos_log["sol_sus"])for b in bandas: datos_log[f"log_{b}"] = np.log(datos_log[b])log_bandas = [f"log_{b}"for b in bandas]X_full = datos_log[log_bandas].valuesy_full = datos_log["log_sol_sus"].values# ─────────────────────────────────────────────────────────────────────# 4. SPLIT HOLD-OUT 80/20 (shuffle=False para respetar orden)# ─────────────────────────────────────────────────────────────────────X_train_full, X_test_full, y_train_full, y_test_full = train_test_split( X_full, y_full, test_size=0.20, shuffle=False)# ─────────────────────────────────────────────────────────────────────# 5. FUNCIONES AUXILIARES# ─────────────────────────────────────────────────────────────────────def aic_gauss(model, X, y):"""AIC con supuesto de ruido gaussiano homocedástico.""" n =len(y) k = X.shape[1] +1# +1 intercepto rss = np.sum((y - model.predict(X)) **2)return n * np.log(rss / n) +2* kdef r2_adj(r2, n, p):return1- (1- r2) * (n -1) / (n - p -1)def cv_metrics(X_mat, y_vec, cv):"""Devuelve medias y sd del RMSE y medias de R² y R²aj.""" model = LinearRegression() rmse_folds, r2_folds, r2a_folds = [], [], []for tr_idx, val_idx in cv.split(X_mat): X_tr, X_val = X_mat[tr_idx], X_mat[val_idx] y_tr, y_val = y_vec[tr_idx], y_vec[val_idx] model.fit(X_tr, y_tr) y_pred = model.predict(X_val) rmse = np.sqrt(mean_squared_error(y_val, y_pred)) r2 = r2_score(y_val, y_pred) r2a = r2_adj(r2, len(y_val), X_mat.shape[1]) rmse_folds.append(rmse) r2_folds.append(r2) r2a_folds.append(r2a)return {"rmse_mean": np.mean(rmse_folds),"rmse_std": np.std(rmse_folds, ddof=1),"r2_mean": np.mean(r2_folds),"r2a_mean": np.mean(r2a_folds) }# Configuración de la validación cruzada interiorkf = KFold(n_splits=5, shuffle=True, random_state=42)# ─────────────────────────────────────────────────────────────────────# 6. EVALUACIÓN UNIVARIABLE (CV + AIC)# ─────────────────────────────────────────────────────────────────────resultados = []for idx_band, b inenumerate(bandas): X_train_band = X_train_full[:, idx_band].reshape(-1, 1)# 6.1 CV stats_cv = cv_metrics(X_train_band, y_train_full, kf)# 6.2 AIC en todo el 80 % modelo_tmp = LinearRegression().fit(X_train_band, y_train_full) aic_val = aic_gauss(modelo_tmp, X_train_band, y_train_full) resultados.append({"Banda": b,"AIC_train": aic_val,"RMSE_CV": stats_cv["rmse_mean"],"CV_std": stats_cv["rmse_std"],"R2_CV": stats_cv["r2_mean"],"R2aj_CV": stats_cv["r2a_mean"] })df_res = ( pd.DataFrame(resultados)# PRIMER CRITERIO: AIC ▸ SEGUNDO: RMSE_CV .sort_values(["AIC_train", "RMSE_CV"]) .reset_index(drop=True) .round(4))print("\nComparación banda-a-banda (ordenada por AIC, luego RMSE_CV):\n")print(df_res.to_string(index=False))# ─────────────────────────────────────────────────────────────────────# 7. BANDA GANADORA → EVALUACIÓN HOLD-OUT# ─────────────────────────────────────────────────────────────────────best_band = df_res.loc[0, "Banda"]idx_best = bandas.index(best_band)X_train_best = X_train_full[:, idx_best].reshape(-1, 1)X_test_best = X_test_full[:, idx_best].reshape(-1, 1)best_model = LinearRegression().fit(X_train_best, y_train_full)y_pred_test = best_model.predict(X_test_best)rmse_test = np.sqrt(mean_squared_error(y_test_full, y_pred_test))r2_test = r2_score(y_test_full, y_pred_test)print(f"\n──────── HOLD-OUT (20 %) ─────────")print(f"Banda ganadora: {best_band}")print(f"RMSE_test = {rmse_test:.3f}")print(f"R²_test = {r2_test:.3f}")
Al incorporar validación cruzada para evaluar la selección univariable por banda en escala logarítmica, la banda B06 se mantiene como la mejor candidata, con el AIC más bajo y el RMSE promedio más pequeño en CV (aprox. 0.31).
Sin embargo, los valores de R² ajustado en CV son negativos para todas las bandas, lo que indica que el modelo lineal simple no logra explicar bien la variabilidad fuera de la muestra, sugiriendo un ajuste pobre o posible falta de linealidad real en los datos.
La evaluación final en el conjunto hold-out (20 %) confirma esta baja capacidad predictiva, con un RMSE_test de 0.491 y un R²_test negativo (-0.291), lo que indica que el modelo predice peor que un modelo que simplemente promedie los datos.
En resumen, aunque B06 es la banda más prometedora según los criterios AIC y RMSE en entrenamiento y validación interna, la baja performance en datos nuevos apunta a la necesidad de modelos más complejos, combinaciones multivariables o transformaciones adicionales para mejorar la predicción.
2.7 Selección de bandas con Forward Selection basado en AIC
En esta etapa realizo una selección de variables (bandas espectrales) combinadas utilizando un método de Forward Selection guiado por el criterio de información de Akaike (AIC). Este método comienza con un modelo vacío e incorpora progresivamente la banda que produce la mayor mejora (reducción) en el AIC, deteniéndose cuando no se consigue una mejora significativa.
La selección se realiza sobre datos transformados en escala logarítmica para estabilizar varianzas y linealizar relaciones. Además, se valida el desempeño del modelo en cada paso mediante validación cruzada (5 folds), evaluando métricas como RMSE y R² en escala original para tener una idea más realista del ajuste.
Finalmente, se evalúa el modelo seleccionado en un conjunto independiente de test (20 % de los datos) para estimar su capacidad predictiva fuera de muestra. También se presenta la ecuación final en escala log-log y en escala original para facilitar la interpretación.
Código
# ─────────────────────────────────────────────────────────────────────# 0 · LIBRERÍAS# ─────────────────────────────────────────────────────────────────────import pandas as pdimport numpy as npimport mathimport matplotlib.pyplot as pltfrom sklearn.linear_model import LinearRegressionfrom sklearn.model_selection import train_test_split, KFoldfrom sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score# ─────────────────────────────────────────────────────────────────────# 1 · CARGAR Y PRE-PROCESAR# ─────────────────────────────────────────────────────────────────────df = pd.read_csv("datos/datos_sol_sus_acolite.csv")bandas = [c for c in df.columns if c.startswith("B")]mask = (df["sol_sus"] >0) & (df[bandas] >0).all(axis=1)df = df.loc[mask].copy()df["log_sol_sus"] = np.log(df["sol_sus"])for b in bandas: df[f"log_{b}"] = np.log(df[b])log_cols = [f"log_{b}"for b in bandas]X_full = df[log_cols].valuesy_full = df["log_sol_sus"].values # todavía en log# ─────────────────────────────────────────────────────────────────────# 2 · HOLD-OUT 80 / 20 (orden intacto → shuffle=False)# ─────────────────────────────────────────────────────────────────────X_tr, X_te, y_tr, y_te = train_test_split( X_full, y_full, test_size=0.20, shuffle=False)# ─────────────────────────────────────────────────────────────────────# 3 · FUNCIONES AUXILIARES# ─────────────────────────────────────────────────────────────────────def aic_gauss(model, X, y): n =len(y) k = X.shape[1] +1 rss = np.sum((y - model.predict(X))**2)return n * np.log(rss / n) +2* kdef r2_adj(r2, n, p):return1- (1- r2) * (n -1) / (n - p -1)def cv_metrics(Xm, ym, cv):""" Devuelve métricas en ESCALA ORIGINAL para 5-fold CV: · rmse_mean, rmse_std, r2_mean, r2a_mean """ model = LinearRegression() rmse_f, r2_f = [], []for tr_idx, vl_idx in cv.split(Xm): model.fit(Xm[tr_idx], ym[tr_idx]) # log-modelo pred_log = model.predict(Xm[vl_idx])# → escala original y_val_orig = np.exp(ym[vl_idx]) pred_orig = np.exp(pred_log) rmse_f.append(np.sqrt(mean_squared_error(y_val_orig, pred_orig))) r2_f .append(r2_score(y_val_orig, pred_orig)) r2_mean = np.mean(r2_f) r2a_mean = r2_adj(r2_mean, len(ym), Xm.shape[1])return {"rmse_mean": np.mean(rmse_f),"rmse_std": np.std(rmse_f, ddof=1),"r2_mean": r2_mean,"r2a_mean": r2a_mean }kf = KFold(n_splits=5, shuffle=True, random_state=42)# ─────────────────────────────────────────────────────────────────────# 4 · FORWARD-SELECTION GUIADO POR AIC# ─────────────────────────────────────────────────────────────────────remain, select, best_aic, hist =list(range(len(bandas))), [], np.inf, []while remain: best_cand =Nonefor idx in remain: cols = select + [idx] mdl = LinearRegression().fit(X_tr[:, cols], y_tr) aic = aic_gauss(mdl, X_tr[:, cols], y_tr)if best_cand isNoneor aic < best_cand[0]: best_cand = (aic, idx)# mejora ≥ 2 unidadesif best_cand[0] +2< best_aic: best_aic = best_cand[0] select.append(best_cand[1]) remain.remove(best_cand[1]) stats = cv_metrics(X_tr[:, select], y_tr, kf) hist.append({"Paso": len(select),"Añadido": bandas[best_cand[1]],"AIC": round(best_aic, 3),"RMSE_CV": round(stats["rmse_mean"], 3),"CV_std": round(stats["rmse_std"], 3),"R2_CV": round(stats["r2_mean"], 3),"R2aj_CV": round(stats["r2a_mean"], 3) })else:breakhist_df = pd.DataFrame(hist)print("\nHistorial forward-AIC:")print(hist_df)sel_b = [bandas[i] for i in select]print(f"\nBandas finales seleccionadas: {sel_b}")# ─────────────────────────────────────────────────────────────────────# 5 · AJUSTE FINAL Y MÉTRICAS TEST (ORIGINAL)# ─────────────────────────────────────────────────────────────────────Xtr_sel = X_tr[:, select]Xte_sel = X_te[:, select]final_model = LinearRegression().fit(Xtr_sel, y_tr)# → escala original para testpred_test_orig = np.exp(final_model.predict(Xte_sel))y_test_orig = np.exp(y_te)rmse_test = np.sqrt(mean_squared_error(y_test_orig, pred_test_orig))mae_test = mean_absolute_error(y_test_orig, pred_test_orig)r2_test = r2_score(y_test_orig, pred_test_orig)# ─────────────────────────────────────────────────────────────────────# 6 · ECUACIÓN FINAL (formato fracción cuando hay exponentes negativos)# ─────────────────────────────────────────────────────────────────────interc = final_model.intercept_coefs = final_model.coef_pos_terms = [f"{b}^{c:.4f}"for b, c inzip(sel_b, coefs) if c >=0]neg_terms = [f"{b}^{-c:.4f}"for b, c inzip(sel_b, coefs) if c <0]k_factor = math.exp(interc)numerator =" * ".join(pos_terms) if pos_terms else"1"denominator =" * ".join(neg_terms)print("\nEcuación en escala log-log:")log_eq = [f"{interc:+.4f}"] + [f"{coefs[i]:+0.4f}·log({sel_b[i]})"for i inrange(len(sel_b))]print("log(sol_sus) = "+" ".join(log_eq))print("\nEscala original:")if denominator:print(f"sol_sus_orig = {k_factor:.2f} · ({numerator}) / ({denominator})")else:print(f"sol_sus_orig = {k_factor:.2f} · {numerator}")# ─────────────────────────────────────────────────────────────────────# 7 · TABLA FINAL DE MÉTRICAS# ─────────────────────────────────────────────────────────────────────final_row = {"Bandas": "+".join(sel_b),"AIC_train": round(best_aic, 3),"RMSE_CV": round(hist[-1]["RMSE_CV"], 3),"CV_std": round(hist[-1]["CV_std"], 3),"R²_CV": round(hist[-1]["R2_CV"], 3),"R²aj_CV": round(hist[-1]["R2aj_CV"], 3),"RMSE_test": round(rmse_test, 3),"MAE_test": round(mae_test, 3),"R²_test": round(r2_test, 3)}print("\n──────── TABLA FINAL ────────")print(pd.DataFrame([final_row]).to_string(index=False))# ─────────────────────────────────────────────────────────────────────# 8 · GRÁFICO REAL vs PREDICHO (train + test)# ─────────────────────────────────────────────────────────────────────# Train → originalpred_train_orig = np.exp(final_model.predict(Xtr_sel))y_train_orig = np.exp(y_tr)plt.figure(figsize=(6, 6))plt.scatter(y_train_orig, pred_train_orig, alpha=0.7, label="Train")plt.scatter(y_test_orig, pred_test_orig, alpha=0.7, marker='s', edgecolors='k', label="Test")# línea y = xmn, mx =min(y_train_orig.min(), y_test_orig.min()), \max(y_train_orig.max(), y_test_orig.max())plt.plot([mn, mx], [mn, mx], "--", color="gray")plt.xlabel("Sólidos suspendidos observados")plt.ylabel("Sólidos suspendidos predichos")plt.title("Ajuste modelo (escala original)")# ecuación en cajaeq_line =rf"$\hat{{y}} = {k_factor:.1f}"for b, c inzip(sel_b, coefs): eq_line +=rf"\times {b}^{{{c:.2f}}}"eq_line +="$"plt.text(0.05, 0.95, eq_line, transform=plt.gca().transAxes, fontsize=8, va="top", bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="gray", lw=0.5))plt.legend()plt.tight_layout()plt.show()
El algoritmo de Forward Selection identificó a las bandas B06 y B02 como las más relevantes para predecir los sólidos suspendidos en escala log-log. El modelo final presenta un buen desempeño, con un RMSE_test de 16.69 y un R²_test de 0.86 en escala original, lo que indica una capacidad predictiva adecuada sobre datos no vistos. Además, la ecuación obtenida permite expresar la variable objetivo en función de las bandas seleccionadas de forma clara y directa.
Aunque el ajuste es satisfactorio, sería conveniente en futuros análisis explorar modelos no lineales o técnicas multivariadas más complejas, como regresión regularizada o árboles de decisión, que podrían captar relaciones más sutiles entre variables.
Referencias
[1]
D. C. R. Ramírez, «Método de Estimación de Sólidos Suspendidos Totales como Indicador de la Calidad del Agua Mediante Imágenes Satelitales». Universidad Nacional de Colombia, Facultad de Ciencias Agrarias, 2017.
[2]
G. D. J. L., J. Stephan, y Delance-Martinic., «Determinación del parámetro sólidos suspendidos totales (sst) mediante imágenes de sensores ópticos en un tramo de la cuenca media del río Bogotá (Colombia).», UD y la Geomática, pp. 19-27, 2014, doi: 10.14483/udistrital.jour.udgeo.2014.9.a02.
[3]
A. Cruz-Retana, C. Fonseca-Ortiz, R. Becerril-Piña, M. A. Gómez-Albores, M. Hernández-Téllez, y R. Arévalo-Mejia, «Characterization of spectral reflectance and TSS concentration in continental water bodies worldwide», vol. 1. pp. 4-18, 2023.
[4]
E. E. C. Vargas, «Modelamiento de Relaciones entre Parámetros Fisicoquímicos y Microbiológicos en Aguas de la Bahía Interior del Lago Titicaca-Puno (Perú) mediante Árboles de Predicción», Revista Tecnica De La Facultad De Ingenieria Universidad Del Zulia, vol. 44, pp. 154-168, ago. 2021, doi: 10.22209/rt.v44n3a02.
[5]
M. Moeini, A. Shojaeizadeh, y M. Geza, «Supervised machine learning for estimation of total suspended solids in urban watersheds», Water (Switzerland), vol. 13, ene. 2021, doi: 10.3390/w13020147.
[6]
L. S. Kupssinskü, T. T. Guimarães, E. M. D. Souza, y D. C. Zanotta, «A method for chlorophyll-a and suspended solids prediction through remote sensing and machine learning», Sensors (Switzerland), vol. 20, abr. 2020, doi: 10.3390/s20072125.