Validación Cruzada y Optimización: Sesión 4

La validación cruzada y la optimización de hiperparámetros son técnicas fundamentales en machine learning para evaluar y mejorar el rendimiento de los modelos. Esta sesión abarca los conceptos clave y técnicas más utilizadas para encontrar los mejores parámetros de configuración de los modelos.

K-Fold Cross Validation

La validación cruzada K-Fold es una técnica robusta para evaluar el rendimiento de un modelo dividiendo los datos en k subconjuntos o “pliegues”. Esta metodología permite una evaluación más confiable que una simple división train-test.

Funcionamiento del K-Fold

El algoritmo funciona de la siguiente manera:

  1. División: Los datos se dividen aleatoriamente en k grupos de tamaño aproximadamente igual
  2. Iteración: Para cada uno de los k grupos:
    • Se usa el grupo como conjunto de validación
    • Los k-1 grupos restantes se usan para entrenamiento
    • Se entrena el modelo y se evalúa en el conjunto de validación
  3. Agregación: Se calcula el promedio de las métricas obtenidas en cada iteración

Ejemplo de implementación en Python

from sklearn.model_selection import cross_val_score, KFold
from sklearn.svm import SVC
from sklearn.datasets import load_iris

# Cargar datos
iris = load_iris()
X, y = iris.data, iris.target

# Crear clasificador SVM
svm_classifier = SVC(kernel='linear')

# Definir número de pliegues
num_folds = 5
kf = KFold(n_splits=num_folds, shuffle=True, random_state=42)

# Realizar validación cruzada k-fold
cross_val_results = cross_val_score(svm_classifier, X, y, cv=kf)

# Mostrar resultados
print("Cross-Validation Results (Accuracy):")
for i, result in enumerate(cross_val_results, 1):
    print(f"Fold {i}: {result * 100:.2f}%")

print(f'Mean Accuracy: {cross_val_results.mean()* 100:.2f}%')

Ventajas del K-Fold

  • Robustez: Cada observación se usa tanto para entrenamiento como para validación
  • Reducción de sesgo: Proporciona una estimación menos sesgada del rendimiento del modelo
  • Mayor utilización de datos: Aprovecha todos los datos disponibles para entrenamiento y validación

Búsqueda de Hiperparámetros

Los hiperparámetros son configuraciones que controlan cómo aprende un modelo y deben establecerse antes del entrenamiento. La optimización de estos parámetros es crucial para obtener el mejor rendimiento posible.

Grid Search: Búsqueda Exhaustiva

Grid Search es un método de fuerza bruta que evalúa todas las combinaciones posibles de hiperparámetros especificados.

  • Exhaustivo: Garantiza encontrar la mejor combinación dentro del espacio de búsqueda definido
  • Computacionalmente costoso: El tiempo de ejecución crece exponencialmente con el número de hiperparámetros
  • Determinístico: Siempre produce los mismos resultados para los mismos parámetros
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
import numpy as np
from sklearn.datasets import make_classification

# Generar datos de ejemplo
X, y = make_classification(
    n_samples=1000, n_features=20, n_informative=10, 
    n_classes=2, random_state=42)

# Definir espacio de búsqueda
c_space = np.logspace(-5, 8, 15)
param_grid = {'C': c_space}

# Crear modelo
logreg = LogisticRegression()

# Configurar Grid Search con validación cruzada
logreg_cv = GridSearchCV(logreg, param_grid, cv=5)

# Entrenar y encontrar mejores parámetros
logreg_cv.fit(X, y)

print("Mejores parámetros:", logreg_cv.best_params_)
print("Mejor puntuación:", logreg_cv.best_score_)

Random Search: Búsqueda Aleatoria

Random Search selecciona aleatoriamente combinaciones de hiperparámetros de un espacio de búsqueda predefinido.

  • Eficiencia computacional: Más rápido que Grid Search, especialmente en espacios de alta dimensionalidad
  • Mejor en espacios complejos: Puede encontrar mejores soluciones cuando solo algunos hiperparámetros son realmente importantes
  • Escalabilidad: El tiempo de ejecución es independiente del tamaño del espacio de búsqueda
