Ingeniería de Características en Python: Guía Práctica con Pandas, Scikit-learn y Feature-engine

Domina la ingeniería de características en Python con esta guía práctica. Aprende a crear pipelines reproducibles con Pandas, Scikit-learn y Feature-engine: imputación, codificación, escalado, outliers y selección de variables.

Introducción: ¿Qué es la ingeniería de características y por qué debería importarte?

Si llevas un tiempo trabajando con modelos de Machine Learning, probablemente ya te hayas dado cuenta de algo: los datos crudos rara vez son suficientes. La ingeniería de características (feature engineering) es el proceso de usar tu conocimiento del dominio para crear, transformar y seleccionar variables que le permitan a tus modelos capturar los patrones que realmente importan.

Y honestamente, es una de las etapas más infravaloradas de cualquier proyecto de ciencia de datos.

Un modelo mediocre con buenas características casi siempre le gana a un modelo súper sofisticado alimentado con datos sin procesar. Andrew Ng lo dijo de forma bastante directa: “Aplicar machine learning es básicamente ingeniería de características”. Y tiene sentido — los algoritmos trabajan con números, y la calidad de esos números determina la calidad de las predicciones.

En Python contamos con tres herramientas clave para esta tarea: Pandas para manipulación directa de datos, Scikit-learn para construir pipelines reproducibles, y Feature-engine como biblioteca especializada que extiende las capacidades de ambas. En esta guía vamos a cubrir las tres con ejemplos prácticos usando el clásico dataset de Titanic y un dataset de ventas simulado.

Tipos de características en Machine Learning

Antes de ponernos a transformar datos, necesitamos entender con qué tipos de variables estamos trabajando. Cada tipo pide estrategias distintas.

Variables numéricas

Valores continuos o discretos que representan cantidades medibles: edad, salario, número de habitaciones, temperatura. Pueden necesitar escalado, normalización, transformaciones logarítmicas o interacciones polinomiales.

Variables categóricas

Representan grupos o categorías: género, ciudad, tipo de producto, nivel educativo. Pueden ser nominales (sin orden, como colores) u ordinales (con orden natural, como “bajo”, “medio”, “alto”). Cómo las codifiques tiene un impacto directo en el rendimiento del modelo.

Variables temporales

Fechas y timestamps que esconden información valiosa: día de la semana, mes, trimestre, si es festivo, tiempo desde un evento. Extraer estas señales suele añadir bastante poder predictivo.

Variables de texto

Campos de texto libre como descripciones, comentarios o nombres. Requieren técnicas específicas: longitud, conteo de palabras, detección de patrones o vectorización TF-IDF.

Ingeniería de características con Pandas

Pandas es la primera herramienta a la que recurro cuando exploro y transformo datos. Su flexibilidad permite crear características de forma rápida durante la fase exploratoria, que es donde más ideas se prueban (y se descartan).

Configuración del dataset de ejemplo

Vamos a trabajar con el dataset de Titanic. Es un clásico por buenas razones: tiene variables de distintos tipos, valores faltantes, y es lo suficientemente pequeño para experimentar rápido.

import pandas as pd
import numpy as np

# Cargar dataset de Titanic
url = "https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv"
df = pd.read_csv(url)

print(df.shape)
print(df.dtypes)
print(df.head())

Creación de nuevas características a partir de columnas existentes

Aquí es donde empieza la diversión. Combinar columnas existentes para crear variables con mayor poder predictivo es una de las técnicas más efectivas que existen:

# Tamaño total de la familia
df['FamilySize'] = df['SibSp'] + df['Parch'] + 1

# Indicador de si viaja solo
df['IsAlone'] = (df['FamilySize'] == 1).astype(int)

# Extraer título del nombre
df['Title'] = df['Name'].str.extract(r' ([A-Za-z]+)\.', expand=False)

# Agrupar títulos poco frecuentes
title_mapping = {
    'Mr': 'Mr', 'Miss': 'Miss', 'Mrs': 'Mrs', 'Master': 'Master'
}
df['Title'] = df['Title'].map(lambda x: title_mapping.get(x, 'Rare'))

# Tarifa por persona (evitando división por cero)
df['FarePerPerson'] = df['Fare'] / df['FamilySize']

# Indicador de cabina conocida
df['HasCabin'] = df['Cabin'].notna().astype(int)

# Extraer la letra del deck de la cabina
df['Deck'] = df['Cabin'].str[0].fillna('Unknown')

print(df[['FamilySize', 'IsAlone', 'Title', 'FarePerPerson', 'HasCabin', 'Deck']].head(10))

Fíjate en FarePerPerson: dividir la tarifa entre el tamaño de la familia captura algo que ninguna de las variables originales dice por sí sola. Es un buen ejemplo de cómo el conocimiento del dominio marca la diferencia.

Manejo de valores faltantes

Los valores nulos son inevitables en datos reales. No hay forma de escapar de ellos, así que más vale tener buenas estrategias:

# Verificar valores faltantes
print(df.isnull().sum())

# Imputar edad con la mediana por clase y título
df['Age'] = df.groupby(['Pclass', 'Title'])['Age'].transform(
    lambda x: x.fillna(x.median())
)

# Si aún quedan nulos, usar la mediana global
df['Age'] = df['Age'].fillna(df['Age'].median())

# Imputar Embarked con la moda
df['Embarked'] = df['Embarked'].fillna(df['Embarked'].mode()[0])

# Imputar Fare con la mediana por clase
df['Fare'] = df.groupby('Pclass')['Fare'].transform(
    lambda x: x.fillna(x.median())
)

