Otimização de Hiperparâmetros em Machine Learning: Um Guia Prático com Exemplos Reais | Data Driven School

Otimização de Hiperparâmetros em Machine Learning: Um Guia Prático com Exemplos Reais

Introdução à Otimização de Hiperparâmetros

Em 2019, enfrentei um desafio interessante em um projeto de previsão de churn para uma startup de SaaS. Nosso modelo de Random Forest estava performando razoavelmente bem, mas eu sabia que podíamos melhorar. O problema? Estávamos usando os parâmetros padrão, algo que vejo muitos profissionais fazendo até hoje.

Se você já trabalhou com machine learning, sabe que a diferença entre um modelo bom e um modelo excepcional frequentemente está na escolha dos hiperparâmetros. Não são os dados (assumindo que você já fez uma boa preparação), nem mesmo o algoritmo em si, mas os ajustes finos que fazem toda a diferença.

Neste artigo, vou compartilhar o que aprendi em anos ajustando modelos para empresas de diferentes setores, incluindo técnicas que não são amplamente discutidas em tutoriais básicos.

O que são hiperparâmetros e por que importam?

Antes de mais nada, vamos esclarecer: parâmetros e hiperparâmetros são coisas distintas. Os parâmetros são valores que o modelo aprende durante o treinamento (como os pesos em uma rede neural). Já os hiperparâmetros são configurações que definimos antes do treinamento começar e que controlam como o modelo aprenderá.

Exemplos clássicos de hiperparâmetros incluem:

  • Número de árvores em um Random Forest
  • Taxa de aprendizado em redes neurais
  • Parâmetro C em um SVM
  • Valor de k em k-NN
  • Profundidade máxima em árvores de decisão

A importância da otimização desses valores é frequentemente subestimada. Em um dos projetos que conduzi para uma empresa de varejo, a simples mudança nos hiperparâmetros de um modelo XGBoost aumentou a precisão de 82% para 89% - uma melhoria que representou milhares de reais em economia por previsões mais precisas de estoque.

As armadilhas comuns da otimização

Antes de entrarmos nas técnicas, preciso alertar sobre algumas armadilhas que já me pegaram no passado:

1. Overfitting nos hiperparâmetros

Esta é sutil e perigosa. Você otimiza exaustivamente seus hiperparâmetros no conjunto de validação e fica empolgado com os resultados... apenas para descobrir que o desempenho no conjunto de teste é muito inferior. Isso aconteceu comigo em 2020, quando trabalhava em um modelo de previsão de vendas e acabei ajustando demais para o conjunto de validação.

2. Ignorar o custo computacional

Algumas técnicas de otimização são extremamente caras computacionalmente. Em um projeto recente para uma fintech, uma busca em grade completa levaria semanas. Precisamos ser estratégicos sobre quais hiperparâmetros otimizar e como fazê-lo.

3. Falta de reprodutibilidade

Sem definir seeds aleatórias, você pode obter resultados diferentes a cada execução. Isso torna impossível determinar se a melhoria veio dos hiperparâmetros ou da aleatoriedade da inicialização do modelo.

Técnicas de otimização que realmente funcionam

Vamos às abordagens que uso regularmente e que têm gerado resultados consistentes:

1. Busca aleatória vs. Busca em grade

A busca em grade (Grid Search) foi por muito tempo o padrão da indústria, mas na maioria das situações práticas, a busca aleatória (Random Search) oferece melhor custo-benefício.

Veja um exemplo de implementação de busca aleatória com Python:

from sklearn.model_selection import RandomizedSearchCV
from sklearn.ensemble import RandomForestClassifier
import numpy as np

# Definindo o espaço de hiperparâmetros
param_dist = {
    'n_estimators': np.arange(100, 1000, 100),
    'max_depth': [None] + list(np.arange(5, 30, 5)),
    'min_samples_split': np.arange(2, 20, 2),
    'min_samples_leaf': np.arange(1, 10),
    'bootstrap': [True, False]
}

# Criando o modelo
rf = RandomForestClassifier(random_state=42)

# Configurando a busca aleatória
random_search = RandomizedSearchCV(
    estimator=rf,
    param_distributions=param_dist,
    n_iter=100,  # Número de combinações a tentar
    cv=5,        # Validação cruzada 5-fold
    random_state=42,
    n_jobs=-1,   # Usar todos os núcleos disponíveis
    scoring='f1'  # Métrica a otimizar
)

# Executando a busca
random_search.fit(X_train, y_train)

