Альтернатива традиционным методам настройки гиперпараметров

То, как модель машинного обучения приспосабливается к данным, определяется набором начальных условий, называемых гиперпараметрами. Гиперпараметры помогают ограничить поведение модели при обучении, чтобы она (надеюсь) могла хорошо соответствовать данным и в разумные сроки. Поиск наилучшего набора гиперпараметров (часто называемый «настройкой») — одна из самых важных и трудоемких частей задачи моделирования. Исторические подходы к настройке гиперпараметров включают либо грубую силу, либо случайный поиск по сетке комбинаций гиперпараметров, называемых поиском по сетке и случайным поиском соответственно. Несмотря на свою популярность, методы Grid и Random Search не имеют никакого способа сходимости к приличному набору гиперпараметров, то есть они основаны исключительно на пробах и ошибках. В этой статье мы рассмотрим относительно новый подход к настройке гиперпараметров — Оценщик Парцена с древовидной структурой (TPE) — и поймем его функцию программно посредством пошаговой реализации на Python.

TPE — это байесовский алгоритм оптимизации. Это означает, что он позволяет нам начать с некоторых первоначальных убеждений о том, каковы наши лучшие гиперпараметры модели, и обновлять эти убеждения принципиальным образом, когда мы узнаем, как различные гиперпараметры влияют на производительность модели. Это уже значительное улучшение по сравнению с поиском по сетке и случайным поиском! Вместо того, чтобы определять лучший набор гиперпараметров методом проб и ошибок, со временем мы можем пробовать больше комбинаций гиперпараметров, которые приводят к хорошим моделям, и меньше тех, которые не приводят.

TPE получил свое название от двух основных идей: 1. использование оценки Парзена для моделирования наших представлений о лучших гиперпараметрах (подробнее об этом позже) и 2. использование древовидной структуры данных, называемой графом апостериорного вывода, для оптимизации времени выполнения алгоритма. В этом примере мы проигнорируем древовидную часть, поскольку она не имеет ничего общего с настройкой гиперпараметров как таковой. Кроме того, мы не будем вдаваться в подробности байесовской статистики, ожидаемых улучшений и т. д. Целью здесь является развитие концептуального понимания TPE на высоком уровне и того, как он работает. Для более подробного рассмотрения этих тем см. оригинальную статью по TPE Дж. Бергстры и его коллег [1].

Настройка примера

Чтобы построить нашу реализацию TPE, нам понадобится игрушечный пример для работы. Давайте представим, что мы хотим найти линию наилучшего соответствия с помощью некоторых случайно сгенерированных данных. В этом примере у нас есть два гиперпараметра для настройки — наклон линии m и точка пересечения b.

import numpy as np

#Generate some data
np.random.seed(1)
x = np.linspace(0, 100, 1000)
m = np.random.randint(0, 100)
b = np.random.randint(-5000, 5000)
y = m*x + b + np.random.randn(1000)*700

Поскольку TPE — это алгоритм оптимизации, нам также нужна некоторая метрика для оптимизации. Мы будем использовать среднеквадратичную ошибку (RMSE). Давайте определим функцию rmse для расчета этой метрики следующим образом:

def rmse(m, b):
    '''
    Consumes coeffiecients for our linear model and returns RMSE.
    
    m (float): line slope
    m (float): line intercept
    y (np.array): ground truth for model prediction
    '''
    preds = m*x + b
    return np.sqrt(((preds - y)**2).sum()/len(preds))

Последнее, что нам понадобится, — это некоторые исходные убеждения о том, каковы наши лучшие гиперпараметры. Допустим, мы считаем, что наклон линии наилучшего соответствия является равномерной случайной величиной на интервале (10 100), а точка пересечения также является равномерной случайной величиной на интервале (-6000, -3000). Эти распределения называются априорными. То, что они являются однородными случайными величинами, равносильно утверждению, что мы считаем, что истинные значения лучших гиперпараметров с равной вероятностью лежат где угодно в пределах их соответствующих интервалов. Мы реализуем класс для инкапсуляции этих переменных и используем их для определения нашего начального пространства поиска.

class UniformDist:
    '''
    Class encapsulates behavior for a uniform distribution.
    '''
    def __init__(self, min_, max_):
        '''
        Initializes our distribution with provided bounds
        '''
        self.min = min_
        self.max = max_
        
    def sample(self, n_samples):
        '''
        Returns samples from our distribution
        '''
        return np.random.uniform(self.min, self.max, n_samples)
#Define hyperparameter search space
search_space = {'m':UniformDist(10,100), 'b':UniformDist(-6000,-3000)}

Когда все это настроено, мы можем перейти к кодированию самого алгоритма.

Шаг 1. Выборочное исследование