# Crear indicador de dato faltante antes de imputar (técnica útil)
df['Age_Missing'] = df['Age'].isnull().astype(int)

print("Valores faltantes después de imputación:")
print(df.isnull().sum())

La imputación por grupo (mediana por clase y título) suele funcionar mejor que simplemente rellenar con la mediana global. Un pasajero de primera clase probablemente tiene una edad diferente a uno de tercera, ¿no?

Extracción de características temporales

Cuando trabajas con fechas, es esencial descomponer la información temporal en componentes que tu modelo pueda digerir. Veamos un ejemplo con datos de ventas:

# Simular un dataset con fechas
ventas = pd.DataFrame({
    'fecha_venta': pd.date_range('2023-01-01', periods=1000, freq='D'),
    'monto': np.random.exponential(scale=500, size=1000),
    'producto': np.random.choice(['A', 'B', 'C'], size=1000)
})

# Extraer componentes temporales
ventas['anio'] = ventas['fecha_venta'].dt.year
ventas['mes'] = ventas['fecha_venta'].dt.month
ventas['dia_semana'] = ventas['fecha_venta'].dt.dayofweek
ventas['dia_mes'] = ventas['fecha_venta'].dt.day
ventas['trimestre'] = ventas['fecha_venta'].dt.quarter
ventas['es_fin_semana'] = (ventas['dia_semana'] >= 5).astype(int)
ventas['semana_anio'] = ventas['fecha_venta'].dt.isocalendar().week.astype(int)

# Características cíclicas con seno y coseno
ventas['mes_sin'] = np.sin(2 * np.pi * ventas['mes'] / 12)
ventas['mes_cos'] = np.cos(2 * np.pi * ventas['mes'] / 12)
ventas['dia_semana_sin'] = np.sin(2 * np.pi * ventas['dia_semana'] / 7)
ventas['dia_semana_cos'] = np.cos(2 * np.pi * ventas['dia_semana'] / 7)

# Días desde el inicio del dataset
ventas['dias_desde_inicio'] = (ventas['fecha_venta'] - ventas['fecha_venta'].min()).dt.days

print(ventas.head())

La codificación cíclica con seno y coseno es un truco que no se usa lo suficiente. Sin ella, tu modelo pensaría que diciembre (mes 12) está lejísimos de enero (mes 1), cuando en realidad son vecinos. Lo mismo con días de la semana.

Binning y discretización

A veces convertir variables continuas en categorías funciona sorprendentemente bien. Captura relaciones no lineales y reduce ruido:

# Binning de edad en grupos significativos
df['AgeGroup'] = pd.cut(
    df['Age'],
    bins=[0, 12, 18, 35, 55, 100],
    labels=['Niño', 'Adolescente', 'Adulto_Joven', 'Adulto', 'Senior']
)

# Binning basado en cuantiles (igual frecuencia)
df['FareQuantile'] = pd.qcut(
    df['Fare'],
    q=5,
    labels=['Muy_Bajo', 'Bajo', 'Medio', 'Alto', 'Muy_Alto']
)

# Binning personalizado para tamaño de familia
df['FamilyCategory'] = pd.cut(
    df['FamilySize'],
    bins=[0, 1, 3, 5, 12],
    labels=['Solo', 'Pequeña', 'Mediana', 'Grande']
)

print(df[['Age', 'AgeGroup', 'Fare', 'FareQuantile', 'FamilySize', 'FamilyCategory']].head(10))

Características de agregación

Las estadísticas de grupo revelan patrones que las variables individuales simplemente no pueden capturar por sí solas:

# Estadísticas de tarifa por clase
for stat in ['mean', 'median', 'std', 'min', 'max']:
    col_name = f'Fare_{stat}_by_Pclass'
    df[col_name] = df.groupby('Pclass')['Fare'].transform(stat)

# Diferencia respecto a la media del grupo
df['Fare_diff_from_class_mean'] = df['Fare'] - df['Fare_mean_by_Pclass']

# Ratio respecto a la media del grupo
df['Fare_ratio_to_class_mean'] = df['Fare'] / df['Fare_mean_by_Pclass']

# Conteo de pasajeros por puerto de embarque
df['Embarked_count'] = df.groupby('Embarked')['PassengerId'].transform('count')

# Tasa de supervivencia por título (solo para entrenamiento, cuidado con data leakage)
df['Survival_rate_by_Title'] = df.groupby('Title')['Survived'].transform('mean')

print(df[['Pclass', 'Fare', 'Fare_mean_by_Pclass', 'Fare_diff_from_class_mean']].head())

Ojo con esto: las características de agregación calculadas sobre la variable objetivo (como Survival_rate_by_Title) deben calcularse únicamente sobre el conjunto de entrenamiento. Si usas información del test durante el entrenamiento, estás haciendo data leakage, y tus métricas serán mentira.

Ingeniería de características con Scikit-learn

Pandas es genial para explorar, pero cuando llega la hora de construir algo que funcione en producción, Scikit-learn es tu mejor amigo. Sus transformadores se ajustan sobre los datos de entrenamiento y aplican las mismas transformaciones sobre datos nuevos, todo sin fugas de información.

ColumnTransformer y Pipeline

El ColumnTransformer permite aplicar diferentes transformaciones a diferentes columnas, y Pipeline encadena pasos de forma secuencial. Veamos cómo se arma todo junto:

from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import (
    StandardScaler, MinMaxScaler, RobustScaler,
    OneHotEncoder, OrdinalEncoder
)
from sklearn.impute import SimpleImputer
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score

