El dataset de MercaFresh ya está limpio y sin huecos, pero "correcto" no es lo mismo que "útil". El gasto total de los clientes tiene una cola larguísima hacia la derecha (recuerda los histogramas del módulo 2), las fechas de alta son solo etiquetas de calendario que ningún modelo sabe interpretar, y la información más rica —los pedidos individuales— está a nivel de pedido cuando el modelo de churn necesita una fila por cliente. En esta lección aprenderás a remodelar variables: transformaciones matemáticas que corrigen la asimetría, discretización en tramos, descomposición de fechas en componentes con significado y agregaciones que convierten miles de pedidos en un perfil por cliente. Cerraremos con los pipelines de scikit-learn, la herramienta que ordena todos estos pasos en una cadena reproducible.

Contenido

  1. Por qué transformar: cuando la forma de los datos estorba
  2. Transformaciones matemáticas: log, raíz y potencias
  3. Box-Cox y Yeo-Johnson: la transformación que se ajusta sola
  4. Binning: discretizar variables continuas
  5. Fechas: de texto muerto a componentes útiles
  6. Agregaciones por entidad: de pedidos a clientes
  7. Pipelines: encadenar transformaciones con orden

Por qué transformar: cuando la forma de los datos estorba

Muchos modelos rinden mejor —o directamente asumen— que las variables tienen distribuciones razonablemente simétricas y relaciones aproximadamente lineales. Pero las variables de negocio rara vez nacen así:

  • Importes y gastos: la mayoría de clientes de MercaFresh gasta poco y unos pocos gastan muchísimo. Es la asimetría positiva que viste en 02-01 (media > mediana) y una distribución parecida a la exponencial de 02-02.
  • Recuentos: número de pedidos, de visitas, de incidencias — colas largas a la derecha.
  • Fechas: un datetime no dice nada por sí mismo; lo que predice es lo que la fecha implica (¿fin de semana?, ¿cuánto hace?).
  • Granularidad equivocada: tenemos 80.000 pedidos, pero el churn se predice por cliente.

Transformar es cambiar la representación de los datos sin cambiar su significado, para que los patrones queden más a la vista del modelo.

Transformaciones matemáticas: log, raíz y potencias

El logaritmo, la reina de las transformaciones

El logaritmo comprime los valores grandes mucho más que los pequeños, así que "recoge" la cola derecha de una distribución asimétrica:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
# Gasto anual simulado de 1000 clientes: mayoría modesta, cola de grandes cuentas
gasto = rng.lognormal(mean=6.5, sigma=0.9, size=1000)

fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))
axes[0].hist(gasto, bins=40)
axes[0].set_title("Gasto anual (original): asimetría fuerte")
axes[1].hist(np.log1p(gasto), bins=40)
axes[1].set_title("log(1 + gasto): casi simétrico")
plt.tight_layout()
plt.show()

print(f"Asimetría original: {pd.Series(gasto).skew():.2f}")        # ~3 (muy sesgada)
print(f"Asimetría tras log: {pd.Series(np.log1p(gasto)).skew():.2f}")  # ~0 (simétrica)

Detalles que importan:

  • Usamos np.log1p(x) (que calcula log(1 + x)) en lugar de np.log(x) porque el logaritmo de 0 es menos infinito, y en MercaFresh hay clientes con gasto 0. log1p maneja el 0 con elegancia (log1p(0) = 0).
  • skew() mide la asimetría numéricamente: ~0 es simétrica, > 1 es sesgo fuerte a la derecha. Es la traducción a número de lo que el histograma muestra a ojo.
  • Tras el logaritmo, la distribución se parece a la normal de 02-02, y las herramientas que asumen normalidad (z-scores incluidos) vuelven a funcionar bien.

La escalera de transformaciones

El logaritmo no es la única opción; hay una "escalera" ordenada por agresividad:

Transformación Fórmula Fuerza Restricción Uso típico
Raíz cuadrada np.sqrt(x) Suave x ≥ 0 Recuentos (nº de pedidos)
Logaritmo np.log1p(x) Media x > -1 Importes, ingresos
Inversa 1 / x Fuerte x ≠ 0 Colas extremas (raro)

