Если вы последние пару лет жили под камнем, Optuna — это библиотека Python, предназначенная для оптимизации гиперпараметров.

Однако в этом посте я хотел бы сделать шаг назад и сделать что-то другое. Само по себе в библиотеке нет ничего, что заставляло бы нас заниматься машинным обучением или даже настраивать гиперпараметры.

API Оптуны

Давайте посмотрим на основные объекты, которые библиотека предоставляет нам для использования, и (в целом) на то, как они взаимодействуют:

  • Исследование: этот объект будет централизовать и хранить все, что касается оптимизации; это главное, с чем мы будем взаимодействовать.
  • Пробная версия (во всех ее различных версиях): каждый из этих объектов представляет собой отдельный экземпляр оценки функции (значения параметров, результаты функции) с соответствующими метаданными (время начала и окончания, данные обрезки, атрибуты и т. д.)
  • Целевая функция: это любой вызываемый объект Python (лямбда-функция, functools.partial, явно определенный метод __call__ или функция определения), который принимает пробную версию в качестве аргумента и возвращает число (хотя он также может возвращать кортеж, содержащий много чисел).

Вот и все!

Очевидно, что есть гораздо больше движущихся частей (таких как сэмплеры, о которых мы расскажем в нескольких разделах), но если вы научитесь эффективно использовать эти три типа объектов, вы уже можете называть себя хорошим пользователем Optuna.

Исследования — это то, что вам нужно, чтобы явно создать свой экземпляр; целевые функции — это то, что вам нужно определить шаг за шагом, и это будет инкапсулировать функцию, для которой вы хотите оптимизировать. Внутри целевой функции вы определите любую логику, необходимую для вашего конкретного варианта использования. Кроме того, поскольку целевая функция принимает испытание в качестве аргумента, именно здесь вы можете взаимодействовать с пробным объектом, используя API-интерфейс предложения.

У него может быть пугающее название, но предлагаемый API в основном позволяет вам передать решение о том, какое значение попробовать дальше, на рассмотрение суда. Если сейчас это кажется слишком абстрактным, не беспокойтесь, скоро мы увидим пример.

Почему?

Теперь обратите внимание, что в предыдущем разделе я ни разу не упомянул машинное обучение, ИИ или какие-либо другие модные словечки: оптимизация функций полностью не зависит от какой-либо конкретной реализации, она просто находит наибольшее/наименьшее значение/значения для заданной функции. набор ограничений.

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

Вот где Optuna (и другие подобные инструменты) приходит на помощь: это структура для нахождения максимумов или минимумов функций, которые по той или иной причине не поддаются практической работе с использованием аналитических методов. Это особенно полезно при работе с функциями, вычисление которых очень требует больших затрат (таких как оценки машинного обучения и т. д.).

Руки вверх

Итак, сегодня я хотел бы ненавязчиво представить, как работает Optuna, на примере из машинного обучения, но довольно нестандартным способом: мы найдем параметры логистической регрессии в полуручном режиме и сравним их с Реализация sklearn убедила нас в том, что это действительно хороший результат.

Во-первых, нам нужно импортировать все, что нам понадобится:

import numpy as np
from matplotlib import pyplot as plt

from sklearn.datasets import make_classification
from sklearn.metrics import accuracy_score, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression

from optuna import create_study, Trial
from optuna.samplers import TPESampler

Далее, давайте сгенерируем и визуализируем некоторые данные,

X, y = make_classification(
    n_features=2,
    n_redundant=0,
    n_informative=2,
    random_state=3,
    n_clusters_per_class=1,
    class_sep=1,
    n_samples=1000
)

X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)

plt.scatter(X_train[:,0], X_train[:,1], c=y_train)

Теперь давайте напишем несколько функций, которые помогут нам построить логистическую регрессию. Конкретная реализация, на которую мы нацелены, максимизирует вероятность всех бета-версий модели. Таким образом, первая функция — это сигмоид (то есть сама логистическая функция), а вторая — отрицательная логарифмическая вероятность (минимизация этого эквивалентна максимизации вероятности, но с вычислительной точки зрения проще иметь дело):

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