# Melhores hiperparâmetros encontrados
print(f'Melhores hiperparâmetros: {random_search.best_params_}')
print(f'Melhor pontuação: {random_search.best_score_:.4f}')

Por que isso funciona? Em 2012, Bergstra e Bengio publicaram um artigo mostrando que a busca aleatória é mais eficiente que a busca em grade para a maioria dos problemas de otimização de hiperparâmetros. Isso porque nem todos os hiperparâmetros são igualmente importantes, e a busca aleatória permite explorar mais valores para os hiperparâmetros críticos.

2. Otimização Bayesiana

Esta é minha técnica favorita para modelos computacionalmente caros. Em vez de escolher pontos aleatoriamente, a otimização bayesiana usa os resultados anteriores para selecionar os próximos pontos a avaliar. É como ter uma busca 'inteligente'.

Um exemplo usando a biblioteca Optuna, que tem se tornado meu padrão para projetos complexos:

import optuna
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import cross_val_score

def objective(trial):
    # Definindo o espaço de hiperparâmetros
    params = {
        'n_estimators': trial.suggest_int('n_estimators', 50, 500),
        'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.3, log=True),
        'max_depth': trial.suggest_int('max_depth', 3, 10),
        'min_samples_split': trial.suggest_int('min_samples_split', 2, 20),
        'min_samples_leaf': trial.suggest_int('min_samples_leaf', 1, 10),
        'subsample': trial.suggest_float('subsample', 0.5, 1.0)
    }
    
    # Criando o modelo com os hiperparâmetros sugeridos
    model = GradientBoostingClassifier(**params, random_state=42)
    
    # Avaliando o modelo com validação cruzada
    score = cross_val_score(model, X_train, y_train, cv=5, scoring='f1').mean()
    
    return score  # Optuna maximiza por padrão

# Criando o estudo de otimização
study = optuna.create_study(direction='maximize')

# Executando a otimização
study.optimize(objective, n_trials=100)

# Melhores hiperparâmetros encontrados
best_params = study.best_params
print(f'Melhores hiperparâmetros: {best_params}')
print(f'Melhor pontuação: {study.best_value:.4f}')

# Visualizando a importância dos hiperparâmetros
optuna.visualization.plot_param_importances(study)

Recentemente, usei esta abordagem para um cliente da área de saúde, e conseguimos reduzir o tempo de otimização de quase uma semana para menos de um dia, encontrando parâmetros ainda melhores que os obtidos por métodos tradicionais.

3. Abordagem em duas etapas

Esta é uma técnica que desenvolvi ao longo dos anos e que tem se mostrado extremamente eficaz:

  1. Exploração ampla: Comece com uma busca aleatória em um espaço amplo de hiperparâmetros para identificar as regiões promissoras.
  2. Refinamento: Use otimização bayesiana em um espaço mais restrito, concentrando-se nas regiões identificadas no passo anterior.

Implementei essa abordagem para uma empresa de energia renovável em 2022, e conseguimos um modelo que superou as expectativas iniciais em mais de 15% de acurácia.

Estudo de caso real: Otimização de um modelo XGBoost

Vamos ver um exemplo concreto de um projeto em que trabalhei em 2023, para previsão de demanda em uma empresa de e-commerce. O desafio era prever as vendas diárias com uma janela de 30 dias.

Inicialmente, o modelo XGBoost com parâmetros padrão atingiu um RMSE de 243 unidades. Depois da otimização, chegamos a um RMSE de 187 - uma melhoria de 23%!

Aqui está o código simplificado da abordagem em duas etapas que utilizei:

# Fase 1: Exploração ampla com busca aleatória
from sklearn.model_selection import RandomizedSearchCV
import xgboost as xgb
import numpy as np

# Espaço amplo de hiperparâmetros para exploração inicial
param_space_wide = {
    'n_estimators': np.arange(50, 1000, 50),
    'learning_rate': np.logspace(-3, 0, 10),  # 0.001 a 1.0
    'max_depth': np.arange(3, 15),
    'min_child_weight': np.arange(1, 10),
    'gamma': np.logspace(-3, 1, 10),  # 0.001 a 10
    'subsample': np.linspace(0.5, 1.0, 6),
    'colsample_bytree': np.linspace(0.5, 1.0, 6),
    'reg_alpha': np.logspace(-3, 1, 10),  # 0.001 a 10
    'reg_lambda': np.logspace(-3, 1, 10)   # 0.001 a 10
}

model = xgb.XGBRegressor(objective='reg:squarederror', random_state=42)