Regla práctica: prueba de menos a más agresiva y comprueba con skew() y el histograma cuál deja la distribución más simétrica sin retorcerla.

Box-Cox y Yeo-Johnson: la transformación que se ajusta sola

En vez de elegir a mano entre raíz y logaritmo, Box-Cox busca automáticamente el exponente óptimo (llamado λ, lambda) que hace la variable lo más parecida posible a una normal. λ = 0 equivale al logaritmo; λ = 0,5, a la raíz; λ = 1, a no hacer nada.

from scipy import stats

# Box-Cox exige valores estrictamente positivos
gasto_bc, lambda_optimo = stats.boxcox(gasto)
print(f"Lambda optimo: {lambda_optimo:.3f}")   # ~0.05: casi un logaritmo puro

Limitación: Box-Cox solo admite valores positivos. Para variables con ceros o negativos (un saldo de puntos que puede ser negativo, una variación de gasto), existe Yeo-Johnson, su generalización. En scikit-learn ambas viven en PowerTransformer, con la ventaja del patrón fit/transform que ya conoces de la lección anterior (aprende λ en train, lo aplica a datos nuevos):

from sklearn.preprocessing import PowerTransformer

pt = PowerTransformer(method="yeo-johnson")   # o method="box-cox"
gasto_transformado = pt.fit_transform(gasto.reshape(-1, 1))
print(pt.lambdas_)   # el λ aprendido, guardado para reutilizar

(El reshape(-1, 1) convierte el vector en una matriz de una columna: los transformadores de scikit-learn siempre esperan datos en 2D, filas × columnas.)

Una advertencia de legibilidad: tras transformar, los valores ya no están en euros. Un coeficiente sobre log(gasto) se interpreta en términos multiplicativos, no aditivos. Ganamos rendimiento del modelo a cambio de interpretación menos directa; conviene guardar siempre la variable original al lado.

Binning: discretizar variables continuas

Discretizar (o binning) es convertir una variable continua en tramos: la edad exacta en franjas de edad, el gasto en niveles bajo/medio/alto. ¿Para qué?

  • Capturar relaciones no lineales sencillas ("los menores de 30 y los mayores de 65 compran distinto").
  • Robustez frente a outliers: el cliente de 15.400 € cae en el tramo "alto" y deja de distorsionar.
  • Comunicación con negocio: "clientes de gasto alto" se entiende mejor que un coeficiente.

El precio: se pierde información (todos los del tramo se vuelven indistinguibles). Es un intercambio, no un truco gratis.

cut: tramos por valor

edades = pd.Series([22, 34, 45, 29, 61, 38, 70, 27, 52, 41])

franjas = pd.cut(edades,
                 bins=[18, 30, 45, 65, 100],
                 labels=["joven", "adulto", "senior", "mayor"])
print(franjas.value_counts())

cut corta donde tú dices: los límites tienen significado de negocio (mayoría de edad, jubilación). Cada intervalo incluye su límite derecho por defecto: (18, 30], (30, 45], etc.

qcut: tramos por cuantiles

gasto_s = pd.Series(gasto)
niveles = pd.qcut(gasto_s, q=4, labels=["Q1", "Q2", "Q3", "Q4"])
print(niveles.value_counts())   # 250 clientes en cada tramo, garantizado

qcut corta por percentiles (los de 02-01): cada tramo tiene el mismo número de observaciones, aunque sus límites en euros salgan "feos". Es la elección natural para variables asimétricas: con cut a intervalos iguales, el 90 % de los clientes caería en el primer tramo.

KBinsDiscretizer: binning dentro de scikit-learn

from sklearn.preprocessing import KBinsDiscretizer

kb = KBinsDiscretizer(n_bins=4, encode="ordinal", strategy="quantile")
gasto_binned = kb.fit_transform(gasto_s.to_frame())
print(kb.bin_edges_[0].round(0))   # los límites aprendidos en fit

strategy acepta "uniform" (anchos iguales, como cut automático), "quantile" (como qcut) y "kmeans". Su valor añadido es el de siempre: los límites se aprenden en fit y se reaplican idénticos a datos nuevos, evitando que cada mes los tramos se muevan.