def neg_log_likelihood(X, y, theta):
    m     = len(y)
    y_hat = sigmoid(X.dot(theta))
    J     = (-1/m) * np.sum(y*np.log(y_hat) + (1-y)*np.log(1-y_hat))
    return J

На данный момент у нас есть способ вычислить прогнозы (путем оценки сигмовидной функции) и рассчитать стоимость любого заданного набора прогнозов (с помощью функции neg_log_likelihood).

А Оптуна? Ничего из того, что мы сделали до сих пор, не относится к Оптуне. Что ж, юный кузнечик, вот суть статьи:

def objective(trial : Trial, X : np.ndarray, y : np.ndarray) -> float:
    X_ = np.hstack((np.ones((X.shape[0], 1)), X))
    beta_1    = trial.suggest_float('beta_1', -10, 10)
    beta_2    = trial.suggest_float('beta_2', -10, 10)
    intercept = trial.suggest_float('intercept', -10, 10)

    B = np.array([intercept, beta_1, beta_2])

    loss = neg_log_likelihood(X_, y, B)

    return loss

Это довольно много, поэтому давайте рассмотрим строки одну за другой:

  • Если вы не знали, Python позволяет использовать аннотации типов для переменных и возвращаемых значений функций; они игнорируются самим интерпретатором, но помогают кодерам лучше понять, и IDE могут использовать их для улучшения своих предложений автозаполнения. Итак, у нас есть пробная переменная типа optuna.Trial, а также переменные X и y, которые являются массивами numpy и возвращают число с плавающей запятой. Пока все просто, да?
  • Функция np.hstack (горизонтальный стек) просто склеивает два массива вместе (аналогично тому, как pd.concat работает по умолчанию). В этом случае мы приклеиваем столбец единиц к исходному массиву. Этот столбец позволит нам оценить количество перехватов для каждой переменной.
  • Следующие три строки в основном одинаковы, так что давайте посмотрим на их структуру: имя_параметра = пробная версия.suggest_float('имя', мин., макс.). Это определение переменной, как и любое другое, где объект присваивается имени переменной, никаких загадок. Но что делает суд? Как это будет работать? Ах, вот где проявляется гениальность Optuna: каждый раз, когда создается новое испытание, оно будет извлекать новые значения параметров из соответствующих им распределений, поэтому метод trial.suggest_float просто вернет число от -10 до 10. Вот и все, ничего больше. . Таким образом, испытание теперь имеет конкретное значение для каждой из бета-версий, включая перехват.
  • Затем мы вставляем эти оценки в массив для удобства.
  • Наконец, мы вызываем функцию neg_log_likelihood с соответствующими переменными и возвращаем ее значение.

Это было не так уж сложно, не так ли? Хорошо, что теперь? Это время учебы!

logistic_study = create_study(study_name='logistic_study',
                              sampler=TPESampler(seed=42),
                              direction='minimize')

Функция create_study просто создает исследование с соответствующими параметрами. Если вы не укажете семплер явно, TPESampler все равно будет использоваться, но я предпочитаю устанавливать начальные значения для обеспечения воспроизводимости.

Теперь самое интересное: давайте найдем эти параметры.

logistic_study.optimize(
    lambda trial: objective(trial, X_train, y_train),
    n_trials=500,
    n_jobs=-1
)

Обратите внимание, что метод оптимизации — это функция высокого порядка (это означает, что ее аргумент — это сама функция), а переданная функция имеет только один свободный аргумент: пробный, где Optuna будет творить свое волшебство. На каждом шаге (или испытании) исследование будет вызывать целевую функцию, передавая новый объект Trial, который, в свою очередь, будет запрашивать у сэмплера значение в пределах его границ. Наконец, функция будет оценена, и ее значение будет возвращено исследованию, которое повторит процесс.

Внутреннее устройство семплера выходит за рамки этой статьи, так что пока не стесняйтесь читать документацию.

Зачем столько испытаний? Вы можете спросить. Что ж, это моя статья, и мне нравится быть тщательным, хотя вы, вероятно, можете получить сопоставимые результаты с гораздо меньшими затратами.

