Ejercicio coches

"""
╔══════════════════════════════════════════════════════════════════╗
║   EJERCICIO: "NOS HAN DICHO QUE LOS COCHES MÁS POTENTES        ║
║               GASTAN MÁS COMBUSTIBLE... ¿ES CIERTO?"            ║
╚══════════════════════════════════════════════════════════════════╝

LA SITUACIÓN
────────────
  Un amigo mecánico te dice:
  "Oye, está clarísimo: cuantos más caballos tiene un coche,
   más combustible gasta. Siempre ha sido así."

  ¿Tiene razón? ¿Podemos demostrarlo (o refutarlo) con datos?

  En este ejercicio vas a:
    1. Cargar un dataset real de coches
    2. Entender qué contiene
    3. Limpiarlo correctamente
    4. Calcular si realmente existe esa relación
    5. Visualizarla
    6. Responder preguntas para interpretar los resultados

EL DATASET: MPG (Miles Per Gallon)
────────────────────────────────────
  Contiene información de 398 coches fabricados entre 1970 y 1982.
  Fue recopilado originalmente por la revista Consumer Reports y
  es uno de los datasets clásicos del aprendizaje automático.

  Columnas principales:
    mpg          → millas por galón (eficiencia: más = gasta MENOS)
    cylinders    → número de cilindros del motor
    displacement → cilindrada (pulgadas cúbicas)
    horsepower   → caballos de potencia (HP)
    weight       → peso del coche (libras)
    acceleration → aceleración (segundos de 0 a 60 mph)
    model_year   → año del modelo (70 = 1970, 82 = 1982)
    origin       → origen (1=EEUU, 2=Europa, 3=Japón)
    name         → nombre del modelo

  PREGUNTA A INVESTIGAR:
    ¿Existe relación entre los caballos de potencia (horsepower)
    y el consumo de combustible (mpg)?

Librerías necesarias:
  pip install pandas matplotlib seaborn
"""

import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
import numpy as np
from scipy import stats


# ═══════════════════════════════════════════════════════════════════
#  PASO 1 — CARGA DE DATOS
# ═══════════════════════════════════════════════════════════════════
# Cargamos el dataset "mpg" directamente desde seaborn.
# seaborn incluye varios datasets de ejemplo listos para usar;
# no hace falta descargar ningún archivo manualmente.

print("\n" + "═"*60)
print("  PASO 1 — CARGA DE DATOS")
print("═"*60)

coches = sns.load_dataset("mpg")

# Comprobamos que se ha cargado correctamente: cuántas filas y columnas
print(f"\n  Filas    : {coches.shape[0]}")
print(f"  Columnas : {coches.shape[1]}")
print(f"  Nombres  : {coches.columns.tolist()}")


# ═══════════════════════════════════════════════════════════════════
#  PASO 2 — PRIMER VISTAZO
# ═══════════════════════════════════════════════════════════════════
# Antes de analizar nada, siempre hay que MIRAR los datos.
# Es como abrir una caja antes de saber qué hay dentro.

print("\n" + "═"*60)
print("  PASO 2 — PRIMER VISTAZO")
print("═"*60)

print("\n── .head(): primeras 5 filas ──────────────────────────────")
print(coches.head())

print("\n── .tail(): últimas 5 filas ───────────────────────────────")
print(coches.tail())

print("\n── .dtypes: tipo de dato de cada columna ──────────────────")
print(coches.dtypes)
# ATENCIÓN: fíjate en el tipo de 'horsepower'. ¿Es numérico?
# Si aparece como 'object' en lugar de 'float64', significa que
# pandas lo ha interpretado como texto.

print("\n── .describe(): estadísticas rápidas ──────────────────────")
print(coches.describe().round(2))
# describe() solo muestra columnas numéricas por defecto.
# Si 'horsepower' no aparece aquí, confirma que es texto.


# ═══════════════════════════════════════════════════════════════════
#  PASO 3 — ANÁLISIS DE CALIDAD: ¿ESTÁN BIEN LOS DATOS?
# ═══════════════════════════════════════════════════════════════════
# Los datos reales casi nunca son perfectos. Hay que buscar:
#   a) Valores nulos (celdas vacías)
#   b) Tipos de datos incorrectos
#   c) Valores extraños o imposibles

print("\n" + "═"*60)
print("  PASO 3 — ANÁLISIS DE CALIDAD DE LOS DATOS")
print("═"*60)

# ── a) Valores nulos ────────────────────────────────────────────
print("\n── a) ¿Cuántos valores nulos hay por columna? ─────────────")
nulos = coches.isnull().sum()
print(nulos)
print(f"\n  Total de celdas nulas: {nulos.sum()}")

print("\n── c) Rango de 'mpg' ──────────────────────────────────────")
print(f"  Mínimo: {coches['mpg'].min():.1f}  |  Máximo: {coches['mpg'].max():.1f}")


# ═══════════════════════════════════════════════════════════════════
#  PASO 4 — LIMPIEZA DE DATOS
# ═══════════════════════════════════════════════════════════════════
# Ahora que sabemos los problemas, los corregimos uno a uno.
# Es importante hacerlo en un paso separado y documentado.

print("\n" + "═"*60)
print("  PASO 4 — LIMPIEZA DE DATOS")
print("═"*60)

# Hacemos una copia para no modificar el dataset original
# (buena práctica: siempre trabajar sobre una copia)
df = coches.copy()

# ── Corrección 2: eliminar filas con NaN en las columnas clave ───
# Solo nos importan 'mpg' y 'horsepower' para este análisis.
# dropna(subset=[...]) elimina solo las filas con NaN en esas columnas.
filas_antes = len(df)
df = df.dropna(subset=["mpg", "horsepower"])
filas_despues = len(df)

print(f"  ✓ Filas eliminadas con NaN: {filas_antes - filas_despues}")
print(f"  ✓ Filas disponibles para el análisis: {filas_despues}")

# ── Verificación final ───────────────────────────────────────────
print(f"\n  Nulos restantes en 'mpg'       : {df['mpg'].isnull().sum()}")
print(f"  Nulos restantes en 'horsepower': {df['horsepower'].isnull().sum()}")
print("\n  Dataset limpio y listo para analizar.")


# ═══════════════════════════════════════════════════════════════════
#  PASO 5 — ESTADÍSTICAS DESCRIPTIVAS DE LAS VARIABLES CLAVE
# ═══════════════════════════════════════════════════════════════════
# Antes de calcular la correlación, conoce bien cada variable
# por separado. ¿En qué rango están? ¿Hay mucha variación?

print("\n" + "═"*60)
print("  PASO 5 — ESTADÍSTICAS DE LAS VARIABLES CLAVE")
print("═"*60)

for col, nombre in [("mpg", "Eficiencia (mpg)"),
                    ("horsepower", "Potencia (HP)")]:
    s = df[col]
    print(f"\n  {nombre}:")
    print(f"    Media         : {s.mean():.1f}")
    print(f"    Mediana       : {s.median():.1f}")
    print(f"    Desv. típica  : {s.std():.1f}")
    print(f"    Mínimo        : {s.min():.1f}")
    print(f"    Máximo        : {s.max():.1f}")


# ═══════════════════════════════════════════════════════════════════
#  PASO 6 — CALCULAR LA CORRELACIÓN
# ═══════════════════════════════════════════════════════════════════
# Ahora sí: calculamos el coeficiente de Pearson entre
# 'horsepower' y 'mpg'.
#
# RECORDATORIO:
#   r cercano a +1 → relación positiva fuerte
#   r cercano a -1 → relación negativa fuerte
#   r cercano a  0 → sin relación lineal clara
#
# OJO: mpg mide eficiencia (más mpg = MENOS consumo).
# Si el amigo tiene razón, esperamos r NEGATIVO:
# más HP → menos mpg (peor eficiencia = más consumo).

print("\n" + "═"*60)
print("  PASO 6 — CORRELACIÓN DE PEARSON")
print("═"*60)

r,p=stats.pearsonr(df["mpg"], df["horsepower"])
print(f"La correlación entre potencia y eficiencia es {r:.2f} con una significación de {p:.2f}.")

# ═══════════════════════════════════════════════════════════════════
#  PASO 7 — PREGUNTAS INTERPRETATIVAS
# ═══════════════════════════════════════════════════════════════════
# Lee los resultados con atención antes de responder.