from sklearn.model_selection import RandomizedSearchCV
from sklearn.ensemble import RandomForestClassifier

# Definir espacio de parámetros para Random Forest
param_distributions = {
    'n_estimators': [10, 50, 100, 200],
    'max_depth': [3, 5, 7, 10, None],
    'min_samples_split': [2, 5, 10],
    'min_samples_leaf': [1, 2, 4]
}

# Grid Search
grid_search = GridSearchCV(
    RandomForestClassifier(random_state=42),
    param_distributions, cv=5, n_jobs=-1
)

# Random Search
random_search = RandomizedSearchCV(
    RandomForestClassifier(random_state=42),
    param_distributions, n_iter=50, cv=5, 
    random_state=42, n_jobs=-1
)

# Comparar tiempos de ejecución
import time

start_time = time.time()
grid_search.fit(X, y)
grid_time = time.time() - start_time

start_time = time.time()
random_search.fit(X, y)
random_time = time.time() - start_time

print(f"Grid Search: {grid_time:.2f}s, Best Score: {grid_search.best_score_:.3f}")
print(f"Random Search: {random_time:.2f}s, Best Score: {random_search.best_score_:.3f}")

Descenso del Gradiente

El descenso del gradiente es un algoritmo de optimización fundamental utilizado para minimizar funciones de costo en machine learning.

Principio Básico

El algoritmo actualiza iterativamente los parámetros del modelo en la dirección opuesta al gradiente de la función de costo:

θ=θηJ(θ)\theta = \theta - \eta \nabla J(\theta)

donde:

  • θ\theta son los parámetros del modelo
  • η\eta es la tasa de aprendizaje (learning rate)
  • J(θ)\nabla J(\theta) es el gradiente de la función de costo

Implementación básica

import numpy as np
import matplotlib.pyplot as plt

def gradient_descent(X, y, learning_rate=0.01, n_iterations=1000):
    m, n = X.shape
    theta = np.zeros(n)  # Inicializar parámetros
    costs = []
    
    for i in range(n_iterations):
        # Predicciones
        predictions = X.dot(theta)
        
        # Calcular costo (MSE)
        cost = (1/(2*m)) * np.sum((predictions - y)**2)
        costs.append(cost)
        
        # Calcular gradientes
        gradients = (1/m) * X.T.dot(predictions - y)
        
        # Actualizar parámetros
        theta = theta - learning_rate * gradients
    
    return theta, costs

# Ejemplo con datos sintéticos
np.random.seed(42)
m = 100
X = np.random.randn(m, 1)
X_with_bias = np.c_[np.ones((m, 1)), X]  # Añadir término de sesgo
y = 4 + 3 * X.ravel() + np.random.randn(m)

# Aplicar descenso del gradiente
theta_optimal, cost_history = gradient_descent(X_with_bias, y)
print(f"Parámetros óptimos: {theta_optimal}")

Gradiente Estocástico (SGD)

El Stochastic Gradient Descent (SGD) es una variante más eficiente que actualiza los parámetros usando un solo ejemplo de entrenamiento en cada iteración.

Diferencias principales con Batch Gradient Descent

AspectoBatch GDStochastic GD
Datos utilizadosTodo el datasetUna muestra por vez
Velocidad de convergenciaLenta pero estableRápida pero ruidosa
Uso de memoriaAltoBajo
Escape de mínimos localesDifícilMás fácil
Precisión del gradienteAltaBaja (ruidosa)

Implementación de SGD

def stochastic_gradient_descent(X, y, learning_rate=0.01, n_epochs=100):
    m, n = X.shape
    theta = np.random.randn(n)
    costs = []
    
    for epoch in range(n_epochs):
        epoch_cost = 0
        # Mezclar datos en cada época
        indices = np.random.permutation(m)
        
        for i in indices:
            # Seleccionar una muestra
            xi = X[i:i+1]
            yi = y[i:i+1]
            
            # Calcular predicción y error
            prediction = xi.dot(theta)
            error = prediction - yi
            
            # Calcular gradiente para esta muestra
            gradient = xi.T.dot(error)
            
            # Actualizar parámetros
            theta = theta - learning_rate * gradient.ravel()
            
            epoch_cost += error**2
        
        costs.append(epoch_cost / m)
    
    return theta, costs