Первым шагом TPE является случайная выборка наборов гиперпараметров из наших априорных значений. Этот процесс дает нам первое приближение к тому, где находятся области нашего пространства поиска, которые создают хорошие модели. Функция sample_priors использует наше начальное пространство поиска и ряд случайных выборок, которые нужно извлечь из него. Затем он оценивает полученные модели, используя нашу целевую функцию rmse, и возвращает Pandas DataFrame, содержащий наклон, точку пересечения и RMSE для каждого испытания.

import pandas as pd

def sample_priors(space, n_samples):
    '''
    Consumes search space defined by priors and returns
    n_samples.
    '''
    seed = np.array([space[hp].sample(n_samples) for hp in space])
    
    #Calculate rmse for each slope intercept pair in the seed
    seed_rmse = np.array([rmse(m, b) for m, b in seed.T]) 
    
    #Concatenate and convert to dataframe
    data = np.stack([seed[0], seed[1], seed_rmse]).T
    trials = pd.DataFrame(data, columns=['m', 'b', 'rmse'])
    
    return trials

Шаг 2: Разделение пространства поиска и оценка Парзена

После создания некоторых начальных выборок из наших априорных значений мы теперь разделяем наше пространство поиска гиперпараметров на две части, используя квантильный порог γ, где γ находится в диапазоне от 0 до 1. Давайте произвольно выберем γ = 0,2. Комбинации гиперпараметров, которые приводят к модели, которая работает в лучших 20% всех моделей, которые мы создали до сих пор, группируются в «хорошее» распределение l(x). Все остальные комбинации гиперпараметров относятся к «плохому» распределению g(x).

Оказывается, наилучшая следующая комбинация наших гиперпараметров для проверки определяется максимумом g(x)/l(x) (если вы хотите увидеть вывод этого, см. [1]). Это интуитивно понятно. Нам нужны гиперпараметры, которые весьма вероятны при нашем хорошем распределении l(x) и маловероятны при нашем плохом распределении g(x). Мы можем смоделировать каждое из значений g(x) и l(x) с помощью оценок Парцена, откуда берется PE в TPE. Грубая идея оценки Парзена, также известной как оценка плотности ядра (или KDE), заключается в том, что мы собираемся усреднить серию нормальных распределений, каждое из которых сосредоточено на наблюдении, принадлежащем g (x) или l (x) (соответственно). Результирующие распределения имеют высокую плотность в областях нашего пространства поиска, где выборки расположены близко друг к другу, и низкую плотность в областях, где выборки находятся далеко друг от друга. Для выполнения оценки Парзена мы будем использовать объект KernelDensity из библиотеки SKLearn. Функция segment_distributions использует данные наших испытаний DataFrame и наш порог γ и возвращает оценку Парзена для l(x) и g(x) для каждого. Полученные распределения визуализированы на рисунке 4.

from sklearn.neighbors import KernelDensity

def segment_distributions(trials, gamma):
    '''
    Splits samples into l(x) and g(x) distributions based on our
    quantile cutoff gamma (using rmse as criteria).
    
    Returns a kerned density estimator (KDE) for l(x) and g(x), 
    respectively.
    '''
    cut = np.quantile(trials['rmse'], gamma)
    
    l_x = trials[trials['rmse']<cut][['m','b']]
    g_x = trials[~trials.isin(l_x)][['m','b']].dropna()
    
    l_kde = KernelDensity(kernel='gaussian', bandwidth=5.0)
    g_kde = KernelDensity(kernel='gaussian', bandwidth=5.0)
    
    l_kde.fit(l_x)
    g_kde.fit(g_x)
    
    return l_kde, g_kde

Шаг 3. Определение следующих «лучших» гиперпараметров для тестирования

Как упоминалось на шаге 2, следующий лучший набор гиперпараметров для проверки максимизирует g(x)/l(x). Мы можем определить, каков этот набор гиперпараметров, следующим образом. Сначала мы берем N случайных выборок из l(x). Затем для каждой из этих выборок мы оцениваем их логарифмическую вероятность относительно l(x) и g(x), выбирая выборку, которая максимизирует g(x)/l(x), в качестве следующей комбинации гиперпараметров для тестирования. Реализация SKLearn KernelDensity, которую мы решили использовать, делает эти вычисления очень простыми.

def choose_next_hps(l_kde, g_kde, n_samples):
    '''
    Consumes KDE's for l(x) and g(x), samples n_samples from 
    l(x) and evaluates each sample with respect to g(x)/l(x).
    The sample which maximizes this quantity is returned as the
    next set of hyperparameters to test.
    '''
    samples = l_kde.sample(n_samples)
    
    l_score = l_kde.score_samples(samples)
    g_score = g_kde.score_samples(samples)
    
    hps = samples[np.argmax(g_score/l_score)]
    
    return hps

Все вместе сейчас