Herramienta Cortes Cuándo usarla
pd.cut Donde tú decides Límites con significado de negocio
pd.qcut Por cuantiles Variables asimétricas, tramos equilibrados
KBinsDiscretizer Aprendidos en fit Dentro de un flujo de ML reproducible

Fechas: de texto muerto a componentes útiles

En 03-01 convertimos las fechas de texto a datetime. Ahora las exprimimos: un modelo no puede usar "2024-06-05", pero sí las señales que esa fecha esconde.

pedidos = pd.DataFrame({
    "id_pedido": range(1, 8),
    "id_cliente": [101, 101, 105, 107, 105, 110, 101],
    "fecha": pd.to_datetime(["2026-05-02", "2026-05-16", "2026-05-17",
                             "2026-06-01", "2026-06-14", "2026-07-25",
                             "2026-08-10"]),
    "importe": [45.2, 38.9, 120.5, 15400.0, 95.3, 22.1, 51.7],
})

# El accesor .dt da acceso a los componentes de la fecha
pedidos["anio"] = pedidos["fecha"].dt.year
pedidos["mes"] = pedidos["fecha"].dt.month
pedidos["dia_semana"] = pedidos["fecha"].dt.dayofweek      # 0 = lunes ... 6 = domingo
pedidos["es_finde"] = (pedidos["dia_semana"] >= 5).astype(int)

# Festivos: con una lista de negocio (o la librería 'holidays' para el calendario oficial)
festivos = pd.to_datetime(["2026-06-01", "2026-08-15"])
pedidos["es_festivo"] = pedidos["fecha"].isin(festivos).astype(int)

# Recencia: días transcurridos desde la fecha hasta una fecha de referencia
hoy = pd.Timestamp("2026-08-24")
pedidos["dias_desde_pedido"] = (hoy - pedidos["fecha"]).dt.days

¿Por qué funciona? Porque para predecir demanda o churn en MercaFresh, lo relevante no es el 5 de junio en sí, sino que era lunes, que era festivo o que fue hace 80 días. Cada componente extraído convierte conocimiento del calendario en una columna que el modelo puede usar.

Dos matices:

  • dayofweek y month son numéricos pero cíclicos: el domingo (6) está "al lado" del lunes (0), y diciembre al lado de enero. Muchos modelos los tratan bien como categorías (lección 03-04); las codificaciones trigonométricas seno/coseno existen para modelos sensibles al orden, y basta con saber que ese matiz está ahí.
  • La "fecha de referencia" de la recencia debe ser una fecha fija del análisis (la de corte del dataset), no Timestamp.now(): si no, el mismo código da resultados distintos cada día.

Agregaciones por entidad: de pedidos a clientes

El modelo de churn necesita una fila por cliente, pero la información viva está en la tabla de pedidos. La solución es agregar: resumir el historial de cada cliente en estadísticos.

perfil = pedidos.groupby("id_cliente").agg(
    num_pedidos=("id_pedido", "count"),
    gasto_medio=("importe", "mean"),
    gasto_total=("importe", "sum"),
    gasto_maximo=("importe", "max"),
    primer_pedido=("fecha", "min"),
    ultimo_pedido=("fecha", "max"),
)

# Frecuencia y recencia derivadas de las fechas agregadas
perfil["dias_como_cliente"] = (hoy - perfil["primer_pedido"]).dt.days
perfil["dias_sin_comprar"] = (hoy - perfil["ultimo_pedido"]).dt.days
perfil["pedidos_por_mes"] = perfil["num_pedidos"] / (perfil["dias_como_cliente"] / 30)

print(perfil.round(2))

La sintaxis agg(nombre_nuevo=("columna", "funcion")) (agregación con nombre) produce columnas con nombres limpios en un solo paso. Observa qué hemos conseguido: el cliente 101, con tres pedidos repartidos entre mayo y agosto, queda descrito por su frecuencia, su ticket medio y —oro puro para el churn— cuántos días lleva sin comprar.

flowchart LR
    A["Tabla de pedidos<br/>(1 fila = 1 pedido)"] -->|"groupby('id_cliente').agg(...)"| B["Perfil de cliente<br/>(1 fila = 1 cliente)"]
    B --> C["Se une a la tabla de clientes<br/>(merge por id_cliente)"]

