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])

Otro ejemplo

import numpy as np  # NumPy es la librería de Python para cálculos matemáticos con listas/arrays.

# Alternativa sin instalar nada: import statistics (librería estándar de Python)

# Lista con 100 alturas en cm. En la vida real esto vendría de un archivo CSV o base de datos.
# Alternativa: 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]

# np.mean() suma todos los valores y divide entre el total. Igual que: sum(alturas)/len(alturas)
# Alternativa: statistics.mean(alturas)
media = np.mean(alturas)
mediana=np.median(alturas)
print(f'Media de altura: {media:.2f}')
print(f'Mediana de altura: {mediana:.2f}')
maximo=max(alturas)
minimo=min(alturas)
rango=maximo-minimo
print(f"El máximo es {maximo}, el mínimo {minimo} y el rango {rango}")
sigma=np.std(alturas)
print(f'Desviación típica: {sigma:.2f}')
print(np.std([2,4,6]))









# Tienda A — ventas muy estables (panadería de barrio, clientela fija)
tienda_a = np.array([198, 205, 201, 197, 203, 199, 202])

# Tienda B — ventas moderadamente variables (restaurante, depende del día)
tienda_b = np.array([150, 185, 210, 165, 230, 175, 195])

# Tienda C — ventas muy irregulares (tienda de souvenirs, turismo estacional)
tienda_c = np.array([80, 320, 45, 410, 95, 280, 170])

for nombre, datos in [("Tienda A", tienda_a), ("Tienda B", tienda_b), ("Tienda C", tienda_c)]:
    print(f"{nombre} → Media: {np.mean(datos):.1f} €  |  σ = {np.std(datos):.1f} €  |  Datos: {datos}")

Ejemplo Outliers

import numpy as np          # Para cálculos matemáticos con arrays
import pandas as pd         # Para trabajar con tablas de datos (DataFrames)
import matplotlib.pyplot as plt  # Para crear gráficos
                            # Alternativa moderna a matplotlib: import seaborn as sns (más bonito por defecto)

# Semilla para reproducibilidad (siempre obtendremos los mismos resultados)
# Alternativa: omitirla si quieres datos distintos cada vez que ejecutes el código
np.random.seed(42)

# Creamos 97 datos normales (media=50, desviacion tipica=8)
# loc = centro de la campana, scale = ancho, size = número de valores generados
# Alternativa con pandas: pd.Series(np.random.normal(50, 8, 97))
datos_normales = np.random.normal(loc=50, scale=8, size=97)

# Anadimos 3 outliers evidentes
# Son valores que claramente se salen del rango normal (50±8 → esperamos valores entre ~26 y ~74)
# Alternativa: outliers_manuales = np.array([datos_normales.mean() - 6*datos_normales.std(), ...])
outliers_manuales = np.array([5, 95, 102])

# Combinamos todo en un array
# np.concatenate une dos arrays como si pegases dos listas una detrás de otra
# Alternativa con listas puras: datos = datos_normales.tolist() + outliers_manuales.tolist()
datos = np.concatenate([datos_normales, outliers_manuales])

# len() cuenta el número total de elementos. Para un array NumPy también puedes usar datos.shape[0]
print(f'Total de datos: {len(datos)}')
# .min() y .max() son métodos de NumPy. Alternativa: np.min(datos), np.max(datos)
print(f'Minimo: {datos.min():.2f}')   # :.2f → muestra solo 2 decimales
print(f'Maximo: {datos.max():.2f}')

# Calculamos los percentiles 25 y 75 (= Q1 y Q3)
# Percentil 25 significa: el valor por debajo del cual está el 25% de los datos
# Alternativa con pandas: Q1 = pd.Series(datos).quantile(0.25)
Q1 = np.percentile(datos, 25)
Q3 = np.percentile(datos, 75)

# Calculamos el IQR
# IQR = distancia entre el "centro" del 50% de los datos. Cuanto mayor, más dispersos están.
# A diferencia de la sigma, el IQR no se ve afectado por los outliers extremos.
IQR = Q3 - Q1

print(f'Q1 (percentil 25): {Q1:.2f}')
print(f'Q3 (percentil 75): {Q3:.2f}')
print(f'IQR = Q3 - Q1:     {IQR:.2f}')

