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.

Datos anidados



# ─────────────────────────────────────────────
# DICCIONARIOS CON LISTAS DENTRO
# ─────────────────────────────────────────────
# Un valor de un diccionario puede ser cualquier tipo:
# un número, una cadena, una lista, otro diccionario…

alumno = {
    'nombre': 'Ana',
    'email':  'ana@ana.com',
    'notas':  [6, 7, 5, 8]      # ← el valor es una lista
}

# Para acceder a la lista usamos la clave 'notas'
# y luego la recorremos con un for como cualquier otra lista
print(f"El alumno {alumno['nombre']} tiene las siguientes notas:")
for nota in alumno['notas']:
    print(nota)

# &#x1f4a1; ALTERNATIVA: calcular la media al vuelo
notas = alumno['notas']
print(f"Media: {sum(notas) / len(notas):.1f}")  # :.1f → un decimal


# ─────────────────────────────────────────────
# DICCIONARIOS ANIDADOS (diccionario dentro de diccionario)
# ─────────────────────────────────────────────
# Un valor también puede ser otro diccionario.
# Para acceder a sus datos encadenamos corchetes: d['clave1']['clave2']

cliente = {
    'nombre': 'Ana',
    'email':  'ana@gmail.com',
    'direccion': {                  # ← valor que es otro diccionario
        'calle':   'Agla',
        'numero':  6,
        'cp':      '08001',
        'ciudad':  'Barcelona'
    }
}

# Acceso en cadena: primero entramos en 'direccion', luego en 'calle' y 'numero'
print(f"El cliente {cliente['nombre']} vive en "
      f"{cliente['direccion']['calle']} número {cliente['direccion']['numero']}")
# → El cliente Ana vive en Agla número 6

# &#x1f4a1; ALTERNATIVA: extraer el sub-diccionario en una variable auxiliar
#    para no repetir cliente['direccion'] varias veces
dir = cliente['direccion']
print(f"{dir['calle']}, {dir['numero']}, {dir['cp']} {dir['ciudad']}")


# ─────────────────────────────────────────────
# LISTA DE DICCIONARIOS
# ─────────────────────────────────────────────
# Patrón muy habitual: una lista donde cada elemento
# es un diccionario que representa un "registro" (alumno, producto, usuario…)

alumnos = [
    {'nombre': 'Ana', 'email': 'ana@gmail.com'},
    {'nombre': 'Pep', 'email': 'pep@pepe.com'},
    {'nombre': 'Iu',  'email': 'iu@iu.com'}
]

# Recorrer la lista y acceder a una clave de cada diccionario
for alumno in alumnos:
    print(alumno['nombre'])    # → Ana / Pep / Iu

# Índice negativo -1: último elemento de la lista
print(alumnos[-1]['nombre'])   # → Iu

# &#x1f4a1; ALTERNATIVA: buscar un alumno concreto con next()
ana = next((a for a in alumnos if a['nombre'] == 'Ana'), None)
print(ana)   # → {'nombre': 'Ana', 'email': 'ana@gmail.com'}
#  · next() devuelve el primer resultado que cumpla la condición
#  · el None al final evita un error si no se encuentra ninguno

# &#x1f4a1; ALTERNATIVA: filtrar varios con comprensión de lista
emails = [a['email'] for a in alumnos]
print(emails)   # → ['ana@gmail.com', 'pep@pepe.com', 'iu@iu.com']


# ─────────────────────────────────────────────
# LISTAS DE LISTAS (matriz / cuadrícula)
# ─────────────────────────────────────────────
# Una lista puede contener otras listas → se comporta como una tabla 2D.
# Se accede con dos índices:  matriz[fila][columna]

cuadrado = [
    [1, 2, 3],   # fila 0
    [4, 5, 6],   # fila 1
    [7, 8, 9]    # fila 2
]

# Recorrer con for anidado: el primer for va fila a fila,
# el segundo va elemento a elemento dentro de cada fila
for linea in cuadrado:
    print(linea)           # imprime la fila entera como lista
    for numero in linea:
        print(numero)      # imprime cada número por separado