# Preparar datos
url = "https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv"
df = pd.read_csv(url)

# Seleccionar características relevantes
features = ['Pclass', 'Sex', 'Age', 'SibSp', 'Parch', 'Fare', 'Embarked']
X = df[features]
y = df['Survived']

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# Definir columnas numéricas y categóricas
num_cols = ['Age', 'SibSp', 'Parch', 'Fare']
cat_cols = ['Pclass', 'Sex', 'Embarked']

# Pipeline para variables numéricas
num_pipeline = Pipeline([
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler', StandardScaler())
])

# Pipeline para variables categóricas
cat_pipeline = Pipeline([
    ('imputer', SimpleImputer(strategy='most_frequent')),
    ('encoder', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])

# Combinar ambos pipelines
preprocessor = ColumnTransformer([
    ('num', num_pipeline, num_cols),
    ('cat', cat_pipeline, cat_cols)
])

# Pipeline completo con modelo
full_pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', RandomForestClassifier(n_estimators=100, random_state=42))
])

# Entrenar y evaluar
full_pipeline.fit(X_train, y_train)
y_pred = full_pipeline.predict(X_test)
print(f"Accuracy: {accuracy_score(y_test, y_pred):.4f}")

Lo bonito de este enfoque es que todo está encapsulado. Puedes serializar el pipeline completo con joblib y llevarlo directo a producción sin preocuparte de que algo se quede fuera.

Escaladores: StandardScaler, MinMaxScaler y RobustScaler

Cada escalador tiene su momento. Elegir bien puede marcar una diferencia real:

  • StandardScaler: resta la media y divide por la desviación estándar. Ideal cuando los datos siguen una distribución más o menos normal.
  • MinMaxScaler: escala al rango [0, 1]. Muy útil para redes neuronales y algoritmos sensibles a la magnitud.
  • RobustScaler: usa la mediana y el rango intercuartílico. Tu mejor opción cuando hay outliers.
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler

# Datos de ejemplo con outliers
data = np.array([[1, 100], [2, 200], [3, 300], [4, 400], [100, 10000]])

scalers = {
    'StandardScaler': StandardScaler(),
    'MinMaxScaler': MinMaxScaler(),
    'RobustScaler': RobustScaler()
}

for name, scaler in scalers.items():
    transformed = scaler.fit_transform(data)
    print(f"\n{name}:")
    print(transformed)
    # Observar cómo RobustScaler maneja mejor el outlier [100, 10000]

Codificadores: OneHotEncoder, OrdinalEncoder y TargetEncoder

La codificación de variables categóricas es una de esas decisiones que parece menor pero tiene un impacto enorme:

from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, TargetEncoder

# OneHotEncoder: para variables nominales sin orden
ohe = OneHotEncoder(handle_unknown='ignore', sparse_output=False)
X_sex = df[['Sex']].fillna('Unknown')
encoded = ohe.fit_transform(X_sex)
print("OneHotEncoder:")
print(f"  Columnas: {ohe.get_feature_names_out()}")
print(f"  Shape: {encoded.shape}")

# OrdinalEncoder: para variables con orden natural
oe = OrdinalEncoder(
    categories=[['Third', 'Second', 'First']],
    handle_unknown='use_encoded_value',
    unknown_value=-1
)
# Mapear Pclass a etiquetas ordinales
pclass_map = {1: 'First', 2: 'Second', 3: 'Third'}
df['PclassLabel'] = df['Pclass'].map(pclass_map)
encoded_ord = oe.fit_transform(df[['PclassLabel']])
print(f"\nOrdinalEncoder: {encoded_ord[:5].flatten()}")

# TargetEncoder: codifica categorías según su relación con la variable objetivo
te = TargetEncoder(smooth='auto', random_state=42)
X_embarked = df[['Embarked']].fillna('S')
encoded_target = te.fit_transform(X_embarked, y)
print(f"\nTargetEncoder para Embarked:")
print(pd.DataFrame({
    'Embarked': df['Embarked'].fillna('S'),
    'Encoded': encoded_target.flatten()
}).drop_duplicates().sort_values('Encoded'))

El TargetEncoder es especialmente útil para variables con alta cardinalidad (muchas categorías). Donde OneHotEncoder te generaría cientos de columnas, TargetEncoder te da una sola columna con regularización interna para no sobreajustar.

PolynomialFeatures: interacciones y términos polinomiales

Crear interacciones entre variables puede capturar relaciones no lineales que los modelos lineales pasarían por alto:

from sklearn.preprocessing import PolynomialFeatures

# Seleccionar variables numéricas para interacciones
X_num = df[['Age', 'Fare']].fillna(df[['Age', 'Fare']].median())

# Solo interacciones (sin términos cuadráticos individuales)
poly_interaction = PolynomialFeatures(
    degree=2,
    interaction_only=True,
    include_bias=False
)
X_interactions = poly_interaction.fit_transform(X_num)
print(f"Columnas originales: {X_num.columns.tolist()}")
print(f"Columnas generadas: {poly_interaction.get_feature_names_out()}")
print(f"Shape original: {X_num.shape} -> Shape con interacciones: {X_interactions.shape}")

# Términos polinomiales completos de grado 2
poly_full = PolynomialFeatures(degree=2, include_bias=False)
X_poly = poly_full.fit_transform(X_num)
print(f"\nColumnas polinomiales: {poly_full.get_feature_names_out()}")
print(f"Shape con polinomiales: {X_poly.shape}")

make_column_selector: detección automática de columnas

En vez de listar las columnas a mano, make_column_selector las detecta automáticamente por tipo de dato. Menos código, menos errores:

from sklearn.compose import make_column_selector

# Selector automático de columnas numéricas
num_selector = make_column_selector(dtype_include='number')

# Selector automático de columnas categóricas
cat_selector = make_column_selector(dtype_include='object')

# Usar en ColumnTransformer
preprocessor_auto = ColumnTransformer([
    ('num', num_pipeline, num_selector),
    ('cat', cat_pipeline, cat_selector)
])

# Esto seleccionará automáticamente las columnas correctas
preprocessor_auto.fit(X_train)
print(f"Columnas numéricas detectadas: {num_selector(X_train)}")
print(f"Columnas categóricas detectadas: {cat_selector(X_train)}")

Esta aproximación es especialmente valiosa cuando construyes pipelines genéricos que deben funcionar con diferentes datasets sin tocar el código.

Feature-engine: la biblioteca especializada que te faltaba

Feature-engine es una biblioteca de código abierto diseñada específicamente para ingeniería de características. Mientras que Scikit-learn es un framework de propósito general, Feature-engine se centra exclusivamente en transformaciones de variables, y se nota.

¿Por qué usar Feature-engine en lugar de Scikit-learn puro?

Buena pregunta. Aquí van las razones principales:

  • Devuelve DataFrames de Pandas: mantiene los nombres de las columnas, lo que facilita mucho la depuración e interpretación.
  • Selección automática de variables: los transformadores detectan solos qué columnas son numéricas o categóricas.
  • Transformadores especializados: incluye codificadores como WoE, Mean Encoding y RareLabelEncoder que no encontrarás en Scikit-learn.
  • Compatibilidad total: se integra sin problemas en pipelines de Scikit-learn.
  • API consistente: todos los transformadores funcionan igual, lo que reduce la curva de aprendizaje.
# Instalación
# pip install feature-engine==1.9.0

import feature_engine
print(f"Feature-engine versión: {feature_engine.__version__}")

Codificadores categóricos avanzados

MeanEncoder

Codifica cada categoría con la media de la variable objetivo. Es útil sobre todo para variables de alta cardinalidad donde OneHotEncoding se vuelve impracticable:

from feature_engine.encoding import MeanEncoder

# Preparar datos
X_train_fe = X_train.copy()
X_test_fe = X_test.copy()

# Mean Encoding para Embarked y Sex
mean_enc = MeanEncoder(
    variables=['Sex', 'Embarked'],
    smoothing=10  # Regularización para categorías con pocas observaciones
)

mean_enc.fit(X_train_fe, y_train)
X_train_encoded = mean_enc.transform(X_train_fe)
X_test_encoded = mean_enc.transform(X_test_fe)

print("Mapeos del MeanEncoder:")
print(mean_enc.encoder_dict_)
print(f"\nEjemplo transformado:\n{X_train_encoded[['Sex', 'Embarked']].head()}")

WoEEncoder (Weight of Evidence)

El codificador WoE es popular en la industria financiera, especialmente para scoring crediticio. Mide la fuerza predictiva de cada categoría de una forma estadísticamente elegante:

from feature_engine.encoding import WoEEncoder

woe_enc = WoEEncoder(variables=['Sex', 'Embarked'])
woe_enc.fit(X_train_fe, y_train)
X_train_woe = woe_enc.transform(X_train_fe)

print("Mapeos WoE:")
print(woe_enc.encoder_dict_)
# Valores positivos indican mayor probabilidad de supervivencia
# Valores negativos indican menor probabilidad

RareLabelEncoder

Una de mis favoritas. Agrupa las categorías poco frecuentes en una única categoría “Rare”, reduciendo la cardinalidad y mejorando la capacidad de generalización:

from feature_engine.encoding import RareLabelEncoder

# Añadir una variable de alta cardinalidad para el ejemplo
df['Title'] = df['Name'].str.extract(r' ([A-Za-z]+)\.', expand=False)
X_train_fe['Title'] = df.loc[X_train.index, 'Title']
X_test_fe['Title'] = df.loc[X_test.index, 'Title']

# Agrupar títulos que aparecen en menos del 5% de las observaciones
rare_enc = RareLabelEncoder(
    tol=0.05,                # Umbral de frecuencia mínima
    n_categories=4,          # Mínimo de categorías antes de agrupar
    replace_with='Rare',     # Etiqueta para categorías raras
    variables=['Title']
)

rare_enc.fit(X_train_fe)
X_train_rare = rare_enc.transform(X_train_fe)

print("Distribución original:")
print(X_train_fe['Title'].value_counts())
print("\nDistribución después de RareLabelEncoder:")
print(X_train_rare['Title'].value_counts())

Manejo de outliers

Feature-engine proporciona herramientas para tratar valores atípicos sin tener que eliminar filas enteras (algo que siempre duele):

from feature_engine.outliers import Winsorizer, OutlierTrimmer

# Winsorizer: recorta los valores extremos a un límite definido
winsorizer = Winsorizer(
    capping_method='iqr',      # Método: 'iqr', 'gaussian', 'quantiles'
    tail='both',               # 'both', 'left', 'right'
    fold=1.5,                  # Multiplicador del IQR
    variables=['Age', 'Fare']
)

winsorizer.fit(X_train_fe)
X_train_winsorized = winsorizer.transform(X_train_fe)

print("Límites de Winsorizer:")
print(f"  Age - inferior: {winsorizer.left_tail_caps_['Age']:.1f}, "
      f"superior: {winsorizer.right_tail_caps_['Age']:.1f}")
print(f"  Fare - inferior: {winsorizer.left_tail_caps_['Fare']:.1f}, "
      f"superior: {winsorizer.right_tail_caps_['Fare']:.1f}")

# OutlierTrimmer: elimina las filas con outliers
trimmer = OutlierTrimmer(
    capping_method='gaussian',
    tail='both',
    fold=3,                    # 3 desviaciones estándar
    variables=['Fare']
)

trimmer.fit(X_train_fe)
X_train_trimmed = trimmer.transform(X_train_fe)
print(f"\nFilas originales: {X_train_fe.shape[0]}")
print(f"Filas después de trimming: {X_train_trimmed.shape[0]}")
print(f"Filas eliminadas: {X_train_fe.shape[0] - X_train_trimmed.shape[0]}")

Imputación de datos faltantes

Los imputadores de Feature-engine ofrecen una API más limpia que la de Scikit-learn, y además trabajan directamente con DataFrames:

from feature_engine.imputation import (
    MeanMedianImputer,
    CategoricalImputer,
    AddMissingIndicator,
    ArbitraryNumberImputer
)

# Imputar variables numéricas con la mediana
median_imputer = MeanMedianImputer(
    imputation_method='median',
    variables=['Age', 'Fare']
)

# Imputar variables categóricas con la moda
cat_imputer = CategoricalImputer(
    imputation_method='frequent',
    variables=['Embarked']
)

# Añadir indicadores de dato faltante
missing_indicator = AddMissingIndicator(
    variables=['Age', 'Fare', 'Embarked']
)

# Imputar con un valor arbitrario (p. ej., -999)
arbitrary_imputer = ArbitraryNumberImputer(
    arbitrary_number=-999,
    variables=['Age']
)

# Aplicar secuencialmente
median_imputer.fit(X_train_fe)
X_train_imputed = median_imputer.transform(X_train_fe)

print("Valores de imputación (medianas):")
print(median_imputer.imputer_dict_)
print(f"\nNulos restantes en Age: {X_train_imputed['Age'].isnull().sum()}")
print(f"Nulos restantes en Fare: {X_train_imputed['Fare'].isnull().sum()}")

Transformación de variables

Muchos algoritmos funcionan mejor cuando las variables siguen una distribución más o menos normal. Feature-engine tiene transformadores específicos para esto:

from feature_engine.transformation import (
    LogTransformer,
    PowerTransformer,
    YeoJohnsonTransformer
)

# Preparar datos sin nulos para las transformaciones
X_train_clean = X_train_fe.copy()
X_train_clean['Age'] = X_train_clean['Age'].fillna(X_train_clean['Age'].median())
X_train_clean['Fare'] = X_train_clean['Fare'].fillna(X_train_clean['Fare'].median())

# LogTransformer: aplica log(x) o log(1+x)
# Solo para valores positivos
log_transformer = LogTransformer(variables=['Fare'])
X_train_log = log_transformer.fit_transform(X_train_clean)

print("Estadísticas de Fare original:")
print(X_train_clean['Fare'].describe())
print("\nEstadísticas de Fare con log:")
print(X_train_log['Fare'].describe())

# YeoJohnsonTransformer: funciona con valores negativos y ceros
yj_transformer = YeoJohnsonTransformer(variables=['Age', 'Fare'])
X_train_yj = yj_transformer.fit_transform(X_train_clean)

print(f"\nParámetros lambda de Yeo-Johnson:")
print(yj_transformer.lambda_dict_)

# PowerTransformer: aplica x^exp
power_transformer = PowerTransformer(
    variables=['Fare'],
    exp=0.5  # Raíz cuadrada
)
X_train_power = power_transformer.fit_transform(X_train_clean)
print(f"\nFare con raíz cuadrada - primeros valores:")
print(X_train_power['Fare'].head())

Selección de características

Más características no siempre significa mejores modelos. A veces menos es más, y Feature-engine incluye métodos inteligentes para limpiar tu espacio de features:

from feature_engine.selection import (
    DropCorrelatedFeatures,
    SmartCorrelatedSelection
)

# Crear un dataset con variables correlacionadas para el ejemplo
np.random.seed(42)
X_corr = pd.DataFrame({
    'var1': np.random.randn(500),
    'var2': np.random.randn(500),
    'var3': np.random.randn(500),
})
X_corr['var4'] = X_corr['var1'] * 0.95 + np.random.randn(500) * 0.1  # Alta correlación con var1
X_corr['var5'] = X_corr['var2'] * 0.90 + np.random.randn(500) * 0.2  # Alta correlación con var2
X_corr['var6'] = np.random.randn(500)  # Variable independiente
y_corr = (X_corr['var1'] + X_corr['var3'] > 0).astype(int)

# DropCorrelatedFeatures: elimina una de cada par correlacionado
drop_corr = DropCorrelatedFeatures(
    method='pearson',
    threshold=0.8
)
drop_corr.fit(X_corr)
X_uncorr = drop_corr.transform(X_corr)

print(f"Variables originales: {X_corr.columns.tolist()}")
print(f"Variables eliminadas: {drop_corr.features_to_drop_}")
print(f"Variables restantes: {X_uncorr.columns.tolist()}")

# SmartCorrelatedSelection: de cada grupo correlacionado, mantiene la más predictiva
smart_sel = SmartCorrelatedSelection(
    method='pearson',
    threshold=0.8,
    selection_method='variance',  # 'variance', 'cardinality', 'model_performance'
)
smart_sel.fit(X_corr)
X_smart = smart_sel.transform(X_corr)

print(f"\nSmartCorrelatedSelection:")
print(f"  Grupos correlacionados: {smart_sel.correlated_feature_sets_}")
print(f"  Variables eliminadas: {smart_sel.features_to_drop_}")
print(f"  Variables restantes: {X_smart.columns.tolist()}")

Pipeline completo: combinando Pandas, Scikit-learn y Feature-engine

Bueno, ahora vamos a lo que de verdad importa: armemos un pipeline completo que integre las tres herramientas. Este ejemplo representa un flujo de trabajo bastante realista para un proyecto de ML:

import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import classification_report

from feature_engine.imputation import MeanMedianImputer, CategoricalImputer
from feature_engine.encoding import RareLabelEncoder, MeanEncoder
from feature_engine.outliers import Winsorizer
from feature_engine.transformation import YeoJohnsonTransformer
from feature_engine.selection import DropCorrelatedFeatures

# =========================================
# Paso 1: Carga y preparación con Pandas
# =========================================
url = "https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv"
df = pd.read_csv(url)

# Ingeniería de características con Pandas (antes del pipeline)
df['FamilySize'] = df['SibSp'] + df['Parch'] + 1
df['IsAlone'] = (df['FamilySize'] == 1).astype(int)
df['Title'] = df['Name'].str.extract(r' ([A-Za-z]+)\.', expand=False)
df['HasCabin'] = df['Cabin'].notna().astype(int)
df['FarePerPerson'] = df['Fare'] / df['FamilySize']
df['Deck'] = df['Cabin'].str[0].fillna('Unknown')

# Seleccionar variables para el modelo
feature_cols = [
    'Pclass', 'Sex', 'Age', 'Fare', 'FarePerPerson',
    'FamilySize', 'IsAlone', 'HasCabin',
    'Embarked', 'Title', 'Deck'
]

X = df[feature_cols].copy()
y = df['Survived']

# Dividir datos
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# =========================================
# Paso 2: Definir columnas por tipo
# =========================================
num_cols = ['Age', 'Fare', 'FarePerPerson', 'FamilySize']
cat_cols = ['Sex', 'Embarked', 'Title', 'Deck']
binary_cols = ['Pclass', 'IsAlone', 'HasCabin']

# =========================================
# Paso 3: Pipeline con Feature-engine y Scikit-learn
# =========================================

# Sub-pipeline para variables numéricas
num_pipeline = Pipeline([
    ('imputer', MeanMedianImputer(
        imputation_method='median',
        variables=num_cols
    )),
    ('winsorizer', Winsorizer(
        capping_method='iqr',
        tail='both',
        fold=1.5,
        variables=num_cols
    )),
    ('yeo_johnson', YeoJohnsonTransformer(
        variables=['Fare', 'FarePerPerson']
    )),
    ('scaler', StandardScaler()),
])

# Sub-pipeline para variables categóricas
cat_pipeline = Pipeline([
    ('cat_imputer', CategoricalImputer(
        imputation_method='frequent',
        variables=cat_cols
    )),
    ('rare_encoder', RareLabelEncoder(
        tol=0.05,
        n_categories=3,
        variables=['Title', 'Deck']
    )),
    ('onehot', OneHotEncoder(
        handle_unknown='ignore',
        sparse_output=False
    )),
])

# Combinar todo con ColumnTransformer
preprocessor = ColumnTransformer(
    transformers=[
        ('num', num_pipeline, num_cols),
        ('cat', cat_pipeline, cat_cols),
        ('binary', 'passthrough', binary_cols)
    ],
    remainder='drop'
)

# Pipeline final
model_pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', GradientBoostingClassifier(
        n_estimators=200,
        max_depth=4,
        learning_rate=0.1,
        random_state=42
    ))
])

# =========================================
# Paso 4: Entrenar y evaluar
# =========================================

# Validación cruzada
cv_scores = cross_val_score(
    model_pipeline, X_train, y_train,
    cv=5, scoring='accuracy'
)
print(f"CV Accuracy: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")

# Entrenar modelo final
model_pipeline.fit(X_train, y_train)

# Evaluar en test
y_pred = model_pipeline.predict(X_test)
print("\nReporte de Clasificación:")
print(classification_report(y_test, y_pred, target_names=['No sobrevivió', 'Sobrevivió']))

# =========================================
# Paso 5: Inspeccionar transformaciones
# =========================================
preprocessor_fitted = model_pipeline.named_steps['preprocessor']

# Ver nombres de las características resultantes
feature_names = preprocessor_fitted.get_feature_names_out()
print(f"\nNúmero total de características: {len(feature_names)}")
print(f"Nombres: {feature_names.tolist()}")

Ejemplo alternativo con Mean Encoding

Para modelos basados en árboles, el Mean Encoding puede ser más efectivo que el OneHotEncoding, especialmente cuando las variables categóricas tienen muchas categorías:

# Pipeline alternativo con Mean Encoding
# Nota: MeanEncoder de Feature-engine necesita fit con y (target)

from feature_engine.encoding import MeanEncoder
from feature_engine.imputation import CategoricalImputer, MeanMedianImputer

# Pipeline secuencial completo (sin ColumnTransformer, aprovechando
# la selección automática de variables de Feature-engine)
fe_pipeline = Pipeline([
    # Imputación
    ('num_imputer', MeanMedianImputer(
        imputation_method='median',
        variables=num_cols
    )),
    ('cat_imputer', CategoricalImputer(
        imputation_method='frequent',
        variables=cat_cols
    )),
    # Tratamiento de outliers
    ('winsorizer', Winsorizer(
        capping_method='iqr',
        tail='both',
        fold=1.5,
        variables=num_cols
    )),
    # Codificación categórica
    ('rare_labels', RareLabelEncoder(
        tol=0.05,
        n_categories=3,
        variables=['Title', 'Deck']
    )),
    ('mean_encoder', MeanEncoder(
        variables=cat_cols,
        smoothing=20
    )),
    # Transformación
    ('yeo_johnson', YeoJohnsonTransformer(
        variables=['Fare', 'FarePerPerson']
    )),
    # Selección de características
    ('drop_correlated', DropCorrelatedFeatures(
        method='pearson',
        threshold=0.85
    )),
    # Modelo
    ('classifier', GradientBoostingClassifier(
        n_estimators=200,
        max_depth=4,
        learning_rate=0.1,
        random_state=42
    ))
])

# Entrenar y evaluar
fe_pipeline.fit(X_train, y_train)
y_pred_fe = fe_pipeline.predict(X_test)

print("Pipeline con Feature-engine completo:")
print(classification_report(y_test, y_pred_fe, target_names=['No sobrevivió', 'Sobrevivió']))

# Ver qué características se eliminaron por correlación
drop_step = fe_pipeline.named_steps['drop_correlated']
print(f"Características eliminadas por correlación: {drop_step.features_to_drop_}")

Mejores prácticas y errores comunes

Después de trabajar con ingeniería de características en varios proyectos, hay ciertos patrones que se repiten. Estas son las prácticas que más impacto tienen (y los errores que más he visto).

1. Evitar el data leakage a toda costa

El data leakage es el error más peligroso en ML. Produce métricas que se ven increíbles en tu notebook pero se desploman en producción:

# INCORRECTO - Data Leakage
# Escalar ANTES de dividir contamina el test con información del train
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # Usa todo el dataset
X_train, X_test = train_test_split(X_scaled, test_size=0.2)

# CORRECTO - Escalar DESPUÉS de dividir
X_train, X_test = train_test_split(X, test_size=0.2)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)   # Ajustar solo en train
X_test_scaled = scaler.transform(X_test)          # Transformar test sin ajustar

# AÚN MEJOR - Usar Pipeline (imposible hacer leakage accidental)
pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', GradientBoostingClassifier())
])
pipeline.fit(X_train, y_train)  # El scaler solo ve X_train

2. Siempre usar pipelines para transformaciones

Los pipelines no son opcionales, son esenciales. Garantizan reproducibilidad y previenen errores cuando pasas de experimentación a producción:

  • Las transformaciones se aplican siempre en el orden correcto
  • Los parámetros se guardan del entrenamiento y se reutilizan en inferencia
  • Se pueden serializar con joblib para despliegue
  • Se integran nativamente con cross_val_score y GridSearchCV
import joblib

# Guardar pipeline completo
joblib.dump(model_pipeline, 'modelo_titanic.pkl')

# Cargar y usar en producción
modelo_cargado = joblib.load('modelo_titanic.pkl')
predicciones = modelo_cargado.predict(nuevos_datos)

3. Conocer tu modelo antes de diseñar características

No todos los modelos necesitan las mismas transformaciones. Conocer tu algoritmo te ahorra trabajo innecesario:

  • Modelos lineales (regresión logística, SVM lineal): requieren escalado, se benefician de interacciones polinomiales y transformaciones de normalidad.
  • Modelos basados en árboles (Random Forest, XGBoost, LightGBM): no necesitan escalado, funcionan bien con Mean Encoding, ignoran la normalidad de las distribuciones.
  • Redes neuronales: requieren escalado (preferiblemente al rango [0, 1]) y se benefician de entradas normalizadas.
  • KNN y métodos basados en distancia: el escalado es obligatorio. Sin él, las variables con mayor rango dominarían el cálculo de distancia.

4. Manejar correctamente las categorías desconocidas

En producción siempre aparecerán categorías que no viste durante el entrenamiento. Prepárate para eso:

# Configurar codificadores para manejar categorías no vistas en entrenamiento
ohe = OneHotEncoder(handle_unknown='ignore')

# En Feature-engine, los codificadores tienen parámetros similares
from feature_engine.encoding import MeanEncoder
mean_enc = MeanEncoder(
    variables=['Sex', 'Embarked'],
    unseen='encode'  # Codificar categorías no vistas con la media global
)

5. Documentar y versionar las transformaciones

# Inspeccionar los pasos de un pipeline
for step_name, step_obj in model_pipeline.named_steps.items():
    print(f"Paso: {step_name}")
    print(f"  Tipo: {type(step_obj).__name__}")
    if hasattr(step_obj, 'get_params'):
        params = step_obj.get_params()
        for key, val in params.items():
            if not key.startswith('_') and '__' not in key:
                print(f"  {key}: {val}")
    print()

6. Validar la distribución de características

Una validación rápida que pocos hacen pero que puede salvarte de problemas:

import warnings

def validar_features(X_train, X_test, threshold=0.1):
    """Verifica que las distribuciones de train y test sean similares."""
    alertas = []
    for col in X_train.select_dtypes(include='number').columns:
        train_mean = X_train[col].mean()
        test_mean = X_test[col].mean()
        if train_mean != 0:
            diff_pct = abs(train_mean - test_mean) / abs(train_mean)
            if diff_pct > threshold:
                alertas.append(
                    f"ALERTA: '{col}' difiere {diff_pct:.1%} entre train y test"
                )

    if alertas:
        for alerta in alertas:
            warnings.warn(alerta)
    else:
        print("Todas las distribuciones son consistentes entre train y test.")

    return alertas

# Usar después de las transformaciones
alertas = validar_features(X_train, X_test)

