Repaso proceso dbt

Fase 1. Explorar los datos y redactar el informe de calidad

Echar un vistazo a los datos, para ver si hay datos erróneos, que faltan, etc. En mi opinión, lo primero que hay que hacer siempre independientemente del tipo de proyecto que tengamos.

Fase 2. Fuentes, seed, macros y staging

Creamos un entorno virtual

python -m venv .venv

Lo activamos

.venv\Scripts\activate

Instalamos dbt-duckdb

pip install dbt-duckdb

Creamos el proyecto:

dbt init bicing

Vamos a la carpeta:

cd bicing

Inicializamos git:

git init

git add .

git commit -m Inicio

Arrancamos el Visual Studio Code (opcional)

code .

Copiamos los datos csv a la carpeta datos, distritos.csv a seed y el carrega_dades.sql. El carregar.py a scripts\

python .\scripts\carregar.py

Cargamos los distritos:

dbt seed

Eliminamos models\example y también de dbt_project

Usamos dbt debug para comprobar si todo está bien

Stagging: Tenemos tres fuentes, pues creamos las carpetas y un sources.yaml

models
– staging
– intermeadiate
– marts

sources.yml

version: 2

sources:
  - name: raw
    description: "Datos originales del ejercicio, cargados desde CSV en el esquema raw. Todas las columnas llegan como texto."
    schema: raw
    tables:
      - name: estaciones
        description: "Una fila por estación y carga. Puede haber estaciones repetidas y filas de prueba."
      - name: uso
        description: "Viajes de salida y de llegada por estación y mes. Puede haber duplicados y claves rotas."

stg_distritos

select * 
from {{ ref('distritos') }}

stg_estaciones