# Acceso directo a un elemento: [fila][columna]
print(cuadrado[1][1])   # → 5  (fila 1, columna 1 → el centro del cuadrado)
print(cuadrado[0][2])   # → 3  (fila 0, columna 2)
print(cuadrado[2][0])   # → 7  (fila 2, columna 0)

# &#x1f4a1; ALTERNATIVA: imprimir el cuadrado de forma más visual
for fila in cuadrado:
    print(' '.join(str(n) for n in fila))
# → 1 2 3
# → 4 5 6
# → 7 8 9


# ─────────────────────────────────────────────
# ESTRUCTURA DE DATOS COMPLEJA (caso real)
# ─────────────────────────────────────────────
# En proyectos reales anidamos todos estos conceptos:
# diccionarios, listas y sub-diccionarios a varios niveles.
# La clave es ir "entrando" nivel a nivel con corchetes.

empresa = {
    'nombre': 'TechCorp',
    'departamentos': {                          # nivel 1: dict de departamentos
        'Desarrollo': {
            'jefe': 'María Ruiz',
            'presupuesto': 150000,
            'empleados': [                      # nivel 2: lista de empleados
                {
                    'id': 'E001',
                    'nombre': 'Ana García',
                    'salario': 35000,
                    'habilidades': ['Python', 'Django', 'SQL'],
                    'proyectos': ['WebApp', 'API REST'],
                    'notas_evaluacion': [8, 9, 8, 9],
                },
                {
                    'id': 'E002',
                    'nombre': 'Carlos López',
                    'salario': 32000,
                    'habilidades': ['JavaScript', 'React', 'CSS'],
                    'proyectos': ['WebApp', 'Dashboard'],
                    'notas_evaluacion': [7, 8, 7, 6],
                },
            ],
        },
        'Marketing': {
            'jefe': 'Pedro Sanz',
            'presupuesto': 80000,
            'empleados': [
                {
                    'id': 'E003',
                    'nombre': 'Lucía Mora',
                    'salario': 28000,
                    'habilidades': ['PowerBI', 'Excel', 'Photoshop'],
                    'proyectos': ['Campaña Q1', 'Redes Sociales'],
                    'notas_evaluacion': [9, 9, 8, 9],
                },
            ],
        },
    }
}

# ── Acceso profundo paso a paso ──────────────────────────────────
# Vamos entrando nivel a nivel hasta llegar a lo que queremos:
#
#  empresa
#    └─ ['departamentos']          → dict de departamentos
#         └─ ['Desarrollo']        → dict del departamento
#              └─ ['empleados']    → lista de empleados
#                   └─ [0]         → primer empleado (Ana García)
#                        └─ ['notas_evaluacion']  → [8, 9, 8, 9]

notas = empresa['departamentos']['Desarrollo']['empleados'][0]['notas_evaluacion']
media = sum(notas) / len(notas)
print(media)   # → 8.5


# &#x1f4a1; ALTERNATIVAS Y USOS PRÁCTICOS sobre la estructura 'empresa'

# ── Listar todos los empleados de todos los departamentos
for nombre_depto, depto in empresa['departamentos'].items():
    print(f"\n── {nombre_depto} (jefe: {depto['jefe']}) ──")
    for emp in depto['empleados']:
        print(f"  {emp['id']} | {emp['nombre']} | {emp['salario']}€")

# ── Calcular la media de evaluación de cada empleado
for nombre_depto, depto in empresa['departamentos'].items():
    for emp in depto['empleados']:
        notas = emp['notas_evaluacion']
        media = sum(notas) / len(notas)
        print(f"{emp['nombre']}: media {media:.1f}")

