Hasta ahora hemos trabajado con datos cómodos: la tabla que devuelve generar_pedidos_ml no tiene huecos, ni duplicados, ni fechas, ni columnas de texto libre, y todas sus columnas están listas para entrar en fit. El pedidos.csv real de NovaMarket no se parece en nada a eso: en 02-03 vimos que tenía importes vacíos, códigos postales mal tecleados y pedidos duplicados por una integración defectuosa. Allí aprendimos a diagnosticar la calidad y el sesgo de los datos; en esta lección aprenderemos a transformarlos para que un algoritmo pueda usarlos: rellenar o eliminar ausentes, tratar atípicos, convertir categorías en números sin engañar al modelo, escalar, extraer información de las fechas, crear características nuevas con conocimiento del negocio, seleccionar las útiles, evitar la fuga de información y, sobre todo, encadenar todo eso en un pipeline que se ajusta solo con los datos de entrenamiento. Es importante porque aquí se decide la mayor parte de la calidad de un modelo: un algoritmo mediocre con buenas características suele ganar a un algoritmo sofisticado con datos mal preparados, y porque los errores de esta fase (sobre todo la fuga de información) producen modelos que parecen excelentes en el laboratorio y fracasan en producción.
Contenido
- Por qué la preparación se lleva el 60-80 % del tiempo
- Los pedidos "sucios" de NovaMarket: la función
ensuciar_pedidos - Valores ausentes, duplicados y atípicos
- Codificación de variables categóricas
- Escalado de variables numéricas
- Fechas e ingeniería de características
- Selección de características
- Fuga de información (data leakage)
- Dividir antes de transformar:
PipelineyColumnTransformer - Tabla resumen: qué técnica para cada tipo de columna
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Por qué la preparación se lleva el 60-80 % del tiempo
Las encuestas a profesionales del dato coinciden desde hace años: entre el 60 % y el 80 % del tiempo de un proyecto de machine learning se va en obtener, limpiar y transformar datos, y solo una fracción pequeña en entrenar modelos. Las razones son estructurales:
- Los algoritmos de scikit-learn (y de casi cualquier librería) exigen una matriz numérica sin huecos: cada fila una instancia, cada columna un número. Los datos de negocio vienen con texto, fechas, vacíos y códigos.
- Los datos reales tienen errores (el importe de 99.999 € que era 99,99 €), inconsistencias (la misma categoría escrita de tres formas) y duplicados.
- La información útil rara vez está en las columnas tal cual: hay que construirla (el importe por artículo, la tasa de devolución previa del cliente) a partir de conocimiento del negocio. Esta "ingeniería de características" es donde Marta aporta más valor.
- Hay decisiones que no puede tomar el algoritmo: qué columnas existirán realmente en el momento de predecir, qué hacer con un valor ausente, si un atípico es un error o un dato valioso.
En 02-03 construimos un informe de calidad (porcentaje de nulos, duplicados, valores fuera de rango, sesgo por grupos) con el módulo csv. Esta lección da el paso siguiente: usar pandas y scikit-learn para transformar. La diferencia de enfoque es importante: en 02-03 el objetivo era saber qué había en los datos; aquí es dejarlos listos para fit sin introducir trampas.
- Los pedidos "sucios" de NovaMarket: la función
ensuciar_pedidos
ensuciar_pedidosPara practicar necesitamos datos realistas. Partimos de generar_pedidos_ml (04-01) y le añadimos, con código, los problemas típicos que Marta encontró en pedidos.csv: identificadores, fecha del pedido, dos columnas de texto nuevas (método de pago y tipo de envío), valores ausentes, un importe imposible, filas duplicadas y una columna que solo existe después de la devolución (motivo_devolucion). Añade la función a novamarket_ml.py:
import numpy as np
import pandas as pd
def ensuciar_pedidos(pedidos, semilla=42):
"""Copia 'realista' de los pedidos: NaN, duplicados, un atípico, fechas, texto y una fuga."""
rng = np.random.default_rng(semilla)
sucio = pedidos.copy()
n = len(sucio)
sucio.insert(0, "id_pedido", [f"P{100000 + i}" for i in range(n)])
sucio.insert(1, "id_cliente", rng.integers(1, 1201, size=n)) # unos 1.200 clientes
fechas = pd.Timestamp("2025-09-01") + pd.to_timedelta(rng.integers(0, 90, size=n), unit="D")
sucio.insert(2, "fecha_pedido", fechas) # 90 días de pedidos
sucio["metodo_pago"] = rng.choice(["tarjeta", "paypal", "contrareembolso", "bizum"],
size=n, p=[0.55, 0.25, 0.08, 0.12])
sucio["tipo_envio"] = rng.choice(["estandar", "rapido", "urgente"], size=n, p=[0.6, 0.3, 0.1])
# valores ausentes
sucio.loc[rng.random(n) < 0.05, "dias_entrega"] = np.nan
sucio.loc[rng.random(n) < 0.02, "importe"] = np.nan
sucio.loc[rng.random(n) < 0.03, "metodo_pago"] = np.nan
# un atípico imposible (error de tecleo)
sucio.loc[17, "importe"] = 99999.0
# columna que solo existe DESPUÉS de la devolución (fuga de información)
motivos = rng.choice(["no le gusta", "defectuoso", "talla/modelo", "llego tarde"], size=n)
sucio["motivo_devolucion"] = np.where(sucio["devuelto"] == 1, motivos, "")
# tres filas duplicadas
sucio = pd.concat([sucio, sucio.iloc[[5, 42, 300]]], ignore_index=True)
return sucio.sort_values("fecha_pedido").reset_index(drop=True)
pedidos = generar_pedidos_ml(3000, 42)
sucio = ensuciar_pedidos(pedidos, 42)
print(sucio.shape)
print(sucio.isna().sum())
print("Duplicados:", sucio.duplicated().sum())
print(sucio["importe"].describe().round(1))Salida (resumida):
(3003, 13) importe 56 dias_entrega 153 metodo_pago 100 (las demás columnas) 0 Duplicados: 3 count 2947.0 mean 158.8 std 1841.7 min 5.9 50% 107.9 75% 163.6 max 99999.0
El diagnóstico (lo que hacíamos en 02-03, ahora con pandas): 3.003 filas, tres duplicadas; 56 importes, 153 días de entrega y 100 métodos de pago ausentes; y un importe máximo de 99.999 € que dispara la media (158,8 € frente a una mediana de 107,9 €) y la desviación típica. Nota: isna().sum() cuenta los nulos por columna, duplicated().sum() las filas repetidas y describe() da los estadísticos básicos.
- Valores ausentes, duplicados y atípicos
3.1 Duplicados
Casi siempre se eliminan; la duda es qué significa "duplicado" (¿toda la fila igual, o el mismo id_pedido?). Aquí, la fila completa:
3.2 Valores ausentes
Dos estrategias:
| Estrategia | Cuándo | Cómo en scikit-learn |
|---|---|---|
| Eliminar filas (o columnas) con ausentes | Pocas filas afectadas y ausencia aleatoria; o columna con >50 % vacía | df.dropna() |
| Imputar (rellenar) con un valor | Es lo habitual: no se tiran datos | SimpleImputer(strategy=...) |
Las estrategias de imputación básicas: media (numéricas simétricas), mediana (numéricas con atípicos, como importe), moda o valor más frecuente (categóricas), constante ("desconocido", 0), y métodos avanzados (imputar con un modelo a partir de las otras columnas, KNNImputer/IterativeImputer). Un consejo extra: a veces la ausencia es información (que falte el método de pago puede indicar un canal concreto); en ese caso se añade una columna binaria "estaba ausente" (SimpleImputer(add_indicator=True)).
No imputaremos todavía a mano: lo haremos dentro del pipeline de la sección 9, y enseguida verás por qué es importante.
3.3 Atípicos (outliers)
Un atípico es un valor muy alejado del resto. El criterio más usado para detectarlos es el rango intercuartílico (IQR): se calculan el primer y el tercer cuartil (Q1, Q3), y se considera atípico lo que queda por debajo de Q1 − 1,5·IQR o por encima de Q3 + 1,5·IQR:
q1, q3 = limpio["importe"].quantile([0.25, 0.75])
iqr = q3 - q1
limite_superior = q3 + 1.5 * iqr
print(f"Q1={q1:.1f} Q3={q3:.1f} IQR={iqr:.1f} límite superior={limite_superior:.1f}")
atipicos = limpio[limpio["importe"] > limite_superior]
print("Atípicos por IQR:", len(atipicos))
print(atipicos["importe"].sort_values(ascending=False).head(4).values)Salida:
Y aquí llega la decisión que no puede tomar el algoritmo: el criterio marca 114 pedidos, pero solo uno es un error. Los 113 pedidos entre 314 y 633 € son atípicos estadísticos y clientes reales que además se devuelven mucho (son justo los que preocupan a Diego); eliminarlos destruiría la señal. El de 99.999 € es imposible en NovaMarket (ningún producto supera los 5.000 €) y es un error de tecleo. Las opciones ante un atípico:
- Eliminar la fila si es un error evidente o imposible (nuestro caso).
- Corregir si se conoce el error (99.999 → 99,99 si hay forma de confirmarlo).
- Recortar (winsorizar) al límite: sustituir todo lo que supere un valor por ese valor.
- Dejarlo y usar modelos robustos (árboles) o transformaciones (logaritmo del importe).
limpio = limpio[limpio["importe"].isna() | (limpio["importe"] < 5000)].copy()
print("Tras quitar el imposible:", limpio.shape) # (2999, 13)(Conservamos las filas con importe ausente, isna(), para imputarlas después.)
- Codificación de variables categóricas
Los algoritmos necesitan números, y categoria, metodo_pago, tipo_envio y codigo_postal_zona son texto. La tentación es asignar enteros arbitrarios (hogar=0, electronica=1, informatica=2, accesorios=3); es un error para categorías sin orden: el modelo interpretaría que accesorios (3) es "más" que hogar (0) y que informatica está "entre" ambas, relaciones que no existen. Las alternativas correctas dependen del tipo de categoría:
| Técnica | Idea | Cuándo | Ejemplo |
|---|---|---|---|
One-hot (OneHotEncoder) |
Una columna binaria por categoría | Nominales (sin orden) con pocas categorías | categoria → categoria_hogar, categoria_electronica, ... (0/1) |
Ordinal (OrdinalEncoder con orden explícito) |
Un entero que respeta el orden real | Ordinales (con orden) | tipo_envio: estandar=0 < rapido=1 < urgente=2 |
| Frecuencia | Sustituir cada categoría por su frecuencia | Nominales con muchísimas categorías (miles de códigos postales), cuando el one-hot crearía demasiadas columnas | codigo_postal → proporción de pedidos de ese código |
| Target encoding | Sustituir por la tasa media de la etiqueta en esa categoría | Muchas categorías; exige hacerlo dentro de validación para no filtrar la etiqueta | codigo_postal → tasa de devolución de ese código |
Con OneHotEncoder(handle_unknown="ignore") el modelo no falla si en producción aparece una categoría nueva ("crypto" como método de pago): simplemente pone ceros. Con OrdinalEncoder(categories=[[...]]) fijamos nosotros el orden; sin ese argumento asignaría enteros alfabéticos, es decir, arbitrarios.
- Escalado de variables numéricas
importe va de 6 a 633 y num_articulos de 1 a 5. Para un árbol de decisión da igual (pregunta "¿importe > 195?" sin comparar columnas), pero para cualquier algoritmo basado en distancias (k vecinos, k-means, SVM) o en gradiente (regresión logística y lineal con regularización, redes neuronales) las columnas grandes dominan y el entrenamiento se resiente. Dos escalados habituales:
| Escalado | Fórmula | Resultado | Cuándo |
|---|---|---|---|
Estandarización (StandardScaler) |
(x − media) / desviación típica | Media 0, desviación 1; sin límites | Por defecto para logística, SVM, k-NN, PCA, redes |
Min-max (MinMaxScaler) |
(x − mín) / (máx − mín) | Entre 0 y 1 | Cuando se necesita un rango acotado (algunas redes, imágenes); sensible a atípicos |
Ejemplo con tres pedidos (importe, num_articulos) = (16,25, 2), (31,60, 1), (25,49, 3): estandarizados quedan (−1,30, 0), (1,13, −1,22), (0,17, 1,22); con min-max, (0, 0,5), (1, 0), (0,6, 1). Ahora las dos columnas pesan lo mismo. Regla práctica: escala siempre salvo que uses solo árboles o bosques; y recuerda que las medias y desviaciones se calculan con el conjunto de entrenamiento (sección 9).
- Fechas e ingeniería de características
6.1 Fechas
Una fecha no se puede usar tal cual, pero contiene información: día de la semana, mes, si es festivo, cuántos días han pasado desde un origen. pandas lo hace con el accesor .dt:
limpio["dia_semana"] = limpio["fecha_pedido"].dt.dayofweek # 0 = lunes ... 6 = domingo
limpio["mes"] = limpio["fecha_pedido"].dt.month
limpio["fin_de_semana"] = (limpio["dia_semana"] >= 5).astype(int)
limpio["dias_desde_inicio"] = (limpio["fecha_pedido"] - limpio["fecha_pedido"].min()).dt.daysPara la previsión de demanda (04-04) usaremos lo mismo sobre la serie semanal: la semana del año captura la estacionalidad y las "ventas de la semana anterior" (un lag) capturan la inercia.
6.2 Ingeniería de características
Es crear columnas nuevas que expresen conocimiento del negocio. Tres tipos que Marta añade al predictor de devoluciones:
# Ratio: cuánto cuesta cada artículo (un pedido de 400 € por 1 artículo no es lo mismo que por 5)
limpio["importe_por_articulo"] = limpio["importe"] / limpio["num_articulos"]
# Agregados por cliente, calculados SOLO con los pedidos anteriores a cada uno
limpio = limpio.sort_values(["fecha_pedido", "id_pedido"])
por_cliente = limpio.groupby("id_cliente")
limpio["pedidos_previos"] = por_cliente.cumcount() # 0 en el primer pedido
limpio["devoluciones_previas"] = por_cliente["devuelto"].cumsum() - limpio["devuelto"]
limpio["tasa_devolucion_previa"] = (limpio["devoluciones_previas"]
/ limpio["pedidos_previos"].replace(0, np.nan)).fillna(0)Para el cliente 1124, por ejemplo, la secuencia de pedidos queda: pedidos previos 0, 1, 2, 3, 4, 5; devoluciones previas 0, 0, 0, 0, 0, 1 (su quinto pedido se devolvió y solo cuenta a partir del sexto); tasa previa 0, ..., 0, 0,2. Fíjate en el detalle de restar limpio["devuelto"]: cumsum() incluye la fila actual, y la devolución del pedido actual no se conoce cuando hay que predecirlo. Este cuidado es la diferencia entre una característica legítima y una fuga (sección 8).
Texto básico: para resenas.csv (caso 4) las primeras características son la longitud de la reseña, el número de signos de exclamación o la presencia de palabras clave ("roto", "tarde", "perfecto"). En pandas: resenas["texto"].str.len(), resenas["texto"].str.count("!"), resenas["texto"].str.contains("roto|dañado").astype(int). La representación seria del texto (bolsas de palabras, embeddings) pertenece al módulo 5.
- Selección de características
Más columnas no siempre es mejor: las irrelevantes añaden ruido, alargan el entrenamiento y facilitan el sobreajuste (04-06). Dos herramientas sencillas para decidir:
- Correlación con la etiqueta (numéricas):
limpio[cols + ["devuelto"]].corr()["devuelto"]. En nuestros datos:importe0,35,cliente_nuevo0,30,importe_por_articulo0,28,dias_entrega0,16,num_articulos−0,08, y prácticamente 0 paradia_semana,fin_de_semana,pedidos_previosytasa_devolucion_previa(esta última porque, con 90 días de histórico, casi ningún cliente tiene devoluciones previas; con dos años de datos sería de las mejores). La correlación solo detecta relaciones lineales: una característica con correlación cero puede ser útil combinada con otra. - Importancia de características de un modelo: los bosques aleatorios (04-04) reparten un 100 % de "importancia" entre las columnas según cuánto ayudan a separar. Entrenando uno con el pipeline de la sección 9 obtenemos:
importe0,27,importe_por_articulo0,19,cliente_nuevo0,17,dias_entrega0,08, ... y las tres columnas decodigo_postal_zonaen torno a 0,01 cada una. Es la comprobación que anunciamos en 04-01: la zona no aporta nada, como corresponde a unos datos donde no influye. En 02-04 vimos el caso contrario, un histórico sesgado donde el modelo aprendía a sospechar de la zona B; la importancia de características es una de las herramientas para detectarlo.
Con esa información Marta puede quitar dia_semana, mes y fin_de_semana (ruido en estos datos), y plantearse quitar codigo_postal_zona también por prudencia ética, no solo estadística.
- Fuga de información (data leakage)
Es el error más peligroso de esta fase, porque no produce fallos sino resultados demasiado buenos. Hay fuga cuando el modelo usa, al entrenar, información que no existirá en el momento de predecir. La columna motivo_devolucion es el ejemplo perfecto: se rellena cuando el cliente devuelve, así que está vacía en todos los pedidos no devueltos y llena en todos los devueltos:
Si añadimos una característica hay_motivo al modelo, acierta el 100 % en test (lo hemos comprobado). Es un resultado espectacular y completamente inútil: cuando llega un pedido nuevo, el motivo de devolución aún no existe. Otras fugas frecuentes, más sutiles:
- Columnas rellenadas después del evento:
fecha_devolucion,importe_reembolsado,estado_final = "reembolsado". - Agregados calculados con todos los datos, incluido el futuro: la tasa de devolución del cliente incluyendo el pedido actual (por eso restamos
devueltoen 6.2), o la media de ventas de todo el año al prever una semana de ese año. - Transformaciones ajustadas con el test: imputar con la mediana de todo el dataset, escalar con la media de todo el dataset. Es una fuga pequeña, pero fuga.
- Duplicados repartidos entre entrenamiento y test: el modelo "ya ha visto" el ejemplo.
La regla para detectarla: para cada columna, pregúntate "¿tendré este valor, exactamente así, en el momento en que el modelo deba decidir?". Y desconfía de resultados demasiado buenos.
- Dividir antes de transformar:
Pipeline y ColumnTransformer
Pipeline y ColumnTransformerTodo lo anterior converge en una regla y una herramienta. La regla: primero se divide en entrenamiento y test, y todas las transformaciones que "aprenden algo" de los datos (la mediana para imputar, la media y desviación para escalar, las categorías del one-hot) se ajustan solo con el entrenamiento (fit en train) y luego se aplican tal cual al test (transform en test). La herramienta: Pipeline encadena pasos, y ColumnTransformer aplica a cada grupo de columnas su propia cadena. Así el pipeline completo, preparación + modelo, se comporta como un único modelo con fit/predict, y es imposible equivocarse en el orden.
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, StandardScaler
from sklearn.linear_model import LogisticRegression
# 1) Quitamos la etiqueta, la fuga y las columnas identificadoras (no son características)
X = limpio.drop(columns=["devuelto", "motivo_devolucion", "id_pedido", "id_cliente",
"fecha_pedido", "devoluciones_previas"])
y = limpio["devuelto"]
# 2) PRIMERO dividimos
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
# 3) Definimos qué hacer con cada grupo de columnas
numericas = ["importe", "num_articulos", "dias_entrega", "importe_por_articulo",
"pedidos_previos", "tasa_devolucion_previa", "dias_desde_inicio"]
binarias = ["cliente_nuevo", "fin_de_semana"]
nominales = ["categoria", "codigo_postal_zona", "metodo_pago"]
ordinales = ["tipo_envio"]
preparacion = ColumnTransformer([
("num", Pipeline([("imputar", SimpleImputer(strategy="median")),
("escalar", StandardScaler())]), numericas),
("bin", "passthrough", binarias),
("nom", Pipeline([("imputar", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore"))]), nominales),
("ord", OrdinalEncoder(categories=[["estandar", "rapido", "urgente"]]), ordinales),
])
# 4) Pipeline completo: preparación + modelo
modelo = Pipeline([("preparacion", preparacion),
("clasificador", LogisticRegression(max_iter=1000))])
# 5) fit ajusta TODO (medianas, escalas, categorías y el clasificador) solo con train
modelo.fit(X_train, y_train)
print("Aciertos en test:", round(modelo.score(X_test, y_test), 3))
matriz = preparacion.transform(X_train)
print("Matriz que ve el clasificador:", matriz.shape)
print(preparacion.get_feature_names_out()[:6])Salida:
Aciertos en test: 0.871 Matriz que ve el clasificador: (2249, 21) ['num__importe' 'num__num_articulos' 'num__dias_entrega' 'num__importe_por_articulo' 'num__pedidos_previos' 'num__tasa_devolucion_previa']
Explicación:
- Cada grupo de columnas recibe su tratamiento: las numéricas se imputan con la mediana (robusta a atípicos) y se estandarizan; las binarias pasan tal cual (
passthrough); las nominales se imputan con la moda y se convierten a one-hot; la ordinal recibe enteros con el orden que fijamos. Las 15 columnas de entrada se convierten en 21 columnas numéricas (el one-hot expandecategoriaen 4,codigo_postal_zonaen 3 ymetodo_pagoen 4). modelo.fit(X_train, y_train)calcula medianas, medias, desviaciones y categorías con X_train y entrena la regresión logística;modelo.score(X_test, y_test)aplica esas mismas medianas y escalas a X_test (sin recalcularlas) y evalúa. La fuga de la sección 8 (tercer punto) queda descartada por construcción.- Cuando el modelo pase a producción, se le entrega el pipeline entero: recibe un pedido tal como llega (con sus textos y sus huecos) y devuelve la predicción. Nadie tiene que recordar "primero imputar con 107,9, luego restar 125 y dividir por 84".
- El acierto (87,1 %) es similar al del árbol de 04-01 y al de la logística de 04-02, pese a tener más columnas y datos más sucios. Es normal: la exactitud es una medida tosca (04-05) y varias columnas nuevas son ruido en estos datos; el valor del pipeline está en que ahora es correcto, reproducible y desplegable, y en que las características de negocio (ratios, historial) están listas para cuando el histórico sea más largo.
Un último detalle útil: modelo.named_steps["clasificador"].coef_ da los coeficientes de la logística por columna transformada. En estos datos, los mayores en valor absoluto son cliente_nuevo (2,0), importe (1,0), dias_entrega (0,6) y categoria_electronica (0,5): el modelo ha redescubierto la "verdad oculta" del generador de 04-01. En 04-04 aprenderemos a leerlos con propiedad.
9.1 Empaquetar la preparación para las lecciones siguientes
Las lecciones 04-04, 04-05 y 04-06 reutilizarán exactamente esta preparación. Para no repetir código, añade a novamarket_ml.py dos funciones: preparar_pedidos(sucio), que aplica la limpieza y la ingeniería de las secciones 3 y 6 y devuelve X e y, y crear_preparacion(), que construye el ColumnTransformer de la sección 9 (una instancia nueva cada vez, para que cada pipeline ajuste la suya):
def preparar_pedidos(sucio):
"""Limpieza + ingeniería de 04-03: devuelve X (características) e y (devuelto)."""
limpio = sucio.drop_duplicates()
limpio = limpio[limpio["importe"].isna() | (limpio["importe"] < 5000)].copy()
limpio["dia_semana"] = limpio["fecha_pedido"].dt.dayofweek
limpio["mes"] = limpio["fecha_pedido"].dt.month
limpio["fin_de_semana"] = (limpio["dia_semana"] >= 5).astype(int)
limpio["dias_desde_inicio"] = (limpio["fecha_pedido"] - limpio["fecha_pedido"].min()).dt.days
limpio["importe_por_articulo"] = limpio["importe"] / limpio["num_articulos"]
limpio = limpio.sort_values(["fecha_pedido", "id_pedido"])
por_cliente = limpio.groupby("id_cliente")
limpio["pedidos_previos"] = por_cliente.cumcount()
limpio["devoluciones_previas"] = por_cliente["devuelto"].cumsum() - limpio["devuelto"]
limpio["tasa_devolucion_previa"] = (limpio["devoluciones_previas"]
/ limpio["pedidos_previos"].replace(0, np.nan)).fillna(0)
X = limpio.drop(columns=["devuelto", "motivo_devolucion", "id_pedido", "id_cliente",
"fecha_pedido", "devoluciones_previas"])
y = limpio["devuelto"]
return X, y
NUMERICAS = ["importe", "num_articulos", "dias_entrega", "importe_por_articulo",
"pedidos_previos", "tasa_devolucion_previa", "dias_desde_inicio"]
BINARIAS = ["cliente_nuevo", "fin_de_semana"]
NOMINALES = ["categoria", "codigo_postal_zona", "metodo_pago"]
ORDINALES = ["tipo_envio"]
def crear_preparacion():
"""ColumnTransformer de 04-03, listo para encadenar con cualquier modelo."""
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, StandardScaler
return ColumnTransformer([
("num", Pipeline([("imputar", SimpleImputer(strategy="median")),
("escalar", StandardScaler())]), NUMERICAS),
("bin", "passthrough", BINARIAS),
("nom", Pipeline([("imputar", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore"))]), NOMINALES),
("ord", OrdinalEncoder(categories=[["estandar", "rapido", "urgente"]]), ORDINALES),
])A partir de ahora, obtener los datos listos será una línea: X, y = preparar_pedidos(ensuciar_pedidos(generar_pedidos_ml(3000, 42), 42)), y cualquier modelo se entrena con Pipeline([("prep", crear_preparacion()), ("modelo", ...)]).
- Tabla resumen: qué técnica para cada tipo de columna
| Tipo de columna | Ejemplo | Ausentes | Transformación | Cuidado con |
|---|---|---|---|---|
| Numérica continua | importe, importe_por_articulo |
Mediana (o media si es simétrica) | Estandarizar (o min-max); a veces logaritmo | Atípicos: decidir si son errores o señal |
| Numérica discreta / conteo | num_articulos, pedidos_previos |
Mediana o 0 si "ausente = ninguno" | Estandarizar | Los conteos previos deben excluir el evento actual |
| Binaria | cliente_nuevo, fin_de_semana |
Moda o constante | Ninguna (passthrough) |
Que sea 0/1, no "sí"/"no" |
| Categórica nominal | categoria, metodo_pago |
Moda o categoría "desconocido" | One-hot; frecuencia/target si hay muchas categorías | Nunca enteros arbitrarios; handle_unknown |
| Categórica ordinal | tipo_envio |
Moda | Ordinal con orden explícito | Fijar el orden a mano |
| Fecha | fecha_pedido |
Rara vez ausente | Extraer día de la semana, mes, días desde…, lags | No usar la fecha en bruto ni fechas posteriores al evento |
| Texto libre | reseña, descripción | Cadena vacía | Longitud, conteos, palabras clave (módulo 5 para más) | Coste de procesado; idioma |
| Identificador | id_pedido, id_cliente |
— | Eliminar (sirve para agrupar, no como característica) | Un modelo puede memorizar el id |
| Posterior al evento | motivo_devolucion, fecha_devolucion |
— | Eliminar | Fuga de información |
Errores Comunes y Consejos
- Imputar o escalar antes de dividir. Es la fuga silenciosa más común. Divide primero; ajusta las transformaciones dentro de un
Pipelinecon los datos de entrenamiento. - Codificar categorías con enteros arbitrarios. Inventa un orden que no existe. Usa one-hot para nominales y ordinal solo cuando el orden sea real y explícito.
- Eliminar todos los atípicos "porque lo dice el IQR". El criterio detecta candidatos; la decisión (error o señal) es de negocio. Los pedidos de 400 € son los que Diego quiere vigilar, no los que hay que borrar.
- Usar columnas del futuro. Antes de incluir una columna, pregúntate si existirá con ese valor en el momento de la predicción. Un resultado sospechosamente perfecto casi siempre es una fuga.
- Confundir identificadores con características.
id_clientesirve para calcular agregados por cliente; nunca como número que entra al modelo. - No escalar antes de usar distancias o gradiente. k-NN, k-means, SVM, logística regularizada y redes lo necesitan; los árboles no.
- Olvidar
handle_unknown="ignore". En producción aparecerán categorías nuevas y el pipeline fallará al transformar. - Hacer la preparación a mano en un cuaderno y no guardarla. Si no está en un
Pipeline, no se puede reproducir ni desplegar.
Ejercicios
Ejercicio 1. Cambia en el pipeline la imputación de las numéricas de median a mean y añade add_indicator=True al SimpleImputer de las nominales (creará una columna que marca si el método de pago faltaba). ¿Cuántas columnas tiene ahora la matriz transformada? ¿Cambia el acierto de forma apreciable? Justifica por qué la mediana era la opción más prudente para importe cuando aún estaba el valor 99.999.
Ejercicio 2. Comete a propósito la fuga "escalar antes de dividir": aplica StandardScaler().fit_transform a las columnas numéricas del dataset completo (imputa antes con la mediana global), divide después y entrena la logística. Compara el acierto con el del pipeline correcto. ¿Se nota mucho? ¿Por qué esta fuga es menos grave que la de motivo_devolucion y aun así hay que evitarla?
Ejercicio 3. Prepara la serie de demanda de 04-02 para un modelo supervisado: a partir de generar_demanda_semanal(104, 42), crea las columnas semana_del_anyo (fecha.dt.isocalendar().week), mes, unidades_semana_anterior (unidades.shift(1)) y media_4_semanas (media móvil de las 4 semanas previas, unidades.shift(1).rolling(4).mean()). ¿Por qué hay que usar shift(1) antes de la media móvil? ¿Qué pasa con las primeras filas y qué harías con ellas?
Soluciones
Solución 1. La matriz pasa de 21 a 23 columnas: el indicador de ausencia se genera antes del OneHotEncoder del mismo sub-pipeline, así que este lo trata como una categoría más y lo expande en dos columnas (nom__missingindicator_metodo_pago_False/True); si quisieras una sola columna 0/1 tendrías que sacar el indicador fuera del one-hot. El acierto en test se mantiene en torno a 0,87 (las diferencias son de una o dos milésimas, dentro del ruido). La mediana era más prudente porque, si el 99.999 hubiera pasado inadvertido, la media de importe habría subido de 125 € a 159 € y todos los importes ausentes se habrían rellenado con un valor inflado; la mediana (107,9 €) no se movió ni un céntimo con el atípico. En general, mediana para columnas con cola larga o atípicos, media para columnas simétricas y limpias.
Solución 2. El acierto queda prácticamente igual (en torno a 0,87). Con 3.000 filas, la media y la desviación calculadas con el 100 % de los datos apenas difieren de las calculadas con el 75 %, así que el efecto es imperceptible. Es menos grave que motivo_devolucion porque no introduce la etiqueta en las características, solo una pizca de información sobre la distribución del test. Aun así hay que evitarla porque: (a) con datasets pequeños o con atípicos en el test sí distorsiona; (b) es la señal de un flujo de trabajo mal montado, en el que otras fugas más graves pasarán inadvertidas; y (c) en producción no existe "el test": el escalador tiene que estar ajustado y guardado de antemano, que es justo lo que garantiza el Pipeline.
Solución 3. shift(1) desplaza la serie una fila hacia abajo, de modo que en la fila de la semana t aparecen las unidades de la semana t−1; sin él, rolling(4).mean() incluiría la propia semana t, es decir, la etiqueta que se quiere predecir: una fuga. Las primeras filas quedan con NaN (la semana 1 no tiene anterior; las 4 primeras no tienen media móvil completa). Se pueden eliminar (dropna(), perdiendo 4 semanas de 104) o imputar dentro del pipeline; con una serie larga, eliminarlas es lo más limpio. La columna semana_del_anyo captura la estacionalidad y las de lag la inercia; en 04-04 entrenaremos con ellas la regresión lineal y comprobaremos que el error medio baja mucho respecto a la recta de 04-02.
Conclusión
En esta lección hemos convertido unos pedidos "sucios" de NovaMarket en la matriz numérica que un algoritmo necesita, y por el camino hemos fijado las técnicas de preparación: duplicados (eliminar), ausentes (imputar con mediana/moda dentro del pipeline), atípicos (detectar con IQR, decidir con criterio de negocio), codificación de categóricas (one-hot para nominales, ordinal con orden explícito, frecuencia o target para muchas categorías; nunca enteros arbitrarios), escalado (estandarización para modelos de distancia y gradiente), fechas (día de la semana, mes, días desde…), ingeniería de características (ratios, agregados por cliente sin mirar el presente, texto básico), selección (correlación e importancia, que confirmó que la zona no aporta nada) y la fuga de información (motivo_devolucion daba un 100 % ilusorio). Todo ello encapsulado en un Pipeline con ColumnTransformer que se ajusta solo con el entrenamiento y que es reproducible y desplegable. Marta tiene ahora la función ensuciar_pedidos para practicar y un pipeline que reutilizaremos en las tres lecciones siguientes.
Con los datos preparados, toca abrir la caja de los algoritmos. En la siguiente lección, Algoritmos de Machine Learning, veremos qué hay dentro de fit: cómo la regresión lineal ajusta una recta a la demanda del NovaClean, cómo la regresión logística convierte una suma ponderada en probabilidad de devolución, cómo deciden los k vecinos, los árboles, los bosques, las máquinas de vectores de soporte y k-means, con la intuición, un poco de matemática y código para cada uno, y compararemos varios clasificadores sobre estos mismos pedidos.
Fundamentos de Inteligencia Artificial (IA)
Módulo 1: Introducción a la Inteligencia Artificial
Módulo 2: Principios Básicos de la IA
- Conceptos Fundamentales: Agentes, Entornos y Racionalidad
- Tipos de Inteligencia Artificial
- Los Datos como Materia Prima de la IA
- Ética y Consideraciones en IA
Módulo 3: Algoritmos en IA
- Introducción a los Algoritmos
- Algoritmos de Búsqueda
- Búsqueda con Adversario: Juegos y Minimax
- Algoritmos de Optimización
Módulo 4: Aprendizaje Automático (Machine Learning)
- Conceptos Básicos de Machine Learning
- Tipos de Aprendizaje Automático
- Preparación de Datos y Características
- Algoritmos de Machine Learning
- Evaluación y Validación de Modelos
- Sobreajuste, Regularización y Ajuste de Hiperparámetros
Módulo 5: Redes Neuronales y Deep Learning
- Introducción a las Redes Neuronales
- Arquitectura de Redes Neuronales
- Cómo Aprende una Red: Descenso del Gradiente y Retropropagación
- Deep Learning y sus Aplicaciones
- Transformers, Grandes Modelos de Lenguaje e IA Generativa
Módulo 6: Lógica y Sistemas Expertos
- Lógica en IA
- Sistemas Expertos
- Razonamiento con Incertidumbre: Probabilidad y Redes Bayesianas
- Aplicaciones de Sistemas Expertos
Módulo 7: Herramientas y Lenguajes de Programación en IA
- Lenguajes de Programación para IA
- Python Científico: NumPy, pandas y Matplotlib
- Herramientas y Librerías Populares
- Entornos de Desarrollo
Módulo 8: Proyectos y Casos de Estudio
Módulo 9: Ejercicios y Prácticas
- Ejercicios de Algoritmos
- Prácticas de Machine Learning
- Proyectos de Redes Neuronales
- Proyecto Integrador: de la Idea al Prototipo