# Creamos un DataFrame de pandas
# Un DataFrame es como una tabla Excel: filas y columnas con nombre.
# Aquí tiene una sola columna llamada 'valor' con los 100 datos.
# Alternativa: df = pd.DataFrame(datos, columns=['valor'])
df = pd.DataFrame({'valor': datos})


# Funcion reutilizable para detectar outliers con IQR
# Recibe una Serie de pandas y un factor (por defecto 1.5, el estándar de Tukey).
# Aumentar el factor (ej: 3.0) hace la detección más permisiva (menos outliers detectados).
# Alternativa: sklearn.preprocessing.RobustScaler para normalizar eliminando outliers automáticamente
def detectar_outliers_iqr(serie, factor=1.5):
    Q1 = serie.quantile(0.25)   # equivale a np.percentile pero opera sobre Series de pandas
    Q3 = serie.quantile(0.75)
    IQR = Q3 - Q1
    limite_inf = Q1 - factor * IQR  # Por debajo de esto → outlier. Con factor=1.5: regla de Tukey
    limite_sup = Q3 + factor * IQR  # Por encima de esto → outlier
    # El operador | es OR lógico: True si el valor es menor que el límite inferior O mayor que el superior
    # Alternativa: mascara = ~serie.between(limite_inf, limite_sup)
    mascara = (serie < limite_inf) | (serie > limite_sup)
    return mascara, limite_inf, limite_sup  # Devuelve 3 valores a la vez (tupla)


# Usamos la funcion
# Python permite recoger los 3 valores devueltos directamente en 3 variables
# mascara es una Serie de True/False con la misma longitud que df
mascara, lim_inf, lim_sup = detectar_outliers_iqr(df['valor'])

# Anadimos una columna al DataFrame
# df['nueva_columna'] = valores crea una nueva columna. Muy habitual en pandas.
# Alternativa: df = df.assign(es_outlier=mascara)
df['es_outlier'] = mascara

# Ver solo los outliers
# df[condicion] filtra las filas donde la condición es True. Igual que un WHERE en SQL.
# Alternativa más corta: df[df['es_outlier']]  (True/False ya es suficiente, sin == True)
print(df[df['es_outlier'] == True])

# plt.subplots(1, 2) crea una figura con 1 fila y 2 columnas de gráficos (dos gráficos lado a lado)
# figsize=(12, 5) → ancho=12 pulgadas, alto=5 pulgadas
# Alternativa con seaborn: fig, axes = plt.subplots(1, 2) + sns.boxplot(..., ax=axes[0])
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# --- Grafico 1: Diagrama de caja (Boxplot) ---
ax1 = axes[0]
# patch_artist=True → rellena la caja con color (por defecto está vacía)
# boxprops, medianprops → diccionarios para personalizar el estilo visual
# Alternativa más sencilla: pd.Series(datos).plot.box(ax=ax1)
ax1.boxplot(datos, patch_artist=True,
            boxprops=dict(facecolor='lightblue'),
            medianprops=dict(color='red', linewidth=2))
# axhline dibuja una línea horizontal en y=valor. Muy útil para marcar umbrales.
# Alternativa: ax1.fill_betweenx para sombrear la zona válida en vez de marcar los límites
ax1.axhline(y=lim_inf, color='red', linestyle='--', label=f'Limite inf: {lim_inf:.1f}')
ax1.axhline(y=lim_sup, color='red', linestyle='--', label=f'Limite sup: {lim_sup:.1f}')
ax1.set_title('Diagrama de Caja')
ax1.legend()  # Muestra la leyenda con los labels definidos arriba

# --- Grafico 2: Histograma ---
ax2 = axes[1]
# ~ es el operador NOT para arrays booleanos: invierte True↔False
# datos[~mascara] → solo los valores donde mascara es False (= los datos normales)
normales = datos[~mascara]
# mascara.values convierte la Serie de pandas a array NumPy para poder indexar datos con ella
# Alternativa: outs = df[df['es_outlier']]['valor'].values
outs = datos[mascara.values]
# Superponemos dos histogramas en el mismo eje: uno azul (normales) y uno rojo (outliers)
# bins=20 → divide el rango en 20 barras. Más bins = más detalle, menos bins = más suavizado
ax2.hist(normales, bins=20, color='steelblue', label='Datos normales')
ax2.hist(outs, bins=5, color='red', label=f'Outliers ({len(outs)})')
# axvline dibuja líneas verticales (igual que axhline pero en vertical) para marcar los límites
ax2.axvline(lim_inf, color='darkred', linestyle='--')
ax2.axvline(lim_sup, color='darkred', linestyle='--')
ax2.set_title('Histograma con Outliers marcados')
ax2.legend()

# tight_layout() ajusta automáticamente los márgenes para que los gráficos no se solapen
# Alternativa: plt.subplots_adjust(wspace=0.3) para controlar el espacio manualmente
plt.tight_layout()
plt.show()  # Muestra la figura. En Jupyter Notebook no hace falta, se muestra automáticamente.

Ejercicio campana

Con estos datos:

[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]

Calcula la media, la desviación típica y cuenta cuantos alumnos miden entre 162 y 178.

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

# Lista con 100 alturas en cm. En la vida real esto vendría de un archivo CSV o base de datos.
# Alternativa: 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]

# np.mean() suma todos los valores y divide entre el total. Igual que: sum(alturas)/len(alturas)
# Alternativa: statistics.mean(alturas)
media=np.mean(alturas)

# np.std() calcula la desviación típica (cuánto se dispersan los datos respecto a la media).
# ⚠️ Por defecto usa ddof=0 (población completa). Si fuera una muestra: np.std(alturas, ddof=1)
# Alternativa: statistics.stdev(alturas)  <-- usa ddof=1 automáticamente
sigma=np.std(alturas)

# f-string: forma moderna de insertar variables dentro de un texto.
# Alternativa antigua: print("La media es: " + str(media) + " y la desviación típica es: " + str(sigma))
print(f"La media es: {media} y la desviación típica es: {sigma}")

# ¿Cuantos hay entre 1, 2 y 3 sigmas
# Contadores inicializados a 0. Irán sumando 1 por cada altura que caiga en cada rango.
sigma1=0
sigma2=0
sigma3=0

# Bucle clásico: recorre cada altura de la lista una por una.
# Alternativa más rápida para datos grandes: usar NumPy (ver abajo con arr = np.array())
for altura in alturas:
    # Python permite encadenar comparaciones: a < x < b es lo mismo que x > a and x < b
    # Rango ±1σ: contiene teóricamente el 68% de los datos en una distribución normal
    if media+sigma >altura > media-sigma:
        sigma1+=1  # += 1 es lo mismo que sigma1 = sigma1 + 1
    # Rango ±2σ: contiene teóricamente el 95% de los datos
    if media + sigma*2 > altura > media - sigma*2:
        sigma2 += 1
    # Rango ±3σ: contiene teóricamente el 99.7% de los datos
    if media + sigma*3 > altura > media - sigma*3:
        sigma3 += 1

# Imprime los tres conteos separados por espacio. Deberían acercarse a 68, 95 y 100.
print(sigma1,sigma2,sigma3)

# ── Segunda versión: más compacta con list comprehension ─────────────────────
# Hace exactamente lo mismo que el bucle for de arriba, pero en una sola línea.
# [x for x in lista if condicion] filtra la lista y len() cuenta cuántos pasaron el filtro.
# Alternativa NumPy (más eficiente): np.sum((arr >= media-sigma) & (arr <= media+sigma))
sigma1=len([altura for altura in alturas if media+sigma >altura > media-sigma])
sigma2=len([altura for altura in alturas if media + sigma*2 > altura > media - sigma*2])
sigma3=len([altura for altura in alturas if media + sigma*3 > altura > media - sigma*3])

print(sigma1,sigma2,sigma3)

# ── Tercera versión: genera datos aleatorios para verificar la regla 68-95-99.7 ──
# Sobreescribe la lista original con 1000 alturas simuladas siguiendo una distribución normal.
# loc=170 → media de 170cm, scale=8 → sigma de 8cm, size=1000 → genera 1000 valores
# ⚠️ Tras esta línea, media y sigma siguen siendo los de las 100 alturas reales, no de estos datos.
alturas = np.random.normal(loc=170, scale=8,  size=1000)  # Datos aleatorios con distribución normal

