Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

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.

🖥️ Diapositivas — Sesión 05 (vie 9 oct)

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()
Loading...
# 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()
Loading...
# How many samples per lithology?
df['label'].value_counts()
label granite 5525 basalt 3490 andesite 985 Name: count, dtype: int64

Las 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")
<Figure size 1200x380 with 2 Axes>
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()
Loading...
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()
<Figure size 1000x800 with 2 Axes>
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()
<Figure size 1000x800 with 2 Axes>

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()
<Figure size 1500x1200 with 9 Axes>
# 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()
<Figure size 1500x1200 with 9 Axes>

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()
Loading...

8. Ejercicio

Responda cada pregunta con código y una breve justificación escrita.

  1. 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.
  2. 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.
  3. 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?
  4. 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