Давайте посмотрим, что мы получили, задав исследование:

logistic_study.best_params
{'beta_1': 3.4065211923063017,
 'beta_2': -7.9512792095013705,
 'intercept': -5.127111119254792}

Хорошо… И что теперь?

Давайте подгоним реализацию sklearn и посмотрим на найденные коэффициенты:

model = LogisticRegression().fit(X_train, y_train)
np.hstack((model.coef_.ravel(), model.intercept_))
array([ 1.88798021, -5.25490865, -2.8369587 ])

Хм. Сравнение коэффициентов не делает нашу модель великолепной. Хорошо, а как насчет производительности в тестовом наборе?

sklearn_likelihood = neg_log_likelihood(
  np.hstack((np.ones((X_test.shape[0], 1)), X_test)),
  y_test,
  np.hstack((model.coef_.ravel(), model.intercept_))
)

optuna_likelihood = neg_log_likelihood(
  np.hstack((np.ones((X_test.shape[0], 1)), X_test)),
  y_test,
  np.array([*logistic_study.best_params.values()])
)

sklearn_likelihood, optuna_likelihood
0.9117186368407011, 0.957710928177615

Это не фантастика, но и не ужасно, так что давайте брать победу.

Теперь мы создадим новый объект логистической регрессии и подставим в него значения, полученные optuna:

model_ = LogisticRegression()
model_.coef_ = np.array([[
    logistic_study.best_params['beta_1'],
    logistic_study.best_params['beta_2']
]])

model_.intercept_ = np.array([
    logistic_study.best_params['intercept']
])

model_.classes_ = np.array([0, 1])

Наконец, постройте несколько графиков с реальными значениями (наземная правда), предсказаниями sklearn и предсказаниями optuna:

fig, ax = plt.subplots(1, 3, figsize=(25,10))
ax[0].scatter(X_train[:, 0], X_train[:, 1], c=y_train)
ax[1].scatter(X_train[:, 0], X_train[:, 1], c=model.predict_proba(X_train)[:, 1])
ax[2].scatter(X_train[:, 0], X_train[:, 1], c=model_.predict_proba(X_train)[:, 1])
ax[0].set_title('Ground Truth')
ax[1].set_title('Sklearn LR')
ax[2].set_title('Optuna LR')
plt.show()

Обе границы принятия решений кажутся, по крайней мере, приблизительно удачными. Если мы сравним оба набора изученных коэффициентов, определяемые Optuna сдвигают границу немного вверх. Глядя на некоторые показатели sklearn, мы видим, что модели очень похожи:

print(accuracy_score(y_test, model.predict(X_test)))
print(accuracy_score(y_test, model_.predict(X_test)))
print(roc_auc_score(y_test, model.predict_proba(X_test)[:,1]))
print(roc_auc_score(y_test, model_.predict_proba(X_test)[:, 1]))
0.968
0.964
0.9912954429083461
0.9908474142345111

Различия находятся в пределах 1%. Совсем неплохо!

Цель этой статьи была двоякой:

  • Во-первых, я хотел показать основы использования Optuna и то, как она работает.
  • Во-вторых, я хотел показать, что, в отличие от таких вещей, как GridSearchCV, API Optuna достаточно гибок, чтобы мы могли оптимизировать почти все, что мы можем написать в виде функции Python.

Первый пункт скорее бонус, если вы спросите меня. Гибкость библиотеки делает ее такой мощной и полезной во многих областях, особенно когда речь идет о машинном обучении. Надеюсь, я смогу написать больше об этом последнем пункте.

И последнее, прежде чем я уйду, так как я чувствую необходимость сделать большую оговорку: НЕ НАЙДИТЕ КОЭФФИЦИЕНТЫ ЛОГИСТИЧЕСКОЙ РЕГРЕССИИ ТАКИМ СПОСОБОМ. Пример был сделан для иллюстративных целей и не более того, я не оправдываю и не рекомендую находить такие параметры модели, особенно когда у нас есть эффективные реализации, написанные людьми, которые намного умнее нас (например, команда разработчиков sklearn).