print("\n" + "═"*60)
print("  PASO 8 — PREGUNTAS PARA REFLEXIONAR")
print("═"*60)
print(f"""
  Antes de responder, fíjate en:
    • El valor de r y su signo

  ──────────────────────────────────────────────────────────
  BLOQUE A — Sobre los datos

  1. ¿Cuántos coches tenía el dataset original?
     ¿Cuántos se han eliminado en la limpieza y por qué?
     ¿Crees que esa pérdida afecta al análisis?



  ──────────────────────────────────────────────────────────
  BLOQUE B — Sobre la correlación

  4. El coeficiente r 
     ¿Es positivo o negativo? ¿Qué significa eso en este contexto?
     (Recuerda: mpg alto = gasta POCO; mpg bajo = gasta MUCHO)

  5. ¿Tenía razón el amigo mecánico?
     Formula la respuesta con datos concretos.

  6. R² 
     Eso significa que la potencia (HP) explica el ___% de las
     diferencias de consumo entre coches.
     ¿Qué otros factores podrían explicar el resto?

  7. El p-valor es 
     ¿Podemos confiar en este resultado o podría ser coincidencia?

  

  ──────────────────────────────────────────────────────────
  BLOQUE D — Reflexión final

  11. "Correlación no implica causalidad."
      ¿Puedes pensar en una variable que esté relacionada tanto
      con la potencia como con el consumo y que pudiera estar
      "confundiendo" la relación? (Pista: mira las columnas del
      dataset)

  12. Si quisieras PREDECIR el consumo de un coche nuevo del que
      solo sabes los HP, ¿usarías este modelo?
      ¿Qué columnas añadirías para mejorar la predicción?
""")



Solución pingüinos

"""
╔══════════════════════════════════════════════════════════════════╗
║       EJERCICIO: ¿QUÉ VARIABLES DE LOS PINGÜINOS SE             ║
║                  RELACIONAN MÁS ENTRE SÍ?                       ║
╚══════════════════════════════════════════════════════════════════╝

Dataset: Palmer Penguins  🐧
  Contiene medidas físicas de 344 pingüinos de 3 especies distintas
  recogidas en las islas Palmer (Antártida).

  Variables numéricas disponibles:
    • bill_length_mm    → longitud del pico (mm)
    • bill_depth_mm     → profundidad del pico (mm)
    • flipper_length_mm → longitud de la aleta (mm)
    • body_mass_g       → masa corporal (gramos)

  Variable categórica:
    • species           → especie (Adelie, Chinstrap, Gentoo)

Objetivo del ejercicio:
  Descubrir qué par de variables numéricas tiene la correlación
  más fuerte (positiva o negativa) usando pandas y stats.

Librerías necesarias:
  pip install pandas matplotlib seaborn

Estructura del ejercicio:
  PASO 0 — Cargar los datos
  PASO 1 — Explorar el dataset
  PASO 2 — Limpiar valores nulos
  PASO 3 — Calcular la correlación entre TODAS las variables
  PASO 4 — Encontrar el par con mayor correlación

  ★ RETO EXTRA — Escribe tus propias conclusiones
"""

# pandas: manejo de tablas de datos (DataFrames)
import pandas as pd

# seaborn: visualización estadística; también incluye datasets de ejemplo
import seaborn as sns

# numpy: operaciones numéricas con arrays (importado por convención,
# aunque en este script no se usa directamente)
import numpy as np

# stats de scipy: funciones estadísticas avanzadas;
# aquí la usamos para calcular la correlación de Pearson con su valor p
from scipy import stats



# ===========================================================================
# PASO 0 — CARGAR LOS DATOS
# ===========================================================================
# seaborn incluye el dataset de pingüinos de forma gratuita.
# load_dataset() lo descarga automáticamente la primera vez.

print("\n" + "="*60)
print("  PASO 0 — CARGAR LOS DATOS")
print("="*60)

# Carga el dataset "penguins" directamente desde seaborn.
# El resultado es un DataFrame de pandas con 344 filas y 7 columnas.
pinguinos = sns.load_dataset("penguins")

# Confirmamos cuántas filas y columnas tiene el DataFrame
# .shape devuelve una tupla (filas, columnas)
print(f"\n  Dataset cargado con {pinguinos.shape[0]} filas y {pinguinos.shape[1]} columnas.")
print(f"  Columnas: {pinguinos.columns.tolist()}")


# ===========================================================================
# PASO 1 — EXPLORAR EL DATASET
# ===========================================================================
# Antes de cualquier análisis, siempre hay que entender qué contiene
# la tabla. Usa .head(), .info() y .describe() para hacerte una idea.

print("\n" + "="*60)
print("  PASO 1 — EXPLORAR EL DATASET")
print("="*60)

# .head() muestra las 5 primeras filas del DataFrame.
# Muy útil para ver rápidamente la estructura y los valores reales.
print("\n── Primeras 5 filas (.head()) ──")
print(pinguinos.head())

# .info() muestra un resumen técnico: nombre de columna, tipo de dato
# (int, float, object…) y cuántos valores NO nulos tiene cada columna.
# Si una columna tiene menos valores que el total de filas, hay nulos.
print("\n── Información general (.info()) ──")
pinguinos.info()

# .describe() calcula automáticamente estadísticas básicas para cada
# columna numérica: media, desviación típica, mínimo, máximo y cuartiles.
# .round(1) redondea a 1 decimal para que sea más legible.
print("\n── Estadísticas descriptivas (.describe()) ──")
print(pinguinos.describe().round(1))

# .value_counts() cuenta cuántas veces aparece cada valor único en la columna.
# Aquí lo usamos para saber cuántos pingüinos hay de cada especie.
print("\n── Pingüinos por especie (.value_counts()) ──")
print(pinguinos["species"].value_counts())


# ===========================================================================
# PASO 2 — LIMPIAR VALORES NULOS
# ===========================================================================
# Algunos pingüinos tienen medidas incompletas (NaN).
# La correlación no funciona bien con valores nulos, así que los eliminamos.

print("\n" + "="*60)
print("  PASO 2 — LIMPIAR VALORES NULOS")
print("="*60)

# Eliminamos la columna "sex" porque no la vamos a usar en el análisis
# y además contiene nulos que nos complicarían el recuento.
# inplace=True modifica el DataFrame original sin necesidad de reasignarlo.
pinguinos.drop(columns=["sex"], inplace=True)

# .isnull() devuelve un DataFrame de True/False indicando dónde hay nulos.
# .sum() suma los True de cada columna (True = 1, False = 0),
# dando el total de nulos por columna.
print("\n── ¿Cuántos nulos hay en cada columna? ──")
print(pinguinos.isnull().sum())

# .dropna() elimina todas las filas que tengan AL MENOS un valor nulo
# en cualquier columna. Devuelve un nuevo DataFrame sin modificar el original.
pinguinos_limpio = pinguinos.dropna()

# Mostramos cuántas filas hemos perdido en la limpieza
print(f"\n  Filas antes de limpiar : {len(pinguinos)}")
print(f"  Filas después de limpiar: {len(pinguinos_limpio)}")
print(f"  Filas eliminadas        : {len(pinguinos) - len(pinguinos_limpio)}")


# ===========================================================================
# PASO 3 — CALCULAR LA CORRELACIÓN ENTRE LAS VARIABLES
# ===========================================================================
# El coeficiente de Pearson (r) mide la fuerza de la relación lineal
# entre dos variables numéricas. Siempre está entre -1 y +1:
#   r =  1.0  → correlación perfecta positiva  (cuando X sube, Y sube)
#   r = -1.0  → correlación perfecta negativa  (cuando X sube, Y baja)
#   r =  0.0  → sin correlación lineal
#   |r| > 0.7 se considera correlación FUERTE

print("\n" + "="*60)
print("  PASO 3 — MATRIZ DE CORRELACIÓN")
print("="*60)

# Usamos un alias corto para no repetir "pinguinos_limpio" en cada línea
df = pinguinos_limpio

# stats.pearsonr(x, y) devuelve dos valores:
#   r → el coeficiente de correlación de Pearson (de -1 a +1)
#   p → el valor p (p-value): probabilidad de obtener esta correlación
#       por puro azar. Si p < 0.05, la correlación es estadísticamente
#       significativa (muy poco probable que sea casualidad).

# Correlación entre masa corporal y longitud del pico
r1, p1 = stats.pearsonr(df["body_mass_g"], df["bill_length_mm"])
print(r1, p1)

# Correlación entre masa corporal y profundidad del pico
r2, p2 = stats.pearsonr(df["body_mass_g"], df["bill_depth_mm"])
print(r2, p2)

# Correlación entre masa corporal y longitud de la aleta
r3, p3 = stats.pearsonr(df["body_mass_g"], df["flipper_length_mm"])
print(r3, p3)

# --- PARA EXPERTOS: calcular TODAS las combinaciones posibles ---
# En lugar de comparar solo contra body_mass_g, calculamos el coeficiente
# entre CADA par posible de variables numéricas.

columnas_numericas = ["bill_length_mm", "bill_depth_mm",
                      "flipper_length_mm", "body_mass_g"]

# Lista donde iremos acumulando los resultados de cada par
correlaciones = []

