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