select station_id as id_estacion,
name  as nombre_estacion,
type  as tipo_estacion,
lat as latitud,
lon as longitud,
altitude as altitud,
address as direccion,
post_code  as codigo_postal,
capacity as capacidad,
district  as distrito,
status as estado,
installed_on  as fecha_instalacion,
loaded_at  as fecha_carga
from {{ source('raw', 'estaciones') 

stg_uso

select station_id as id_estacion,
period as periodo,
departures as salidas,
arrivals as llegadas,
loaded_at as fecha_carga
from {{ source('raw', 'uso') }}

Usaremos una macro para normalizar texto
macros
– normalizar_texto.sql

{% macro normalizar_texto(columna) %}
    nullif(
        strip_accents(lower(trim({{ columna }}))),
        ''
    )
{% endmacro %}

La usaremos en stg_estaciones:

select station_id as id_estacion,
{{ normalizar_texto('name')}}  as nombre_estacion,
{{ normalizar_texto('type')}}  as tipo_estacion,
lat as latitud,
lon as longitud,
altitude as altitud,
address as direccion,
post_code  as codigo_postal,
capacity as capacidad,
district  as distrito,
status as estado,
installed_on  as fecha_instalacion,
loaded_at  as fecha_carga
from {{ source('raw', 'estaciones') }}

Fase 3. Intermediate: reglas de calidad y cruces

Sirve para realizar cálculos que puedan ser útiles en siguientes fases. Relacionan tablas, hacen sumas, enriquecen los datos. Puedo tener una tabla ‘pedidos’ con el id cliente sin el nombre, o puedo calcular el total por clientes.

En este caso vamos a calcular el total de salidas y llegadas por estación y mes:

intermediate
– int_total_por_mes

select id_estacion,
extract(month from periodo) as mes,
sum(salidas) as total_salidas,
sum(llegadas) as total_llegadas
from stg_uso
group by id_estacion,
extract(month from periodo) 

Aquí nos encontramos varios problemas que nos obligarán a limpiar los datos. Por un lado convertiremos el periodo en fecha con una macro:

normalizar_periodo.sql

{% macro normalizar_periodo(columna) %}
    coalesce(
        try_strptime(trim(cast({{ columna }} as varchar)), '%Y-%m'),
        try_strptime(trim(cast({{ columna }} as varchar)), '%m/%Y')
    )::date
{% endmacro %}

Y por otro cambiaremos el stg_uso:

select station_id as id_estacion,
{{normalizar_periodo('period')}} as periodo,
try_cast(departures as integer) as salidas,
try_cast(arrivals as integer) as llegadas,
loaded_at as fecha_carga
from {{ source('raw', 'uso') }}

 

Fase 4. Marts: indicadores finales

Hacemos un informe con la cantidad de salidas y llegadas de cada estación:
fct_totales_estacion

with estaciones as (
    select
        *
    from {{ ref('stg_estaciones') }}
    where id_estacion is not null
),
uso as (select * from
{{ref('int_total_por_mes')}}
)
select 
e.nombre_estacion,
sum(total_salidas) as total_salidas,
sum(total_llegadas) as total_llegadas
 from
estaciones e join
uso u on
e.id_estacion=u.id_estacion
group by e.nombre_estacion

Fase 5. Jinja: variables y bucles

Fase 6. Tests y documentación

Probamos un test personalizado para ver si todas las salidas son correctas (no hay negativas):

select * from {{ref('stg_uso')}}
where salidas<0

int_bicing_uso_valido

select id_estacion,
extract(month from periodo) as mes,
sum(salidas) as total_salidas,
sum(llegadas) as total_llegadas
from stg_uso
group by id_estacion,
extract(month from periodo) 

int_estaciones_enriquecidas

with estaciones as (
    select
        *
    from {{ ref('stg_estaciones') }}
    where id_estacion is not null
),
distritos as (
    select
        distrito_id,
        distrito_nombre,
        {{ normalizar_texto('distrito_nombre') }} as distrito_clave
    from {{ ref('stg_distritos') }}
)
select
    e.id_estacion,
    e.nombre_estacion,
    coalesce(e.tipo_estacion, 'desconocido')                          as tipo_estacion,
    e.estado,
    capacidad,
    e.latitud,
    e.longitud,
    e.altitud,
    e.codigo_postal,
    e.direccion,
    d.distrito_id,
    e.distrito,
    e.fecha_instalacion
from estaciones as e
left join distritos as d
    on d.distrito_clave = e.distrito
    

Ejemplo redes

# ==============================================================================
# CONVERSIÓN FAHRENHEIT → CELSIUS CON REDES NEURONALES
#
# FÓRMULA REAL:   C = (F - 32) × 5/9   ≈   C = F × 0.5556 - 17.778
#
# OBJETIVO: que la red aprenda sola esa fórmula, SIN que nosotros se la digamos.
# Solo le damos pares de datos (F, C) y dejamos que ajuste sus pesos.
#
# MODELOS:
#   Modelo 1 — UNA sola neurona           (1 peso + 1 bias = 2 parámetros)
#   Modelo 2 — Dos capas de 4 neuronas    (más potente, más parámetros)
#
# INSTALACIÓN (una sola vez):
#   pip install tensorflow numpy matplotlib
# ==============================================================================


# ==============================================================================
# PASO 1: Importar librerías
# ==============================================================================

import numpy as np
import matplotlib.pyplot as plt
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

tf.random.set_seed(42)
np.random.seed(42)

print("=" * 60)
print("  REDES NEURONALES: Fahrenheit → Celsius")
print("=" * 60)
print(f"  TensorFlow: {tf.__version__}")


# ==============================================================================
# PASO 2: Crear el dataset — 20 pares (Fahrenheit, Celsius)
# ==============================================================================
#
# Generamos 20 temperaturas en Fahrenheit distribuidas entre -40 °F y 212 °F
# y calculamos su equivalente exacto en Celsius con la fórmula real.
#
# -40 °F es un punto especial: coincide exactamente con -40 °C.
# 212 °F = punto de ebullición del agua = 100 °C.

fahrenheit = np.array([
    -40,  -22,   -4,   14,   32,   50,   68,   77,   86,   95,
    104,  113,  122,  140,  158,  176,  185,  194,  203,  212
], dtype=float)

# Fórmula exacta: C = (F - 32) × 5 / 9
celsius = (fahrenheit - 32) * 5 / 9

print("\n" + "=" * 60)
print("PASO 2: Dataset — 20 pares Fahrenheit / Celsius")
print("=" * 60)
print(f"{'Fahrenheit':>12} │ {'Celsius':>10} │ {'Nota'}")
print("-" * 45)
notas = {-40: "F=C", 32: "Congela", 98.6: "Cuerpo", 212: "Hierve"}
for f, c in zip(fahrenheit, celsius):
    nota = notas.get(f, "")
    print(f"{f:>12.1f} │ {c:>10.4f} │ {nota}")


# ==============================================================================
# PASO 3: Normalizar los datos
# ==============================================================================
#
# Las redes neuronales aprenden mejor cuando los datos están en rangos pequeños
# (cerca de 0). Guardamos la media y desviación para desnormalizar al final.

f_media  = fahrenheit.mean()
f_std    = fahrenheit.std()
c_media  = celsius.mean()
c_std    = celsius.std()

F_norm = (fahrenheit - f_media) / f_std    # Fahrenheit normalizado
C_norm = (celsius    - c_media) / c_std    # Celsius    normalizado

print("\n" + "=" * 60)
print("PASO 3: Normalización (media=0, desv=1)")
print("=" * 60)
print(f"  Fahrenheit → media: {f_media:.2f}, desv: {f_std:.2f}")
print(f"  Celsius    → media: {c_media:.2f}, desv: {c_std:.2f}")


# ==============================================================================
# PASO 4A: MODELO 1 — Una sola neurona
# ==============================================================================
#
# Arquitectura:   [1 entrada F]  →  [1 neurona]  →  [1 salida C]
#
# Esta neurona tiene exactamente DOS parámetros:
#   w (peso / weight): multiplica a F
#   b (bias / sesgo):  suma un valor fijo
#
# La neurona calcula:   salida = w × F + b
#
# ¿Se puede aprender C = F × 0.5556 - 17.778 con una sola neurona?
# SÍ, porque la conversión es una función LINEAL (línea recta).
# Una neurona sin activación no lineal es exactamente eso: una línea.
#
# Sin activation= → la neurona es lineal (el valor por defecto es 'linear')

modelo1 = keras.Sequential([
    layers.Dense(units=1, input_shape=(1,))   # 1 neurona, sin activación no lineal
], name="Una_Neurona")

modelo1.compile(
    optimizer=keras.optimizers.Adam(learning_rate=0.05),
    loss="mean_squared_error"   # MSE: error cuadrático medio entre C_pred y C_real
)

print("\n" + "=" * 60)
print("PASO 4A: Modelo 1 — Una sola neurona")
print("=" * 60)
modelo1.summary()

hist1 = modelo1.fit(
    F_norm, C_norm,
    epochs=500,
    verbose=0          # silencioso para no llenar la pantalla
)

loss_final_m1 = hist1.history["loss"][-1]
print(f"\n  ✅ Entrenamiento completado — Loss final: {loss_final_m1:.8f}")


# ==============================================================================
# PASO 4B: MODELO 2 — Dos capas de 4 neuronas
# ==============================================================================
#
# Arquitectura:
#   [1 entrada]  →  [4 neuronas ReLU]  →  [4 neuronas ReLU]  →  [1 salida]
#
# Parámetros:
#   Capa oculta 1: (1 entrada × 4 neuronas) + 4 bias = 8
#   Capa oculta 2: (4 entradas × 4 neuronas) + 4 bias = 20
#   Capa salida:   (4 entradas × 1 neurona)  + 1 bias = 5
#   TOTAL: 33 parámetros
#
# Para un problema LINEAL como este, esta arquitectura es "excesiva",
# pero sirve perfectamente para comparar comportamiento y mostrar los pesos.
#
# ReLU: si el valor es negativo → 0; si es positivo → lo deja pasar.
# La combinación de varias ReLU puede aproximar cualquier función.

modelo2 = keras.Sequential([
    layers.Dense(units=4, activation="relu", input_shape=(1,)),  # capa oculta 1
    layers.Dense(units=4, activation="relu"),                     # capa oculta 2
    layers.Dense(units=1)                                         # capa de salida (lineal)
], name="Dos_Capas_4_Neuronas")

modelo2.compile(
    optimizer=keras.optimizers.Adam(learning_rate=0.05),
    loss="mean_squared_error"
)

print("\n" + "=" * 60)
print("PASO 4B: Modelo 2 — Dos capas de 4 neuronas")
print("=" * 60)
modelo2.summary()

hist2 = modelo2.fit(
    F_norm, C_norm,
    epochs=500,
    verbose=0
)

loss_final_m2 = hist2.history["loss"][-1]
print(f"\n  ✅ Entrenamiento completado — Loss final: {loss_final_m2:.8f}")


# ==============================================================================
# PASO 5: Evaluar el rendimiento de ambos modelos
# ==============================================================================

def evaluar_modelo(modelo, F_norm, celsius_real, f_media, f_std, c_media, c_std):
    """
    Evalúa un modelo comparando sus predicciones con los valores reales.
    Desnormaliza tanto la predicción como el valor real para mostrar °C verdaderos.
    """
    C_pred_norm = modelo.predict(F_norm, verbose=0).flatten()

    # Desnormalizamos para obtener grados Celsius reales
    C_pred_real = C_pred_norm * c_std + c_media

    errores_abs = np.abs(C_pred_real - celsius_real)
    mae  = errores_abs.mean()     # Error Absoluto Medio (MAE) en °C
    rmse = np.sqrt(((C_pred_real - celsius_real) ** 2).mean())  # Raíz del Error Cuadrático Medio

    return C_pred_real, mae, rmse

C_pred1, mae1, rmse1 = evaluar_modelo(modelo1, F_norm, celsius, f_media, f_std, c_media, c_std)
C_pred2, mae2, rmse2 = evaluar_modelo(modelo2, F_norm, celsius, f_media, f_std, c_media, c_std)

print("\n" + "=" * 60)
print("PASO 5: Rendimiento comparado")
print("=" * 60)
print(f"\n  {'Modelo':<28} {'MAE (°C)':>10} {'RMSE (°C)':>12}")
print("  " + "-" * 52)
print(f"  {'Modelo 1: Una neurona':<28} {mae1:>10.6f} {rmse1:>12.6f}")
print(f"  {'Modelo 2: Dos capas × 4':<28} {mae2:>10.6f} {rmse2:>12.6f}")
print("\n  MAE  = error promedio en grados Celsius")
print("  RMSE = error promedio (penaliza más los errores grandes)")
print("\n  Un MAE < 0.5 °C es excelente para uso práctico.")

# Tabla predicciones vs real
print("\n  Tabla de predicciones (primeras 10 muestras):")
print(f"  {'F (°F)':>8} │ {'C real':>8} │ {'M1 pred':>8} │ {'M1 err':>8} │ "
      f"{'M2 pred':>8} │ {'M2 err':>8}")
print("  " + "-" * 65)
for i in range(10):
    print(f"  {fahrenheit[i]:>8.1f} │ {celsius[i]:>8.4f} │ {C_pred1[i]:>8.4f} │ "
          f"{abs(C_pred1[i]-celsius[i]):>8.4f} │ {C_pred2[i]:>8.4f} │ "
          f"{abs(C_pred2[i]-celsius[i]):>8.4f}")


# ==============================================================================
# PASO 6: Mostrar los pesos de cada neurona
# ==============================================================================
#
# Los pesos son los NÚMEROS que la red aprendió ajustando sus parámetros.
# Para Modelo 1 (una neurona lineal), deberían aproximarse a:
#   w ≈ 1.0  (en escala normalizada)
#   b ≈ 0.0  (la fórmula C = F × 0.5556 - 17.78 se transforma en la escala norm.)
#
# Los pesos reales en escala original se pueden calcular así:
#   pendiente = w × (c_std / f_std)
#   intercepto = b × c_std + c_media - w × (c_std / f_std) × f_media

print("\n" + "=" * 60)
print("PASO 6: Pesos aprendidos por cada neurona")
print("=" * 60)

# ---- MODELO 1: UNA NEURONA ----
print("\n  ── MODELO 1: Una sola neurona ──")
pesos_m1, bias_m1 = modelo1.layers[0].get_weights()

# get_weights() devuelve una lista: [matriz_de_pesos, vector_de_bias]
# Para 1 neurona con 1 entrada: pesos shape=(1,1), bias shape=(1,)
w = pesos_m1[0][0]     # el único peso
b = bias_m1[0]         # el único bias

# Convertir de escala normalizada a escala real (°C / °F)
pendiente    = w * (c_std / f_std)
intercepto   = b * c_std + c_media - w * (c_std / f_std) * f_media

print(f"\n  Neurona 1:")
print(f"    Peso  (w) en escala norm.:  {w:>10.6f}")
print(f"    Bias  (b) en escala norm.:  {b:>10.6f}")
print(f"\n  Traducido a escala real °C = w×°F + b:")
print(f"    Pendiente aprendida:  {pendiente:>10.6f}  (real: 0.555556)")
print(f"    Intercepto aprendido: {intercepto:>10.6f}  (real: -17.777778)")
print(f"\n  Error de la pendiente:  {abs(pendiente - 0.555556):>10.8f} °C/°F")
print(f"  Error del intercepto:   {abs(intercepto - (-17.777778)):>10.8f} °C")

# ---- MODELO 2: DOS CAPAS DE 4 NEURONAS ----
print("\n\n  ── MODELO 2: Dos capas de 4 neuronas ──")

nombres_capas = ["Capa oculta 1 (ReLU)", "Capa oculta 2 (ReLU)", "Capa de salida (lineal)"]

for idx, (capa, nombre) in enumerate(zip(modelo2.layers, nombres_capas)):
    pesos, bias = capa.get_weights()
    # pesos shape: (n_entradas, n_neuronas)
    # bias  shape: (n_neuronas,)
    n_entradas, n_neuronas = pesos.shape

    print(f"\n  ┌─ {nombre}")
    print(f"  │  Entradas: {n_entradas}   Neuronas: {n_neuronas}")
    print(f"  │")

    for j in range(n_neuronas):
        print(f"  │  Neurona {j+1}:")
        for i in range(n_entradas):
            print(f"  │    w[entrada_{i+1}→neurona_{j+1}] = {pesos[i][j]:>10.6f}")
        print(f"  │    bias[neurona_{j+1}]           = {bias[j]:>10.6f}")
        if j < n_neuronas - 1:
            print(f"  │")
    print(f"  └{'─' * 50}")


# ==============================================================================
# PASO 7: Gráficos
# ==============================================================================

fig, axes = plt.subplots(2, 2, figsize=(15, 11))
fig.suptitle("Redes Neuronales: Aprender Fahrenheit → Celsius", fontsize=15, fontweight="bold")

VERDE   = "#27AE60"
NARANJA = "#E67E22"
AZUL    = "#2980B9"
ROJO    = "#E74C3C"

# ---- Gráfico 1: Curvas de pérdida ----
ax = axes[0, 0]
ax.plot(hist1.history["loss"], color=NARANJA, linewidth=2, label="Modelo 1: 1 neurona")
ax.plot(hist2.history["loss"], color=AZUL,    linewidth=2, label="Modelo 2: 2 capas × 4")
ax.set_title("Curva de pérdida (MSE) durante el entrenamiento")
ax.set_xlabel("Época")
ax.set_ylabel("MSE (escala normalizada)")
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_yscale("log")   # escala logarítmica para ver mejor la convergencia

# ---- Gráfico 2: Predicciones vs real ----
ax = axes[0, 1]
ax.plot(fahrenheit, celsius,  color=ROJO,    linewidth=2.5, linestyle="--",
        label="Fórmula real", zorder=5)
ax.scatter(fahrenheit, C_pred1, color=NARANJA, s=80, marker="o",
           label="Modelo 1: 1 neurona", zorder=4)
ax.scatter(fahrenheit, C_pred2, color=AZUL,    s=60, marker="^",
           label="Modelo 2: 2 capas × 4", zorder=3)
ax.set_title("Predicciones vs valores reales")
ax.set_xlabel("Temperatura (°F)")
ax.set_ylabel("Temperatura (°C)")
ax.legend()
ax.grid(True, alpha=0.3)

# ---- Gráfico 3: Error absoluto por muestra ----
ax = axes[1, 0]
x_idx = np.arange(len(fahrenheit))
ancho = 0.35
bars1 = ax.bar(x_idx - ancho/2, np.abs(C_pred1 - celsius), ancho,
               color=NARANJA, alpha=0.85, label="Modelo 1: 1 neurona")
bars2 = ax.bar(x_idx + ancho/2, np.abs(C_pred2 - celsius), ancho,
               color=AZUL, alpha=0.85, label="Modelo 2: 2 capas × 4")
ax.set_title("Error absoluto por muestra (°C)")
ax.set_xlabel("Índice de muestra")
ax.set_ylabel("|Predicción − Real|  (°C)")
ax.set_xticks(x_idx)
ax.set_xticklabels([f"{f:.0f}" for f in fahrenheit], rotation=45, ha="right", fontsize=8)
ax.legend()
ax.grid(True, alpha=0.3, axis="y")

# ---- Gráfico 4: Visualización de pesos del Modelo 1 ----
ax = axes[1, 1]
categorias  = ["Peso (w)\n(escala norm.)", "Bias (b)\n(escala norm.)",
               "Pendiente real\n(°C/°F)", "Intercepto real\n(°C)"]
valores_aprendidos = [w,          b,          pendiente,       intercepto]
valores_reales     = [None,       None,        0.555556,       -17.777778]
colores = [NARANJA, NARANJA, VERDE, VERDE]

bars = ax.bar(categorias, valores_aprendidos, color=colores, alpha=0.8, edgecolor="white")

# Líneas horizontales con los valores teóricos (solo donde aplica)
ax.axhline(0.555556,    xmin=0.49, xmax=0.74, color=ROJO, linewidth=2.5,
           linestyle="--", label="Valor teórico")
ax.axhline(-17.777778,  xmin=0.74, xmax=1.0,  color=ROJO, linewidth=2.5,
           linestyle="--")

for bar, val in zip(bars, valores_aprendidos):
    ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.15 * np.sign(val + 0.001),
            f"{val:.4f}", ha="center", va="bottom" if val >= 0 else "top", fontsize=9, fontweight="bold")

ax.set_title("Modelo 1: Pesos aprendidos vs teóricos")
ax.set_ylabel("Valor del parámetro")
ax.axhline(0, color="black", linewidth=0.8)
ax.legend()
ax.grid(True, alpha=0.3, axis="y")

plt.tight_layout()
plt.savefig("fahrenheit_celsius_redes.png", dpi=150, bbox_inches="tight")
plt.show()


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

print("\n" + "=" * 60)
print("RESUMEN FINAL")
print("=" * 60)
print(f"\n  Fórmula real:       C = F × 0.555556 − 17.777778")
print(f"  Fórmula aprendida:  C = F × {pendiente:.6f} − {abs(intercepto):.6f}")
print(f"\n  {'Modelo':<28} {'Parámetros':>12} {'MAE (°C)':>10}")
print("  " + "-" * 52)
print(f"  {'Modelo 1: Una neurona':<28} {'2':>12} {mae1:>10.6f}")
print(f"  {'Modelo 2: Dos capas × 4':<28} {'33':>12} {mae2:>10.6f}")
print(f"\n  ✅ Código completado. Gráfico guardado: fahrenheit_celsius_redes.png")

Ejemplo K-means

# ── Importaciones ─────────────────────────────────────────────────────
import numpy as np                  # para operaciones numéricas
import matplotlib.pyplot as plt     # para hacer gráficos
from sklearn.cluster import KMeans  # el algoritmo K-Means
from sklearn.datasets import make_blobs  # generador de datos de ejemplo


# ── PASO 1: Crear datos de ejemplo ────────────────────────────────────
#
# make_blobs() genera puntos artificiales agrupados en 'manchas' (blobs).
# Es como inventarse datos ya organizados para practicar.
#
# n_samples  = cuántos puntos queremos generar en total
# centers    = cuántos grupos/manchas queremos que tenga
# cluster_std = qué tan dispersos están los puntos dentro de cada grupo
#               (número pequeño = puntos muy juntos, número grande = dispersos)
# random_state = semilla aleatoria (garantiza que siempre salga igual)
#
X, y_real = make_blobs(
    n_samples=300,    # 300 puntos en total
    centers=4,        # distribuidos en 4 grupos
    cluster_std=0.8,  # puntos bastante juntos en cada grupo
    random_state=42   # para reproducibilidad
)
#
# X tiene forma (300, 2): 300 filas (puntos) y 2 columnas (coordenadas x, y)
# y_real contiene el grupo real de cada punto (solo para comparar después)


# ── PASO 2: Crear el modelo K-Means ───────────────────────────────────
#
# Aquí solo DEFINIMOS el modelo, todavía no lo entrenamos.
# Parámetros importantes:
#
# n_clusters  = el K: cuántos grupos queremos encontrar
#               Elegimos 4 porque sabemos que los datos tienen 4 grupos.
#               En la vida real no sabremos esto y usaremos el Método del Codo.
#
# n_init      = cuántas veces reinicia el algoritmo con centroides distintos.
#               Por defecto prueba 10 inicializaciones y elige la mejor.
#               Más intentos = más fiable, pero más lento.
#
# random_state = semilla para que el resultado sea siempre el mismo.
#
kmeans = KMeans(
    n_clusters=4,
    n_init=10,
    random_state=42
)


# ── PASO 3: Entrenar el modelo ─────────────────────────────────────────
#
# .fit(X) ejecuta el algoritmo K-Means sobre nuestros datos X.
# Internamente hace todos los pasos descritos antes:
# coloca centroides → asigna puntos → recalcula centroides → repite.
#
kmeans.fit(X)


# ── PASO 4: Obtener los resultados ────────────────────────────────────
#
# Después del entrenamiento, el modelo guarda sus resultados en atributos:
#
# .labels_          → array con el número de cluster asignado a cada punto
#                     Ejemplo: [0, 2, 1, 0, 3, 1, 2, ...] (un número por punto)
#
# .cluster_centers_ → array con las coordenadas de los K centroides finales
#                     Tiene forma (K, n_características)
#
# .inertia_         → el valor WCSS final (qué tan compactos son los clusters)
#
etiquetas  = kmeans.labels_
centroides = kmeans.cluster_centers_
inercia    = kmeans.inertia_

print(f'Inercia (WCSS): {inercia:.2f}')
print(f'Centroides encontrados:')
print(centroides)


# ── PASO 5: Visualizar los resultados ─────────────────────────────────
#
# Hacemos un scatter plot (nube de puntos).
# Cada punto se colorea según el cluster al que fue asignado.
# Los centroides se marcan con una X grande en rojo.
#
plt.figure(figsize=(8, 6))

# scatter() dibuja los puntos:
# X[:, 0] = primera columna (coordenada x de todos los puntos)
# X[:, 1] = segunda columna (coordenada y de todos los puntos)
# c=etiquetas → el color de cada punto depende de su cluster
# cmap='viridis' → la paleta de colores a usar
# alpha=0.7 → transparencia (0=invisible, 1=sólido)
plt.scatter(X[:, 0], X[:, 1], c=etiquetas, cmap='viridis', s=50, alpha=0.7)

# Dibujamos los centroides con una X roja más grande:
# centroides[:, 0] = coordenadas x de todos los centroides
# centroides[:, 1] = coordenadas y de todos los centroides
# marker='X' → símbolo de X
# zorder=5   → se dibuja por encima de los puntos
plt.scatter(centroides[:, 0], centroides[:, 1],
            c='red', marker='X', s=200, zorder=5, label='Centroides')

plt.title('K-Means: Resultado del clustering')
plt.xlabel('Característica 1')
plt.ylabel('Característica 2')
plt.legend()
plt.colorbar(label='Número de cluster')
plt.tight_layout()
plt.show()

Regresión ( de nuevo)

# ==============================================================================
# 1. IMPORTACIÓN DE LIBRERÍAS
# ==============================================================================
# Pandas es la librería estrella para manejar datos en formato de tabla (como un Excel).
import pandas as pd

# De la librería 'sklearn' (scikit-learn), importamos herramientas específicas:
# 'train_test_split' nos servirá para dividir nuestros datos en dos grupos (entrenamiento y examen).
from sklearn.model_selection import train_test_split    

# 'StandardScaler' se usa para "normalizar" o "escalar" los datos (ponerlos todos en una misma escala).
from sklearn.preprocessing import StandardScaler        

# 'LogisticRegression' es el algoritmo que creará nuestro modelo de clasificación.
from sklearn.linear_model import LogisticRegression     

# Importamos las métricas para evaluar qué tan bueno es nuestro modelo al final:
from sklearn.metrics import (
    accuracy_score,        # Exactitud: Porcentaje total de aciertos sobre el total.
    precision_score,       # Precisión: De los que el modelo dijo que eran positivos, ¿cuántos lo eran de verdad?
    recall_score,          # Exhaustividad: De los positivos reales que había, ¿cuántos fue capaz de detectar?
    f1_score,              # Puntuación F1: Un equilibrio o "media" entre precisión y recall.
    confusion_matrix,      # Matriz de confusión: Una tabla que muestra dónde acertó y dónde se equivocó.
    classification_report  # Un resumen de texto que junta todas las métricas anteriores.
)

# ==============================================================================
# 2. PREPARACIÓN DE LOS DATOS INICIALES
# ==============================================================================
# Creamos dos listas de Python con nuestros datos brutos.
# 'horas_entrenamiento' es lo que queremos usar para predecir (la causa).
horas_entrenamiento = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
# 'clasificado' es el resultado (el efecto): 0 significa "No clasificado" y 1 significa "Clasificado".
clasificado = [0,0,0,0,0,0,0,1,0,0,1,1,0,1,1,1,1,1,0,1]

# Verificamos que ambas listas tengan la misma cantidad de elementos (en este caso, 20).
# Esto es vital: cada alumno/caso debe tener sus horas y su resultado correspondiente.
print("Cantidad de datos de horas:", len(horas_entrenamiento))
print("Cantidad de datos de clasificación:", len(clasificado))

# ==============================================================================
# 3. CREACIÓN DEL DATAFRAME (LA TABLA DE DATOS)
# ==============================================================================
# Juntamos las dos listas en una estructura de tabla organizada gracias a Pandas.
df = pd.DataFrame({
    "horas": horas_entrenamiento,
    "clasificado": clasificado
})
# Imprimimos la tabla en consola para ver visualmente cómo quedan las filas y columnas.
print("\n--- Nuestra tabla de datos (DataFrame) ---")
print(df)

# Separamos la tabla en dos partes fundamentales para el Machine Learning:
# X: Las variables "predictoras" o características (en mayúscula por convención matemática).
# Usamos doble corchete [[ ]] porque los modelos de sklearn siempre esperan una matriz de dos dimensiones para las X.
X = df[["horas"]]

# y: La variable "objetivo" o lo que queremos predecir (en minúscula por ser un solo vector).
y = df["clasificado"]

# ==============================================================================
# 4. DIVISIÓN EN ENTRENAMIENTO (TRAIN) Y PRUEBA (TEST)
# ==============================================================================
# Imagina que preparas a un alumno para un examen: no le das las preguntas del examen para estudiar.
# Por eso dividimos los datos: un grupo para estudiar (Train) y otro oculto para evaluar (Test).
# 'test_size=0.2' significa que dejamos el 20% de los datos para el examen (4 filas) y el 80% para estudiar (16 filas).
# 'random_state=0' es una "semilla" para que el reparto aleatorio sea siempre el mismo si volvemos a ejecutar el código.
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)