El último paso es unir este perfil a la tabla de clientes limpia de las lecciones anteriores:

# clientes: la tabla limpia de 03-01/03-02
# perfil:   las agregaciones que acabamos de construir
dataset = clientes.merge(perfil, on="id_cliente", how="left")

Con how="left" conservamos también a los clientes sin pedidos (sus agregados quedarán como NaN... que ya sabes tratar de la lección anterior: aquí un 0 en num_pedidos y un indicador sin_pedidos serían imputaciones con pleno sentido de negocio). Estas agregaciones son la antesala del análisis RFM que completaremos en 03-06.

Pipelines: encadenar transformaciones con orden

Llevamos ya varios pasos encadenados: imputar, transformar con logaritmos, discretizar... y cada uno con su fit sobre train y su transform sobre el resto. Hacerlo a mano, en orden, sin olvidar ninguno y sin fugas, es frágil. scikit-learn ofrece dos piezas para ensamblarlo:

Pipeline: pasos en serie

from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import PowerTransformer

pipe_numerico = Pipeline(steps=[
    ("imputar", SimpleImputer(strategy="median")),
    ("desasimetrizar", PowerTransformer(method="yeo-johnson")),
])

resultado = pipe_numerico.fit_transform(perfil[["gasto_medio", "gasto_total"]])

Un Pipeline es una lista de pasos con nombre que se ejecutan en orden: la salida de cada uno alimenta al siguiente. Al llamar a fit_transform, cada paso hace su fit y su transform en cadena; al llamar después a transform sobre datos nuevos, todos aplican lo aprendido sin reaprender nada. Un solo objeto encapsula todo el preprocesamiento.

ColumnTransformer: pasos en paralelo por columnas

No todas las columnas necesitan lo mismo: las numéricas quieren imputación y logaritmos; las categóricas, otras técnicas (las de la próxima lección). ColumnTransformer aplica a cada grupo de columnas su propio tratamiento y junta los resultados:

from sklearn.compose import ColumnTransformer

preprocesador = ColumnTransformer(transformers=[
    ("numericas", pipe_numerico, ["gasto_medio", "gasto_total", "num_pedidos"]),
    ("fechas_ya_derivadas", "passthrough", ["dias_sin_comprar"]),
    # ("categoricas", ..., ["ciudad", "plan"])  <- lo rellenaremos en 03-04
])

X = preprocesador.fit_transform(dataset_ejemplo)

Cada tupla es (nombre, transformador, lista_de_columnas); "passthrough" deja las columnas pasar tal cual. De momento quédate con la idea introductoria: el preprocesamiento completo puede vivir en un único objeto que se ajusta con fit sobre entrenamiento y se aplica con transform a cualquier dato futuro. En las próximas lecciones iremos rellenando las ramas que faltan, y en el módulo 6 el pipeline demostrará su valor definitivo al combinarse con la validación.

Errores Comunes y Consejos

  • Aplicar np.log a valores con ceros. Produce -inf silenciosos que revientan el modelo más tarde. Usa np.log1p, y verifica antes que no hay negativos.
  • Transformar y olvidar la des-transformación. Si el modelo predice log(gasto), la predicción en euros es np.expm1(prediccion). Documenta qué variables están transformadas.
  • Elegir tramos de cut que dejan grupos vacíos. Comprueba siempre value_counts() tras discretizar; un tramo con 3 observaciones no aporta nada. Con asimetría, qcut suele ser mejor punto de partida.
  • Usar Timestamp.now() como referencia de recencia. El dataset debe tener una fecha de corte fija y documentada, o los resultados cambian cada día que ejecutas el código.
  • Agregar con mean en columnas con outliers. El gasto medio del cliente 107 está dominado por su pedido de 15.400 €; considera añadir también la mediana por cliente.
  • Ajustar transformadores con todo el dataset. PowerTransformer y KBinsDiscretizer aprenden parámetros: mismo riesgo de fuga que la imputación (03-02). Dentro de un Pipeline, este error se vuelve casi imposible de cometer — otra razón para usarlos.
  • Consejo: antes y después de cada transformación, dibuja el histograma y mira skew(). Dos líneas de código que evitan transformar a ciegas.

Ejercicios

Ejercicio 1