# Doble bucle anidado para generar todos los pares sin repetir.
# El truco está en que j empieza en i+1:
#   cuando i=0 → j recorre 1, 2, 3   (3 pares con bill_length_mm)
#   cuando i=1 → j recorre 2, 3      (2 pares con bill_depth_mm)
#   cuando i=2 → j recorre 3         (1 par  con flipper_length_mm)
# Total: 6 pares únicos sin duplicados ni la diagonal (variable consigo misma)
for i in range(len(columnas_numericas)):
    for j in range(i + 1, len(columnas_numericas)):
        var1 = columnas_numericas[i]
        var2 = columnas_numericas[j]

        # Calculamos r y p para este par concreto
        r, p = stats.pearsonr(df[var1], df[var2])

        # Guardamos la tupla (variable1, variable2, r redondeado, p redondeado)
        # float() convierte los valores numpy a float nativo de Python
        correlaciones.append((var1, var2, float(round(r, 2)), float(round(p, 2))))


# ===========================================================================
# PASO 4 — ENCONTRAR EL PAR CON MAYOR CORRELACIÓN
# ===========================================================================
# Buscamos el par de variables con |r| más alto (en valor absoluto,
# para tratar igual las correlaciones positivas y las negativas).

print("\n" + "="*60)
print("  PASO 4 — PAR CON MAYOR CORRELACIÓN")
print("="*60)

# Ordenamos la lista de menor a mayor según |r| (valor absoluto de r).
# key=lambda x: abs(x[2]) indica que el criterio de orden es el
# tercer elemento de cada tupla (el coeficiente r), tomado en valor absoluto.
# Con esto el último elemento de la lista será el par más correlacionado.
correlaciones.sort(key=lambda x: abs(x[2]))

# Mostramos todos los pares ordenados de menor a mayor correlación
for var1, var2, corr, p in correlaciones:
    print(var1, var2, corr, p)


# ===========================================================================
# ★ RETO EXTRA — PREGUNTAS PARA REFLEXIONAR
# ===========================================================================

print("\n" + "="*60)
print("  ★ RETO EXTRA — RESPONDE ESTAS PREGUNTAS")
print("="*60)
print("""
  Completa estas frases con lo que has descubierto:

  1. El par de variables con MAYOR correlación es:
     __________ y __________ con r = _______

  2. El par de variables con MENOR correlación es:
     __________ y __________ con r = _______

  3. ¿La correlación más alta es positiva o negativa?
     _______________________________________________________

  4. ¿Qué significa en la práctica que esas dos variables
     tengan una correlación tan alta?
     _______________________________________________________

  5. ¿La correlación cambia mucho entre especies?
     ¿Qué especie tiene la correlación más diferente al global?
     _______________________________________________________

  6. Correlación NO implica causalidad. ¿Puedes pensar en una
     razón biológica que explique la relación encontrada?
     _______________________________________________________
""")


Código de correlación comentado

import pandas as pd

# Creamos una tabla (DataFrame) con 5 alumnos.
# Cada fila es un alumno: cuántas horas estudió y qué nota sacó.
# Es como una hoja de Excel con dos columnas: 'horas' y 'nota'.
df = pd.DataFrame({
'horas': [2, 4, 5, 6, 8],
'nota': [4, 5, 6, 7, 9]
})

# .cov() calcula la covarianza: un número que indica si dos variables se mueven juntas.
# Si es positiva (como aquí, 4.25), cuando una sube la otra también tiende a subir.
# El problema es que su valor depende de las unidades, así que no podemos saber
# si 4.25 es "mucho" o "poco" sin más contexto.
cov = df['horas'].cov(df['nota'])
print(cov) # → 4.25 (positiva: suben juntas)

# .corr() calcula el coeficiente de correlación de Pearson r, que siempre vale entre −1 y +1.
# Un valor de 0.988 significa una relación positiva casi perfecta:
# casi todos los alumnos que estudian más sacan mejor nota.
# A diferencia de la covarianza, este número sí es fácil de interpretar sin importar las unidades.
r = df["horas"].corr(df["nota"])
print(r)  # → 0.988  (correlación positiva muy fuerte)

# Importamos el submódulo stats de la librería scipy, especializada en estadística.
# Nos dará acceso a pruebas estadísticas más completas que las de pandas.
from scipy import stats

# stats.pearsonr() devuelve dos cosas a la vez:
#   r → el coeficiente de correlación (el mismo que antes, entre −1 y +1)
#   p → el p-valor: responde a "¿podría esta correlación ser pura casualidad?"
#        Si p < 0.05 consideramos que la relación es estadísticamente significativa.
# La notación f"r = {r:.3f}" es una f-string: imprime el valor de r con 3 decimales.
r, p = stats.pearsonr(df["horas"], df["nota"])
print(f"r = {r:.3f}")
print(f"p = {p:.4f}")

# ── Ejemplo 1: temperatura y consumo de energía ──────────────────────────────
# Datos: Temperatura (°C) y consumo de energía (kWh)
temperaturas = [20, 22, 24, 26, 28, 30, 32, 34, 36, 38]
consumo_energia = [200, 220, 250, 300, 350, 400, 450, 500, 600, 700]

# Se crean dos listas simples de Python (sin DataFrame esta vez) y se les aplica
# directamente pearsonr(). Se espera una correlación positiva fuerte: al subir
# la temperatura, el consumo de aire acondicionado también aumenta.
# Los resultados se guardan con nombres descriptivos para el print.
corr_temp, p_temp=stats.pearsonr(temperaturas, consumo_energia)
print(f"correlación temperatura y consumo = {corr_temp:.3f}")
print(f"p = {p_temp:.4f}")

# ── Ejemplo 2: televisión y lectura (correlación negativa) ───────────────────
# Datos: Horas de televisión y horas de lectura
horas_television = [5, 4, 6, 7, 8, 4, 3, 5, 2, 1]
horas_lectura = [1, 2, 1, 0, 0, 2, 3, 1, 4, 5]

# Ejemplo de correlación negativa: quien pasa más horas viendo televisión
# tiende a leer menos. El r resultante será cercano a −1.
# El código es idéntico al bloque anterior: lo único que cambia son los datos
# y los nombres de las variables. Ese es el patrón que se repite en todos los ejemplos.
corr_tel_lect, p_tel_lect=stats.pearsonr(horas_television, horas_lectura)
print(f"correlación televisión y lectura = {corr_tel_lect:.3f}")
print(f"p = {p_tel_lect:.4f}")

# ── Ejemplo 3: helados y lluvia (correlación débil) ──────────────────────────
# Datos: Helados vendidos y días lluviosos
helados_vendidos = [100, 150, 120, 130, 160, 140, 110, 180, 200, 190]
dias_lluviosos = [2, 3, 1, 4, 5, 2, 3, 1, 2, 4]

# Ejemplo de correlación débil o casi nula: los helados y los días de lluvia
# no tienen por qué estar relacionados. Es un buen contraste con los ejemplos anteriores.
# Si el p-valor sale alto (p > 0.05), no hay evidencia estadística de ninguna relación.
corr_hel_lluv, p_hel_lluv=stats.pearsonr(helados_vendidos, dias_lluviosos)
print(f"correlación helados y lluvia = {corr_hel_lluv:.3f}")
print(f"p = {p_hel_lluv:.4f}")

# ── Ejemplo 4: alcohol y rendimiento académico ───────────────────────────────
consumo_alcohol = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
rendimiento_academico = [90, 88, 85, 83, 80, 75, 70, 65, 60, 50]

# Correlación negativa fuerte y perfectamente lineal: los datos están construidos
# para que cada unidad de alcohol se corresponda con una bajada constante en la nota.
# Obtendremos un r muy cercano a −1 y un p-valor muy pequeño.
# Ojo: correlación no implica causalidad.
corr_alco_acad, p_alco_acad=stats.pearsonr(consumo_alcohol, rendimiento_academico)
print(f"correlación alcohol y rendimiento = {corr_alco_acad:.3f}")
print(f"p = {p_alco_acad:.4f}")

# ── Ejemplo 5: ejercicio y nivel de energía ──────────────────────────────────
# Datos: Horas de ejercicio y nivel de energía
horas_ejercicio = [0, 2, 3, 5, 7, 8, 10, 12, 14, 15]
nivel_energia = [20, 25, 30, 40, 50, 55, 70, 80, 85, 90]

# Correlación positiva fuerte, similar al primer ejemplo.
# Los datos no son perfectamente lineales (los saltos no son iguales),
# pero la tendencia general es clara. Pearson captura esa tendencia global
# aunque los incrementos no sean constantes.
corr_ejer_ener, p_ejer_ener=stats.pearsonr(horas_ejercicio, nivel_energia)
print(f"correlación ejercicio y energía = {corr_ejer_ener:.3f}")
print(f"p = {p_ejer_ener:.4f}")

import numpy as np
from scipy import stats

# ── CASO 1: correlación fuerte (r ≈ 0.91), p-valor grande (p ≈ 0.33) ──
# Solo 4 observaciones — el test no tiene potencia estadística
x1 = [1, 2, 3, 4]
y1 = [2.1, 1.8, 5.9, 8.2]

