Antes de cualquier modelado, necesita saber qué hay en su tabla. Esta lección recorre un flujo estándar de preparación sobre un DataFrame de pandas: leer los datos, inspeccionar los tipos y las estadísticas de resumen, manejar los valores faltantes, decidir qué hacer con los ceros, y explorar las correlaciones y las distribuciones por clase.
El conjunto de datos es un levantamiento sintético de geoquímica de roca total generado por el paquete del curso mlgeo_synth. Cada fila es una muestra de roca con:
- Óxidos de elementos mayores (SIO2, AL2O3, FEO, MGO, CAO, NA2O, K2O) en porcentaje en peso (wt%),
- density_g_cm3: densidad aparente en g/cm3,
- mag_susc_si: susceptibilidad magnética (unidades SI),
- label: la litología (granito, basalto, andesita), que un clasificador intentaría predecir más adelante.
El generador siembra correlaciones realistas: un índice latente de diferenciación gobierna los óxidos, de modo que el SiO2 se anticorrelaciona con el FeO, el MgO y el CaO, y la densidad sigue a los óxidos máficos. Su trabajo en esta lección es recuperar esas relaciones a partir de la tabla.
1. Leer y explorar los datos¶
import pandas as pd
import numpy as np
import mlgeo_synth
df = mlgeo_synth.geochem_table(n=10000, seed=42)
df.head()# what datatypes are in the dataset
df.info()<class 'pandas.DataFrame'>
RangeIndex: 10000 entries, 0 to 9999
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 SIO2 10000 non-null float64
1 AL2O3 10000 non-null float64
2 FEO 10000 non-null float64
3 MGO 10000 non-null float64
4 CAO 10000 non-null float64
5 NA2O 10000 non-null float64
6 K2O 10000 non-null float64
7 density_g_cm3 10000 non-null float64
8 mag_susc_si 10000 non-null float64
9 label 10000 non-null str
dtypes: float64(9), str(1)
memory usage: 847.3 KB
La columna label contiene cadenas de texto; todo lo demás es numérico. Revise ahora las estadísticas de resumen. Lea las filas de mínimo y máximo contra las expectativas físicas: los óxidos deben estar entre 0 y 100 wt%, y las densidades de rocas corticales entre aproximadamente 2.5 y 3.3 g/cm3.
# Summary statistics
df.describe()# How many samples per lithology?
df['label'].value_counts()label
granite 5525
basalt 3490
andesite 985
Name: count, dtype: int64Las clases están desbalanceadas: los granitos superan a las andesitas por más de cinco a uno. Tenga presente ese número; vuelve en el ejercicio.
2. Manejo de valores faltantes (NaN)¶
Los conjuntos de datos de campo y de laboratorio tienen huecos: un sensor falla, un laboratorio omite un análisis, un valor se pierde durante la transcripción. En pandas, los valores faltantes aparecen como NaN o como el más reciente pd.NA. Estrategias comunes:
- Eliminar las filas/columnas con valores faltantes: aceptable cuando la fracción faltante es pequeña y la ausencia no está relacionada con el valor en sí.
- Imputar: rellenar los huecos con una media, una mediana, una interpolación o una estimación basada en un modelo. La imputación conserva el tamaño de la muestra pero inyecta supuestos.
Esta tabla sintética sale completa del generador, algo que los datos reales nunca son. Para darle a la lección algo que limpiar, eliminamos a propósito una pequeña fracción aleatoria de las mediciones de densidad y de susceptibilidad magnética, y fijamos unos pocos valores de densidad en 0 para imitar un instrumento que escribe 0 cuando una medición falla. Este tipo de cero “centinela” es común en los archivos de datos reales.
# Deliberately introduce missing data (this is the simulation of a messy archive)
rng = np.random.default_rng(42)
# 2% of density and 3% of susceptibility go missing
mask_density = rng.random(len(df)) < 0.02
mask_susc = rng.random(len(df)) < 0.03
df.loc[mask_density, 'density_g_cm3'] = pd.NA
df.loc[mask_susc, 'mag_susc_si'] = pd.NA
# 15 sentinel zeros in density: the "instrument failure" code
sentinel_rows = rng.choice(len(df), size=15, replace=False)
df.loc[df.index[sentinel_rows], 'density_g_cm3'] = 0.0# Check for missing values
print(df.isna().sum())SIO2 0
AL2O3 0
FEO 0
MGO 0
CAO 0
NA2O 0
K2O 0
density_g_cm3 187
mag_susc_si 302
label 0
dtype: int64
Observe el estilo moderno de pandas usado arriba: asignamos con df.loc[mask, col] = value y reasignamos los resultados (df = df.something()) en lugar de usar inplace=True sobre vistas o rebanadas. Las operaciones inplace encadenadas sobre una rebanada de un DataFrame lanzan errores en pandas 3.
3. Ceros: ¿valor físico o código de dato faltante?¶
Un atajo tentador es “reemplazar todos los ceros por NA”. No lo haga. Que un cero sea real depende de la física de la variable:
- Un MgO cercano a 0 wt% es real. Los granitos muy evolucionados pueden quedar prácticamente sin magnesio; el fundido lo fraccionó. Lo mismo vale para el FeO, el CaO y el K2O en los tipos de roca adecuados. Borrar esos ceros eliminaría de manera preferente los granitos evolucionados y sesgaría el conjunto de datos.
- Una densidad de 0 g/cm3 es imposible. Ninguna roca tiene densidad cero. Un cero ahí es un centinela: un código que algún instrumento o base de datos usó para decir “sin medición”.
La comprobación es simple: cuente los ceros por columna y pregunte, para cada columna, si el cero está dentro del rango físicamente posible.
# Count zeros per numeric column
numeric_cols = df.select_dtypes(include='number').columns
print((df[numeric_cols] == 0).sum())SIO2 0
AL2O3 0
FEO 184
MGO 1434
CAO 367
NA2O 0
K2O 824
density_g_cm3 15
mag_susc_si 0
dtype: int64
Miles de ceros en los óxidos, y exactamente los 15 ceros de densidad que sembramos. Ahora observe qué rocas llevan los ceros de MgO.
# Which lithologies have MgO exactly at 0?
print(df.loc[df['MGO'] == 0, 'label'].value_counts())
print("\nMedian SiO2 of MgO=0 samples:", round(df.loc[df['MGO'] == 0, 'SIO2'].median(), 1), "wt%")
print("Median SiO2 of the full table:", round(df['SIO2'].median(), 1), "wt%")label
granite 1432
andesite 2
Name: count, dtype: int64
Median SiO2 of MgO=0 samples: 74.2 wt%
Median SiO2 of the full table: 69.2 wt%
Los ceros de MgO son casi todos granitos, y son ricos en sílice. Esa es exactamente la expectativa geoquímica para fundidos evolucionados: estos ceros llevan información. Los conservamos.
Los ceros de densidad son otra historia. Reemplace solo esos por pd.NA, por asignación, no inplace.
# Convert only the impossible zeros (density) to missing values
df = df.replace({'density_g_cm3': {0.0: pd.NA}})
print(df.isna().sum())
print("\ndensity dtype after replace:", df['density_g_cm3'].dtype)SIO2 0
AL2O3 0
FEO 0
MGO 0
CAO 0
NA2O 0
K2O 0
density_g_cm3 202
mag_susc_si 302
label 0
dtype: int64
density dtype after replace: object
Observe el dtype: replace con pd.NA promovió en silencio la columna de float64 a object. Eso importa, porque cada llamada posterior a select_dtypes(include='number') — las matrices de correlación, describe() — descartaría en silencio la densidad, justo la columna que esta limpieza protegía. Una columna puede ser numérica en contenido y no numérica en dtype, y pandas no se lo advertirá. Devuélvala a su tipo antes de continuar, y haga de las comprobaciones de dtype parte de cada paso de limpieza, no solo del primero.
# Cast back to float so density stays numeric downstream
df['density_g_cm3'] = pd.to_numeric(df['density_g_cm3'])
print("density dtype after cast:", df['density_g_cm3'].dtype)density dtype after cast: float64
4. Eliminación de las filas incompletas¶
La fracción faltante es pequeña (alrededor del 5 % de las filas tiene al menos un hueco) y, por construcción aquí, no guarda relación con los valores en sí, de modo que eliminarlas es defendible. Con datos reales, primero preguntaría por qué faltan los valores antes de eliminar nada.
df_cleaned = df.dropna()
print(f"Rows before: {len(df)}, after dropping incomplete rows: {len(df_cleaned)}")
df_cleaned.info()Rows before: 10000, after dropping incomplete rows: 9499
<class 'pandas.DataFrame'>
Index: 9499 entries, 0 to 9999
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 SIO2 9499 non-null float64
1 AL2O3 9499 non-null float64
2 FEO 9499 non-null float64
3 MGO 9499 non-null float64
4 CAO 9499 non-null float64
5 NA2O 9499 non-null float64
6 K2O 9499 non-null float64
7 density_g_cm3 9499 non-null float64
8 mag_susc_si 9499 non-null float64
9 label 9499 non-null str
dtypes: float64(9), str(1)
memory usage: 880.1 KB
# Final check: no missing values, class counts still imbalanced but intact
print(df_cleaned.isna().sum())
print()
print(df_cleaned['label'].value_counts())SIO2 0
AL2O3 0
FEO 0
MGO 0
CAO 0
NA2O 0
K2O 0
density_g_cm3 0
mag_susc_si 0
label 0
dtype: int64
label
granite 5182
basalt 3357
andesite 960
Name: count, dtype: int64
5. Valores censurados: presentes pero incorrectos¶
Los valores faltantes se anuncian solos — isna() los encuentra. Un valor censurado no: el instrumento reporta un número, y el número está mal de manera sistemática. El caso clásico es el límite de detección: un sensor con un piso de reporte escribe el propio piso cada vez que el valor verdadero cae por debajo de él. Los análisis geoquímicos lo hacen (“<0.01 wt%” se vuelve 0.01), los aforos de ríos lo hacen con caudales bajos y los sistemas de posicionamiento lo hacen cerca de su resolución.
La tabla de geoquímica no tiene censura, así que tomamos prestado un flujo de sensor para ver la firma. mlgeo_synth.degrade_series inyecta un límite de detección en una serie sintética de desplazamiento GNSS y devuelve además la verdad sin censurar, de modo que podamos medir exactamente qué le hace la censura a una estadística.
import matplotlib.pyplot as plt
# A 4-year GNSS displacement series moving at 12 mm/yr, reported by a
# sensor that cannot resolve displacements below 5 mm
gnss = mlgeo_synth.gnss_series(n_years=4, velocity_mm_yr=12.0, seed=7)
censored, truth = mlgeo_synth.degrade_series(gnss, detection_limit_mm=5.0, seed=7)
t = np.arange(len(censored)) / 365.25 # time in years
fig, ax = plt.subplots(1, 2, figsize=(12, 3.8))
ax[0].plot(t, truth['clean'], lw=0.5, color='gray', label='true (uncensored)')
ax[0].plot(t, censored['disp_mm'], lw=0.5, color='tab:red', label='reported')
ax[0].axhline(5.0, color='k', ls='--', lw=1, label='detection limit')
ax[0].set_xlabel('time (yr)'); ax[0].set_ylabel('displacement (mm)')
ax[0].legend()
ax[1].hist(censored['disp_mm'][:365], bins=40, color='tab:red', alpha=0.6, label='reported, year 1')
ax[1].hist(truth['clean'][:365], bins=40, histtype='step', color='gray', label='true, year 1')
ax[1].set_xlabel('displacement (mm)'); ax[1].set_ylabel('count')
ax[1].legend()
plt.tight_layout()
plt.show()
print(f"fraction of samples censored: {censored['censored'].mean():.3f}")
print(f"trend fit on reported values: {np.polyfit(t, censored['disp_mm'], 1)[0]:.2f} mm/yr")
print(f"trend fit on uncensored values: {np.polyfit(t, truth['clean'], 1)[0]:.2f} mm/yr")
fraction of samples censored: 0.120
trend fit on reported values: 11.59 mm/yr
trend fit on uncensored values: 12.44 mm/yr
Tres firmas para recordar. Primera: isna() reporta cero valores faltantes — los datos censurados pasan todas las comprobaciones de ausencia. Segunda: el histograma tiene un montón de valores idénticos exactamente en el límite; un pico en un número sospechosamente redondo es la huella de la censura (igual que los ceros de densidad eran la huella de un código centinela). Tercera: las estadísticas quedan sesgadas en una dirección predecible — la media reportada del primer año es demasiado alta y la tendencia ajustada demasiado baja, porque cada excursión baja fue empujada hacia arriba hasta el piso. Eliminar las muestras censuradas no arregla esto: quita justamente los valores bajos, lo que es un sesgo peor. Las opciones honestas son modelar la censura (métodos de supervivencia/tobit) o reportar el límite junto con los datos, como exige la ficha de datos de la lección 2.13.
Una trampa relacionada es el cambio de instrumento a mitad del registro: un sensor nuevo con un límite de detección más bajo, o un aforo reubicado, cambia las estadísticas del registro sin ningún cambio en la física. Un escalón en la varianza o en el valor mínimo de un registro es una pregunta para los metadatos de la estación antes que un resultado científico.
Ausencia informativa¶
La sección 4 eliminó filas con el argumento de que los huecos “no guardaban relación con los valores en sí” — cierto aquí por construcción, porque los sembramos al azar (el término de la estadística es missing completely at random, MCAR, ausencia completamente aleatoria). Los instrumentos reales rara vez cooperan. El aforo del río se ahoga durante la inundación que debía medir; la antena GNSS enmudece bajo la carga de nieve de la tormenta invernal; la brigada de campo omite el sitio cuando el camino queda destruido. En cada caso, el valor falta a causa del valor que se habría medido (missing not at random, MNAR, ausencia no aleatoria), y eliminar esas filas borra de manera sistemática los extremos — las inundaciones, las tormentas — que probablemente son lo que más le importa al análisis. Los ceros de MgO de antes en esta lección eran la misma idea al revés: los valores “sospechosos” se concentraban en los granitos evolucionados, así que quitarlos habría reescrito la geología. Antes de eliminar o imputar cualquier cosa, pregunte qué proceso creó el hueco. Si la respuesta involucra la cantidad medida, la ausencia es en sí misma un dato, y pertenece a la política de datos faltantes de su ficha de datos.
6. Análisis de correlación¶
Las correlaciones entre características (features) le dicen qué mediciones llevan información redundante e insinúan la física detrás de la tabla. Calculamos dos matrices:
- La correlación de Pearson mide relaciones lineales.
- La correlación de rangos de Spearman mide relaciones monótonas. Es robusta frente a valores atípicos y frente a vínculos no lineales pero monótonos, comunes en geoquímica.
La columna label es una cadena de texto, así que primero conserve solo las columnas numéricas.
df_numerical = df_cleaned.select_dtypes(include='number')
df_numerical.head()import seaborn as sns
import matplotlib.pyplot as plt
corr_matrix = df_numerical.corr()
plt.figure(figsize=(10, 8))
sns.heatmap(corr_matrix, annot=True, fmt='.2f', cmap='coolwarm', vmin=-1, vmax=1)
plt.title('Pearson correlation')
plt.tight_layout()
plt.show()
spearman_corr_matrix = df_numerical.corr(method='spearman')
plt.figure(figsize=(10, 8))
sns.heatmap(spearman_corr_matrix, annot=True, fmt='.2f', cmap='coolwarm', vmin=-1, vmax=1)
plt.title('Spearman rank correlation')
plt.tight_layout()
plt.show()
Lea las dos matrices lado a lado. El SiO2 está fuertemente anticorrelacionado con el FeO, el MgO y el CaO, y positivamente correlacionado con el K2O. La densidad sigue a los óxidos máficos y se anticorrelaciona con el SiO2. Donde el valor de Spearman es notablemente más fuerte que el de Pearson, la relación es monótona pero no lineal; la susceptibilidad magnética es un caso típico, porque abarca órdenes de magnitud.
Una razón estructural para las correlaciones negativas: los análisis de óxidos son datos composicionales. Los valores en wt% de los óxidos mayores suman aproximadamente 100, así que si un óxido sube, los demás deben bajar. Este efecto de cierre incorpora correlación negativa a la tabla, sin importar la petrología. Téngalo presente antes de interpretar cada entrada negativa como un proceso geológico.
7. Distribuciones de las características por litología¶
Las matrices de correlación mezclan todas las clases. Para ver qué características separan las litologías, grafique la distribución de cada característica por clase.
granite = df_cleaned[df_cleaned['label'] == 'granite']
basalt = df_cleaned[df_cleaned['label'] == 'basalt']
andesite = df_cleaned[df_cleaned['label'] == 'andesite']
features = ['SIO2', 'AL2O3', 'FEO', 'MGO', 'CAO', 'NA2O', 'K2O', 'density_g_cm3', 'mag_susc_si']
fig, axes = plt.subplots(3, 3, figsize=(15, 12))
for ax, feature in zip(axes.ravel(), features):
sns.histplot(granite[feature], kde=True, color='tab:orange', label='granite', stat='density', ax=ax)
sns.histplot(basalt[feature], kde=True, color='tab:blue', label='basalt', stat='density', ax=ax)
sns.histplot(andesite[feature], kde=True, color='tab:green', label='andesite', stat='density', ax=ax)
ax.set_title(feature)
ax.legend()
plt.tight_layout()
plt.show()
# Boxplots give a compact per-class view of the same information
fig, axes = plt.subplots(3, 3, figsize=(15, 12))
for ax, feature in zip(axes.ravel(), features):
sns.boxplot(data=df_cleaned, x='label', y=feature, hue='label', ax=ax)
ax.set_title(feature)
ax.set_xlabel('')
plt.tight_layout()
plt.show()
Algunas características separan las clases casi por completo; otras se traslapan mucho. Algunas distribuciones lucen aproximadamente gaussianas; otras son asimétricas o de colas pesadas. Para cuantificar la forma, calcule la asimetría (skewness) y la curtosis junto con los momentos habituales.
from scipy.stats import skew, kurtosis
def summarize_distribution(frame, features):
summary = {}
for feature in features:
values = frame[feature].to_numpy(dtype=float)
summary[feature] = {
'mean': values.mean(),
'median': np.median(values),
'std': values.std(),
'skewness': skew(values),
'kurtosis': kurtosis(values),
}
return pd.DataFrame(summary)
print("Granite:")
print(summarize_distribution(granite, features).round(3))
print("\nBasalt:")
print(summarize_distribution(basalt, features).round(3))
print("\nAndesite:")
print(summarize_distribution(andesite, features).round(3))Granite:
SIO2 AL2O3 FEO MGO CAO NA2O K2O density_g_cm3 \
mean 72.043 13.981 2.481 0.947 1.799 3.511 4.213 2.650
median 72.040 13.977 2.477 0.756 1.753 3.503 4.210 2.649
std 2.128 0.677 1.319 0.930 1.129 0.672 0.785 0.037
skewness -0.007 -0.001 0.180 0.880 0.332 -0.003 0.033 -0.006
kurtosis 0.015 0.121 -0.326 0.191 -0.370 0.061 -0.067 0.058
mag_susc_si
mean 0.000
median 0.000
std 0.000
skewness 1.796
kurtosis 5.938
Basalt:
SIO2 AL2O3 FEO MGO CAO NA2O K2O density_g_cm3 \
mean 48.931 15.014 10.044 7.547 11.032 2.479 0.677 2.951
median 48.976 15.028 10.029 7.564 11.025 2.470 0.571 2.951
std 2.121 0.681 1.367 1.187 1.187 0.690 0.631 0.036
skewness 0.001 -0.002 0.017 -0.031 -0.038 0.053 0.854 0.023
kurtosis 0.083 0.001 0.087 -0.040 0.079 -0.025 0.352 -0.105
mag_susc_si
mean 0.001
median 0.001
std 0.001
skewness 1.616
kurtosis 4.949
Andesite:
SIO2 AL2O3 FEO MGO CAO NA2O K2O density_g_cm3 \
mean 58.910 17.041 6.544 3.545 6.553 3.779 1.775 2.798
median 58.943 17.050 6.519 3.504 6.565 3.761 1.780 2.798
std 2.142 0.681 1.381 1.227 1.169 0.668 0.791 0.036
skewness -0.009 -0.093 0.044 0.155 -0.063 0.117 0.061 -0.062
kurtosis -0.132 0.117 -0.235 -0.032 -0.089 -0.085 -0.206 0.151
mag_susc_si
mean 0.001
median 0.000
std 0.000
skewness 3.393
kurtosis 27.926
Una asimetría cercana a 0 y una curtosis cercana a 0 (scipy usa la convención de curtosis en exceso) indican una forma aproximadamente gaussiana. Una asimetría positiva grande, como en la susceptibilidad magnética, señala una cola derecha pesada; estas características suelen transformarse con logaritmo antes del modelado.
Guarde la tabla limpia para los capítulos posteriores.
df_cleaned.to_csv('cleaned_geochem.csv', index=False)
df_cleaned.describe()8. Ejercicio¶
Responda cada pregunta con código y una breve justificación escrita.
- Correlaciones y cierre. A partir de la matriz de Pearson, enumere las tres anticorrelaciones más fuertes con el SiO2. ¿Cuánto de esa anticorrelación es petrología (minerales máficos que se fraccionan a medida que los fundidos evolucionan) y cuánto es el efecto de cierre (los óxidos mayores suman ~100 wt%, así que si uno sube obliga a los demás a bajar)? Proponga una comprobación que pueda ayudar a separar las dos explicaciones.
- La característica que mejor separa. Usando los histogramas por clase y las estadísticas de resumen, ¿qué característica individual separa mejor las tres litologías? Sustente su elección cuantitativamente, por ejemplo comparando la diferencia entre las medias de clase con las desviaciones estándar de clase para cada característica.
- Desbalance de clases. Los granitos constituyen más de la mitad de la tabla y las andesitas alrededor del 10 %. Si entrenara un clasificador con esta tabla y midiera solo la exactitud (accuracy) global, ¿qué exactitud alcanzaría un modelo que siempre predice “granito”? ¿Qué implica eso sobre cómo debería evaluar un futuro clasificador con este conjunto de datos?
- Los ceros, otra vez. Suponga que un colega le entrega una versión de esta tabla donde todos los ceros de MgO ya fueron reemplazados por NaN y eliminados. ¿Qué clase perdería más muestras, y cómo cambiaría la distribución de SiO2 de los granitos restantes?
# your answers here