# ── Acceso seguro con get() cuando no sabemos si la clave existe
salario = empresa['departamentos']['Desarrollo']['empleados'][0].get('bonus', 0)
print(f"Bonus: {salario}€")   # → Bonus: 0€  (no existe la clave, devuelve 0)
# ─────────────────────────────────────────────
# FUNCIONES QUE BUSCAN DENTRO DE ESTRUCTURAS ANIDADAS
# ─────────────────────────────────────────────
# Estas funciones reciben el diccionario 'empresa' del ejercicio anterior
# y devuelven una lista con los empleados que coinciden con la búsqueda.
# Si no encuentran nada, devuelven una lista vacía [].


# ─────────────────────────────────────────────
# BUSCAR EMPLEADOS POR NOMBRE
# ─────────────────────────────────────────────

def buscar_empleado(empresa, nombre):
    resultado = []                              # lista donde iremos guardando los encontrados

    departamentos = empresa['departamentos']    # extraemos el dict de departamentos

    for departamento in departamentos.values():  # recorremos cada departamento
        for empleado in departamento['empleados']:  # recorremos cada empleado del departamento

            # 'in' sobre una cadena comprueba si nombre está CONTENIDO en empleado['nombre']
            # Por eso 'ía' encuentra 'Ana García' y 'María Ruiz'
            # Es sensible a mayúsculas: 'ana' NO encontraría 'Ana'
            if nombre in empleado['nombre']:
                resultado.append(empleado)      # añadimos el dict completo del empleado

    return resultado    # devuelve [] si no encontró nadie, o una lista de coincidencias


# Encuentra a Ana García y María Ruiz porque ambas contienen 'ía'
print(buscar_empleado(empresa, 'ía'))

# Devuelve [] porque no existe ningún 'Carlos González' (hay 'Carlos López')
print(buscar_empleado(empresa, 'Carlos González'))


# &#x1f4a1; ALTERNATIVA: búsqueda sin distinguir mayúsculas/minúsculas
#    Convirtiendo ambas cadenas a minúsculas antes de comparar
def buscar_empleado_v2(empresa, nombre):
    resultado = []
    for departamento in empresa['departamentos'].values():
        for empleado in departamento['empleados']:
            if nombre.lower() in empleado['nombre'].lower():  # .lower() → todo minúsculas
                resultado.append(empleado)
    return resultado

# Ahora 'ana' encuentra 'Ana García', 'ANA' también, etc.
print(buscar_empleado_v2(empresa, 'ana'))


# &#x1f4a1; ALTERNATIVA: devolver solo el dato que interesa, no el dict entero
def buscar_nombres(empresa, nombre):
    return [
        emp['nombre']                                  # solo el nombre
        for depto in empresa['departamentos'].values()
        for emp in depto['empleados']
        if nombre.lower() in emp['nombre'].lower()     # búsqueda insensible
    ]
# Comprensión de lista con dos for anidados: más compacta, misma lógica


# ─────────────────────────────────────────────
# BUSCAR EMPLEADOS POR HABILIDAD
# ─────────────────────────────────────────────

def buscar_empleado_habilidades(empresa, habilidad):
    resultado = []

    departamentos = empresa['departamentos']

    for departamento in departamentos.values():
        for empleado in departamento['empleados']:

            # Aquí 'in' comprueba si habilidad es un elemento de la LISTA
            # empleado['habilidades'] es ['Python', 'Django', 'SQL'] etc.
            # Esto busca coincidencia EXACTA: 'React' sí, 'react' NO
            if habilidad in empleado['habilidades']:
                resultado.append(empleado)

    return resultado


# Encuentra a Carlos López porque tiene 'React' en su lista de habilidades
print(buscar_empleado_habilidades(empresa, 'React'))

# Devuelve [] porque nadie tiene 'C#' en sus habilidades
print(buscar_empleado_habilidades(empresa, 'C#'))


# &#x1f4a1; ALTERNATIVA: búsqueda insensible a mayúsculas en la lista de habilidades
def buscar_empleado_habilidades_v2(empresa, habilidad):
    resultado = []
    for departamento in empresa['departamentos'].values():
        for empleado in departamento['empleados']:
            # Convertimos cada habilidad a minúsculas antes de comparar
            habilidades_lower = [h.lower() for h in empleado['habilidades']]
            if habilidad.lower() in habilidades_lower:
                resultado.append(empleado)
    return resultado