# Aplicar SGD
theta_sgd, cost_history_sgd = stochastic_gradient_descent(X_with_bias, y)
print(f"Parámetros SGD: {theta_sgd}")

Mini-batch Gradient Descent

Una variante híbrida que procesa pequeños grupos de muestras (batches) en cada iteración:

def mini_batch_gradient_descent(X, y, batch_size=32, learning_rate=0.01, n_epochs=100):
    m, n = X.shape
    theta = np.random.randn(n)
    costs = []
    
    for epoch in range(n_epochs):
        epoch_cost = 0
        
        # Crear mini-batches
        for i in range(0, m, batch_size):
            X_batch = X[i:i+batch_size]
            y_batch = y[i:i+batch_size]
            
            # Calcular predicciones y costo
            predictions = X_batch.dot(theta)
            cost = np.mean((predictions - y_batch)**2)
            epoch_cost += cost
            
            # Calcular gradientes
            gradients = (2/len(X_batch)) * X_batch.T.dot(predictions - y_batch)
            
            # Actualizar parámetros
            theta = theta - learning_rate * gradients
        
        costs.append(epoch_cost)
    
    return theta, costs

Learning Rate: Hiperparámetro Crítico

La tasa de aprendizaje controla el tamaño de los pasos que da el algoritmo hacia el mínimo. Es uno de los hiperparámetros más importantes en optimización.

Efectos del Learning Rate

  • Learning Rate alto: Convergencia rápida pero puede sobrepasar el mínimo
  • Learning Rate bajo: Convergencia lenta pero estable
  • Learning Rate adaptativo: Ajusta automáticamente durante el entrenamiento

Ejemplo de comparación de learning rates

# Comparar diferentes learning rates
learning_rates = [0.001, 0.01, 0.1, 0.5]
plt.figure(figsize=(12, 8))

for i, lr in enumerate(learning_rates):
    theta, costs = gradient_descent(X_with_bias, y, learning_rate=lr, n_iterations=200)
    plt.subplot(2, 2, i+1)
    plt.plot(costs)
    plt.title(f'Learning Rate = {lr}')
    plt.xlabel('Iterations')
    plt.ylabel('Cost')
    plt.grid(True)

plt.tight_layout()
plt.show()

Técnicas de Learning Rate Adaptativo

