Новости и статьи об искусственном интеллекте и нейросетях. Мы собираем и обрабатываем самую актуальную информацию из мира AI. О проекте

Статьи

3 метода тюнинга гиперпараметров лучше Grid Search

Статья показывает три альтернативы grid search для настройки гиперпараметров: случайный поиск, байесовская оптимизация и последовательное деление пополам. На примерах с случайным лесом и датасетом MNIST демонстрируется код на Python с использованием scikit-learn и Optuna. Байесовская оптимизация дала максимальную точность, а деление пополам — наибольшую скорость.

20 января 2026 г.
9 мин
79
3 метода настройки гиперпараметров, превосходящие Grid Search

Введение

При построении моделей машинного обучения средней или высокой сложности приходится задавать множество параметров заранее, без обучения на данных. Такие параметры называют гиперпараметрами. Случайные леса и нейронные сети полны ими: каждый может принимать разные значения, и комбинаций выходит неисчислимое множество. Выбрать оптимальный набор, дающий максимальную производительность модели, — задача не из лёгких, словно искать иголку в океане сена.

Здесь мы разберём на практике три продвинутых подхода к настройке гиперпараметров:

  • случайный поиск
  • байесовская оптимизация
  • последовательное деление пополам

Начальная настройка

Сначала подключим нужные библиотеки. Если возникнет ошибка "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 секунды.

Горячее

Загружаем популярные статьи...