# ==============================================================================
# 5. NORMALIZACIÓN / ESCALADO DE DATOS
# ==============================================================================
# A los algoritmos les cuesta más aprender si los números son gigantescos o están en escalas muy diferentes.
# El escalador ajusta los datos para que tengan una media de 0 y una desviación de 1 (los "centra").

escalador = StandardScaler()

# ¡OJO aquí! Calculamos la escala con los datos de entrenamiento y los transformamos.
# Es fundamental guardar el resultado de vuelta en las variables 'X_train' y 'X_test'.
X_train = escalador.fit_transform(X_train)

# Con los datos de test (el examen) SOLO transformamos, no recalculamos la escala, 
# para no hacer "trampa" y mantener las reglas del grupo de entrenamiento.
X_test = escalador.transform(X_test)

# ==============================================================================
# 6. CREACIÓN Y ENTRENAMIENTO DEL MODELO
# ==============================================================================
# Llamamos al algoritmo de Regresión Logística (pese a llamarse regresión, sirve para clasificar 0s y 1s).
modelo = LogisticRegression()

# Entrenamos el modelo con la función '.fit()'. 
# Aquí es donde el modelo "estudia" la relación entre las horas (X_train) y si clasificó o no (y_train).
modelo.fit(X_train, y_train)
print("\n¡El modelo ha sido entrenado con éxito!")

# ==============================================================================
# 7. PREDICCIÓN Y EVALUACIÓN (EL EXAMEN)
# ==============================================================================
# Le pedimos al modelo que intente adivinar el resultado de las horas del grupo de examen (X_test).
# El modelo NO conoce las respuestas reales (y_test), solo ve las horas.
y_pred = modelo.predict(X_test)

# Ahora comparamos las respuestas reales (y_test) con las que el modelo predijo (y_pred).
exactitud = accuracy_score(y_test, y_pred)

# Mostramos el resultado final por pantalla. 
# Si da 1.0 significa 100% de aciertos en el examen; si da 0.5 significa un 50%, etc.
print("\n--- Evaluación del Modelo ---")
print(f"La exactitud (Accuracy) del modelo en el examen es de: {exactitud}")