# Primeira fase: 100 iterações de busca aleatória
random_search = RandomizedSearchCV(
    estimator=model,
    param_distributions=param_space_wide,
    n_iter=100,
    cv=5,
    scoring='neg_root_mean_squared_error',
    random_state=42,
    n_jobs=-1
)

random_search.fit(X_train, y_train)

# Melhores parâmetros da primeira fase
best_params_phase1 = random_search.best_params_
print(f'Melhores parâmetros da fase 1: {best_params_phase1}')
print(f'Melhor RMSE da fase 1: {-random_search.best_score_:.2f}')

# Fase 2: Refinamento com otimização bayesiana
import optuna

def create_param_space(trial, best_params):
    # Criamos um espaço mais restrito em torno dos melhores valores encontrados
    params = {
        'n_estimators': trial.suggest_int('n_estimators', 
                                         max(50, int(best_params['n_estimators'] * 0.7)),
                                         min(1000, int(best_params['n_estimators'] * 1.3))),
        'learning_rate': trial.suggest_float('learning_rate', 
                                             best_params['learning_rate'] / 3,
                                             best_params['learning_rate'] * 3, log=True),
        'max_depth': trial.suggest_int('max_depth', 
                                       max(3, best_params['max_depth'] - 2),
                                       min(15, best_params['max_depth'] + 2)),
        'min_child_weight': trial.suggest_int('min_child_weight', 
                                              max(1, best_params['min_child_weight'] - 2),
                                              min(10, best_params['min_child_weight'] + 2)),
        'gamma': trial.suggest_float('gamma', 
                                     best_params['gamma'] / 3,
                                     best_params['gamma'] * 3, log=True),
        'subsample': trial.suggest_float('subsample', 
                                         max(0.5, best_params['subsample'] - 0.1),
                                         min(1.0, best_params['subsample'] + 0.1)),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 
                                                max(0.5, best_params['colsample_bytree'] - 0.1),
                                                min(1.0, best_params['colsample_bytree'] + 0.1)),
        'reg_alpha': trial.suggest_float('reg_alpha', 
                                         best_params['reg_alpha'] / 3,
                                         best_params['reg_alpha'] * 3, log=True),
        'reg_lambda': trial.suggest_float('reg_lambda', 
                                          best_params['reg_lambda'] / 3,
                                          best_params['reg_lambda'] * 3, log=True)
    }
    return params

def objective(trial):
    params = create_param_space(trial, best_params_phase1)
    params['objective'] = 'reg:squarederror'
    params['random_state'] = 42
    
    model = xgb.XGBRegressor(**params)
    
    # Usamos validação cruzada para evitar overfitting nos hiperparâmetros
    from sklearn.model_selection import cross_val_score
    rmse_scores = cross_val_score(model, X_train, y_train, 
                                  cv=5, scoring='neg_root_mean_squared_error')
    return rmse_scores.mean()  # Optuna maximiza por padrão (então usamos valores negativos do RMSE)

# Segunda fase: 50 iterações de otimização bayesiana
study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=50)

# Melhores parâmetros da segunda fase
best_params_final = study.best_params
best_params_final['objective'] = 'reg:squarederror'
best_params_final['random_state'] = 42

print(f'Melhores parâmetros finais: {best_params_final}')
print(f'Melhor RMSE final: {-study.best_value:.2f}')

# Treinamento do modelo final com todos os dados de treinamento
final_model = xgb.XGBRegressor(**best_params_final)
final_model.fit(X_train, y_train)

# Avaliação no conjunto de teste
from sklearn.metrics import mean_squared_error
y_pred = final_model.predict(X_test)
final_rmse = np.sqrt(mean_squared_error(y_test, y_pred))
print(f'RMSE no conjunto de teste: {final_rmse:.2f}')

Cross-validation aninhada: a estratégia definitiva

Uma técnica avançada que incorporei recentemente em meus projetos é a validação cruzada aninhada (nested cross-validation). Ela resolve de forma elegante o problema de overfitting nos hiperparâmetros.

A ideia é usar um loop externo de validação cruzada para avaliar o desempenho e um loop interno para otimizar os hiperparâmetros, garantindo uma estimativa imparcial da performance do modelo.

from sklearn.model_selection import GridSearchCV, KFold, cross_val_score

# Configuração dos loops de validação cruzada
outer_cv = KFold(n_splits=5, shuffle=True, random_state=42)
inner_cv = KFold(n_splits=3, shuffle=True, random_state=42)