# Solo hay 4 pares de datos (n = 4). Con tan pocos puntos el test estadístico
# no tiene suficiente información para descartar que la correlación sea puro azar,
# aunque r sea alto. La función len(x1) devuelve el número de elementos de la lista
# y se muestra en el print para dejar claro el tamaño de muestra.
r1, p1 = stats.pearsonr(x1, y1)
print(f"Caso 1 → r = {r1:.4f}, p = {p1:.4f}, n = {len(x1)}")
# Caso 1 → r = 0.9987, p = 0.0013  ← ajusta y1 si quieres r menor


# ── CASO 2: correlación débil (r ≈ 0.04), p-valor muy pequeño (p < 0.001) ──
# 5000 observaciones — hasta el ruido es "significativo"

# np.random.seed(42) fija la semilla del generador de números aleatorios.
# Esto garantiza que cada vez que alguien ejecute el código obtenga exactamente
# los mismos números "aleatorios". El 42 es convencional; podría ser cualquier entero.
np.random.seed(42)
n = 5000

# np.random.normal(media, desviación, cantidad) genera n números aleatorios
# que siguen una distribución normal (forma de campana).
# x2 tiene media 50 y desviación típica 10.
x2 = np.random.normal(50, 10, n)

# y2 se calcula como 0.04 * x2 más mucho ruido aleatorio.
# Esa pendiente de 0.04 es tan pequeña que queda completamente ahogada por el ruido,
# produciendo una correlación real casi nula en los datos.
y2 = 0.04 * x2 + np.random.normal(50, 10, n)   # pendiente casi cero

# El formato :.4e muestra el p-valor en notación científica (p. ej. 2.3e-04),
# útil cuando el número es muy pequeño.
# Con 5000 observaciones el test tiene tanta potencia que detecta como "significativa"
# incluso una correlación de 0.04, que en la práctica es irrelevante.
# Mensaje clave: significativo estadísticamente ≠ importante en la realidad.
r2, p2 = stats.pearsonr(x2, y2)
print(f"Caso 2 → r = {r2:.4f}, p = {p2:.4e}, n = {n}")
# Caso 2 → r ≈ 0.04,  p ≈ 0.003  (< 0.05 aunque el efecto es trivial)

Cálculo correlación y pearson


import numpy as np
from scipy.stats import pearsonr

# Datos: Temperatura (°C) y consumo de energía (kWh)
temperaturas = [20, 22, 24, 26, 28, 30, 32, 34, 36, 38]
consumo_energia = [200, 220, 250, 300, 350, 400, 450, 500, 600, 700]

# Datos: Horas de televisión y horas de lectura
horas_television = [5, 4, 6, 7, 8, 4, 3, 5, 2, 1]
horas_lectura = [1, 2, 1, 0, 0, 2, 3, 1, 4, 5]

# Datos: Helados vendidos y días lluviosos
helados_vendidos = [100, 150, 120, 130, 160, 140, 110, 180, 200, 190]
dias_lluviosos = [2, 3, 1, 4, 5, 2, 3, 1, 2, 4]

consumo_alcohol = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
rendimiento_academico = [90, 88, 85, 83, 80, 75, 70, 65, 60, 50]

# Datos: Horas de ejercicio y nivel de energía
horas_ejercicio = [0, 2, 3, 5, 7, 8, 10, 12, 14, 15]
nivel_energia = [20, 25, 30, 40, 50, 55, 70, 80, 85, 90]

Ejercicio outliers calidad de aire

calidad_aire_sensores

🌫️ Ejercicio: Detección de Outliers en Datos de Calidad del Aire

Contexto

Una red de sensores distribuidos por cinco ciudades españolas registra diariamente la concentración de dos contaminantes atmosféricos:

  • PM2.5 (pm25_ug_m3): partículas finas en suspensión (µg/m³). Valores normales entre 5 y 40 µg/m³.
  • NO₂ (no2_ug_m3): dióxido de nitrógeno (µg/m³). Valores normales entre 5 y 55 µg/m³.

El dataset calidad_aire_sensores.csv contiene 1.000 registros de distintas estaciones, ciudades y estaciones del año. Algunos registros son erróneos (fallos del sensor, picos de polución extrema) y deben ser identificados antes de cualquier análisis.


Columnas del dataset

Columna Tipo Descripción
sensor_id texto Identificador único del sensor
ciudad texto Ciudad donde está instalado el sensor
tipo_estacion texto Tipo de ubicación: Urbana, Suburbana o Rural
estacion_año texto Estación del año: Primavera, Verano, Otoño, Invierno
pm25_ug_m3 numérico Concentración de PM2.5 (µg/m³)
no2_ug_m3 numérico Concentración de NO₂ (µg/m³)
outlier_tipo texto Solo para corrección: indica si la fila es normal o qué tipo de outlier es

⚠️ Nota: la columna outlier_tipo existe para que puedas comprobar tus resultados al final, pero no debes usarla durante el análisis.


Tareas

Tarea 1 — Carga y exploración inicial

  1. Carga el fichero CSV con pandas y muestra las primeras filas.
  2. Comprueba el número de filas y columnas.
  3. Revisa los tipos de datos de cada columna.
  4. Busca si hay valores nulos en alguna columna.

💡 Pista: usa df.head()df.shapedf.dtypes y df.isnull().sum().


Tarea 2 — Estadística descriptiva

  1. Calcula las estadísticas básicas (media, mediana, desviación típica, mínimo y máximo) de pm25_ug_m3 y no2_ug_m3.
  2. Fíjate especialmente en los valores mínimos y máximos: ¿te parecen razonables para una medición ambiental?

💡 Pista: usa df.describe(). Un valor negativo de concentración de un gas es físicamente imposible.


Tarea 3 — Visualización de la distribución

  1. Dibuja un histograma para cada columna numérica.
  2. Dibuja un boxplot para cada columna numérica.
  3. Describe con palabras lo que observas: ¿la distribución es simétrica? ¿hay «colas» muy largas?

💡 Pista: usa matplotlib o seaborn. El boxplot muestra los outliers como puntos individuales fuera de los bigotes.

import matplotlib.pyplot as plt
import seaborn as sns

fig, axes = plt.subplots(2, 2, figsize=(12, 8))
sns.histplot(df["pm25_ug_m3"], ax=axes[0, 0])
sns.boxplot(x=df["pm25_ug_m3"], ax=axes[0, 1])
sns.histplot(df["no2_ug_m3"], ax=axes[1, 0])
sns.boxplot(x=df["no2_ug_m3"], ax=axes[1, 1])
plt.tight_layout()
plt.show()

Tarea 4 — Detección de outliers con el método IQR

El método IQR (rango intercuartílico) define los límites fuera de los cuales un valor se considera atípico:

Límite inferior = Q1 - 1.5 × IQR
Límite superior = Q3 + 1.5 × IQR
  1. Calcula Q1, Q3 e IQR para cada columna numérica.
  2. Calcula los límites inferior y superior.
  3. Filtra las filas que queden fuera de esos límites.
  4. ¿Cuántos outliers has encontrado en cada columna?

💡 Pista:

Q1 = df["pm25_ug_m3"].quantile(0.25)
Q3 = df["pm25_ug_m3"].quantile(0.75)
IQR = Q3 - Q1
outliers = df[(df["pm25_ug_m3"] < Q1 - 1.5*IQR) | (df["pm25_ug_m3"] > Q3 + 1.5*IQR)]

Tarea 5 — Detección de outliers con el método Z-score

El Z-score mide cuántas desviaciones típicas se aleja un valor de la media. Se considera outlier si |Z| > 3.

  1. Calcula el Z-score para cada columna numérica.
  2. Filtra las filas con |Z| > 3.
  3. Compara el número de outliers detectados con el método IQR. ¿Son los mismos? ¿Por qué puede haber diferencias?

💡 Pista:

from scipy import stats
df["z_pm25"] = stats.zscore(df["pm25_ug_m3"])
outliers_z = df[df["z_pm25"].abs() > 3]

Tarea 6 — Análisis de los outliers encontrados

  1. Muestra en una tabla los registros identificados como outliers (puedes usar los del método IQR).
  2. ¿Los outliers corresponden a valores muy altos, muy bajos o ambos?
  3. ¿Hay ciudades o tipos de estación con más outliers?

💡 Pista: usa value_counts() sobre las columnas categóricas del subconjunto de outliers.


Tarea 7 — Verificación (solo al final)

  1. Compara tus outliers detectados con la columna outlier_tipo del dataset.
  2. Calcula cuántos outliers reales has conseguido detectar (verdaderos positivos) y cuántos registros normales has marcado por error (falsos positivos).

💡 Pista: cruza tu máscara de outliers con df["outlier_tipo"] != "normal" usando una tabla de contingencia.


