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


Publicado por

Juan Pablo Fuentes

Formador de programación y bases de datos