Feature Engineering y Limpieza de Datos#

Open In Colab

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#

Hide code cell source

# @title *Esta celda clona el repositorio (en Colab) e importa las utilidades comunes*
import sys
import os

IN_COLAB = "google.colab" in sys.modules

if IN_COLAB:
    import subprocess
    REPO_NAME = "applied-ai-engineering"
    if not os.path.exists(REPO_NAME):
        subprocess.run(["git", "clone", "https://github.com/AxelSkrauba/applied-ai-engineering.git"], check=True)
    os.chdir(f"/content/{REPO_NAME}")
    sys.path.append(f"/content/{REPO_NAME}")
else:
    # Repositorio en local, apuntar path a la raiz
    os.chdir(f"../../")

from utils.plots import setup_plot_style
setup_plot_style()
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_m2 introduce 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#

Hide code cell source

from utils.environment import environment_table
environment_table()
Reproducibility Environment Information
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