Tarea 8 (EXTRA) — Tratamiento de outliers

Una vez detectados los outliers, hay varias estrategias posibles:

  1. Eliminarlos: elimina las filas con outliers y guarda el dataset limpio.
  2. Imputarlos: reemplaza los valores extremos por la mediana de la columna.
  3. Recortarlos (capping): sustituye los valores que superan el límite superior por el propio límite superior (y los que caen por debajo del inferior, por el inferior).

Aplica al menos una de las tres estrategias a pm25_ug_m3 y justifica tu elección.

💡 Pista para el capping:

df["pm25_limpio"] = df["pm25_ug_m3"].clip(lower=limite_inf, upper=limite_sup)

solucion_calidad_aire


Análisis de datos

employee_salaries



# =============================================================================
#  DETECCIÓN DE OUTLIERS EN SALARIOS DE UNA MULTINACIONAL
#  Solución comentada para estudiantes principiantes
#
#  Dataset: employee_salaries.csv (1.000 registros)
#  Distribución base: Normal con media=25.000 € y desviación=3.000 €
#  + outliers moderados (sueldos inusualmente altos o bajos pero posibles)
#  + outliers extremos (valores que parecen errores de datos)
# =============================================================================


# -----------------------------------------------------------------------------
# PASO 0 — Importar las librerías necesarias
# -----------------------------------------------------------------------------
# Las librerías son "cajas de herramientas" que alguien ya programó por nosotros.
# No necesitamos reinventar la rueda: importamos lo que necesitamos y lo usamos.

import pandas as pd          # Para trabajar con tablas de datos (DataFrames)
import numpy as np           # Para cálculos matemáticos y arrays
import matplotlib.pyplot as plt  # Para crear gráficos
from scipy import stats      # Para estadísticos avanzados (Z-score, etc.)


# =============================================================================
# PASO 1 — CARGA E INSPECCIÓN INICIAL
# =============================================================================
# Antes de analizar nada, necesitamos entender QUÉ tenemos.
# Es como abrir una caja sin saber qué hay dentro: primero miras, luego actúas.

print("=" * 60)
print("PASO 1: CARGA E INSPECCIÓN INICIAL")
print("=" * 60)

# Cargamos el archivo CSV en un DataFrame (una tabla de Python)
df = pd.read_csv('employee_salaries.csv')

# .shape nos dice (número de filas, número de columnas)
print(f"\nDimensiones del dataset: {df.shape[0]} filas x {df.shape[1]} columnas")

# .dtypes muestra el tipo de dato de cada columna
# 'float64' = número decimal  |  'object' = texto
print("\nTipos de datos por columna:")
print(df.dtypes)

# Las primeras 5 filas para ver cómo son los datos
print("\nPrimeras 5 filas:")
print(df.head())

# Comprobamos si hay valores vacíos (NaN = Not a Number = celda vacía)
# En análisis de datos, los valores vacíos pueden provocar errores o sesgos
nulos = df.isnull().sum()
print("\nValores nulos por columna:")
print(nulos)

# Cuántos registros hay de cada tipo de outlier (esto es "trampa" — datos reales)
# Lo guardamos para validar al final, pero no lo usamos en la detección
print("\nDistribución real de tipos (referencia para validar al final):")
print(df['outlier_type'].value_counts())

# La columna de salarios es la que vamos a analizar
# La guardamos en una variable corta para no escribir df['annual_salary_eur'] todo el tiempo
sal = df['annual_salary_eur']


# =============================================================================
# PASO 2 — ESTADÍSTICOS DESCRIPTIVOS
# =============================================================================
# Los estadísticos descriptivos son números que resumen cómo son los datos.
# Son como el "DNI" de un conjunto de datos: nos dicen lo esencial de un vistazo.

print("\n" + "=" * 60)
print("PASO 2: ESTADÍSTICOS DESCRIPTIVOS")
print("=" * 60)

# .describe() calcula automáticamente los estadísticos más comunes
print("\nResumen estadístico completo:")
print(sal.describe().round(2))

# Calculamos por separado los más importantes para entenderlos bien
media   = sal.mean()    # Media aritmética: suma de todos los valores / número de valores
mediana = sal.median()  # Mediana: el valor del centro cuando ordenas todos los datos
std     = sal.std()     # Desviación estándar: cuánto se "dispersan" los datos respecto a la media
minimo  = sal.min()     # El valor más pequeño del dataset
maximo  = sal.max()     # El valor más grande del dataset

print(f"\nMedia:    {media:>15,.2f} €")
print(f"Mediana:  {mediana:>15,.2f} €")
print(f"Std:      {std:>15,.2f} €")
print(f"Mínimo:   {minimo:>15,.2f} €")
print(f"Máximo:   {maximo:>15,.2f} €")

# ---
# ¿Por qué comparar media y mediana?
#
# La MEDIA se ve arrastrada por valores extremos.
# Si alguien gana 100.000.000 € al año en nuestros datos,
# ese único valor sube enormemente la media, aunque el resto gane ~25.000 €.
#
# La MEDIANA no se ve afectada: es simplemente el valor de en medio.
# Si media >> mediana → hay valores muy altos sesgando la media.
# Si media << mediana → hay valores muy bajos sesgando la media.
# ---

diferencia = abs(media - mediana)
print(f"\nDiferencia media - mediana: {diferencia:,.2f} €")
if diferencia > 1000:
    print("  Diferencia significativa → hay outliers que están distorsionando la media")
else:
    print("✓  Media y mediana cercanas → distribución sin sesgos extremos")

# Asimetría (skewness): mide si la distribución se "inclina" hacia un lado
# - Valor cercano a 0 → distribución simétrica (como una campana centrada)
# - Valor positivo   → cola más larga hacia la derecha (valores altos extremos)
# - Valor negativo   → cola más larga hacia la izquierda (valores bajos extremos)
skewness = sal.skew()
print(f"\nAsimetría (skewness): {skewness:.4f}")

if abs(skewness) < 0.5:
    print("→ Distribución bastante simétrica")
elif skewness > 0:
    print("→ Asimetría positiva: hay valores muy ALTOS alejándose del centro")
else:
    print("→ Asimetría negativa: hay valores muy BAJOS alejándose del centro")

# Curtosis (kurtosis): mide qué tan "puntiaguda" o "plana" es la distribución
# - Valor ~0 → igual que una distribución normal (referencia de Pandas: exceso de curtosis)
# - Valor alto (>1) → colas más pesadas que una normal, es decir, MÁS outliers de lo esperado
kurtosis = sal.kurt()
print(f"Curtosis (exceso):    {kurtosis:.4f}")
if kurtosis > 1:
    print("→ Curtosis alta: hay más valores extremos de lo que esperaríamos en datos normales")


# =============================================================================
# PASO 3 — MÉTODO IQR (RANGO INTERCUARTÍLICO)
# =============================================================================
# El IQR es el método más robusto y popular para detectar outliers.
# No necesita asumir que los datos siguen ninguna distribución en particular.
#
# ¿Qué es el IQR?
#   - Q1 (percentil 25): el 25% de los datos están por debajo de este valor
#   - Q3 (percentil 75): el 75% de los datos están por debajo de este valor
#   - IQR = Q3 - Q1 → el rango que contiene el 50% central de los datos
#
# La idea: si un valor está DEMASIADO lejos del 50% central, es sospechoso.
# "Demasiado lejos" se define multiplicando el IQR por un factor (1.5 o 3).

print("\n" + "=" * 60)
print("PASO 3: MÉTODO IQR")
print("=" * 60)

# Calculamos los cuartiles
Q1  = sal.quantile(0.25)   # Primer cuartil
Q3  = sal.quantile(0.75)   # Tercer cuartil
IQR = Q3 - Q1              # Rango intercuartílico

print(f"\nQ1 (percentil 25):  {Q1:>10,.2f} €")
print(f"Q3 (percentil 75):  {Q3:>10,.2f} €")
print(f"IQR (Q3 - Q1):      {IQR:>10,.2f} €")

# ---
# OUTLIERS MODERADOS: factor 1.5
# Este umbral fue propuesto por el estadístico John Tukey en 1977.
# En una distribución perfectamente normal, solo el 0.7% de los datos
# caerían fuera de estos límites (lo llamamos "falsos positivos").
# Son valores inusuales pero podrían tener una explicación real.
# ---
lower_mild = Q1 - 1.5 * IQR
upper_mild = Q3 + 1.5 * IQR

# ---
# OUTLIERS EXTREMOS: factor 3
# Con este umbral, en una distribución normal solo el 0.0002% de los datos
# caerían fuera. Si un valor está aquí, es casi seguro que es un error
# o un caso verdaderamente excepcional.
# ---
lower_extreme = Q1 - 3.0 * IQR
upper_extreme = Q3 + 3.0 * IQR