7. Errores comunes a evitar

He visto estos errores lo suficiente como para hacer una lista:

  • No tratar los valores faltantes: muchos modelos no aceptan NaN. Siempre incluye un paso de imputación en tu pipeline.
  • Crear demasiadas características: más no es mejor. El ruido puede superar la señal. Usa selección de características.
  • Ignorar la multicolinealidad: variables altamente correlacionadas desestabilizan modelos lineales y dificultan la interpretación. Usa DropCorrelatedFeatures.
  • Aplicar OneHotEncoding a variables de alta cardinalidad: si una variable tiene 1000 categorías, vas a generar 1000 columnas. Usa TargetEncoder o MeanEncoder.
  • No manejar categorías nuevas en producción: configura siempre handle_unknown en tus codificadores.
  • Escalar variables binarias: las columnas 0/1 no necesitan escalado, y hacerlo perjudica la interpretabilidad.
  • No validar con validación cruzada: evalúa cada característica nueva con CV, no con un solo split.

8. Estrategia iterativa recomendada

La ingeniería de características es un proceso iterativo. No intentes hacerlo todo de golpe. Este es el enfoque que mejor me ha funcionado:

  1. Baseline: modelo simple con variables crudas. Registra la métrica base.
  2. EDA: explora las relaciones entre variables y el objetivo. Identifica oportunidades.
  3. Transformaciones básicas: imputación, escalado, codificación categórica. Mide la mejora.
  4. Características de dominio: crea variables usando conocimiento del negocio (como FamilySize o FarePerPerson). Mide.
  5. Interacciones y polinomiales: prueba combinaciones entre las mejores variables. Mide.
  6. Selección: elimina características redundantes. Mide.
  7. Iteración: repite los pasos 4-6 hasta que no haya mejoras significativas.
# Ejemplo de evaluación iterativa
from sklearn.model_selection import cross_val_score
from sklearn.ensemble import GradientBoostingClassifier

def evaluar_features(X, y, nombre=""):
    """Evalúa un conjunto de características con validación cruzada."""
    # Preprocesar solo numéricos para simplificar
    X_num = X.select_dtypes(include='number').fillna(0)

    model = GradientBoostingClassifier(
        n_estimators=100, max_depth=3, random_state=42
    )
    scores = cross_val_score(model, X_num, y, cv=5, scoring='accuracy')
    print(f"{nombre:40s} | Accuracy: {scores.mean():.4f} (+/- {scores.std():.4f})")
    return scores.mean()

# Baseline
evaluar_features(
    df[['Pclass', 'Age', 'SibSp', 'Parch', 'Fare']],
    y, "Baseline (variables crudas)"
)

# Con características de familia
evaluar_features(
    df[['Pclass', 'Age', 'SibSp', 'Parch', 'Fare', 'FamilySize', 'IsAlone']],
    y, "+ FamilySize, IsAlone"
)

# Con todas las características ingenieradas
evaluar_features(
    df[['Pclass', 'Age', 'SibSp', 'Parch', 'Fare',
        'FamilySize', 'IsAlone', 'HasCabin', 'FarePerPerson']],
    y, "+ HasCabin, FarePerPerson"
)

Tabla comparativa: Pandas vs Scikit-learn vs Feature-engine

Para que tengas claro cuándo usar cada herramienta, aquí va un resumen práctico:

  • Pandas: ideal para exploración y creación manual de características. No se integra directamente en pipelines de producción, pero es insustituible para características de dominio, extracción de texto e ingeniería temporal.
  • Scikit-learn: infraestructura robusta para pipelines reproducibles. Amplio soporte de escaladores, codificadores y transformadores con API estándar fit/transform. Ideal para escalado, codificación básica, pipelines de producción y validación cruzada.
  • Feature-engine: transformadores especializados que devuelven DataFrames. Codificadores avanzados (WoE, Mean, Target), selección automática de variables e imputación sofisticada. Perfecto para cuando Scikit-learn se queda corto.

En la práctica, la mejor combinación suele ser: Pandas para la fase exploratoria y características de dominio, y Feature-engine junto con Scikit-learn para el pipeline final reproducible.

Conclusión

La ingeniería de características sigue siendo una de las habilidades más valiosas en ciencia de datos. Sí, AutoML y deep learning han automatizado parte del proceso, pero la capacidad de transformar datos crudos en representaciones que realmente signifiquen algo sigue marcando la diferencia entre un modelo que funciona y uno que destaca.

En esta guía hemos cubierto bastante terreno con las tres herramientas fundamentales del ecosistema Python:

  • Pandas para manipulación directa y características basadas en conocimiento del dominio: tamaño de familia, extracción de títulos, descomposición temporal, binning y agregaciones.
  • Scikit-learn para pipelines reproducibles con ColumnTransformer y Pipeline, usando escaladores, codificadores e interacciones polinomiales.
  • Feature-engine para transformaciones avanzadas: MeanEncoder, WoEEncoder, RareLabelEncoder, Winsorizer, YeoJohnsonTransformer y SmartCorrelatedSelection.

Los principios clave: siempre usa pipelines para evitar data leakage, adapta las transformaciones a tu modelo, itera y mide el impacto de cada característica, y nunca subestimes el poder del conocimiento del dominio.

El siguiente paso es experimentar con tus propios datos. Empieza con un baseline simple, mide, añade características de forma incremental, y deja que los resultados guíen tus decisiones. Al final, la mejor ingeniería de características es la que nace de entender profundamente tu problema y tus datos.

Sobre el Autor Editorial Team

Our team of expert writers and editors.