# Lista vacía donde se irán añadiendo los porcentajes para sigma 1, 2 y 3.
# Alternativa: sigmas = {f"sigma{i}": ... for i in range(1,4)}  (diccionario más descriptivo)
sigmas=[]

# range(1,4) genera [1, 2, 3]. Para cada i, cuenta cuántas alturas caen dentro de ±i*sigma
# y divide entre 10 para convertirlo en porcentaje (1000 datos → dividir/10 = % directamente).
# ⚠️ Truco del /10: solo funciona porque hay exactamente 1000 datos. Para N genérico: /len(alturas)*100
for i in range(1,4):
    sigmas.append(len([altura for altura in alturas if media+sigma*i >altura > media-sigma*i])/10)

# Debería imprimir algo cercano a [68.0, 95.0, 99.7] si los datos son suficientemente normales.
# Alternativa más clara: print(f"±1σ: {sigmas[0]}%  ±2σ: {sigmas[1]}%  ±3σ: {sigmas[2]}%")
print(sigmas)

Comprensión de listas

# ─────────────────────────────────────────────
# COMPRENSIÓN DE LISTAS (List Comprehensions)
# ─────────────────────────────────────────────
# Es una forma compacta de crear una lista nueva a partir de otra.
# Sintaxis básica:
#
#   nueva_lista = [expresion for elemento in iterable]
#
# Equivale exactamente a:
#
#   nueva_lista = []
#   for elemento in iterable:
#       nueva_lista.append(expresion)


# ─────────────────────────────────────────────
# TRANSFORMACIÓN: aplicar una operación a cada elemento
# ─────────────────────────────────────────────

lista = [1, 2, 3, 4, 5, 6]

# Por cada número de la lista, guardamos ese número multiplicado por 2
dobles = [numero * 2 for numero in lista]
print(dobles)   # → [2, 4, 6, 8, 10, 12]

# 💡 EQUIVALENTE con for tradicional (hace exactamente lo mismo):
dobles = []
for numero in lista:
    dobles.append(numero * 2)


# ─────────────────────────────────────────────
# TRANSFORMAR CADENAS
# ─────────────────────────────────────────────

alumnos = ["ana", "jUAN", "pep", "IU", "Eva"]

# .title() pone en mayúscula la primera letra de cada palabra y el resto en minúscula
alumnos_bien = [alumno.title() for alumno in alumnos]
print(alumnos_bien)   # → ['Ana', 'Juan', 'Pep', 'Iu', 'Eva']

# 💡 ALTERNATIVAS a .title() para normalizar texto:
# "jUAN".upper()      → "JUAN"     todo mayúsculas
# "jUAN".lower()      → "juan"     todo minúsculas
# "jUAN".capitalize() → "Juan"     solo la primera letra en mayúscula
# "jUAN".title()      → "Juan"     mayúscula al inicio de cada palabra

# [0:2] → slice con los dos primeros caracteres de cada nombre
alumnos_comienzos = [alumno[0:2] for alumno in alumnos]
print(alumnos_comienzos)   # → ['an', 'jU', 'pe', 'IU', 'Ev']


# ─────────────────────────────────────────────
# USAR EL ELEMENTO COMO CANTIDAD
# ─────────────────────────────────────────────

lista = [2, 4, 5, 3]

# El número indica cuántas veces repetimos el carácter "*"
# "*" * 3  →  "***"
asteriscos = ["*" * n for n in lista]
print(asteriscos)   # → ['**', '****', '*****', '***']

# 💡 ALTERNATIVA: usar otro carácter o construir una barra de progreso
barras = ["█" * n for n in lista]
print(barras)   # → ['██', '████', '█████', '███']


# ─────────────────────────────────────────────
# COMBINAR SLICE Y COMPRENSIÓN
# ─────────────────────────────────────────────

letras = "abcdefghijklmnopqrstuvwxyz"

# Para cada n de la lista, tomamos los primeros n caracteres del abecedario
# letras[0:2] → "ab" | letras[0:4] → "abcd" | letras[0:5] → "abcde"
comienzos = [letras[0:n] for n in lista]
print(comienzos)   # → ['ab', 'abcd', 'abcde', 'abc']