La variable num_incidencias de MercaFresh (número de incidencias de entrega por cliente) toma valores [0, 0, 1, 0, 2, 0, 1, 9, 0, 3]. Calcula su asimetría con skew(), aplica la transformación de la escalera que consideres adecuada (justifica cuál y por qué el logaritmo simple daría problemas) y recalcula la asimetría.

Ejercicio 2

Divide los importes de pedido [12, 18, 22, 25, 31, 38, 45, 60, 85, 240] en 4 niveles con qcut y en 4 tramos de igual anchura con cut. Compara los value_counts() de ambos y explica cuál es más útil aquí y por qué.

Ejercicio 3

A partir del DataFrame pedidos de la lección, construye con una sola agregación el perfil por cliente con: número de pedidos, mediana de importe y días desde el último pedido (referencia: 2026-08-24). Nombra las columnas n_pedidos, importe_mediano y recencia_dias.

Soluciones

Ejercicio 1

s = pd.Series([0, 0, 1, 0, 2, 0, 1, 9, 0, 3])
print(s.skew())                    # ~2.6: asimetría fuerte a la derecha

# Es un recuento con ceros: np.log(0) = -inf, así que el log simple falla.
# Opciones válidas: np.sqrt (suave, admite 0) o np.log1p (media, admite 0).
t = np.log1p(s)
print(t.skew())                    # ~1.1: mucho más contenida

Con recuentos pequeños y ceros abundantes, sqrt o log1p son las elecciones naturales; el logaritmo puro queda descartado por los ceros.

Ejercicio 2

imp = pd.Series([12, 18, 22, 25, 31, 38, 45, 60, 85, 240])

por_cuantiles = pd.qcut(imp, q=4)
por_anchura = pd.cut(imp, bins=4)

print(por_cuantiles.value_counts())  # 3-2-2-3: tramos equilibrados
print(por_anchura.value_counts())    # 9-0-0-1: ¡casi todo en el primer tramo!

Con cut, el outlier de 240 estira el rango y deja dos tramos vacíos: inútil. qcut reparte las observaciones por percentiles y produce niveles equilibrados: es la opción adecuada para esta distribución asimétrica.

Ejercicio 3

hoy = pd.Timestamp("2026-08-24")

perfil = pedidos.groupby("id_cliente").agg(
    n_pedidos=("id_pedido", "count"),
    importe_mediano=("importe", "median"),
    ultimo=("fecha", "max"),
)
perfil["recencia_dias"] = (hoy - perfil["ultimo"]).dt.days
perfil = perfil.drop(columns=["ultimo"])
print(perfil)

La mediana de importe protege el perfil del cliente 107 frente a su pedido gigante, y recencia_dias —los días sin comprar— será una de las variables estrella del modelo de churn.

Conclusión

Has aprendido a remodelar los datos para que muestren sus patrones: corregir asimetrías con logaritmos, raíces y las transformaciones automáticas Box-Cox/Yeo-Johnson; discretizar con cut, qcut y KBinsDiscretizer sabiendo qué se gana y qué se pierde; convertir fechas en componentes con poder predictivo (día de semana, festivo, recencia); resumir la tabla de pedidos en perfiles por cliente con groupby().agg(); y encadenar todo en Pipeline y ColumnTransformer para que el preprocesamiento sea reproducible y a prueba de fugas.

Habrás notado que el ColumnTransformer dejó una rama vacía: las columnas categóricas. La ciudad del cliente, la categoría de producto o el plan de suscripción de MercaFresh siguen siendo texto, y ningún modelo sabe multiplicar por "Barcelona". En la próxima lección resolvemos exactamente eso: la codificación de variables categóricas.

Curso de Machine Learning

Módulo 1: Introducción al Machine Learning

Módulo 2: Fundamentos de Estadística y Probabilidad

Módulo 3: Preprocesamiento de Datos

Módulo 4: Algoritmos de Machine Learning Supervisado

Módulo 5: Algoritmos de Machine Learning No Supervisado

Módulo 6: Evaluación y Validación de Modelos

Módulo 7: Técnicas Avanzadas y Optimización

Módulo 8: Implementación y Despliegue de Modelos

Módulo 9: Proyectos Prácticos

Módulo 10: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados