SECCIÓN 13

MÓDULO 3.FeatureEng()

DE VARIABLES CATEGÓRICAS A MATRICES BINARIAS

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.

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 ❌

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
100
010
001
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
Matriz sparse:
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:
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0]
Almacena 10 valores (40 bytes)

Sparse Vector:
SparseVector(10, [4], [1.0])
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

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]
Ahorro de Memoria
Si hubiera 1000 categorías:
• 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
150Menor
251Joven
452Adulto
703Senior
¿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.
¿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