
Введение
При построении моделей машинного обучения средней или высокой сложности приходится задавать множество параметров заранее, без обучения на данных. Такие параметры называют гиперпараметрами. Случайные леса и нейронные сети полны ими: каждый может принимать разные значения, и комбинаций выходит неисчислимое множество. Выбрать оптимальный набор, дающий максимальную производительность модели, — задача не из лёгких, словно искать иголку в океане сена.
Здесь мы разберём на практике три продвинутых подхода к настройке гиперпараметров:
- случайный поиск
- байесовская оптимизация
- последовательное деление пополам
Начальная настройка
Сначала подключим нужные библиотеки. Если возникнет ошибка "Module not Found", установите их через pip install. Нужны NumPy, scikit-learn и Optuna.
import numpy as np
import time
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.ensemble import RandomForestClassifier
import optuna
import warnings
warnings.filterwarnings('ignore')Далее загрузим датасет для примеров — упрощённую версию MNIST от Modified National Institute of Standards and Technology. Это изображения рукописных цифр низкого разрешения для задач классификации.
print("=" * 70)
print("LOADING MNIST DATASET FOR IMAGE CLASSIFICATION")
print("=" * 70)
# Load digits dataset (lightweight version of MNIST: 8x8 images, 1797 samples)
digits = load_digits()
X, y = digits.data, digits.target
# Train-test split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
print(f"Training instances: {X_train.shape[0]}")
print(f"Test instances: {X_test.shape[0]}")
print(f"Features: {X_train.shape[1]}")
print(f"Classes: {len(np.unique(y))}")
print()Теперь зададим пространство поиска гиперпараметров — то есть параметры случайного леса и диапазоны значений для них.
print("=" * 70)
print("HYPERPARAMETER SEARCH SPACE")
print("=" * 70)
# Typical hyperparameters to explore in a random forest ensemble
param_space = {
'n_estimators': (10, 200), # Number of trees
'max_depth': (5, 50), # Maximum tree depth
'min_samples_split': (2, 20), # Min samples to split node
'min_samples_leaf': (1, 10), # Min samples in leaf node
'max_features': (0.1, 1.0) # Fraction of features to consider
}
print("Search space:")
for param, bounds in param_space.items():
print(f" {param}: {bounds}")
print()Ещё один ключевой элемент — функция для оценки модели. Она обучает случайный лес с заданными гиперпараметрами и вычисляет точность с помощью кросс-валидации. Эта функция будет вызвана множество раз каждым методом.
def evaluate_model(params, X_train, y_train, cv=3):
# Instantiate a random forest model with given hyperparameters
model = RandomForestClassifier(
n_estimators=int(params['n_estimators']),
max_depth=int(params['max_depth']),
min_samples_split=int(params['min_samples_split']),
min_samples_leaf=int(params['min_samples_leaf']),
max_features=float(params['max_features']),
random_state=42,
n_jobs=-1 # Use all CPU cores for speed
)
# Use CV to measure performance
# This gives us a more robust estimate than a single train/val split
scores = cross_val_score(model, X_train, y_train, cv=cv, scoring='accuracy', n_jobs=-1)
# Return the average cross-validation accuracy
return np.mean(scores)Переходим к методам!
Случайный поиск
Этот подход берёт случайные комбинации гиперпараметров из пространства поиска, не проверяя все возможные варианты, как в grid search. Каждая попытка независима, без учёта предыдущих. Но метод работает быстро и часто находит хорошие настройки.
Вот как применить его к случайному лесу на MNIST:
def randomized_search(n_trials=30):
start_time = time.time() # Optional: used to measure execution time
results = []
print(f"\nRunning {n_trials} random trials...")
for i in range(n_trials):
# RANDOM SAMPLING: hyperparameters are sampled independently using numpy's random number generation
params = {
'n_estimators': np.random.randint(param_space['n_estimators'][0], param_space['n_estimators'][1]),
'max_depth': np.random.randint(param_space['max_depth'][0], param_space['max_depth'][1]),
'min_samples_split': np.random.randint(param_space['min_samples_split'][0], param_space['min_samples_split'][1]),
'min_samples_leaf': np.random.randint(param_space['min_samples_leaf'][0], param_space['min_samples_leaf'][1]),
'max_features': np.random.uniform(param_space['max_features'][0], param_space['max_features'][1])
}
# Evaluate a randomly defined configuration
score = evaluate_model(params, X_train, y_train)
results.append({'params': params, 'score': score})
# Provide a progress update every 10 trials, for informative purposes
if (i + 1) % 10 == 0:
best_so_far = max(results, key=lambda x: x['score'])
print(f" Trial {i+1}/{n_trials}: Best score so far = {best_so_far['score']:.4f}")
# Measure total time taken
elapsed_time = time.time() - start_time
# Identify best configuration found
best_result = max(results, key=lambda x: x['score'])
print(f"\n✓ Completed in {elapsed_time:.2f} seconds")
print(f"Best validation accuracy: {best_result['score']:.4f}")
print(f"Best parameters: {best_result['params']}")
return best_result, results
# Call the method to perform randomized search over 30 trials
random_best, random_results = randomized_search(n_trials=30)Комментарии в коде помогут разобраться. Результаты похожи на эти:
Running 30 random trials...
Trial 10/30: Best score so far = 0.9617
Trial 20/30: Best score so far = 0.9617
Trial 30/30: Best score so far = 0.9617
✓ Completed in 64.59 seconds
Best validation accuracy: 0.9617
Best parameters: {'n_estimators': 195, 'max_depth': 16, 'min_samples_split': 8, 'min_samples_leaf': 2, 'max_features': 0.28306570555707966}Обратите внимание на время работы и достигнутую точность. Здесь 10 попыток хватило для хорошего результата.
Байесовская оптимизация
Здесь используется surrogate-модель — вероятностная, на основе гауссовских процессов или деревьев, — чтобы предсказывать удачные гиперпараметры. Каждая итерация учитывает прошлые, балансируя поиск новых областей и доработку перспективных. Получается умнее случайного или сеточного подхода.
Библиотека Optuna реализует это через Tree-structured Parzen Estimator (TPE). Она делит попытки на удачные и неудачные, моделирует распределения и семплирует из лучших зон.
Реализация:
def bayesian_optimization(n_trials=30):
"""
Implementation of Bayesian optimization using Optuna library.
"""
start_time = time.time()
def objective(trial):
"""
Optuna objective function: given a trial, returns a score.
"""
# Optuna can suggest values based on past performance
params = {
'n_estimators': trial.suggest_int('n_estimators', param_space['n_estimators'][0], param_space['n_estimators'][1]),
'max_depth': trial.suggest_int('max_depth', param_space['max_depth'][0], param_space['max_depth'][1]),
'min_samples_split': trial.suggest_int('min_samples_split', param_space['min_samples_split'][0], param_space['min_samples_split'][1]),
'min_samples_leaf': trial.suggest_int('min_samples_leaf', param_space['min_samples_leaf'][0], param_space['min_samples_leaf'][1]),
'max_features': trial.suggest_float('max_features', param_space['max_features'][0], param_space['max_features'][1])
}
# Evaluate and return score (maximizing by default in Optuna)
return evaluate_model(params, X_train, y_train)
# The create_study() function is used in Optuna to manage and run
# the overall optimization process
print(f"\nRunning {n_trials} Bayesian optimization trials...")
study = optuna.create_study(
direction='maximize', # We want to maximize accuracy
sampler=optuna.samplers.TPESampler(seed=42) # Bayesian algorithm
)
# Perform optimization process with progress callback
def callback(study, trial):
if trial.number % 10 == 9:
print(f" Trial {trial.number + 1}/{n_trials}: Best score = {study.best_value:.4f}")
study.optimize(objective, n_trials=n_trials, callbacks=[callback], show_progress_bar=False)
elapsed_time = time.time() - start_time
print(f"\n✓ Completed in {elapsed_time:.2f} seconds")
print(f"Best validation accuracy: {study.best_value:.4f}")
print(f"Best parameters: {study.best_params}")
return study.best_params, study.best_value, study
bayesian_best_params, bayesian_best_score, bayesian_study = bayesian_optimization(n_trials=30)Вывод (в сокращении):
✓ Completed in 62.66 seconds
Best validation accuracy: 0.9673
Best parameters: {'n_estimators': 150, 'max_depth': 33, 'min_samples_split': 2, 'min_samples_leaf': 1, 'max_features': 0.19145126698170384}Последовательное деление пополам
Этот метод распределяет ресурсы вычислений умно: начинается с множества конфигураций, но мало данных на каждую. Постепенно отсеиваются слабые, а сильным даётся больше ресурсов — как в турнире, где выживают лидеры.
Реализация меняет размер тренировочного набора поэтапно:
def successive_halving(n_initial=32, min_resource=0.25, max_resource=1.0):
start_time = time.time()
# Step 1: Defining initial hyperparameter configurations at random
print(f"\nGenerating {n_initial} initial random configurations...")
configs = []
for _ in range(n_initial):
config = {
'n_estimators': np.random.randint(param_space['n_estimators'][0], param_space['n_estimators'][1]),
'max_depth': np.random.randint(param_space['max_depth'][0], param_space['max_depth'][1]),
'min_samples_split': np.random.randint(param_space['min_samples_split'][0], param_space['min_samples_split'][1]),
'min_samples_leaf': np.random.randint(param_space['min_samples_leaf'][0], param_space['min_samples_leaf'][1]),
'max_features': np.random.uniform(param_space['max_features'][0], param_space['max_features'][1])
}
configs.append(config)
# Step 2: apply tournament-like successive rounds of elimination
current_configs = configs
current_resource = min_resource
round_num = 1
while len(current_configs) > 1 and current_resource <= max_resource:
# Determine amount of training instances to use in the current round
n_samples = int(len(X_train) * current_resource)
print(f"\n--- Round {round_num}: Evaluating {len(current_configs)} configs ---")
print(f" Using {current_resource*100:.0f}% of training data ({n_samples} samples)")
# Subsample training instances
indices = np.random.choice(len(X_train), size=n_samples, replace=False)
X_subset = X_train[indices]
y_subset = y_train[indices]
# Evaluate all current configs with the current resources
scores = []
for i, config in enumerate(current_configs):
score = evaluate_model(config, X_subset, y_subset, cv=2) # Use cv=2 (minimum)
scores.append(score)
if (i + 1) % 10 == 0 or (i + 1) == len(current_configs):
print(f" Evaluated {i+1}/{len(current_configs)} configs...")
# Elimination policy: keep top-performing half only
n_keep = max(1, len(current_configs) // 2)
sorted_indices = np.argsort(scores)[::-1] # Descending order
current_configs = [current_configs[i] for i in sorted_indices[:n_keep]]
best_score = scores[sorted_indices[0]]
print(f" → Keeping top {n_keep} configs. Best score: {best_score:.4f}")
# Update resources, doubling them for the next round
current_resource = min(current_resource * 2, max_resource)
round_num += 1
# Final evaluation of best config found, given full training set
best_config = current_configs[0]
final_score = evaluate_model(best_config, X_train, y_train, cv=3)
elapsed_time = time.time() - start_time
print(f"\n✓ Completed in {elapsed_time:.2f} seconds")
print(f"Best validation accuracy: {final_score:.4f}")
print(f"Best parameters: {best_config}")
return best_config, final_score
halving_best, halving_score = successive_halving(n_initial=32, min_resource=0.25, max_resource=1.0)Итоговый результат примерно такой:
✓ Completed in 56.18 seconds
Best validation accuracy: 0.9645
Best parameters: {'n_estimators': 158, 'max_depth': 39, 'min_samples_split': 5, 'min_samples_leaf': 2, 'max_features': 0.2269785516325355}Сравнение итогов
Все три метода достигли точности валидации от 96% до 97%, с небольшим преимуществом байесовской оптимизации. Разница ярче в скорости: последовательное деление пополам уложилось в 56 секунд, обогнав остальные на 62–64 секунды.