Cálculo de outliers con pandas

alturas


import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# ════════════════════════════════════════════════════════════════
# PASO 1: CARGAR LOS DATOS
# ════════════════════════════════════════════════════════════════
# pd.read_csv() lee un archivo CSV y lo convierte en un DataFrame.
# Un DataFrame es como una tabla de Excel: filas y columnas con nombre.
# El archivo tiene dos columnas: 'id' (número de alumno) y 'altura' (en cm).
# Alternativa si el CSV usa punto y coma: pd.read_csv("alturas.csv", sep=";")

df = pd.read_csv("alturas.csv")

print("═" * 55)
print("PASO 1 — VISTA GENERAL DEL DATASET")
print("═" * 55)

# .shape devuelve (filas, columnas). Siempre lo primero que comprobamos.
print(f"  Dimensiones:   {df.shape[0]} filas × {df.shape[1]} columnas")

# .head() muestra las 5 primeras filas. Útil para verificar que cargó bien.
# Alternativa: df.tail(5) para ver las últimas, df.sample(5) para 5 aleatorias.
print(f"\n  Primeras filas:\n{df.head()}")

# .dtypes muestra el tipo de cada columna (int, float, object...).
# Es importante verificar que 'altura' sea numérico y no texto.
print(f"\n  Tipos de datos:\n{df.dtypes}")


# ════════════════════════════════════════════════════════════════
# PASO 2: ESTADÍSTICAS DESCRIPTIVAS BÁSICAS
# ════════════════════════════════════════════════════════════════
# .describe() es el resumen estadístico más rápido de pandas.
# De un vistazo muestra: count, mean, std, min, cuartiles y max.
# Contexto: si min o max tienen valores absurdos, ya sabemos que hay outliers.
# Alternativa manual: calcular cada estadístico por separado con np.mean(), etc.

print("\n" + "═" * 55)
print("PASO 2 — ESTADÍSTICAS DESCRIPTIVAS")
print("═" * 55)
print(f"\n{df['altura'].describe().round(2)}")

# Extraemos los valores individuales para usarlos después
media   = df['altura'].mean()
sigma   = df['altura'].std()
minimo  = df['altura'].min()
maximo  = df['altura'].max()

print(f"\n  Media:    {media:.2f} cm")
print(f"  Sigma:    {sigma:.2f} cm")
print(f"  Mínimo:   {minimo:.1f} cm  ← ¿sospechoso?")
print(f"  Máximo:   {maximo:.1f} cm  ← ¿sospechoso?")


# ════════════════════════════════════════════════════════════════
# PASO 3: CÁLCULO DEL IQR Y LOS LÍMITES DE TUKEY
# ════════════════════════════════════════════════════════════════
# El IQR (Rango Intercuartílico) es la distancia entre el percentil 25 (Q1)
# y el percentil 75 (Q3). Contiene el 50% central de los datos.
#
# ¿Por qué no usar la sigma directamente para detectar outliers?
# Porque la sigma se ve DISTORSIONADA por los propios outliers.
# Si hay un dato de 320 cm, la sigma sube y los límites se amplían,
# haciendo que ese mismo outlier parezca menos extremo. El IQR no tiene
# ese problema porque ignora los extremos al calcular Q1 y Q3.
#
# REGLA DE TUKEY:
#   Factor 1.5 → outlier "moderado" (el estándar habitual)
#   Factor 3.0 → outlier "extremo" (solo los más alejados)
#
# Alternativa: from scipy import stats → stats.iqr(datos)

print("\n" + "═" * 55)
print("PASO 3 — IQR Y LÍMITES DE TUKEY")
print("═" * 55)

Q1  = df['altura'].quantile(0.25)   # El 25% de las alturas están por debajo
Q3  = df['altura'].quantile(0.75)   # El 75% de las alturas están por debajo
IQR = Q3 - Q1                       # Rango del 50% central

# Factor 1.5: límites estándar (los "bigotes" del boxplot)
lim_inf_15 = Q1 - 1.5 * IQR
lim_sup_15 = Q3 + 1.5 * IQR

# Factor 3.0: límites estrictos (solo los outliers más extremos)
lim_inf_30 = Q1 - 3.0 * IQR
lim_sup_30 = Q3 + 3.0 * IQR

print(f"\n  Q1  (percentil 25):  {Q1:.2f} cm")
print(f"  Q3  (percentil 75):  {Q3:.2f} cm")
print(f"  IQR (Q3 - Q1):       {IQR:.2f} cm")
print(f"\n  ── Factor 1.5 (outliers moderados) ──")
print(f"  Límite inferior:     {lim_inf_15:.2f} cm")
print(f"  Límite superior:     {lim_sup_15:.2f} cm")
print(f"\n  ── Factor 3.0 (outliers extremos) ──")
print(f"  Límite inferior:     {lim_inf_30:.2f} cm")
print(f"  Límite superior:     {lim_sup_30:.2f} cm")


