GA como Wrapper para Machine Learning (Feature Selection y HPO)#

Open In Colab

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:

  1. Overfitting: El modelo memoriza el ruido de las variables irrelevantes.

  2. Latencia y Costo: Más variables implican modelos más pesados, lentos para inferir en producción y costosos de mantener.

  3. 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#


Configuración del Entorno#

Hide code cell source

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

IN_COLAB = "google.colab" in sys.modules

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

from utils.plots import setup_plot_style
setup_plot_style()

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import random
import time
import warnings
warnings.filterwarnings('ignore')

# DEAP y Scikit-Learn
try:
    import deap
except ImportError:
    !pip install deap
from deap import base, creator, tools, algorithms

from sklearn.base import clone
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.ensemble import RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.datasets import make_classification, load_iris, load_breast_cancer, load_wine, load_digits

SEED = 42
random.seed(SEED)
np.random.seed(SEED)
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 _cache para 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)#

  1. 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.

  2. 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.
# 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.

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_)
../../../_images/5c3b90dde6530747ebb66d1c0c1ef9cc27e42767b5a7633e5a9bc2d9f3434227.png

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#

  1. 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).

  2. Penalizaciones Inteligentes: Usar alpha permite balancear entre precisión y tamaño. De modo de ajustar los resultados según las necesidades concretas del problema a abordar.

  3. 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#

Hide code cell source

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