# Espaço de hiperparâmetros
param_grid = {
    'n_estimators': [100, 300, 500],
    'learning_rate': [0.01, 0.1, 0.3],
    'max_depth': [3, 5, 7]
}

# Resultados para armazenar as pontuações
scores = []

# Loop externo de validação cruzada
for train_idx, test_idx in outer_cv.split(X, y):
    X_train_outer, X_test_outer = X[train_idx], X[test_idx]
    y_train_outer, y_test_outer = y[train_idx], y[test_idx]
    
    # Criação do modelo base
    model = xgb.XGBRegressor(objective='reg:squarederror', random_state=42)
    
    # Configuração da busca em grade com o loop interno
    grid_search = GridSearchCV(
        estimator=model,
        param_grid=param_grid,
        cv=inner_cv,
        scoring='neg_root_mean_squared_error'
    )
    
    # Otimização dos hiperparâmetros no conjunto de treinamento externo
    grid_search.fit(X_train_outer, y_train_outer)
    
    # Melhor modelo encontrado
    best_model = grid_search.best_estimator_
    
    # Avaliação no conjunto de teste externo
    y_pred = best_model.predict(X_test_outer)
    rmse = np.sqrt(mean_squared_error(y_test_outer, y_pred))
    scores.append(rmse)
    
    print(f'Fold RMSE: {rmse:.2f}, Melhores parâmetros: {grid_search.best_params_}')

# Resultado final
print(f'
RMSE médio da validação cruzada aninhada: {np.mean(scores):.2f} ± {np.std(scores):.2f}')

Esta abordagem é mais cara computacionalmente, mas oferece uma estimativa muito mais confiável do desempenho real do modelo, algo crucial para aplicações críticas como detecção de fraudes ou diagnóstico médico.

Dicas práticas que aprendi com a experiência

Além das técnicas formais, há várias lições que aprendi empiricamente:

1. Use escalas logarítmicas para alguns hiperparâmetros

Parâmetros como taxas de aprendizado ou regularização geralmente são melhor explorados em escala logarítmica. Por exemplo, testar valores como [0.001, 0.01, 0.1, 1.0] em vez de [0.1, 0.2, 0.3, 0.4].

2. Otimize hiperparâmetros de forma progressiva

Para modelos com muitos hiperparâmetros (como redes neurais), às vezes é melhor otimizar grupos de parâmetros relacionados sequencialmente. Por exemplo, primeiro o tamanho e arquitetura da rede, depois os parâmetros de otimização.

3. Use diferentes métricas para diferentes fases

Em um projeto recente de classificação de crédito, descobri que otimizar primeiro para recall e depois para pontuação F1 produzia melhores resultados do que otimizar diretamente para F1.

4. Monitore o tempo de execução dos modelos

Às vezes um pequeno sacrifício na performance (por exemplo, 0.5% menos de precisão) vale a pena se o modelo executa significativamente mais rápido. Isso foi crucial em um projeto de recomendação em tempo real.

Ferramentas que recomendo

Ao longo dos anos, experimentei diversas bibliotecas para otimização de hiperparâmetros. Estas são minhas recomendações atuais:

  • Scikit-learn: Para projetos simples, RandomizedSearchCV e GridSearchCV são suficientes.
  • Optuna: Minha escolha atual para a maioria dos projetos. Interface excelente, visualizações informativas e extremamente flexível.
  • Hyperopt: Boa alternativa, especialmente para espaços de parâmetros mais complexos.
  • Ray Tune: Quando a escalabilidade é crítica ou quando estou trabalhando com modelos distribuídos.
  • Weights & Biases: Para projetos maiores onde o rastreamento de experimentos é crucial.

Conclusão: O equilíbrio entre arte e ciência

A otimização de hiperparâmetros é tanto uma arte quanto uma ciência. As técnicas formais que compartilhei aqui fornecem a estrutura, mas a intuição e experiência são igualmente importantes.

Em meu curso Formação Machine Learning, dedico um módulo inteiro a este tema, explorando estratégias avançadas de otimização para diferentes algoritmos e casos de uso. Se você quer aprofundar seu conhecimento, recomendo também o curso Machine Learning: Classificação com Python, onde cobrimos a otimização de hiperparâmetros para modelos de classificação.

Lembre-se: a otimização de hiperparâmetros não é apenas sobre encontrar o melhor modelo possível, mas sobre encontrar o melhor modelo para seu problema específico, considerando todos os trade-offs envolvidos.

Quais são suas experiências com otimização de hiperparâmetros? Compartilhe nos comentários abaixo - adoro aprender com as experiências da comunidade!