# ════════════════════════════════════════════════════════════════
# PASO 4: DETECCIÓN Y CLASIFICACIÓN DE OUTLIERS
# ════════════════════════════════════════════════════════════════
# Clasificamos cada dato en una de tres categorías:
#   - Normal:   dentro de los límites 1.5×IQR
#   - Moderado: fuera de 1.5 pero dentro de 3.0×IQR
#   - Extremo:  fuera de 3.0×IQR → casi seguro un error
#
# Usamos una función auxiliar para asignar la etiqueta a cada fila.
# Alternativa: pd.cut() para categorizar rangos numéricos automáticamente.

print("\n" + "═" * 55)
print("PASO 4 — CLASIFICACIÓN DE OUTLIERS")
print("═" * 55)

def clasificar(altura):
    """Devuelve la categoría estadística de una altura."""
    if altura < lim_inf_30 or altura > lim_sup_30:
        return "extremo"
    elif altura < lim_inf_15 or altura > lim_sup_15:
        return "moderado"
    else:
        return "normal"

# .apply() aplica la función fila por fila sobre la columna 'altura'.
# Crea una nueva columna 'categoria' con el resultado.
# Alternativa con np.select() para condiciones múltiples sin función auxiliar.
df['categoria'] = df['altura'].apply(clasificar)

# .value_counts() cuenta cuántas veces aparece cada categoría.
print(f"\n{df['categoria'].value_counts()}")
print(f"\n  Total outliers moderados: {(df['categoria'] == 'moderado').sum()}")
print(f"  Total outliers extremos:  {(df['categoria'] == 'extremo').sum()}")

# Mostramos los outliers ordenados para inspeccionarlos
outliers = df[df['categoria'] != 'normal'].sort_values('altura')
print(f"\n  Detalle de todos los outliers:")
print(outliers[['id','altura','categoria']].to_string(index=False))


# ════════════════════════════════════════════════════════════════
# PASO 5: VISUALIZACIÓN
# ════════════════════════════════════════════════════════════════
# Dibujamos tres gráficos para contar la historia completa:
#   1. Histograma con las líneas de Tukey y colores por categoría
#   2. Boxplot clásico (que ya usa el factor 1.5 internamente)
#   3. Zoom sobre los datos normales (sin los outliers extremos)
#
# plt.subplots(1, 3) crea 1 fila con 3 gráficos lado a lado.
# figsize=(18, 6) → ancho total 18 pulgadas, alto 6 pulgadas.

print("\n" + "═" * 55)
print("PASO 5 — VISUALIZACIÓN")
print("═" * 55)
print("  Generando gráficos...")

fig, axes = plt.subplots(1, 3, figsize=(18, 6))
fig.suptitle(
    "Análisis de Outliers en Alturas (n=1000)\n"
    "Regla de Tukey: factor 1.5 (moderados) y factor 3.0 (extremos)",
    fontsize=13, fontweight='bold', y=1.02
)

# ── Gráfico 1: Histograma completo con líneas de Tukey ───────────────────────
ax1 = axes[0]

# Separamos los datos por categoría para colorearlos distinto en el histograma.
# ~ es el operador NOT booleano: ~(condición) invierte True&#x2194;False.
normales   = df[df['categoria'] == 'normal']['altura']
moderados  = df[df['categoria'] == 'moderado']['altura']
extremos   = df[df['categoria'] == 'extremo']['altura']

# Superponemos tres histogramas en el mismo eje.
# alpha controla la transparencia (0=invisible, 1=sólido).
# bins=60 → más barras = más detalle en la forma de la distribución.
ax1.hist(normales,  bins=60, color='steelblue', alpha=0.85, label=f'Normales ({len(normales)})')
ax1.hist(moderados, bins=10, color='orange',    alpha=0.9,  label=f'Outliers moderados ({len(moderados)})')
ax1.hist(extremos,  bins=10, color='red',       alpha=0.9,  label=f'Outliers extremos ({len(extremos)})')

# Líneas verticales para los límites de Tukey.
# linestyle='--' = línea discontinua, '-.' = punto-raya, ':' = punteada.
ax1.axvline(lim_inf_15, color='orange', linestyle='--', lw=2, label=f'±1.5 IQR ({lim_inf_15:.0f} / {lim_sup_15:.0f} cm)')
ax1.axvline(lim_sup_15, color='orange', linestyle='--', lw=2)
ax1.axvline(lim_inf_30, color='red',    linestyle=':',  lw=2, label=f'±3.0 IQR ({lim_inf_30:.0f} / {lim_sup_30:.0f} cm)')
ax1.axvline(lim_sup_30, color='red',    linestyle=':',  lw=2)
ax1.axvline(media,      color='black',  linestyle='-',  lw=1.5, label=f'Media ({media:.1f} cm)')