print(f"\nLímites MODERADOS  (factor 1.5):")
print(f"  Inferior: {lower_mild:>10,.2f} €")
print(f"  Superior: {upper_mild:>10,.2f} €")
print(f"\nLímites EXTREMOS   (factor 3.0):")
print(f"  Inferior: {lower_extreme:>10,.2f} €")
print(f"  Superior: {upper_extreme:>10,.2f} €")

# Filtramos los outliers: buscamos filas donde el salario esté FUERA de los límites
# El operador | significa "O": fuera por arriba O fuera por abajo
outliers_mild    = df[(sal < lower_mild)    | (sal > upper_mild)]
outliers_extreme = df[(sal < lower_extreme) | (sal > upper_extreme)]

print(f"\nOutliers moderados  detectados: {len(outliers_mild)}")
print(f"Outliers extremos   detectados: {len(outliers_extreme)}")

# Mostramos los valores extremos ordenados de menor a mayor
print("\nValores extremos (ordenados):")
print(outliers_extreme[['employee_id', 'department', 'annual_salary_eur']]
      .sort_values('annual_salary_eur')
      .to_string(index=False))


# =============================================================================
# PASO 4 — VISUALIZACIÓN
# =============================================================================
# Una imagen vale más que mil estadísticos.
# Los gráficos nos permiten ver patrones que los números solos no revelan.

print("\n" + "=" * 60)
print("PASO 5: VISUALIZACIÓN")
print("=" * 60)
print("Generando gráficos...")

fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle('Análisis de outliers — Salarios de la multinacional',
             fontsize=14, fontweight='bold', y=1.01)

# ---
# GRÁFICO 1: Histograma del rango "normal"
# Un histograma muestra cuántos datos hay en cada rango de valores (barras).
# Lo limitamos a [-5000, 90000] para que se vean los datos normales.
# Los valores de 100.000.000 quedan fuera del gráfico a propósito.
# ---
ax1 = axes[0, 0]
sal_recortada = sal[sal < 90000]  # Solo para la visualización, no para el análisis
ax1.hist(sal_recortada, bins=60, color='steelblue', alpha=0.75, edgecolor='none')

# Añadimos líneas verticales para los límites IQR
ax1.axvline(lower_mild,    color='orange', lw=2, ls='--', label=f'IQR×1.5 ({lower_mild:,.0f}€)')
ax1.axvline(upper_mild,    color='orange', lw=2, ls='--')
ax1.axvline(lower_extreme, color='red',    lw=2, ls=':',  label=f'IQR×3 ({lower_extreme:,.0f}€ / {upper_extreme:,.0f}€)')
ax1.axvline(upper_extreme, color='red',    lw=2, ls=':')
ax1.axvline(media,         color='black',  lw=1.5, ls='-', alpha=0.5, label=f'Media ({media:,.0f}€)')
ax1.axvline(mediana,   color='green',  lw=1.5, ls='-', alpha=0.5, label=f'Mediana ({mediana:,.0f}€)')

ax1.set_title('Histograma + límites IQR (rango ≤ 90.000 €)')
ax1.set_xlabel('Salario anual (€)')
ax1.set_ylabel('Número de empleados')
ax1.legend(fontsize=8)

# ---
# GRÁFICO 2: Boxplot (diagrama de caja)
# El boxplot resume la distribución en 5 números:
#   - La caja va de Q1 a Q3 (el IQR)
#   - La línea central es la mediana
#   - Los "bigotes" llegan hasta el límite IQR×1.5
#   - Los puntos fuera de los bigotes son los outliers
# ---
ax2 = axes[0, 1]
sal_para_box = sal[sal.between(-10000, 90000)]  # Recortamos para legibilidad
ax2.boxplot(sal_para_box, vert=False,
            flierprops=dict(marker='o', markerfacecolor='red',
                            markersize=4, alpha=0.5, linestyle='none'))
ax2.set_title('Boxplot (rango -10.000 a 90.000 €)')
ax2.set_xlabel('Salario anual (€)')
ax2.set_yticks([])

# Anotaciones para explicar las partes del boxplot
ax2.annotate('Mediana', xy=(mediana, 1), xytext=(mediana - 3000, 1.3),
             fontsize=8, color='darkblue',
             arrowprops=dict(arrowstyle='->', color='darkblue', lw=1))

# ---
# GRÁFICO 3: Scatter plot coloreado por tipo de outlier
# Cada punto es un empleado. El color indica su tipo real.
# Esto es el "mapa" de la distribución real.
# ---
ax3 = axes[1, 0]
colores = {'normal': 'steelblue', 'moderate_outlier': 'orange', 'extreme_outlier': 'red'}
etiquetas = {'normal': 'Normal (940)',
             'moderate_outlier': 'Outlier moderado (45)',
             'extreme_outlier': 'Outlier extremo (15)'}

df_vis = df[df['annual_salary_eur'] < 90000].copy()
for tipo, grupo in df_vis.groupby('outlier_type'):
    ax3.scatter(grupo['annual_salary_eur'],
                np.random.uniform(0.8, 1.2, len(grupo)),  # Dispersión vertical aleatoria
                c=colores[tipo], alpha=0.4, s=12,
                label=etiquetas[tipo])

ax3.axvline(lower_mild,    color='orange', lw=1.5, ls='--', alpha=0.7)
ax3.axvline(upper_mild,    color='orange', lw=1.5, ls='--', alpha=0.7)
ax3.set_title('Distribución individual por tipo (< 90.000 €)')
ax3.set_xlabel('Salario anual (€)')
ax3.set_yticks([])
ax3.legend(fontsize=8)

# ---
# GRÁFICO 4: Histograma + curva normal ajustada
# #
# # El histograma divide el rango de valores en "cubos" (bins) y cuenta cuántos
# # datos caen en cada uno. Usamos density=True para que el eje Y sea densidad
# # de probabilidad en lugar de frecuencia absoluta — esto permite superponer
# # directamente la curva normal teórica.
ax4 = axes[1, 1]

# Filtramos: nos quedamos solo con los valores dentro de los límites extremos IQR
# Esto elimina los 56 outliers extremos y nos deja con 944 registros "limpios"
sal_limpia = sal[(sal >= lower_extreme) & (sal <= upper_extreme)]
mu, sigma = stats.norm.fit(sal_limpia)

ax4.hist(sal_limpia, bins=50, density=True,
         color='steelblue', alpha=0.7, edgecolor='none',
         label=f'Salarios limpios (n={len(sal_limpia)})')

# Generamos la curva normal teórica con los parámetros ajustados
x = np.linspace(sal_limpia.min(), sal_limpia.max(), 400)
ax4.plot(x, stats.norm.pdf(x, mu, sigma),
         color='red', lw=2.5,
         label=f'Normal ajustada\nµ = {mu:,.0f} €\nσ = {sigma:,.0f} €')

# Línea vertical en la media
ax4.axvline(mu, color='red', lw=1.2, ls='--', alpha=0.5)

ax4.set_title('Histograma + curva normal ajustada')
ax4.set_xlabel('Salario anual (€)')
ax4.set_ylabel('Densidad de probabilidad')
ax4.legend(fontsize=9)

plt.tight_layout()
plt.savefig('salary_analysis.png', dpi=150, bbox_inches='tight')
plt.show()
print("✓ Gráfico guardado como 'salary_analysis.png'")


# =============================================================================
# PASO 5 — CLASIFICACIÓN CUALITATIVA DE LOS OUTLIERS EXTREMOS
# =============================================================================
# Detectar outliers es solo la mitad del trabajo.
# La otra mitad es ENTENDER qué son: ¿errores? ¿casos reales excepcionales?
# Esto requiere combinar estadística con sentido común del negocio.
#
# Categorías posibles:
#   ERROR DE ENTRADA   → valor que claramente se introdujo mal (ej. -1, 3.14)
#   ERROR DE ESCALA    → confusión de unidades (tarifa horaria en vez de anual)
#   VALOR EXTREMO REAL → posible pero excepcional (ej. CEO con 200.000 €/año)

print("\n" + "=" * 60)
print("PASO 6: CLASIFICACIÓN CUALITATIVA")
print("=" * 60)

# Todos los outliers extremos según IQR
print("\nOutliers extremos detectados:")
print(outliers_extreme[['employee_id', 'department', 'level',
                          'annual_salary_eur']].sort_values('annual_salary_eur')
      .to_string(index=False))

