GA como Wrapper para Machine Learning (Feature Selection y HPO)#
Contexto del Problema#
En la industria, es común enfrentarse a datasets con cientos o miles de características (datos de sensores, logs de servidores, por ejemplo). Entrenar modelos de Machine Learning con todas ellas genera tres problemas críticos:
Overfitting: El modelo memoriza el ruido de las variables irrelevantes.
Latencia y Costo: Más variables implican modelos más pesados, lentos para inferir en producción y costosos de mantener.
Pérdida de Interpretabilidad: Un modelo con 5 variables clave es explicable a la gerencia; uno con 500, no.
Objetivos y Restricciones (Constraints)#
Objetivo principal: Encontrar el subconjunto mínimo de características que maximice el rendimiento predictivo del modelo.
Restricciones:
Costo Computacional: Evaluar un individuo implica entrenar un modelo con Validación Cruzada (CV). Es una operación muy costosa. Debemos minimizar las re-evaluaciones.
Validez Estructural: Un individuo no puede tener 0 características seleccionadas.
Prerrequisitos#
Haber completado el capítulo de machine learning clásico.
Haber completado el notebook Optimización Combinatoria: Espacios Discretos y Permutaciones
Configuración del Entorno#
Collecting deap
Downloading deap-1.4.4-py3-none-any.whl.metadata (13 kB)
Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (from deap) (2.0.2)
Collecting moocore (from deap)
Downloading moocore-0.3.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (6.5 kB)
Requirement already satisfied: cffi>=1.17.1 in /usr/local/lib/python3.12/dist-packages (from moocore->deap) (2.0.0)
Requirement already satisfied: platformdirs in /usr/local/lib/python3.12/dist-packages (from moocore->deap) (4.10.0)
Requirement already satisfied: pycparser in /usr/local/lib/python3.12/dist-packages (from cffi>=1.17.1->moocore->deap) (3.0)
Downloading deap-1.4.4-py3-none-any.whl (93 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 93.1/93.1 kB 3.1 MB/s eta 0:00:00
?25hDownloading moocore-0.3.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (866 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 866.9/866.9 kB 17.8 MB/s eta 0:00:00
?25hInstalling collected packages: moocore, deap
Successfully installed deap-1.4.4 moocore-0.3.1
Comprensión del Enfoque: Filter vs. Wrapper#
Para seleccionar características, existen dos enfoques principales:
Filter Methods (Filtros): Evalúan cada característica individualmente usando estadística (ej. Correlación de Pearson, Información Mutua). Son rapidísimos, pero ignoran las interacciones. Si dos variables parecen inútiles por separado, el filtro las descarta, ignorando que juntas podrían ser el predictor perfecto.
Wrapper Methods (Envolturas): Usan el propio modelo de ML para evaluar subconjuntos de características. Capturan sinergias perfectamente, pero probar todas las combinaciones (\(2^N\)) es NP-Hard.
Nuestra Solución: Usar un Algoritmo Genético como un Wrapper Inteligente. El GA explorará el inmenso espacio de combinaciones guiado por el rendimiento real del modelo.
Diseño de la Arquitectura (“Mini-Framework”)#
Vamos a construir un framework robusto para este problema. Desglosamos el código en tres partes fundamentales.
1. Evaluador (Fitness y Caché)#
Nuestro cromosoma será un vector binario (ej. [1, 0, 1, 0]).
El fitness debe premiar dos cosas: un alto Accuracy y una alta Compresión (pocas variables). Usamos una suma ponderada controlada por un parámetro \(\alpha\).
⚠️ Criterio de Ingeniería (El Caché): En un GA, es muy común que el cruce o la mutación generen un individuo que ya fue evaluado en generaciones anteriores. Re-entrenar un Random Forest para un individuo repetido es un desperdicio inaceptable de cómputo. Implementamos un diccionario
_cachepara guardar y recuperar evaluaciones previas instantáneamente.
from dataclasses import dataclass
@dataclass
class GAConfig:
population_size: int = 40
n_generations: int = 60
crossover_prob: float = 0.8
mutation_prob: float = 0.2
alpha: float = 0.8 # 80% peso al accuracy, 20% a la compresión
cv_folds: int = 5
min_features: int = 1
class FitnessEvaluator:
def __init__(self, estimator, X, y, config):
self.estimator = estimator
self.X = X
self.y = y
self.config = config
self.n_features = X.shape[1]
self.cv = StratifiedKFold(n_splits=config.cv_folds, shuffle=True, random_state=SEED)
self._cache = {} # Salvavidas computacional!
self.eval_count = 0
def __call__(self, individual):
# Convertimos la lista a tupla para usarla como llave del diccionario
key = tuple(individual)
if key in self._cache:
return self._cache[key]
self.eval_count += 1
selected_indices = [i for i, bit in enumerate(individual) if bit == 1]
n_sel = len(selected_indices)
# Penalización si no selecciona nada
if n_sel < self.config.min_features:
self._cache[key] = (0.0,)
return (0.0,)
# Evaluamos el modelo solo con las características seleccionadas
X_sub = self.X[:, selected_indices]
est = clone(self.estimator)
scores = cross_val_score(est, X_sub, self.y, scoring='accuracy', cv=self.cv, n_jobs=1)
cv_score = np.mean(scores)
# Calculamos la compresión (1.0 = eliminó todo, 0.0 = usó todo)
compression = 1.0 - (n_sel / self.n_features)
# Fitness ponderado
fitness_val = self.config.alpha * cv_score + (1.0 - self.config.alpha) * compression
result = (fitness_val,)
self._cache[key] = result
return result
2. Operadores y Reparación Estructural#
Si usamos mutación aleatoria, el GA podría apagar todos los bits ([0, 0, 0, 0]). Esto rompería el modelo de ML. Necesitamos un Operador de Reparación que se ejecute después del cruce y la mutación para garantizar que siempre haya al menos min_features activas.
def repair_individual(individual, min_features=1):
"""Garantiza que el individuo tenga al menos 'min_features' activos."""
active = sum(individual)
if active < min_features:
inactive_indices = [i for i, bit in enumerate(individual) if bit == 0]
needed = min_features - active
# Encendemos bits aleatorios hasta cumplir el mínimo
to_activate = random.sample(inactive_indices, min(needed, len(inactive_indices)))
for i in to_activate:
individual[i] = 1
return individual
def cx_uniform_with_repair(ind1, ind2, indpb=0.5):
tools.cxUniform(ind1, ind2, indpb=indpb)
repair_individual(ind1)
repair_individual(ind2)
return ind1, ind2
def mut_flip_with_repair(individual, indpb):
tools.mutFlipBit(individual, indpb=indpb)
repair_individual(individual)
return (individual,)
3. Bucle Evolutivo (Con Early Stopping)#
Ensamblamos el framework. Usamos un bucle manual para poder inyectar Early Stopping: si el GA no encuentra nada mejor en \(N\) generaciones, detenemos el proceso para ahorrar tiempo y cómputo.
def run_ga_wrapper(X, y, estimator, config):
# Limpieza de DEAP
for cls in ['FitnessMax', 'Individual']:
if hasattr(creator, cls): delattr(creator, cls)
creator.create("FitnessMax", base.Fitness, weights=(1.0,))
creator.create("Individual", list, fitness=creator.FitnessMax)
toolbox = base.Toolbox()
# Inicialización sesgada (50% de probabilidad de encender un bit)
toolbox.register("attr_bool", lambda: 1 if random.random() < 0.5 else 0)
toolbox.register("individual", tools.initRepeat, creator.Individual, toolbox.attr_bool, n=X.shape[1])
toolbox.register("population", tools.initRepeat, list, toolbox.individual)
evaluator = FitnessEvaluator(estimator, X, y, config)
toolbox.register("evaluate", evaluator)
toolbox.register("mate", cx_uniform_with_repair, indpb=0.5)
toolbox.register("mutate", mut_flip_with_repair, indpb=1.0/X.shape[1])
toolbox.register("select", tools.selTournament, tournsize=3)
pop = toolbox.population(n=config.population_size)
hof = tools.HallOfFame(1)
# Evaluación inicial
for ind in pop:
ind.fitness.values = toolbox.evaluate(ind)
hof.update(pop)
# Variables para Early Stopping
best_fitness_historico = hof[0].fitness.values[0]
paciencia = 15 # Hardcodeado acá, cambiar a gusto...
sin_mejora = 0
print(f"Iniciando evolución (Features: {X.shape[1]})...")
t0 = time.time()
for gen in range(config.n_generations):
offspring = toolbox.select(pop, len(pop))
offspring = list(map(toolbox.clone, offspring))
for child1, child2 in zip(offspring[::2], offspring[1::2]):
if random.random() < config.crossover_prob:
toolbox.mate(child1, child2)
del child1.fitness.values
del child2.fitness.values
for mutant in offspring:
if random.random() < config.mutation_prob:
toolbox.mutate(mutant)
del mutant.fitness.values
invalid_ind = [ind for ind in offspring if not ind.fitness.valid]
for ind in invalid_ind:
ind.fitness.values = toolbox.evaluate(ind)
# Elitismo simple (preservamos al mejor)
offspring[0] = toolbox.clone(hof[0])
pop[:] = offspring
hof.update(pop)
# Early Stopping
current_best = hof[0].fitness.values[0]
if current_best > best_fitness_historico + 1e-4:
best_fitness_historico = current_best
sin_mejora = 0
else:
sin_mejora += 1
if sin_mejora >= paciencia:
print(f"Early stopping en generación {gen}.")
break
tiempo_total = time.time() - t0
mejor_ind = hof[0]
indices_seleccionados = [i for i, bit in enumerate(mejor_ind) if bit == 1]
print(f"Evolución terminada en {tiempo_total:.2f}s. Evaluaciones reales: {evaluator.eval_count}")
return indices_seleccionados, evaluator
Caso de Estudio 1: Dataset Sintético (Prueba de Cordura)#
Antes de ir al mundo real, validemos nuestro framework. Creamos un dataset con 20 variables:
5 variables informativas (las que realmente importan).
10 variables redundantes (combinaciones lineales de las informativas).
5 variables de ruido puro.
print("Generando dataset sintético...")
X_syn, y_syn = make_classification(
n_samples=500, n_features=20, n_informative=5, n_redundant=10,
n_repeated=0, n_clusters_per_class=2, flip_y=0.05, random_state=SEED
)
# Baseline con todos los features
clf_base = DecisionTreeClassifier(max_depth=5, random_state=SEED)
cv_all = cross_val_score(clf_base, X_syn, y_syn, scoring='accuracy', cv=5)
print(f"Baseline (20 features): {np.mean(cv_all):.4f}")
# Ejecutamos el GA
config_syn = GAConfig(population_size=40, n_generations=50, alpha=0.75)
indices_syn, eval_syn = run_ga_wrapper(X_syn, y_syn, clf_base, config_syn)
# Evaluamos el resultado
X_syn_sel = X_syn[:, indices_syn]
cv_sel = cross_val_score(clf_base, X_syn_sel, y_syn, scoring='accuracy', cv=5)
print(f"\n--- Resultados Sintéticos ---")
print(f"Accuracy GA ({len(indices_syn)} features): {np.mean(cv_sel):.4f}")
print(f"Features seleccionados: {indices_syn}")
# Análisis de qué seleccionó
informativos = set(range(5))
ruido = set(range(15, 20))
seleccionados = set(indices_syn)
print(f"Informativos originales recuperados: {len(seleccionados & informativos)}/5")
print(f"Variables de ruido seleccionadas: {len(seleccionados & ruido)}/5")
Generando dataset sintético...
Baseline (20 features): 0.8440
Iniciando evolución (Features: 20)...
Early stopping en generación 19.
Evolución terminada en 20.68s. Evaluaciones reales: 286
--- Resultados Sintéticos ---
Accuracy GA (3 features): 0.8480
Features seleccionados: [4, 7, 11]
Informativos originales recuperados: 1/5
Variables de ruido seleccionadas: 0/5
Discusión de Resultados (El Pragmatismo del GA)#
Es muy probable que el GA no haya seleccionado los 5 features informativos originales (experimentar con otras configuraciones, otros modelos de base, etc.), sino una mezcla de informativos y redundantes, ignorando el ruido. ¿Por qué?
Porque para un Árbol de Decisión, una combinación lineal (feature redundante) puede representar un “atajo” más limpio para dividir el espacio que las variables originales. El GA es pragmático, no purista. Optimiza el rendimiento del modelo específico que le pasamos, no la verdad fundamental del universo.
Caso de Estudio 2: Benchmarks Reales#
Veamos cómo se comporta el framework en datasets reales clásicos. Usamos ahora un Random Forest rápido.
# Demora unos minutos...
# APROX.:
# 2 para Breast Cancer
# 5 para Digits
datasets = {
'Breast Cancer': load_breast_cancer(return_X_y=True),
'Digits': load_digits(return_X_y=True)
}
config_real = GAConfig(population_size=30, n_generations=40, alpha=0.8)
clf_real = RandomForestClassifier(n_estimators=30, random_state=SEED, n_jobs=1)
for name, (X, y) in datasets.items():
print(f"\n{'='*40}\nDataset: {name} | Shape: {X.shape}")
# Baseline
cv_base = cross_val_score(clf_real, X, y, scoring='accuracy', cv=5).mean()
# GA Wrapper
indices_sel, evaluator = run_ga_wrapper(X, y, clf_real, config_real)
# Resultado
X_sel = X[:, indices_sel]
cv_ga = cross_val_score(clf_real, X_sel, y, scoring='accuracy', cv=5).mean()
compresion = (1 - len(indices_sel) / X.shape[1]) * 100
print(f"Baseline Accuracy: {cv_base:.4f} (con {X.shape[1]} features)")
print(f"GA Accuracy: {cv_ga:.4f} (con {len(indices_sel)} features)")
print(f"Compresión: {compresion:.1f}%")
========================================
Dataset: Breast Cancer | Shape: (569, 30)
Iniciando evolución (Features: 30)...
Early stopping en generación 26.
Evolución terminada en 101.85s. Evaluaciones reales: 245
Baseline Accuracy: 0.9561 (con 30 features)
GA Accuracy: 0.9525 (con 3 features)
Compresión: 90.0%
========================================
Dataset: Digits | Shape: (1797, 64)
Iniciando evolución (Features: 64)...
Early stopping en generación 28.
Evolución terminada en 265.75s. Evaluaciones reales: 388
Baseline Accuracy: 0.9371 (con 64 features)
GA Accuracy: 0.8998 (con 15 features)
Compresión: 76.6%
Análisis de Trade-offs (Criterio de Ingeniería)#
Breast Cancer: Pasamos de 30 a \(\approx 3\) características y el accuracy se mantuvo. ¡Éxito rotundo! El GA eliminó el ruido y la multicolinealidad, dándonos un modelo igual de preciso y explicable a los médicos.
Digits: Pasamos de 64 a \(\approx 15\) características, pero el accuracy bajó ligeramente (de 0,93 a 0,9). ¿Es un fracaso? Depende del negocio. Si este modelo debe correr en un microcontrolador de bajo consumo (Edge AI) donde la memoria RAM es crítica, sacrificar un 3% de accuracy para reducir el modelo un 75% es un trade-off excelente.
Plus: evo-suite#
Una implementación avanzada (y compatible con Pipeline) del Selector de Features implementado aquí de manera muy minimalista, se encuentra en: evo-suite
A continuación, pequeño ejemplo de uso con su API de alto nivel:
pip install evo-gafs -q
from evo_gafs import GAConfig, GAFeatureSelector
# Dataset
X, y = load_breast_cancer(return_X_y=True, as_frame=True)
# Configuraciones
config_ga = GAConfig(population_size=30, n_generations=40, alpha=0.8,
early_stopping_rounds=10,
verbose=True)
# Modelo base
clf_base = DecisionTreeClassifier(max_depth=5, random_state=SEED)
selector = GAFeatureSelector(
estimator=clf_base,
config=config_ga,
)
selector.fit(X, y)
Gen 0 | BestFit=0.8873 | MeanFit=0.8652±0.0152 | BestFeats= 11 | MeanFeats=12.6 | Time=0.60s
Gen 10 | BestFit=0.9220 | MeanFit=0.9203±0.0041 | BestFeats= 6 | MeanFeats=6.1 | Time=0.05s
Gen 20 | BestFit=0.9259 | MeanFit=0.9256±0.0014 | BestFeats= 5 | MeanFeats=5.0 | Time=0.06s
Gen 30 | BestFit=0.9336 | MeanFit=0.9336±0.0000 | BestFeats= 3 | MeanFeats=3.0 | Time=0.04s
Gen 39 | BestFit=0.9339 | MeanFit=0.9338±0.0002 | BestFeats= 4 | MeanFeats=3.6 | Time=0.04s
============================================================
RESULT - GA Feature Selection
============================================================
Original features : 30
Selected features : 4
Compression ratio : 86.7%
Best CV score : 0.9508
Best fitness : 0.9339
Total evaluations : 165
Total time : 6.81s
------------------------------------------------------------
Selected features:
[ 15] compactness error
[ 20] worst radius
[ 21] worst texture
[ 25] worst compactness
============================================================
GAFeatureSelector(config=GAConfig(population_size=30,
n_generations=40,
crossover_prob=0.8,
mutation_prob=0.15,
mutation_indpb=None,
tournament_size=3,
mode='single',
alpha=0.8,
cv_folds=5,
min_features=1,
elite_size=2,
random_seed=42,
n_jobs=1,
verbose=True,
early_stopping_rounds=10,
early_stopping_tol=0.0001),
estimator=DecisionTreeClassifier(max_depth=5,
random_state=42))In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
GAFeatureSelector(config=GAConfig(population_size=30,
n_generations=40,
crossover_prob=0.8,
mutation_prob=0.15,
mutation_indpb=None,
tournament_size=3,
mode='single',
alpha=0.8,
cv_folds=5,
min_features=1,
elite_size=2,
random_seed=42,
n_jobs=1,
verbose=True,
early_stopping_rounds=10,
early_stopping_tol=0.0001),
estimator=DecisionTreeClassifier(max_depth=5,
random_state=42))DecisionTreeClassifier(max_depth=5, random_state=42)
DecisionTreeClassifier(max_depth=5, random_state=42)
# Si se tiene el verbose en false para búsquedas arduas, ejemplo de acceso a la info:
print(selector.summary())
X_reduced = selector.transform(X)
print("Índices seleccionados:", selector.get_support(indices=True))
============================================================
RESULT - GA Feature Selection
============================================================
Original features : 30
Selected features : 4
Compression ratio : 86.7%
Best CV score : 0.9508
Best fitness : 0.9339
Total evaluations : 165
Total time : 6.81s
------------------------------------------------------------
Selected features:
[ 15] compactness error
[ 20] worst radius
[ 21] worst texture
[ 25] worst compactness
============================================================
Índices seleccionados: [15 20 21 25]
El modo de objetivo único (por defecto) combina el rendimiento y la compresión en un solo valor mediante el parámetro alpha. El mode="multiobjective" los mantiene separados y los optimiza simultáneamente con NSGA-II, devolviendo el frente de Pareto completo de soluciones no dominadas.
selector = GAFeatureSelector(
estimator=clf_base,
config=GAConfig(mode="multiobjective", population_size=40, n_generations=40, verbose=True),
)
selector.fit(X, y)
Gen 0 | BestFit=0.9508 | MeanFit=0.9335±0.0149 | BestFeats= 12 | MeanFeats=12.8 | Time=1.54s
Gen 10 | BestFit=0.9578 | MeanFit=0.9211±0.0597 | BestFeats= 6 | MeanFeats=5.0 | Time=0.89s
Gen 20 | BestFit=0.9614 | MeanFit=0.9248±0.0152 | BestFeats= 7 | MeanFeats=2.4 | Time=0.11s
Gen 30 | BestFit=0.9614 | MeanFit=0.9573±0.0127 | BestFeats= 7 | MeanFeats=6.2 | Time=0.22s
Gen 39 | BestFit=0.9614 | MeanFit=0.9573±0.0127 | BestFeats= 7 | MeanFeats=6.2 | Time=0.20s
============================================================
RESULT - GA Feature Selection
============================================================
Original features : 30
Selected features : 7
Compression ratio : 76.7%
Best CV score : 0.9614
Best fitness : 0.9614
Total evaluations : 751
Total time : 22.97s
------------------------------------------------------------
Selected features:
[ 0] mean radius
[ 3] mean area
[ 4] mean smoothness
[ 7] mean concave points
[ 9] mean fractal dimension
[ 20] worst radius
[ 21] worst texture
============================================================
GAFeatureSelector(config=GAConfig(population_size=40,
n_generations=40,
crossover_prob=0.8,
mutation_prob=0.15,
mutation_indpb=None,
tournament_size=3,
mode='multiobjective',
alpha=0.8,
cv_folds=5,
min_features=1,
elite_size=2,
random_seed=42,
n_jobs=1,
verbose=True,
early_stopping_rounds=None,
early_stopping_tol=0.0001),
estimator=DecisionTreeClassifier(max_depth=5,
random_state=42))In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
GAFeatureSelector(config=GAConfig(population_size=40,
n_generations=40,
crossover_prob=0.8,
mutation_prob=0.15,
mutation_indpb=None,
tournament_size=3,
mode='multiobjective',
alpha=0.8,
cv_folds=5,
min_features=1,
elite_size=2,
random_seed=42,
n_jobs=1,
verbose=True,
early_stopping_rounds=None,
early_stopping_tol=0.0001),
estimator=DecisionTreeClassifier(max_depth=5,
random_state=42))DecisionTreeClassifier(max_depth=5, random_state=42)
DecisionTreeClassifier(max_depth=5, random_state=42)
Cada entrada de selector.result_.pareto_front es un diccionario con mask, cv_score, compression and n_features. Dado que el frente contiene compensaciones no dominadas, se puede elegir el subconjunto más pequeño que cumpla con el objetivo de precisión, o el subconjunto más preciso dentro de un presupuesto de tamaño (que esto siempre lo indica el contexto del problema que se está abordando).
from evo_gafs import GAPlotter
fig = GAPlotter.plot_pareto_front(selector.result_)
Extensión Práctica: Optimización de Hiperparámetros (HPO) con Cromosoma Mixto#
Volvemos a implementaciones manuales…
Si el GA puede elegir características usando 1s y 0s, también puede afinar los hiperparámetros de un modelo. El desafío aquí es que los hiperparámetros tienen naturalezas distintas:
n_estimators: Entero (ej. 10 a 200).min_samples_split: Entero (ej. 2 a 20).max_features: Categórico ('sqrt','log2',None).
Solución de Ingeniería: Usar un cromosoma de números reales continuos en el rango \([0, 1]\). Luego, escribir una función decodificadora que mapee estos valores continuos a los rangos y tipos específicos que necesita el modelo.
A continuación, un ejemplo rudimentario de cómo se podría plantear esto.
1. Decodificación y Función de Fitness#
def decodificar_hpo(individuo):
"""
Mapea un vector de 4 genes continuos en [0, 1] a hiperparámetros reales de Random Forest.
"""
g = individuo
# Mapeo a Enteros
n_estimators = int(g[0] * 190 + 10) # Rango: [10, 200]
# Mapeo con condición (10% de probabilidad de ser None, sino de 2 a 30)
max_depth = int(g[1] * 28 + 2) if g[1] < 0.9 else None
min_samples_split = int(g[2] * 18 + 2) # Rango: [2, 20]
# Mapeo a Categóricos
opciones_mf = ['sqrt', 'log2', None]
# Multiplicamos por 2.99 para que el índice sea 0, 1 o 2
max_features = opciones_mf[int(g[3] * 2.99)]
return {
'n_estimators': n_estimators,
'max_depth': max_depth,
'min_samples_split': min_samples_split,
'max_features': max_features,
'random_state': SEED
}
def evaluar_hpo(individuo, X, y):
params = decodificar_hpo(individuo)
modelo = RandomForestClassifier(**params)
# Usamos CV de 3 folds para acelerar el ejemplo
scores = cross_val_score(modelo, X, y, scoring='accuracy', cv=3, n_jobs=1)
return (np.mean(scores),)
2. Implementación en DEAP#
Como nuestros genes ahora son continuos (\([0, 1]\)), cambiamos nuestros operadores: usamos Cruce Aritmético (cxBlend) y Mutación Gaussiana (mutGaussian), asegurándonos de hacer clipping para no salir del rango \([0, 1]\).
# Usamos el dataset de Breast Cancer que ya cargamos arriba
X_bc, y_bc = datasets['Breast Cancer']
# Limpieza de DEAP
for cls in ['FitnessMax', 'Individual']:
if hasattr(creator, cls): delattr(creator, cls)
creator.create("FitnessMax", base.Fitness, weights=(1.0,))
creator.create("Individual", list, fitness=creator.FitnessMax)
tb_hpo = base.Toolbox()
# Inicialización: 4 genes continuos entre 0 y 1
tb_hpo.register("attr_float", random.random)
tb_hpo.register("individual", tools.initRepeat, creator.Individual, tb_hpo.attr_float, n=4)
tb_hpo.register("population", tools.initRepeat, list, tb_hpo.individual)
tb_hpo.register("evaluate", evaluar_hpo, X=X_bc, y=y_bc)
tb_hpo.register("mate", tools.cxBlend, alpha=0.3)
tb_hpo.register("mutate", tools.mutGaussian, mu=0, sigma=0.2, indpb=0.5)
tb_hpo.register("select", tools.selTournament, tournsize=3)
# Decorador para mantener los genes en [0, 1]
def limitar_cero_uno(func):
def wrapper(*args, **kwargs):
resultados = func(*args, **kwargs)
for ind in resultados:
for i in range(len(ind)):
ind[i] = max(0.0, min(1.0, ind[i]))
return resultados
return wrapper
tb_hpo.decorate("mate", limitar_cero_uno)
tb_hpo.decorate("mutate", limitar_cero_uno)
# Ejecución del GA para HPO
print("Iniciando Optimización de Hiperparámetros (HPO)...")
pop_hpo = tb_hpo.population(n=20)
hof_hpo = tools.HallOfFame(1)
stats_hpo = tools.Statistics(lambda ind: ind.fitness.values[0])
stats_hpo.register("max", np.max)
# Corremos pocas generaciones por ser un ejemplo, experimentar a demanda
pop_hpo, log_hpo = algorithms.eaSimple(pop_hpo, tb_hpo, cxpb=0.8, mutpb=0.2, ngen=15,
stats=stats_hpo, halloffame=hof_hpo, verbose=True)
mejor_genotipo = hof_hpo[0]
mejores_hiperparametros = decodificar_hpo(mejor_genotipo)
print(f"\nHPO Terminado. Mejor Accuracy CV: {mejor_genotipo.fitness.values[0]:.4f}")
print("Mejores Hiperparámetros encontrados:")
for k, v in mejores_hiperparametros.items():
if k != 'random_state':
print(f" - {k}: {v}")
Iniciando Optimización de Hiperparámetros (HPO)...
gen nevals max
0 20 0.959584
1 12 0.961348
2 16 0.961348
3 18 0.963093
4 16 0.963093
5 16 0.961348
6 18 0.963074
7 19 0.961348
8 16 0.961348
9 16 0.961348
10 19 0.961348
11 14 0.961348
12 18 0.961348
13 15 0.961348
14 16 0.961348
15 18 0.961348
HPO Terminado. Mejor Accuracy CV: 0.9631
Mejores Hiperparámetros encontrados:
- n_estimators: 103
- max_depth: 16
- min_samples_split: 4
- max_features: log2
⚠️ Nota de Ingeniería: Realidad del HPO#
Aunque podemos usar GAs para HPO, debemos ser honestos: Para HPO puro, la Optimización Bayesiana (ej. la implementada en la librería Optuna) suele ser superior.
¿Por qué? Porque Optuna construye un modelo probabilístico del paisaje de hiperparámetros, lo que le permite converger con muchísimas menos evaluaciones que un GA.
¿Cuándo brilla el GA entonces?
En la Co-evolución. Si se quiere optimizar el subconjunto de características Y los hiperparámetros al mismo tiempo, el espacio de búsqueda se vuelve tan complejo y discontinuo que los GAs vuelven a ser altamente competitivos.
Lecciones de Ingeniería y Trabajo Futuro#
El Caché es Rey: En métodos Wrapper, el 99% del tiempo se va en entrenar el modelo. Implementar un diccionario de caché reduce el tiempo de nuestros experimentos. A mayor costo para la evaluación de la función de fitness, más sentido tiene un buen sistema de caché (el nuestro es rudimentario, pero funcional al caso de estudio).
Penalizaciones Inteligentes: Usar
alphapermite balancear entre precisión y tamaño. De modo de ajustar los resultados según las necesidades concretas del problema a abordar.Trabajo Futuro (NSGA-II): En lugar de forzar un peso
alpha, podríamos tratar el Accuracy y la Cantidad de Features como dos objetivos separados usando NSGA-II. Esto nos devolvería un Frente de Pareto, permitiendo elegir el modelo final viendo la curva exacta de trade-off.
Entorno de Ejecución#
| Package | Version |
|---|---|
| Python | 3.12.13 |
| Platform | Linux-6.6.122+-x86_64-with-glibc2.35 |
| IPython | 7.34.0 |
| deap | 1.4 |
| 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 |