class AdaptiveLearningRate:
    def __init__(self, initial_lr=0.01, decay_rate=0.95, decay_steps=100):
        self.initial_lr = initial_lr
        self.decay_rate = decay_rate
        self.decay_steps = decay_steps
    
    def exponential_decay(self, step):
        return self.initial_lr * (self.decay_rate ** (step // self.decay_steps))
    
    def time_decay(self, step):
        return self.initial_lr / (1 + step * 0.001)
    
    def step_decay(self, step):
        if step < 50:
            return 0.1
        elif step < 100:
            return 0.01
        else:
            return 0.001

# Ejemplo de uso
adaptive_lr = AdaptiveLearningRate()
for step in range(200):
    current_lr = adaptive_lr.exponential_decay(step)
    if step % 50 == 0:
        print(f"Step {step}: Learning Rate = {current_lr:.6f}")

Best Estimator: Mejor Configuración

Después de realizar validación cruzada y búsqueda de hiperparámetros, el best estimator representa el modelo con la configuración óptima encontrada.

Obtención del Best Estimator

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV
from sklearn.datasets import make_classification
from sklearn.metrics import classification_report

# Generar datos
X, y = make_classification(n_samples=1000, n_features=20, n_classes=2, random_state=42)

# Definir parámetros a optimizar
param_grid = {
    'n_estimators': [50, 100, 200],
    'max_depth': [3, 5, 7, 10],
    'min_samples_split': [2, 5, 10],
    'min_samples_leaf': [1, 2, 4]
}

# Configurar Grid Search
grid_search = GridSearchCV(
    RandomForestClassifier(random_state=42),
    param_grid=param_grid,
    cv=5,
    scoring='accuracy',
    n_jobs=-1,
    verbose=1
)

# Entrenar y encontrar mejor modelo
grid_search.fit(X, y)

# Obtener el mejor estimador
best_model = grid_search.best_estimator_
print("Mejores parámetros:", grid_search.best_params_)
print("Mejor puntuación CV:", grid_search.best_score_)

# Usar el mejor modelo para predicciones
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# El mejor estimador ya está entrenado con todos los datos
predictions = best_model.predict(X_test)
print("\nReporte de clasificación:")
print(classification_report(y_test, predictions))

Validación final del Best Estimator

# Evaluación más robusta del mejor modelo
from sklearn.model_selection import cross_validate

# Evaluar el mejor modelo con validación cruzada
cv_results = cross_validate(
    best_model, X, y, 
    cv=5, 
    scoring=['accuracy', 'precision', 'recall', 'f1'],
    return_train_score=True
)

print("Resultados de validación cruzada del mejor modelo:")
for metric in ['accuracy', 'precision', 'recall', 'f1']:
    test_scores = cv_results[f'test_{metric}']
    train_scores = cv_results[f'train_{metric}']
    print(f"{metric.upper()}:")
    print(f"  Test: {test_scores.mean():.3f} ± {test_scores.std():.3f}")
    print(f"  Train: {train_scores.mean():.3f} ± {train_scores.std():.3f}")

Flujo de trabajo completo

def complete_model_optimization_pipeline(X, y, models, param_grids):
    """
    Pipeline completo de optimización de modelos
    """
    results = {}
    
    for model_name, (model, param_grid) in zip(models.keys(), 
                                               zip(models.values(), param_grids.values())):
        print(f"\nOptimizando {model_name}...")
        
        # Grid Search con validación cruzada
        grid_search = GridSearchCV(
            model, param_grid, cv=5, scoring='accuracy', 
            n_jobs=-1, verbose=0
        )
        
        grid_search.fit(X, y)
        
        # Guardar resultados
        results[model_name] = {
            'best_estimator': grid_search.best_estimator_,
            'best_params': grid_search.best_params_,
            'best_score': grid_search.best_score_,
            'cv_results': grid_search.cv_results_
        }
        
        print(f"Mejores parámetros: {grid_search.best_params_}")
        print(f"Mejor puntuación: {grid_search.best_score_:.3f}")
    
    # Encontrar el mejor modelo general
    best_model_name = max(results, key=lambda x: results[x]['best_score'])
    best_overall_model = results[best_model_name]['best_estimator']
    
    print(f"\nMejor modelo general: {best_model_name}")
    print(f"Puntuación: {results[best_model_name]['best_score']:.3f}")
    
    return results, best_overall_model

# Ejemplo de uso
from sklearn.svm import SVC
from sklearn.linear_model import LogisticRegression

models = {
    'Random Forest': RandomForestClassifier(random_state=42),
    'SVM': SVC(random_state=42),
    'Logistic Regression': LogisticRegression(random_state=42, max_iter=1000)
}

param_grids = {
    'Random Forest': {'n_estimators': [50, 100], 'max_depth': [3, 5, 7]},
    'SVM': {'C': [0.1, 1, 10], 'kernel': ['rbf', 'linear']},
    'Logistic Regression': {'C': [0.1, 1, 10], 'solver': ['lbfgs', 'liblinear']}
}

# Ejecutar pipeline
results, best_model = complete_model_optimization_pipeline(X, y, models, param_grids)

La validación cruzada y optimización de hiperparámetros son procesos iterativos que requieren experimentación y comprensión profunda de los algoritmos utilizados. La combinación de estas técnicas permite desarrollar modelos robustos y bien optimizados que generalizan efectivamente a datos no vistos.

Validación Cruzada y Optimización de Hiperparámetros - Machine Learning

Author

Juan Fuentes

Publish Date

06 - 17 - 2023