Все, что нам осталось сделать, это объединить все ранее обсуждавшиеся компоненты в единый алгоритм, и у нас есть реализация TPE! Решения, которые мы должны принять здесь, заключаются в том, сколько раундов случайного исследования мы хотим выполнить, сколько полных итераций алгоритма мы хотим выполнить и каким будет порог отсечки γ (да, даже у TPE есть гиперпараметры). Вот несколько вещей, которые следует учитывать при выборе этих количеств.

  1. Если «лучший» набор гиперпараметров не будет получен вашими априорными значениями до того, как вы начнете TPE, у алгоритма могут возникнуть трудности со схождением.
  2. Чем больше раундов случайного исследования вы выполните, тем лучше будут ваши начальные приближения g(x) и l(x), что может улучшить результаты tpe.
  3. Чем выше значение γ, тем меньше выборок попадет в l(x). Наличие только нескольких выборок для использования при оценке l(x) может привести к плохому выбору гиперпараметра с помощью tpe.
def tpe(space, n_seed, n_total, gamma):
    '''
    Consumes a hyperparameter search space, number of iterations for seeding
    and total number of iterations and performs Bayesian Optimization. TPE
    can be sensitive to choice of quantile cutoff, which we control with gamma.
    '''
    
    #Seed priors
    trials = sample_priors(space, n_seed)
    
    for i in range(n_seed, n_total):
        
        #Segment trials into l and g distributions
        l_kde, g_kde = segment_distributions(trials, gamma)
        
        #Determine next pair of hyperparameters to test
        hps = choose_next_hps(l_kde, g_kde, 100)
        
        #Evaluate with rmse and add to trials
        result = np.concatenate([hps, [rmse(hps[0], hps[1])]])
        
        trials = trials.append(
            {col:result[i] for i, col in enumerate(trials.columns)},
            ignore_index=True
        )
        
    return trials

Полученные результаты

Чтобы выполнить TPE над нашими синтетическими данными, которые мы создали ранее, мы запускаем следующее:

#Define hyperparameter search space
np.random.seed(1)
search_space = {'m':UniformDist(10,100), 'b':UniformDist(-6000,-3000)}

df = tpe(search_space, 
         n_seed=30, 
         n_total=200, 
         gamma=.2)

Вот и все! Теперь мы можем проанализировать наши результаты. Для краткости код, используемый для создания следующих визуализаций, не будет показан. Однако исходный код этого проекта можно найти в репозитории GitHub.

Сначала давайте сравним наш лучший набор гиперпараметров из TPE с фактическим лучшим наклоном и точкой пересечения из регрессионного решателя.

Как видно из рисунка 5, с помощью TPE мы можем приблизиться к наилучшему набору гиперпараметров для нашей линии. Мы не ожидаем, что TPE превзойдет решатель регрессии, потому что линейная регрессия имеет решение в закрытой форме. Однако мы ожидаем, что после бесконечного числа испытаний оно сойдется к аналогичному решению.

TPE — это алгоритм оптимизации, поэтому нас не волнует только то, что мы смогли найти достойный набор гиперпараметров. Нам также нужно проверить, что за 200 итераций наша целевая функция уменьшается. Рисунок 6 демонстрирует, что после наших первоначальных 30 случайных выборок наша реализация TPE продолжает минимизировать нашу целевую функцию с ясной тенденцией.

Пока все выглядит отлично! Последнее, что мы хотим проверить, — это то, как наши убеждения о «наилучшем» распределении наших гиперпараметров изменились за наши 200 итераций.

Как видно на рис. 7, мы начинаем с очень широкого распределения l(x), которое быстро сводится к чему-то, приближающемуся к нашему конечному результату. На рисунках 6 и 7 ясно показано, как каждый из трех простых шагов TPE объединяется, чтобы дать нам алгоритм, способный исследовать пространство поиска сложным, но интуитивно понятным способом.

Выводы

Настройка гиперпараметров является важной частью процесса моделирования. В то время как подходы Grid Search и Random Search просты в реализации, TPE в качестве альтернативы обеспечивает более принципиальный способ настройки гиперпараметров и довольно прост с концептуальной точки зрения. Существует несколько библиотек Python с очень хорошими реализациями TPE, включая Hyperopt (которая была создана и поддерживается авторами [1]) и Optuna. Будь то что-то столь же простое, как наш игрушечный пример, или такое сложное, как настройка гиперпараметров для нейронной сети, TPE — это универсальный, эффективный и простой метод, который за последние несколько лет набирает популярность в науке о данных и машинном обучении. В следующий раз, когда вы обнаружите, что настраиваете гиперпараметры своей модели, возможно, пропустите поиск по сетке.

Рекомендации

  1. Бергстра Дж., Барденет Р., Бенжио Ю. и Кегл Б., Алгоритмы оптимизации гиперпараметров (2011 г.), Достижения в системах обработки нейронной информации, 24.

Все изображения, если не указано иное, принадлежат автору.