Feature Engineering y Limpieza de Datos#
Objetivos#
Entender la diferencia entre limpieza de datos e ingeniería de características (Feature Engineering).
Aplicar técnicas de imputación de valores nulos (simples y avanzadas).
Transformar datos categóricos para que los modelos matemáticos puedan entenderlos.
Extraer características útiles a partir de datos complejos (como fechas).
Identificar y prevenir el Data Leakage (Fuga de Datos) derivado de dependencias funcionales directas.
Prerrequisitos#
Configuración del Entorno#
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.impute import KNNImputer
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
import warnings
warnings.filterwarnings('ignore')
1. El Problema: Datos Crudos vs Datos Útiles#
Los algoritmos de Machine Learning son, en el fondo, ecuaciones matemáticas. No entienden palabras como “Rojo” o “Lunes”, ni saben qué hacer con casillas vacías (Nulos/NaN).
Limpieza de Datos: Es el proceso de arreglar lo que está roto (nulos, duplicados, errores tipográficos).
Feature Engineering: Es el arte de crear nuevas variables a partir de las existentes para ayudar al modelo a encontrar patrones.
Vamos a crear un dataset simulado “sucio” para practicar.
# Creación de un dataset simulado de ventas
np.random.seed(42)
fechas = pd.date_range(start='2023-01-01', periods=20, freq='D')
ciudades = ['Buenos Aires', 'Madrid', 'Bogota', 'Lima']
df = pd.DataFrame({
'Fecha': fechas,
'Ciudad': np.random.choice(ciudades, 20),
'Talla': np.random.choice(['S', 'M', 'L', 'XL'], 20),
'Visitas_Web': np.random.randint(100, 5000, 20).astype(float),
'Ventas_USD': np.random.randint(10, 1000, 20).astype(float)
})
# Introducimos nulos a propósito (simulando errores de sistema)
df.loc[[2, 5, 10], 'Visitas_Web'] = np.nan
df.loc[[1, 8, 15], 'Ventas_USD'] = np.nan
df.head(6)
| Fecha | Ciudad | Talla | Visitas_Web | Ventas_USD | |
|---|---|---|---|---|---|
| 0 | 2023-01-01 | Bogota | M | 1182.0 | 325.0 |
| 1 | 2023-01-02 | Lima | S | 2658.0 | NaN |
| 2 | 2023-01-03 | Buenos Aires | M | NaN | 251.0 |
| 3 | 2023-01-04 | Bogota | XL | 2847.0 | 786.0 |
| 4 | 2023-01-05 | Bogota | XL | 1075.0 | 355.0 |
| 5 | 2023-01-06 | Lima | M | NaN | 574.0 |
2. Imputación de Valores Nulos#
2.1 Imputación Simple (Media o Mediana)#
Lo más rápido es rellenar los huecos con el valor promedio de esa columna.
Media: Sensible a valores extremos (outliers).
Mediana: Más robusta, ideal si hay valores muy atípicos.
# Hacemos una copia para no modificar el original aún
df_simple = df.copy()
mediana_visitas = df_simple['Visitas_Web'].median()
df_simple['Visitas_Web_Imputado'] = df_simple['Visitas_Web'].fillna(mediana_visitas)
print(f"Mediana calculada: {mediana_visitas}")
df_simple[['Visitas_Web', 'Visitas_Web_Imputado']].head(6)
Mediana calculada: 2658.0
| Visitas_Web | Visitas_Web_Imputado | |
|---|---|---|
| 0 | 1182.0 | 1182.0 |
| 1 | 2658.0 | 2658.0 |
| 2 | NaN | 2658.0 |
| 3 | 2847.0 | 2847.0 |
| 4 | 1075.0 | 1075.0 |
| 5 | NaN | 2658.0 |
2.2 Imputación Avanzada: KNN Imputer#
¿Qué pasa si en lugar del promedio general, miramos las filas “más parecidas” a la que tiene el nulo?
KNNImputer (K-Nearest Neighbors) hace exactamente eso. Busca las muestras con datos similares y promedia sus valores para rellenar el nulo. Es mucho más preciso que la imputación simple.
# KNNImputer solo funciona con números, usamos las columnas numéricas
df_knn = df[['Visitas_Web', 'Ventas_USD']].copy()
# Instanciamos el imputador (busca los 3 'vecinos' más cercanos)
imputer = KNNImputer(n_neighbors=3)
# Transformamos y devolvemos a DataFrame
df_knn_imputed = pd.DataFrame(imputer.fit_transform(df_knn), columns=df_knn.columns)
print("Comparación de Ventas (Original vs KNN):")
comparacion = pd.concat([df['Ventas_USD'], df_knn_imputed['Ventas_USD'].rename('Ventas_KNN')], axis=1)
comparacion.head(5)
Comparación de Ventas (Original vs KNN):
| Ventas_USD | Ventas_KNN | |
|---|---|---|
| 0 | 325.0 | 325.000000 |
| 1 | NaN | 408.333333 |
| 2 | 251.0 | 251.000000 |
| 3 | 786.0 | 786.000000 |
| 4 | 355.0 | 355.000000 |
3. Codificación de Variables Categóricas#
Tenemos variables de texto: Ciudad y Talla. Hay dos enfoques principales según la naturaleza de la categoría.
3.1 Ordinal Encoding (Tienen orden)#
La Talla tiene un orden lógico natural: S < M < L < XL. Podemos transformarlo directamente en números: 0, 1, 2, 3.
# Definimos el orden explícito
orden_tallas = [['S', 'M', 'L', 'XL']]
ordinal_encoder = OrdinalEncoder(categories=orden_tallas)
df['Talla_Num'] = ordinal_encoder.fit_transform(df[['Talla']])
df[['Talla', 'Talla_Num']].head()
| Talla | Talla_Num | |
|---|---|---|
| 0 | M | 1.0 |
| 1 | S | 0.0 |
| 2 | M | 1.0 |
| 3 | XL | 3.0 |
| 4 | XL | 3.0 |
3.2 One-Hot Encoding (No tienen orden)#
La Ciudad no tiene un orden lógico (Madrid no es “mayor” que Lima). Si les ponemos números (0, 1, 2, 3), el modelo creerá erróneamente que hay una relación de magnitud.
La solución: One-Hot Encoding (OHE). Creamos una nueva columna binaria (0 o 1) por cada categoría.
# Pandas tiene una función rápida para esto: get_dummies
df_ohe = pd.get_dummies(df, columns=['Ciudad'], dtype=int)
df_ohe.head()
| Fecha | Talla | Visitas_Web | Ventas_USD | Talla_Num | Ciudad_Bogota | Ciudad_Buenos Aires | Ciudad_Lima | Ciudad_Madrid | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 2023-01-01 | M | 1182.0 | 325.0 | 1.0 | 1 | 0 | 0 | 0 |
| 1 | 2023-01-02 | S | 2658.0 | NaN | 0.0 | 0 | 0 | 1 | 0 |
| 2 | 2023-01-03 | M | NaN | 251.0 | 1.0 | 0 | 1 | 0 | 0 |
| 3 | 2023-01-04 | XL | 2847.0 | 786.0 | 3.0 | 1 | 0 | 0 | 0 |
| 4 | 2023-01-05 | XL | 1075.0 | 355.0 | 3.0 | 1 | 0 | 0 | 0 |
Nota: OHE aumenta el tamaño del dataset agregando columnas (esto se llama “aumentar la cardinalidad” o maldición de la dimensionalidad). Si tenemos 1000 ciudades, se agregarían 1000 columnas, lo cual puede romper algunos modelo. En esos casos se usan técnicas avanzadas como Target Encoding.
4. Manejo de Fechas#
Las fechas son una mina de oro de información que los algoritmos por defecto ignoran. Extraer características de una fecha a menudo revela los verdaderos patrones de negocio (¿Se vende más los fines de semana? ¿A fin de mes?).
# Extraemos componentes clave de la fecha
df['Dia_Mes'] = df['Fecha'].dt.day
df['Dia_Semana'] = df['Fecha'].dt.dayofweek # 0=Lunes, 6=Domingo
df['Es_Fin_Semana'] = df['Dia_Semana'].apply(lambda x: 1 if x >= 5 else 0)
df[['Fecha', 'Dia_Mes', 'Dia_Semana', 'Es_Fin_Semana']].head()
| Fecha | Dia_Mes | Dia_Semana | Es_Fin_Semana | |
|---|---|---|---|---|
| 0 | 2023-01-01 | 1 | 6 | 1 |
| 1 | 2023-01-02 | 2 | 0 | 0 |
| 2 | 2023-01-03 | 3 | 1 | 0 |
| 3 | 2023-01-04 | 4 | 2 | 0 |
| 4 | 2023-01-05 | 5 | 3 | 0 |
Nota:
Podría surgir la pregunta: ¿Qué pasa si simplemente dejamos el timestamp (fecha/hora) tal cual y se lo damos a un modelo? ¿No debería un algoritmo como un Random Forest descubrir automáticamente patrones como fines de semana, estacionalidad o fin de mes?
En la práctica, esto rara vez funciona bien. Si el timestamp entra como un número continuo (por ejemplo, segundos desde 2023), muchos modelos —especialmente los basados en árboles como Random Forest— tienden a particionar el tiempo en rangos cada vez más específicos, acercándose peligrosamente a memorizar el conjunto de entrenamiento.
Esto puede llevar a árboles extremadamente profundos que separan observaciones casi individuales. Un síntoma típico es que la profundidad del árbol termina siendo comparable a la cantidad de observaciones de entrenamiento. El modelo entonces aprende reglas del estilo:
si timestamp \(∈\)
[1623456780, 1623456890]entonces temperatura ≈ 23.4 °C
En lugar de capturar patrones más generales como hora del día, día de la semana o estación del año.
El resultado: excelente desempeño en entrenamiento, pero pobre capacidad de generalización, especialmente cuando el modelo debe predecir para timestamps futuros que nunca vio.
Por eso, en problemas con datos temporales suele ser más efectivo extraer explícitamente características temporales (hora, día de la semana, mes, indicadores de fin de semana, variables cíclicas, etc.) que dejar el timestamp crudo.
5. ⚠️ El Peligro Oculto: Data Leakage y Dependencia Funcional#
Imaginemos que necesitamos predecir si va a llover mañana. Como variable de entrada (feature), se incluye la “cantidad de paraguas abiertos mañana”. El modelo logrará un 100% de precisión en el entrenamiento, pero en la vida real será inútil: no se puede saber cuántos paraguas habrá abiertos hasta que ya esté lloviendo (utilizamos información del futuro).
A esto se le llama Data Leakage (Fuga de Datos). Ocurre cuando el modelo tiene acceso a información durante el entrenamiento que no estará disponible en el momento de la predicción, o cuando incluimos una variable que es una operación matemática directa de la variable objetivo (target).
Va un ejemplo clásico para el sector inmobiliario:
# Simulamos un error común de Feature Engineering
df_inmobiliario = pd.DataFrame({
'Superficie_m2': [80, 120, 160, 200],
'Barrio_Premium': [0, 1, 0, 1],
'Precio_Venta': [200000, 360000, 400000, 600000] # Nuestro Target
})
# El analista junior decide crear una "nueva característica" muy descriptiva:
df_inmobiliario['Precio_con_descuento_10%'] = df_inmobiliario['Precio_Venta'] * 0.9
df_inmobiliario
| Superficie_m2 | Barrio_Premium | Precio_Venta | Precio_con_descuento_10% | |
|---|---|---|---|---|
| 0 | 80 | 0 | 200000 | 180000.0 |
| 1 | 120 | 1 | 360000 | 324000.0 |
| 2 | 160 | 0 | 400000 | 360000.0 |
| 3 | 200 | 1 | 600000 | 540000.0 |
# Veamos la correlación con el target
print("\nCorrelación con el Precio de Venta:")
df_inmobiliario.corr()[['Precio_Venta']]
Correlación con el Precio de Venta:
| Precio_Venta | |
|---|---|
| Superficie_m2 | 0.973035 |
| Barrio_Premium | 0.631676 |
| Precio_Venta | 1.000000 |
| Precio_con_descuento_10% | 1.000000 |
Análisis de Ingeniería:
Observar la altísima correlación de Precio_con_descuento_10% con el Precio_Venta (perfecta en este ejemplo “extremo”). Si le damos este dataset a un algoritmo, ignorará la superficie y el barrio. Simplemente aprenderá la ecuación: Precio_Venta = Precio_con_descuento_10% / 0.9.
¿Por qué es un error fatal?
Porque en un escenario real, si un cliente nos pide predecir a cuánto puede vender su casa nueva, no conocemos el precio de venta, por lo tanto, ¡es matemáticamente imposible calcular el Precio_con_descuento_10% para dárselo al modelo! (necesitaríamos conocer la salida para predecir la salida…)
Regla de oro: Nunca incluir variables que contengan información del target o que se calculen a partir de él. (se va a entender mejor cuando veamos modelos…)
Aunque normalmente, no sea tan evidente este tipo de situaciones (como un descuento directo), crear variables como
Precio_por_m2introduce el mismo problema: el modelo puede reconstruir el target usando información que no estará disponible en producción (a la hora de usar el modelo en la vida real).
Resultados y Discusión#
Nuestro dataset original no podía ser procesado por una Red Neuronal o una Regresión. Luego de aplicar imputación de nulos, encoding para el texto y extracción temporal, ahora tenemos una matriz puramente numérica, lista para que la matemática haga su magia.
Conexiones y Próximos Pasos#
➡️ Siguiente: Ya tenemos los datos limpios y sin trampas. Ahora vamos a entender matemáticamente cómo se relacionan entre sí en Análisis de Correlación y Asociación.
🔄 Relacionado: Ya limpiamos los datos y los pasamos a números, pero ¿qué pasa si una columna va de 0 a 1 y la otra va de 0 a 1.000.000? Vamos a verlo en Escalado y Transformación de Datos.
Entorno de Ejecución#
| Package | Version |
|---|---|
| Python | 3.12.12 |
| Platform | Linux-6.6.113+-x86_64-with-glibc2.35 |
| IPython | 7.34.0 |
| ipywidgets | 7.7.1 |
| joblib | 1.5.3 |
| matplotlib | 3.10.0 |
| numpy | 2.0.2 |
| pandas | 2.2.2 |
| scipy | 1.16.3 |
| seaborn | 0.13.2 |
| sklearn | 1.5.3 |
| statsmodels | 0.14.6 |