# ─────────────────────────────────────────────
# COPIA DE UNA LISTA
# ─────────────────────────────────────────────

# Si la expresión es simplemente el propio elemento, obtenemos una copia
lista_igual = [n for n in lista]
print(lista_igual)   # → [2, 4, 5, 3]

# 💡 ALTERNATIVA más directa para copiar una lista:
# lista_igual = lista.copy()
# lista_igual = lista[:]


# ─────────────────────────────────────────────
# FILTRADO: añadir una condición con 'if'
# ─────────────────────────────────────────────
# Sintaxis con filtro:
#
#   nueva_lista = [expresion for elemento in iterable if condicion]
#
# Solo se incluyen los elementos que cumplen la condición.

palabras = ["patata", "bustrofedónico", "alubia",
            "otorrinolaringólogo", "mesa", "supercalifragilísticoespialidoso"]

# Solo incluimos la palabra si su longitud (len) es mayor de 10
palabras_largas = [palabra for palabra in palabras if len(palabra) &gt; 10]
print(palabras_largas)   # → ['bustrofedónico', 'otorrinolaringólogo', 'supercalifragilísticoespialidoso']

# 💡 ALTERNATIVAS de filtrado:
palabras_cortas  = [p for p in palabras if len(p) &lt;= 5]     # longitud ≤ 5
con_a            = [p for p in palabras if p.startswith('a')] # que empiecen por 'a'
con_vocal_final  = [p for p in palabras if p[-1] in 'aeiou'] # acaban en vocal


# ─────────────────────────────────────────────
# COMBINAR FILTRADO Y TRANSFORMACIÓN
# ─────────────────────────────────────────────
# La expresión y la condición pueden ser distintas:
# · la condición decide SI se incluye el elemento
# · la expresión decide QUÉ se guarda de ese elemento

# Objetivo: de una lista de nombres, quedarse solo con los de
# longitud PAR e invertirlos.
# ["Pep","Iu","Eva","Juan"] → longitud par: "Iu"(2), "Juan"(4) → invertidos: ["uI","nauJ"]

lista = ["Pep", "Iu", "Eva", "Juan"]

# Paso a paso:
#   1. if len(i) % 2 == 0 → filtra: solo "Iu" y "Juan" tienen longitud par
#   2. i[::-1]            → transforma: invierte cada cadena seleccionada
par_invertidas = [i[::-1] for i in lista if len(i) % 2 == 0]
print(par_invertidas)   # → ['uI', 'nauJ']

# 💡 EQUIVALENTE con for tradicional, más explicado:
par_invertidas = []
for i in lista:
    if len(i) % 2 == 0:       # condición: longitud par
        par_invertidas.append(i[::-1])   # transformación: invertir

# 💡 ALTERNATIVA: guardar también el original para comparar
pares_con_original = [(i, i[::-1]) for i in lista if len(i) % 2 == 0]
print(pares_con_original)   # → [('Iu', 'uI'), ('Juan', 'nauJ')]

Anatomía de una comprensión de lista

resultado = [ expresión   for elemento in iterable   if condición ]
               │                │             │            │
               │                │             │            └── (opcional) filtro:
               │                │             │                solo los que cumplan esto
               │                │             └─────────────── de dónde vienen los datos
               │                └───────────────────────────── nombre temporal
               └────────────────────────────────────────────── qué guardamos

Comparativa: for tradicional vs comprensión

Con for tradicional Con comprensión
resultado = [] + for + append() Todo en una línea
Más fácil de leer al principio Más compacto y expresivo
Fácil de depurar paso a paso Ideal cuando la lógica es simple
Mejor si hay lógica compleja Evitar si hay más de una condición

Los tres sabores de la comprensión

# 1. Solo transformación
[expresion for x in lista]

# 2. Solo filtrado
[x for x in lista if condicion]

# 3. Transformación + filtrado
[expresion for x in lista if condicion]

💡 Regla de oro: si el for + if cabe en una línea corta y se lee de forma natural, usa comprensión. Si necesitas anidar dos for, añadir varios if o la lógica es compleja, el for tradicional será más claro y fácil de mantener.