print(buscar_empleado_habilidades_v2(empresa, 'python'))  # Encuentra a Ana García


# &#x1f4a1; ALTERNATIVA: buscar empleados que tengan VARIAS habilidades a la vez
def buscar_por_varias_habilidades(empresa, habilidades_buscadas):
    resultado = []
    for departamento in empresa['departamentos'].values():
        for empleado in departamento['empleados']:
            # set y operador <= comprueban si un conjunto está contenido en otro
            # {'Python','SQL'} <= {'Python','Django','SQL'}  → True
            if set(habilidades_buscadas) <= set(empleado['habilidades']):
                resultado.append(empleado['nombre'])
    return resultado

print(buscar_por_varias_habilidades(empresa, ['Python', 'SQL']))
# → ['Ana García']


# &#x1f4a1; ALTERNATIVA: función genérica que busca por CUALQUIER campo
def buscar_por_campo(empresa, campo, valor):
    resultado = []
    for departamento in empresa['departamentos'].values():
        for empleado in departamento['empleados']:
            if empleado.get(campo) == valor:   # get() evita error si el campo no existe
                resultado.append(empleado['nombre'])
    return resultado

print(buscar_por_campo(empresa, 'salario', 32000))  # → ['Carlos López']

Patrón general: «recorrer y filtrar»

def buscar_X(estructura, criterio):
    resultado = []                          # 1. empezamos con lista vacía

    for nivel1 in estructura.values():      # 2. recorremos el nivel 1
        for item in nivel1['coleccion']:    # 3. recorremos el nivel 2

            if criterio cumple condicion:   # 4. comprobamos el criterio
                resultado.append(item)      # 5. guardamos el encontrado

    return resultado  

Diccionarios


# ─────────────────────────────────────────────
# DICCIONARIOS EN PYTHON
# ─────────────────────────────────────────────
# Un diccionario guarda pares  clave: valor
# · Las claves son únicas (no puede haber dos iguales)
# · Los valores pueden ser de cualquier tipo
# · Se definen con llaves  { }
# · A diferencia de las listas, NO se accede por posición
#   sino por el nombre de la clave

producto = {
    'referencia': 'SK789',
    'precio': 200
}
print(producto)   # → {'referencia': 'SK789', 'precio': 200}

clase = {'nombre': 'WEB', 'capacidad': 20}
print(clase)   # → {'nombre': 'WEB', 'capacidad': 20}


# ─────────────────────────────────────────────
# LEER Y MODIFICAR VALORES
# ─────────────────────────────────────────────

# Acceder a un valor con su clave entre corchetes
print(clase['nombre'])      # → WEB
print(clase['capacidad'])   # → 20

# Modificar un valor existente (misma sintaxis que al leer)
clase['capacidad'] = 15
print(clase)   # → {'nombre': 'WEB', 'capacidad': 15}


camisa = {
    'talla':   'L',
    'color':   'rojo',
    'precio':  20,
    'moneda':  '€'
}

# f-string con acceso al diccionario dentro de las llaves { }
print(f"La talla de la camisa es {camisa['talla']}")   # → La talla de la camisa es L


# ─────────────────────────────────────────────
# ACCESO SEGURO: get()
# ─────────────────────────────────────────────

# Si accedemos a una clave que NO existe con corchetes → ERROR (KeyError)
# print(camisa['stock'])   ← esto lanzaría un KeyError y detendría el programa

# get(clave) devuelve el valor si existe, o None si no existe (sin error)
print(camisa.get('stock'))      # → None

# get(clave, valor_por_defecto) devuelve el valor por defecto si no existe
print(camisa.get('stock', 0))   # → 0   ← mucho más útil en la práctica

# 💡 ALTERNATIVA: comprobar antes con 'in'
if 'stock' in camisa:
    print(camisa['stock'])
