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↔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 ✅ Análisis completado.")