ax1.set_title('Histograma completo\n(con todos los outliers)', fontsize=11, fontweight='bold')
ax1.set_xlabel('Altura (cm)', fontsize=11)
ax1.set_ylabel('Frecuencia', fontsize=11)
ax1.legend(fontsize=8.5, loc='upper right')
ax1.spines[['top', 'right']].set_visible(False)

# ── Gráfico 2: Boxplot ────────────────────────────────────────────────────────
# El boxplot resume en un solo dibujo: mediana, Q1, Q3 y bigotes (=1.5×IQR).
# Los puntos fuera de los bigotes son exactamente los outliers del factor 1.5.
# La caja central contiene el 50% central de los datos (el IQR).
# Alternativa más bonita: seaborn.boxplot() con paletas de color automáticas.
ax2 = axes[1]
bp = ax2.boxplot(
    df['altura'],
    patch_artist=True,                              # rellena la caja con color
    boxprops=dict(facecolor='lightblue', color='steelblue'),
    medianprops=dict(color='red', linewidth=2.5),   # línea roja = mediana
    whiskerprops=dict(color='steelblue', lw=1.5),   # bigotes = límites 1.5×IQR
    flierprops=dict(marker='o', color='red', markersize=5, alpha=0.6),  # outliers
    widths=0.5
)

# Añadimos la línea del factor 3.0 que el boxplot estándar no dibuja.
ax2.axhline(lim_inf_30, color='red',   linestyle=':', lw=2, label=f'Límite 3.0 IQR inferior ({lim_inf_30:.0f} cm)')
ax2.axhline(lim_sup_30, color='red',   linestyle=':', lw=2, label=f'Límite 3.0 IQR superior ({lim_sup_30:.0f} cm)')
ax2.axhline(media,      color='black', linestyle='--', lw=1.5, label=f'Media ({media:.1f} cm)')

ax2.set_title('Boxplot (diagrama de caja)\nLos puntos rojos son outliers ×1.5', fontsize=11, fontweight='bold')
ax2.set_ylabel('Altura (cm)', fontsize=11)
ax2.set_xticks([])
ax2.legend(fontsize=8.5, loc='upper right')
ax2.spines[['top', 'right']].set_visible(False)

# ── Gráfico 3: Zoom — solo datos normales con curva gaussiana ────────────────
# Al eliminar los outliers extremos vemos la distribución normal real.
# Contexto: así es como deberían verse los datos si no hubiera errores de medición.
# La curva gaussiana teórica nos permite comprobar si los datos son realmente normales.
# Alternativa: from scipy.stats import probplot → gráfico Q-Q más técnico.
ax3 = axes[2]

from scipy.stats import norm  # para dibujar la curva teórica

# Usamos solo los datos sin outliers extremos para el zoom.
datos_zoom = df[df['categoria'] != 'extremo']['altura']
media_zoom  = datos_zoom.mean()
sigma_zoom  = datos_zoom.std()

ax3.hist(datos_zoom, bins=40, color='steelblue', alpha=0.7, density=True,
         label='Datos sin outliers extremos')

# density=True normaliza el histograma para que el área total = 1,
# lo que permite superponerlo con la curva de densidad de probabilidad.
x = np.linspace(datos_zoom.min(), datos_zoom.max(), 300)
ax3.plot(x, norm.pdf(x, media_zoom, sigma_zoom),
         color='darkblue', lw=2.5, label='Curva normal teórica')

# Sombreamos el rango ±1σ (donde debería estar el 68% de los datos).
ax3.axvline(lim_inf_15, color='orange', linestyle='--', lw=2, label=f'Límite 1.5 IQR')
ax3.axvline(lim_sup_15, color='orange', linestyle='--', lw=2)
ax3.axvline(media_zoom,  color='black', linestyle='-',  lw=1.5, label=f'Media ({media_zoom:.1f} cm)')

ax3.set_title('Zoom: datos sin outliers extremos\ncon curva normal teórica', fontsize=11, fontweight='bold')
ax3.set_xlabel('Altura (cm)', fontsize=11)
ax3.set_ylabel('Densidad', fontsize=11)
ax3.legend(fontsize=8.5)
ax3.spines[['top', 'right']].set_visible(False)

plt.tight_layout()
plt.show()

print("\n  &#x2705; Análisis completado.")

Publicado por

Juan Pablo Fuentes

Formador de programación y bases de datos