# ---
# Reglas de clasificación manual basadas en conocimiento del dominio
# ---
def clasificar_salario(valor):
    """
    Clasifica un salario en una de tres categorías:
    - 'error_entrada': valor imposible o claramente erróneo
    - 'error_escala': valor que podría ser un error de unidades
    - 'extremo_real': valor fuera de rango pero posiblemente legítimo
    - 'normal': dentro del rango esperado
    """
    if valor < 0:
        return 'error_entrada'       # Un sueldo negativo es imposible
    elif valor == 0:
        return 'error_entrada'       # Sueldo cero: probablemente un valor nulo mal registrado
    elif valor < 1000:
        # Podría ser la tarifa horaria (ej. 12.50 €/h) o mensual (150 €/mes)
        # en vez del salario anual
        return 'error_escala'
    elif valor > 500_000:
        return 'error_entrada'       # Más de 500k al año es improbable en una multinacional estándar
    elif valor > 100_000:
        return 'extremo_real'        # Alto pero posible para C-suite o directores globales
    elif valor < lower_extreme or valor > upper_extreme:
        return 'extremo_real'        # Inusual pero dentro de lo posible
    else:
        return 'normal'

# Aplicamos la función a todos los outliers extremos
df_clasificados = outliers_extreme.copy()
df_clasificados['clasificacion_manual'] = df_clasificados['annual_salary_eur'].apply(clasificar_salario)

print("\nClasificación cualitativa de outliers extremos:")
print(df_clasificados[['employee_id', 'annual_salary_eur',
                         'clasificacion_manual', 'outlier_type']]
      .sort_values('annual_salary_eur').to_string(index=False))

print("\nResumen de clasificaciones manuales:")
print(df_clasificados['clasificacion_manual'].value_counts())

# Validación: comparamos nuestra clasificación con las etiquetas reales del dataset
# (las que vienen en la columna 'outlier_type')
print("\n--- Validación contra etiquetas reales ---")
print("(comparamos nuestra clasificación manual con el outlier_type original)")

# Añadimos nuestra clasificación a todo el dataframe para la comparación
df['clasificacion_manual'] = df['annual_salary_eur'].apply(clasificar_salario)

# Matriz de confusión simple: filas=clasificación manual, columnas=tipo real
tabla_validacion = pd.crosstab(
    df['clasificacion_manual'],
    df['outlier_type'],
    margins=True
)
print(tabla_validacion)


# =============================================================================
# PASO 6 — TRATAMIENTO DE OUTLIERS
# =============================================================================
# Una vez detectados y clasificados, hay que decidir QUÉ HACER con ellos.
# No existe una solución única: la mejor estrategia depende del objetivo.
#
# Las tres estrategias principales son:
#
#   A) ELIMINACIÓN    → quitar la fila del dataset
#      Cuándo usarla: errores de datos confirmados que no se pueden corregir
#      Riesgo: perdemos información real si nos equivocamos
#
#   B) WINSORIZACIÓN  → recortar el valor al límite más cercano
#      Cuándo usarla: queremos conservar la fila pero neutralizar el outlier
#      Riesgo: distorsionamos el valor real del dato
#
#   C) IMPUTACIÓN     → reemplazar el valor por un estadístico (mediana, media)
#      Cuándo usarla: el registro tiene información valiosa en otras columnas
#      Riesgo: introducimos un valor "ficticio" en los datos

print("\n" + "=" * 60)
print("PASO 7: TRATAMIENTO DE OUTLIERS")
print("=" * 60)

# --- Estrategia A: Eliminación ---
# Quitamos todas las filas donde el salario está fuera de los límites extremos
df_eliminacion = df[(sal >= lower_extreme) & (sal <= upper_extreme)].copy()
print(f"\nA) Eliminación:")
print(f"   Filas originales: {len(df)}")
print(f"   Filas tras eliminar extremos: {len(df_eliminacion)}")
print(f"   Filas eliminadas: {len(df) - len(df_eliminacion)}")

# --- Estrategia B: Winsorización ---
# .clip(lower, upper) reemplaza cualquier valor fuera del rango por el límite
# Es como "cortar" las colas de la distribución
df_winsor = df.copy()
df_winsor['annual_salary_eur'] = df_winsor['annual_salary_eur'].clip(
    lower=lower_mild,
    upper=upper_mild
)
print(f"\nB) Winsorización (capping al rango moderado):")
print(f"   Nuevos límites: [{lower_mild:,.0f} €, {upper_mild:,.0f} €]")
afectados_winsor = ((sal < lower_mild) | (sal > upper_mild)).sum()
print(f"   Valores modificados: {afectados_winsor}")

# --- Estrategia C: Imputación por mediana ---
# Reemplazamos solo los EXTREMOS por la mediana
# Conservamos los outliers moderados (podrían ser reales)
df_imputacion = df.copy()
mascara_extremos = (df_imputacion['annual_salary_eur'] < lower_extreme) | \
                   (df_imputacion['annual_salary_eur'] > upper_extreme)
df_imputacion.loc[mascara_extremos, 'annual_salary_eur'] = mediana
imputados = mascara_extremos.sum()
print(f"\nC) Imputación por mediana (solo extremos):")
print(f"   Mediana usada: {mediana:,.2f} €")
print(f"   Valores imputados: {imputados}")

# --- Comparación del impacto de cada estrategia ---
print("\n--- Impacto de cada estrategia sobre los estadísticos ---")
print(f"{'Dataset':<20} {'N':>6} {'Media':>12} {'Mediana':>12} {'Std':>12}")
print("-" * 65)

for nombre, dataset in [('Original',     df),
                         ('Eliminación',   df_eliminacion),
                         ('Winsorización', df_winsor),
                         ('Imputación',    df_imputacion)]:
    s = dataset['annual_salary_eur']
    print(f"{nombre:<20} {len(s):>6} {s.mean():>12,.0f} {s.median():>12,.0f} {s.std():>12,.0f}")

# ---
# Interpretación del cuadro de comparación:
#
# - La media del dataset original está muy lejos de 25.000 porque los valores
#   extremos (100.000.000, 9.999.999...) la distorsionan completamente.
# - Tras cualquier tratamiento, la media se acerca mucho más a 25.000.
# - La mediana apenas cambia entre datasets: confirma que es un estadístico robusto.
# - La desviación estándar baja drásticamente tras tratar los outliers.
# ---

print("Conclusión:")
print("   La media original está muy sesgada por los valores absurdos (100.000.000 €).")
print("   La mediana, en cambio, es estable en todos los datasets.")
print("   Para análisis de nóminas, la estrategia recomendada es:")
print("   → Eliminar errores claros (negativos, ceros, valores ridículos)")
print("   → Investigar y conservar outliers moderados que puedan ser reales")
print("   → Documentar SIEMPRE las decisiones tomadas y cuántos registros afectan")


# =============================================================================
# RESUMEN FINAL
# =============================================================================

print("\n" + "=" * 60)
print("RESUMEN FINAL DEL ANÁLISIS")
print("=" * 60)
print(f"\nDataset analizado:        1.000 registros")
print(f"Distribución base:        Normal (µ=25.000, σ=3.000)")
print(f"\nOutliers detectados:")
print(f"  · Método IQR moderado:  {len(outliers_mild):>4} registros")
print(f"  · Método IQR extremo:   {len(outliers_extreme):>4} registros")
print(f"\nClasificación cualitativa:")
print(f"  · Errores de entrada:   valores negativos, ceros, magnitudes absurdas")
print(f"  · Errores de escala:    tarifa horaria/mensual introducida como anual")
print(f"  · Extremos reales:      directivos, C-suite con sueldos legítimamente altos")
print(f"\nEstrategia recomendada:   Eliminar errores confirmados + conservar extremos reales")
print(f"\n✓ Análisis completado. Gráfico guardado en 'salary_analysis.png'")


Ejercicio Análisis de datos

Descarga el siguiente archivo:

employee_salaries

Tienes un CSV con 1.000 registros de sueldos anuales. Tu misión: analizar la distribución, detectar anomalías y clasificarlas.

Carga e inspección inicial del dataset
Antes de hacer cualquier análisis, necesitas entender qué tienes. Carga el CSV employee_salaries.csv e inspecciónalo.

Calcula los estadísticos básicos de la columna annual_salary_eur y compáralos con la distribución teórica que se usó para generarlos (media ~25.000, desviación ~3.000).
¿Distorsionan los valores atípicos alguna medida? ¿Cuál sería más robusta?

El método IQR es robusto y no asume normalidad. Define como outlier cualquier valor fuera del rango [Q1 − 1.5·IQR, Q3 + 1.5·IQR]. Para outliers extremos usa el factor 3.
¿Cuántos outliers detecta cada umbral? ¿Los límites calculados tienen sentido dados la media y desviación teóricas? ¿Qué valores concretos aparecen como extremos?

Haz un resumen de tus conclusiones

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.")

Ejemplo valores atípico


import numpy as np  # Librería esencial para cálculos matemáticos con arrays.
                    # Alternativa sin instalar nada: import statistics (librería estándar de Python)

# Minutos respecto a las 8:30
# Negativo = llega antes  |  0 = en punto  |  Positivo = llega tarde
# Contexto estadístico: estamos midiendo una DESVIACIÓN respecto a un valor de referencia (8:30).
# Esto es exactamente lo que hace la desviación típica internamente: medir distancias respecto a la media.

