Feature Engineering Avanzado
Transforma variables categóricas en representaciones numéricas que los modelos de ML pueden procesar eficientemente.
01. El Problema con Variables Categóricas
¿Por qué no podemos usar strings directamente?
Modelo de regresión lineal:
$$
y = \beta_0 + \beta_1 x_1 + \beta_2 x_2 + \cdots + \beta_p x_p
$$
Requiere que todas las \(x_i\) sean números reales. Pero, ¿qué pasa si \(x_1\) = "Rojo", "Verde", "Azul"?
No se puede hacer:
$$
y = 5 + 3 \times \text{"Rojo"}
$$
No existe definición matemática para multiplicar números por strings.
Necesitamos codificación:
"Rojo" →
[1, 0, 0]
"Verde" →
[0, 1, 0]
"Azul" →
[0, 0, 1]
Ejemplo: Dataset de E-commerce
| producto_id | categoria | marca | precio | ventas |
|---|---|---|---|---|
| P001 | Electrónica | Samsung | 299.99 | 150 |
| P002 | Ropa | Nike | 89.99 | 320 |
| P003 | Electrónica | Apple | 999.99 | 450 |
🎯 Objetivo: Predecir
ventas usando categoria, marca y precio.
Pero categoria y marca son strings. ¡Necesitamos codificarlas!
02. Codificación Ordinal (StringIndexer)
Matemática: Mapeo a Enteros
$$
f: \mathcal{C} \to \mathbb{N}, \quad \mathcal{C} = \{\text{Rojo}, \text{Verde}, \text{Azul}\}
$$
$$
\begin{aligned}
f(\text{Rojo}) &= 0 \\
f(\text{Verde}) &= 1 \\
f(\text{Azul}) &= 2
\end{aligned}
$$
⚠️ Problema: Implica orden: Rojo < Verde < Azul.
El modelo interpretará que "Azul" es "mayor" que "Rojo" (distancia = 2), lo cual es falso si son categorías nominales.
El modelo interpretará que "Azul" es "mayor" que "Rojo" (distancia = 2), lo cual es falso si son categorías nominales.
Código: StringIndexer
from pyspark.ml.feature import StringIndexer
df = spark.createDataFrame([
("Rojo",),
("Verde",),
("Azul",),
("Rojo",)
], ["color"])
# Mapea strings → índices (por frecuencia)
indexer = StringIndexer(
inputCol="color",
outputCol="color_index"
)
model = indexer.fit(df)
df_indexed = model.transform(df)
df_indexed.show()
# +------+-----------+
# | color|color_index|
# +------+-----------+
# | Rojo| 0.0| # Más frecuente
# | Verde| 1.0|
# | Azul| 2.0| # Menos frecuente
# | Rojo| 0.0|
# +------+-----------+
⚠️ Cuándo usar codificación ordinal
Solo si hay orden natural:
• Talla: "S" < "M" < "L" < "XL" (0, 1, 2, 3) ✅
• Nivel educativo: "Primaria" < "Secundaria" < "Universidad" ✅
No usar si son categorías nominales:
• Color, Ciudad, Marca → No tienen orden intrínseco ❌
• Talla: "S" < "M" < "L" < "XL" (0, 1, 2, 3) ✅
• Nivel educativo: "Primaria" < "Secundaria" < "Universidad" ✅
No usar si son categorías nominales:
• Color, Ciudad, Marca → No tienen orden intrínseco ❌
03. One-Hot Encoding: La Solución Correcta
Matemática: Indicador Binario
$$
\mathbb{1}(x = c) = \begin{cases}
1 & \text{si } x = c \\
0 & \text{si } x \neq c
\end{cases}
$$
Crea una columna binaria por cada categoría. La fila tiene 1 en la categoría activa, 0 en el resto.
Transformación Visual
Antes (String):
| color |
|---|
| Rojo |
| Verde |
| Azul |
Después (One-Hot):
| Rojo | Verde | Azul |
|---|---|---|
| 1 | 0 | 0 |
| 0 | 1 | 0 |
| 0 | 0 | 1 |
Matriz Resultante
$$
\mathbf{X}_{\text{encoded}} = \begin{bmatrix}
1 & 0 & 0 \\
0 & 1 & 0 \\
0 & 0 & 1
\end{bmatrix}
$$
Cada fila tiene exactamente un 1. No hay orden implícito.
✅ Ahora el modelo puede aprender: \(\beta_{\text{Rojo}} x_{\text{Rojo}} + \beta_{\text{Verde}} x_{\text{Verde}} + \beta_{\text{Azul}} x_{\text{Azul}}\)
Dummy Variable Trap (Colinealidad)
Problema: Si incluyes las 3 columnas [Rojo, Verde, Azul], son linealmente dependientes:
$$
x_{\text{Rojo}} + x_{\text{Verde}} + x_{\text{Azul}} = 1 \quad \text{(siempre)}
$$
Solución (k-1 encoding): Elimina una columna (la primera o la última).
Con 2 columnas puedes inferir la tercera:
Si
Rojo=0 y Verde=0 → entonces Azul=1 (implícito)
04. Matrices Sparse (Dispersas)
El Problema de Dimensionalidad
Ejemplo: Dataset de productos
$$
\begin{aligned}
\text{Categorías: } & k = 10,000 \\
\text{Filas: } & n = 1,000,000
\end{aligned}
$$
Cada fila one-hot encoded:
Matriz densa:
10,000 valores × 4 bytes = 40 KB por fila
1M filas × 40 KB = 40 GB total
10,000 valores × 4 bytes = 40 KB por fila
1M filas × 40 KB = 40 GB total
Matriz sparse:
Solo almacenar índice del "1" = 4 bytes por fila
1M filas × 4 bytes = 4 MB total (10,000x menos)
Solo almacenar índice del "1" = 4 bytes por fila
1M filas × 4 bytes = 4 MB total (10,000x menos)
Formato CSR (Compressed Sparse Row)
Spark usa este formato para almacenar vectores sparse eficientemente:
Dense Vector:
Almacena 10 valores (40 bytes)
Sparse Vector:
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0]Almacena 10 valores (40 bytes)
Sparse Vector:
SparseVector(10, [4], [1.0])
•
•
•
• Resto son ceros implícitos
10: Dimensión total•
[4]: Índice del valor no-cero•
[1.0]: Valor en ese índice• Resto son ceros implícitos
$$
\text{Ahorro} = \frac{40 \text{ bytes}}{8 \text{ bytes}} = 5x
$$
Con 10,000 categorías, el ahorro es 1000x+
graph LR
A[String: 'Electrónica'] -->|StringIndexer| B[Index: 342]
B -->|OneHotEncoder| C[SparseVector
10000, [342], [1.0]] style A fill:#3b82f6,stroke:#fff style C fill:#10b981,stroke:#fff
10000, [342], [1.0]] style A fill:#3b82f6,stroke:#fff style C fill:#10b981,stroke:#fff
05. OneHotEncoder en PySpark
Pipeline Completo: String → Índice → Vector
from pyspark.ml.feature import StringIndexer, OneHotEncoder, VectorAssembler
from pyspark.ml import Pipeline
# Dataset ejemplo
df = spark.createDataFrame([
("Electrónica", "Samsung", 299.99),
("Ropa", "Nike", 89.99),
("Electrónica", "Apple", 999.99),
("Hogar", "Ikea", 149.99)
], ["categoria", "marca", "precio"])
# ===========================================
# Paso 1: String → Índice (StringIndexer)
# ===========================================
categoria_indexer = StringIndexer(
inputCol="categoria",
outputCol="categoria_index"
)
marca_indexer = StringIndexer(
inputCol="marca",
outputCol="marca_index"
)
# ===========================================
# Paso 2: Índice → Vector binario (OneHotEncoder)
# ===========================================
categoria_encoder = OneHotEncoder(
inputCol="categoria_index",
outputCol="categoria_vec",
dropLast=True # k-1 encoding (evita colinealidad)
)
marca_encoder = OneHotEncoder(
inputCol="marca_index",
outputCol="marca_vec",
dropLast=True
)
# ===========================================
# Paso 3: Ensamblar todas las features
# ===========================================
assembler = VectorAssembler(
inputCols=["categoria_vec", "marca_vec", "precio"],
outputCol="features"
)
# ===========================================
# Pipeline: Ejecuta todas las etapas en orden
# ===========================================
pipeline = Pipeline(stages=[
categoria_indexer,
marca_indexer,
categoria_encoder,
marca_encoder,
assembler
])
model = pipeline.fit(df)
df_transformed = model.transform(df)
# Ver resultado
df_transformed.select("categoria", "marca", "features").show(truncate=False)
# +------------+-------+----------------------------------+
# |categoria |marca |features |
# +------------+-------+----------------------------------+
# |Electrónica |Samsung|(6,[0,2,5],[1.0,1.0,299.99]) | # Sparse!
# |Ropa |Nike |(6,[1,3,5],[1.0,1.0,89.99]) |
# |Electrónica |Apple |(6,[0,4,5],[1.0,1.0,999.99]) |
# |Hogar |Ikea |(6,[5],[149.99]) | # Solo precio!
# +------------+-------+----------------------------------+
Interpretación
(6, [0,2,5], [1.0,1.0,299.99])
• 6 dimensiones totales
• Índices [0,2,5] no-ceros
• Valores [1.0, 1.0, 299.99]
• Índices [0,2,5] no-ceros
• Valores [1.0, 1.0, 299.99]
Ahorro de Memoria
Si hubiera 1000 categorías:
• Dense: 1000 × 8 bytes = 8KB
• Sparse: 3 × 8 bytes = 24 bytes
→ 333x menos
• Dense: 1000 × 8 bytes = 8KB
• Sparse: 3 × 8 bytes = 24 bytes
→ 333x menos
dropLast=True
Con 3 categorías, genera 2 columnas (k-1). La tercera se infiere:
[0,0] → categoría 3
06. Bucketizer: Discretización
Convertir Continuo → Categórico
$$
g(x) = k \quad \text{si } x \in [b_k, b_{k+1})
$$
Divide el rango continuo en bins (buckets) con límites \(b_0, b_1, \ldots, b_n\).
Ejemplo: Edad en Grupos
Splits:
[0, 18, 35, 60, 100]
| Edad | Bucket | Etiqueta |
|---|---|---|
| 15 | 0 | Menor |
| 25 | 1 | Joven |
| 45 | 2 | Adulto |
| 70 | 3 | Senior |
¿Por qué discretizar?
- Relaciones no lineales: Salario vs edad no es lineal (crece, luego se estanca).
- Interpretabilidad: "Jóvenes compran más" es más claro que "β_edad = 0.03".
- Robustez a outliers: Edad = 150 (error) → cae en último bucket.
Código: Bucketizer en PySpark
from pyspark.ml.feature import Bucketizer
df = spark.createDataFrame([
(15,), (25,), (35,), (45,), (70,)
], ["edad"])
# Definir límites de buckets
splits = [0.0, 18.0, 35.0, 60.0, 100.0]
bucketizer = Bucketizer(
splits=splits,
inputCol="edad",
outputCol="edad_bucket"
)
df_bucketed = bucketizer.transform(df)
df_bucketed.show()
# +----+-----------+
# |edad|edad_bucket|
# +----+-----------+
# | 15| 0.0| # [0, 18)
# | 25| 1.0| # [18, 35)
# | 35| 2.0| # [35, 60)
# | 45| 2.0| # [35, 60)
# | 70| 3.0| # [60, 100)
# +----+-----------+
# Luego puedes aplicar OneHotEncoder a edad_bucket
07. PolynomialExpansion: Features Polinómicas
Kernel Trick Manual
$$
\phi(\mathbf{x}) = [x_1, x_2, x_1^2, x_1 x_2, x_2^2, \ldots]
$$
Expande features originales a polinomios de grado \(d\). Permite a modelos lineales aprender relaciones no lineales.
Ejemplo: Grado 2 con 2 features
$$
\begin{aligned}
\mathbf{x} &= [x_1, x_2] \\[0.5em]
\phi(\mathbf{x}) &= [x_1, x_2, x_1^2, x_1 x_2, x_2^2]
\end{aligned}
$$
Modelo resultante:
\(y = \beta_0 + \beta_1 x_1 + \beta_2 x_2 + \beta_3 x_1^2 + \beta_4 x_1 x_2 + \beta_5 x_2^2\)
Aunque sigue siendo regresión lineal (en los coeficientes), puede modelar curvas.
\(y = \beta_0 + \beta_1 x_1 + \beta_2 x_2 + \beta_3 x_1^2 + \beta_4 x_1 x_2 + \beta_5 x_2^2\)
Aunque sigue siendo regresión lineal (en los coeficientes), puede modelar curvas.
¿Por qué usar esto?
Problema: Relación entre X e Y es cuadrática (parábola).
Modelo lineal simple \(y = \beta_0 + \beta_1 x\) no la captura.
Solución: Agregar \(x^2\) como feature.
Ahora: \(y = \beta_0 + \beta_1 x + \beta_2 x^2\) ajusta perfectamente.
Cuidado: Grado alto (d=5+) puede causar overfitting.
Código: PolynomialExpansion
from pyspark.ml.feature import PolynomialExpansion
from pyspark.ml.linalg import Vectors
df = spark.createDataFrame([
(Vectors.dense([2.0, 1.0]),),
(Vectors.dense([0.0, 0.0]),),
(Vectors.dense([3.0, -1.0]),)
], ["features"])
# Expandir a grado 2
poly_expansion = PolynomialExpansion(
degree=2,
inputCol="features",
outputCol="poly_features"
)
df_poly = poly_expansion.transform(df)
df_poly.select("features", "poly_features").show(truncate=False)
# +---------+------------------------------------------+
# |features |poly_features |
# +---------+------------------------------------------+
# |[2.0,1.0]|[2.0, 4.0, 2.0, 1.0, 1.0] | # [x₁, x₁², x₁x₂, x₂, x₂²]
# |[0.0,0.0]|[0.0, 0.0, 0.0, 0.0, 0.0] |
# |[3.0,-1.0]|[3.0, 9.0, -3.0, -1.0, 1.0] |
# +---------+------------------------------------------+
# Verificación manual para [2.0, 1.0]:
# x₁ = 2, x₂ = 1
# → [x₁, x₁², x₁x₂, x₂, x₂²] = [2, 4, 2, 1, 1] ✓
💡 Usa esto cuando detectes relaciones no lineales en tus gráficos de exploración. Combina con regularización (Ridge/Lasso) para evitar overfitting.
08. Reto Intermedio
Pipeline Multi-Categórico
📋 Desafío
Crea un pipeline que procese este dataset de clientes:
| edad | ciudad | producto | compro |
|---|---|---|---|
| 25 | Bogotá | Laptop | 1 |
| 45 | Medellín | Smartphone | 0 |
| 32 | Cali | Laptop | 1 |
1
Discretizar edad:
5 bins: [0-18, 18-25, 25-40, 40-60, 60-100]
2
One-hot encode ciudad:
StringIndexer → OneHotEncoder (con dropLast=True)
3
One-hot encode producto:
StringIndexer → OneHotEncoder (con dropLast=True)
4
Ensamblar:
VectorAssembler con [edad_bucket, ciudad_vec, producto_vec]
✅ Validación
Verificar
- • Vector final debe ser SparseVector
- • Mostrar dimensión total del vector
- • Imprimir una fila completa transformada
Bonus
- • Calcular % de ceros en el vector final
- • Comparar tamaño Dense vs Sparse
- • Guardar pipeline entrenado