else:
    print("La clave 'stock' no existe")


# ─────────────────────────────────────────────
# AÑADIR CLAVES NUEVAS
# ─────────────────────────────────────────────

# Asignar a una clave que no existe → la crea automáticamente
camisa['stock'] = 20
print(camisa)
# → {'talla':'L','color':'rojo','precio':20,'moneda':'€','stock':20}


# ─────────────────────────────────────────────
# UPDATE: añadir o modificar varias claves a la vez
# ─────────────────────────────────────────────

# update() acepta argumentos con nombre (clave=valor)
camisa.update(almacen='Central', activo=True)
print(camisa)
# → {..., 'almacen': 'Central', 'activo': True}

# 💡 ALTERNATIVA: pasarle otro diccionario
# camisa.update({'almacen': 'Central', 'activo': True})

# 💡 ALTERNATIVA desde Python 3.9: operador |=
# camisa |= {'almacen': 'Central', 'activo': True}


# ─────────────────────────────────────────────
# VISTAS: keys(), values(), items()
# ─────────────────────────────────────────────
# Estas tres funciones devuelven "vistas" del diccionario.
# No son listas normales, pero se pueden recorrer con for
# y se actualizan automáticamente si el diccionario cambia.

claves   = camisa.keys()     # Todas las claves
valores  = camisa.values()   # Todos los valores
elementos = camisa.items()   # Pares (clave, valor) como tuplas

print(claves)     # → dict_keys(['talla', 'color', 'precio', ...])
print(valores)    # → dict_values(['L', 'rojo', 20, ...])
print(elementos)  # → dict_items([('talla','L'), ('color','rojo'), ...])

# 💡 Si necesitas una lista real puedes convertirlas:
# list(camisa.keys())    → ['talla', 'color', 'precio', ...]
# list(camisa.values())  → ['L', 'rojo', 20, ...]


# ─────────────────────────────────────────────
# ACCEDER CON UNA VARIABLE COMO CLAVE
# ─────────────────────────────────────────────

# La clave puede estar guardada en una variable, no tiene que ser literal
miclave = "stock"
print(camisa[miclave])      # → 20

miclave = "almacen"
print(camisa[miclave])      # → Central

# Esto es muy útil cuando la clave viene de una entrada del usuario
# o se calcula en tiempo de ejecución


# ─────────────────────────────────────────────
# RECORRER UN DICCIONARIO CON FOR
# ─────────────────────────────────────────────

# OPCIÓN 1: iterar solo por las claves y acceder al valor manualmente
for clave in camisa.keys():
    print(clave, camisa[clave])

# OPCIÓN 2 (más elegante): iterar por clave y valor a la vez con items()
for clave, valor in camisa.items():
    print(clave, valor)

# 💡 ALTERNATIVA: iterar directamente (sin .keys()) hace lo mismo que la opción 1
# for clave in camisa:
#     print(clave, camisa[clave])

# 💡 ALTERNATIVA: crear una lista de pares formateados con comprensión de lista
# pares = [f"{k} → {v}" for k, v in camisa.items()]


# ─────────────────────────────────────────────
# ELIMINAR ELEMENTOS: pop() y popitem()
# ─────────────────────────────────────────────

# pop(clave): elimina la clave indicada y DEVUELVE su valor
elemento = camisa.pop('almacen')
print(elemento)   # → Central          (el valor que tenía 'almacen')
print(camisa)     # → {...} sin 'almacen'

# 💡 pop() también acepta valor por defecto para evitar errores:
# camisa.pop('clave_inexistente', None)   → devuelve None sin error

# popitem(): elimina y devuelve el ÚLTIMO par (clave, valor) insertado
# Devuelve una tupla  →  ('clave', valor)
elemento = camisa.popitem()
print(elemento)   # → ('activo', True)   (el último que se añadió)
print(camisa)     # → {...} sin 'activo'

# 💡 ALTERNATIVA para eliminar sin recuperar el valor:
# del camisa['precio']   → borra la clave directamente