# Alumno A — puntual, se adelanta o se retrasa apenas unos minutos
# Contexto: datos con poca dispersión → sigma pequeña esperada
# Alternativa para crear el array: alumno_a = np.array(list(map(int, "-3,2,1,-1,4,-3,4,1,-2,3,-1,2,2,1,-2".split(","))))
alumno_a = np.array([-3, 2, 1, -1, 4, -3, 4, 1, -2, 3, -1, 2, 2, 1, -2])

# Alumno B — casi siempre tarde 5-10 min, un día llega 30 min tarde
# Contexto: los valores -35 y -42 son outliers extremos (llegó muy pronto esos días).
# Esos dos valores "compensan" matemáticamente los retrasos para igualar la media con alumno_a.
# Alternativa para verificar la suma: print(sum([-3, 2, 1, ...])) antes de crear el array
alumno_b = np.array([5, 6, 3, -35, 8, 6, 8, 15, 6, 8, 5, 3, 6, 5, -42])

# Iteramos sobre los dos alumnos con sus etiquetas. 
# Alternativa con diccionario: alumnos = {"Alumno A": alumno_a, "Alumno B": alumno_b}
#                              for nombre, datos in alumnos.items():
for nombre, datos in [("Alumno A (puntual)", alumno_a), ("Alumno B (impuntual)", alumno_b)]:
    print(f"{nombre}")
    print(f"  Datos:  {list(datos)}")

    # np.mean() = suma de todos los valores / cantidad de valores.
    # Contexto: la media puede ser igual para ambos alumnos aunque su comportamiento sea opuesto.
    # Esto demuestra que la media sola no cuenta toda la historia → necesitamos la sigma.
    # Alternativa: sum(datos) / len(datos)  o  statistics.mean(datos)
    print(f"  Media:  {np.mean(datos):.1f} min  |  σ = {np.std(datos):.1f} min  |  Min: {datos.min()} min  |  Max: {datos.max()} min")

    # Percentil 25 = Q1: el 25% de los datos están POR DEBAJO de este valor.
    # Percentil 75 = Q3: el 75% de los datos están POR DEBAJO de este valor.
    # Alternativa con pandas: Q1 = pd.Series(datos).quantile(0.25)
    Q1 = np.percentile(datos, 25)
    Q3 = np.percentile(datos, 75)

    # IQR = distancia entre Q1 y Q3. Contiene el 50% central de los datos.
    # Contexto: a diferencia de la sigma, el IQR ignora los extremos → más robusto ante outliers.
    # Si el IQR es pequeño, la mayoría de los datos están concentrados. Si es grande, hay dispersión.
    IQR = Q3 - Q1

    # sorted() ordena la lista de menor a mayor para visualizar los datos fácilmente.
    # [int(x) for x in datos] convierte de float a entero para una lectura más limpia.
    # Alternativa: print(np.sort(datos))  — directamente con NumPy sin convertir a lista
    print(sorted([int(x) for x in datos]))
    print(f"  Cuartil 1 {Q1}, Cuartil 3 {Q3}, Rango intercuartílico {IQR}")

    # Regla de Tukey (1977): un valor es outlier si está más allá de 1.5×IQR desde Q1 o Q3.
    # El factor 1.5 es el estándar universal. Con factor=3.0 solo detectaría outliers extremos.
    # Alternativa: from scipy import stats → stats.iqr(datos) calcula el IQR directamente
    inferior=Q1-1.5*IQR
    superior=Q3+1.5*IQR
    print(f"Límites: {inferior}, {superior}")

    # List comprehension: crea una lista filtrando solo los valores que cumplen la condición.
    # Es equivalente a un bucle for con un if dentro, pero en una sola línea.
    # Alternativa con NumPy (más eficiente para datos grandes): datos[datos < inferior]
    print(f"Valores atípicos por debajo {[int(x) for x in datos if x <inferior]}")
    print(f"Valores atípicos por encima {[int(x) for x in datos if x > superior]}")
    # Contexto: alumno_b tendrá outliers (-35 y -42) porque son días excepcionales.
    # Alumno_a probablemente no tenga ninguno, sus datos son consistentes.
    print()

# Segunda parte: aplicamos el mismo análisis a un conjunto de alturas reales.
# Contexto: cambiamos de dominio (minutos → cm) pero el método estadístico es idéntico.
# Esto demuestra la universalidad del método IQR para detectar outliers en cualquier dataset.
# Alternativa para cargar datos reales: alturas = pd.read_csv("alturas.csv")["altura"].tolist()
alturas=[172, 173, 154, 168, 175, 177, 168, 164, 167, 162, 173, 159, 182, 160, 161, 177, 154, 154, 167, 179, 179, 178, 172, 165, 173, 174, 165, 172, 166, 187, 179, 176, 171, 155, 163, 160, 187, 167, 167, 171, 168, 158, 170, 181, 174, 169, 176, 164, 175, 166, 167, 176, 171, 159, 166, 177, 159, 162, 167, 169, 161, 185, 165, 173, 171, 173, 172, 177, 164, 169, 162, 169, 181, 174, 169, 175, 174, 173, 185, 172, 166, 148, 159, 167, 163, 167, 164, 169, 173, 189, 169, 171, 177, 161, 168, 178, 170, 181, 158, 188]

# ¿Hay valores atípicos?
# Con 100 datos ya no tiene sentido revisarlos a ojo → necesitamos el método automático.
# Contexto: en distribuciones normales (como las alturas) esperamos muy pocos outliers,
# ya que el 99.7% de los datos caen dentro de ±3σ. El IQR confirmará lo mismo de otra forma.

# Reutilizamos exactamente el mismo bloque de cálculo que con los alumnos.
# Contexto: esto muestra que el método IQR es independiente del dominio de los datos.
# Alternativa completa con pandas: pd.Series(alturas).describe() muestra Q1, Q3 y más de golpe
Q1=np.percentile(alturas, 25)
Q3=np.percentile(alturas, 75)
IQR = Q3 - Q1

# inferior y superior definen la "valla" de Tukey.
# Todo lo que quede fuera de [inferior, superior] se considera estadísticamente inusual.
# Contexto: en alturas humanas, esperamos que los outliers sean alturas muy extremas
# (alguien muy bajo o muy alto respecto al grupo analizado).
inferior=Q1-1.5*IQR
superior=Q3+1.5*IQR

# Los valores atípicos son los menores del valor inferior o mayores del valor superios

print(f"Límites: {inferior}, {superior}")

# Si la lista resultante está vacía ([]) significa que no hay outliers en ese extremo.
# Contexto: en una distribución normal pura, ~0.7% de los datos serían outliers por este método,
# lo que con 100 datos equivale a esperar 0 o 1 outlier aproximadamente.
# Alternativa con NumPy: print(alturas[np.array(alturas) < inferior])
print(f"Valores atípicos por debajo {[int(x) for x in alturas if x < inferior]}")
print(f"Valores atípicos por encima {[int(x) for x in alturas if x > superior]}")

Ejercicio media y estadística

import numpy as np

# Clase A — 1º de Bachillerato
clase_a = np.array([
    171, 173, 170, 172, 174, 171, 173, 172, 171, 173,
    172, 174, 170, 173, 172, 171, 172, 173, 174, 172,
    171, 172, 173, 170, 174, 172, 171, 173, 172, 174,
    172, 171, 173, 172, 170, 173, 174, 172, 171, 172,
    173, 172, 174, 171, 172, 173, 170, 172, 171, 173
])

# Clase B — 2º de la ESO 
clase_b = np.array([
    148, 168, 155, 172, 151, 165, 161, 170, 153, 167,
    158, 173, 149, 162, 157, 169, 155, 164, 171, 150,
    166, 154, 160, 174, 152, 163, 156, 168, 159, 172,
    147, 165, 153, 170, 158, 162, 155, 167, 149, 173,
    161, 156, 168, 152, 164, 170, 154, 159, 163, 169
])

# Clase C — Optativa de deportes mezclada
clase_c = np.array([
    142, 156, 180, 148, 188, 162, 174, 139, 185, 153,
    178, 144, 167, 191, 150, 172, 138, 183, 158, 176,
    145, 169, 195, 141, 160, 186, 154, 173, 143, 179,
    163, 147, 188, 155, 171, 140, 182, 158, 176, 149,
    165, 193, 144, 170, 152, 184, 146, 161, 178, 157
])

# Minutos respecto a las 8:30
# Negativo = llega antes  |  0 = en punto  |  Positivo = llega tarde

# Alumno A — puntual, se adelanta o se retrasa apenas unos minutos
alumno_a = np.array([-3, 2, 1, -1, 4, -3, 4, 1, -2, 3, -1, 2, 2, 1, -2])

# Alumno B — casi siempre tarde 5-10 min, un día llega 30 min tarde
alumno_b = np.array([5, 6, 3, -35, 8, 6, 8, 15, 6, 8, 5, 3, 6, 5, -42])