使用 Optuna 优化你的优化器

2021-10-27 16:08:59 浏览数 (1)


磐创AI分享

作者 | Sion

编译 | Flin

来源 | analyticsvidhya

介绍

Optuna 是一个最先进的自动超参数调整框架,完全用 Python 编写。在过去的 2 年里,Kaggle社区一直在广泛使用它,具有巨大的竞争力。在本文中,我们将有一个实际操作的方法,并了解它的工作原理。

什么是Optuna?

Optuna 是一个自动超参数调优软件框架,专为机器学习而设计,可以与 PyTorch、TensorFlow、Keras、SKlearn 等其他框架一起使用。

Optuna 使用一种称为运行定义 API 的东西,它帮助用户编写高度模块化的代码并动态构建超参数的搜索空间,我们将在本文后面学习。

使用网格搜索、随机、贝叶斯和进化算法等不同的采样器来自动找到最佳参数。让我们简要讨论一下 Optuna 中可用的不同采样器。

  • 网格搜索:搜索目标算法整个超参数空间的预定子集。
  • 贝叶斯:此方法使用概率分布为每个超参数选择一个值。
  • 随机搜索:顾名思义,对搜索空间进行随机采样,直到满足停止条件。
  • 进化算法:适应度函数用于找到超参数的值。

但是是什么让它在 Kaggler 中如此受欢迎呢?

以下是他们在网站上提到的主要功能:

1) 轻量级和多功能:需要一个简单的安装,然后你就可以开始了。可以处理广泛的任务并找到最佳的替代方案。

2) 渴望搜索空间:使用熟悉的 Pythonic 语法(如条件和循环)来自动搜索最佳超参数。

3)最先进的算法:快速搜索大空间,并更快地剪枝没有希望的试验,以获得更好、更快的结果。

4) 轻松并行化:可以轻松地并行化超参数搜索,而对原始代码几乎没有更改。

5)快速可视化:各种可视化功能也可用于可视化分析优化结果。

在开始本教程之前,我们必须了解一些 Optuna 术语和约定。

Optuna 术语

在 Optuna 中,有两个主要术语,即:

1) Study:整个优化过程基于一个目标函数,即研究需要一个可以优化的函数。

2) Trial:优化函数的单次执行称为trial。因此,这项研究(Study)是一系列试验(trial)。

将使用 Optuna 的数据集

在本文中,我们将使用来自ASHRAE – Great Energy Predictor III的数据集,这是一项 Kaggle 竞赛,用于预测建筑物消耗的能源量。数据集的大小为 2.5 Gb,因此非常大。

为了便于使用,数据被分成几个文件。为了训练我们的模型,我们提供了train.csvweather_train.csvbuilding_metadata.csv

除此之外,我们还有一些 trs.csv 文件。以下是文件的简要说明以及显示的列。

加载库

代码语言:javascript复制
import gc
import os
from pathlib import Path
import random
import sys

from tqdm import tqdm_notebook as tqdm
import numpy as np # linear algebra
import pandas as pd # data processing

import matplotlib.pyplot as plt
import seaborn as sns

from IPython.core.display import display, HTML

# --- plotly ---
from plotly import tools, subplots
import plotly.offline as py
py.init_notebook_mode(connected=True)
import plotly.graph_objs as go
import plotly.express as px
import plotly.figure_factory as ff

# --- models ---
from sklearn import preprocessing
from sklearn.model_selection import KFold
import lightgbm as lgb
import xgboost as xgb
import catboost as cb

读取数据

代码语言:javascript复制
%%time

# Read data...
root = '../input/ashrae-energy-prediction'

train_df = pd.read_csv(os.path.join(root, 'train.csv'))
weather_train_df = pd.read_csv(os.path.join(root, 'weather_train.csv'))
test_df = pd.read_csv(os.path.join(root, 'test.csv'))
weather_test_df = pd.read_csv(os.path.join(root, 'weather_test.csv'))
building_meta_df = pd.read_csv(os.path.join(root, 'building_metadata.csv'))
sample_submission = pd.read_csv(os.path.join(root, 'sample_submission.csv'))

数据预处理

我们本次比赛的目标是预测建筑物的能源消耗。我们必须预测的 4 种能量类型是:

标签

能量类型

0

1

冷冻水

2

蒸汽

3

热水

电和水的消耗量可能会有所不同,并且具有不同的预测因素。所以我分别训练和预测了模型。

删除奇怪的数据

代码语言:javascript复制
train_df['date'] = train_df['timestamp'].dt.date
train_df['meter_reading_log1p'] = np.log1p(train_df['meter_reading'])
代码语言:javascript复制
def plot_date_usage(train_df, meter=0, building_id=0):
    train_temp_df = train_df[train_df['meter'] == meter]
    train_temp_df = train_temp_df[train_temp_df['building_id'] == building_id]    
    train_temp_df_meter = train_temp_df.groupby('date')['meter_reading_log1p'].sum()
    train_temp_df_meter = train_temp_df_meter.to_frame().reset_index()
    fig = px.line(train_temp_df_meter, x='date', y='meter_reading_log1p')
    fig.show()
代码语言:javascript复制
plot_date_usage(train_df, meter=0, building_id=0)

如你所见,2016 年 5 月中旬之前的数据看起来很奇怪,因为它位于图表的底部。这样做的原因是直到 5 月 20 日,对于site_id == 0 的所有电表读数都是0。一位与会者指出了这一点。因此,合乎逻辑的做法是删除该数据段。

代码语言:javascript复制
building_meta_df[building_meta_df.site_id == 0]

因此我们可以看到所有带有site_id == 0的条目都有building_id <= 104。现在我们删除它们:

代码语言:javascript复制
train_df = train_df.query('not (building_id <= 104 & meter == 0 & timestamp <= "2016-05-20")')

添加时间功能

我们可以使用时间戳特性来获得更好的特征。

代码语言:javascript复制
def preprocess(df):
    df["hour"] = df["timestamp"].dt.hour
    df["month"] = df["timestamp"].dt.month
    df["dayofweek"] = df["timestamp"].dt.dayofweek
    df["weekend"] = df["dayofweek"] >= 5
代码语言:javascript复制
preprocess(train_df)
代码语言:javascript复制
df_group = train_df.groupby(['building_id', 'meter'])['meter_reading_log1p']
building_mean = df_group.mean().astype(np.float16)
building_median = df_group.median().astype(np.float16)
building_min = df_group.min().astype(np.float16)
building_max = df_group.max().astype(np.float16)
building_std = df_group.std().astype(np.float16)
代码语言:javascript复制
building_stats_df = pd.concat([building_mean, building_median, building_min, building_max, building_std], axis=1,
                              keys=['building_mean', 'building_median', 'building_min', 'building_max', 'building_std']).reset_index()
train_df = pd.merge(train_df, building_stats_df, on=['building_id', 'meter'], how='left', copy=False)
代码语言:javascript复制
train_df.head()

(注意:以上代码段中未包含所有列

填充 NaN 值

天气数据有很多NaN 值,所以我们不能直接去掉条目。我们将尝试通过插入数据来填充这些值。

代码语言:javascript复制
weather_train_df.head()

(注意:以上代码段中未包含所有列

代码语言:javascript复制
weather_train_df.isna().sum()
代码语言:javascript复制
weather_test_df = pd.read_feather(root/'weather_test.feather')
weather = pd.concat([weather_train_df, weather_test_df],ignore_index=True)
del weather_test_df
weather_key = ['site_id', 'timestamp']
代码语言:javascript复制
temp_skeleton = weather[weather_key   ['air_temperature']].drop_duplicates(subset=weather_key).sort_values(by=weather_key).copy()

temp_skeleton['temp_rank'] = temp_skeleton.groupby(['site_id', temp_skeleton.timestamp.dt.date])['air_temperature'].rank('average')

df_2d = temp_skeleton.groupby(['site_id', temp_skeleton.timestamp.dt.hour])['temp_rank'].mean().unstack(level=1)

site_ids_offsets = pd.Series(df_2d.values.argmax(axis=1) - 14)
site_ids_offsets.index.name = 'site_id'

def timestamp_align(df):
    df['offset'] = df.site_id.map(site_ids_offsets)
    df['timestamp_aligned'] = (df.timestamp - pd.to_timedelta(df.offset, unit='H'))
    df['timestamp'] = df['timestamp_aligned']
    del df['timestamp_aligned']
    return df

del weather
del temp_skeleton
gc.collect()
代码语言:javascript复制
weather_train_df = timestamp_align(weather_train_df)
weather_train_df = weather_train_df.groupby('site_id').apply(lambda group: group.interpolate(limit_direction='both'))

现在,我们数据集中的 NaN 值数量已显着减少。

添加滞后

代码语言:javascript复制
def add_lag_feature(weather_df, window=3):
    group_df = weather_df.groupby('site_id')
    c = ['dew_temperature', 'cloud_coverage', 'precip_depth_1_hr', 'air_temperature', 'sea_level_pressure', 'wind_direction', 'wind_speed']
    cols = c
    rolled = group_df[cols].rolling(window=window, min_periods=0)
    lag_mean = rolled.mean().reset_index().astype(np.float16)
    lag_max = rolled.max().reset_index().astype(np.float16)
    lag_min = rolled.min().reset_index().astype(np.float16)
    lag_std = rolled.std().reset_index().astype(np.float16)
    for col in cols:
        weather_df[f'{col}_mean_lag{window}'] = lag_mean[col]
        weather_df[f'{col}_max_lag{window}'] = lag_max[col]
        weather_df[f'{col}_min_lag{window}'] = lag_min[col]
        weather_df[f'{col}_std_lag{window}'] = lag_std[col]
代码语言:javascript复制
add_lag_feature(weather_train_df, window=3)
add_lag_feature(weather_train_df, window=72)

现在,加入滞后,我们将对primary_use列进行分类,以减少合并时的内存:

代码语言:javascript复制
primary_use_list = building_meta_df['primary_use'].unique()
primary_use_dict = {key: value for value, key in enumerate(primary_use_list)} 
print('primary_use_dict: ', primary_use_dict)
building_meta_df['primary_use'] = building_meta_df['primary_use'].map(primary_use_dict)

gc.collect()

使用 Optuna 进行模型训练

在本节中,我们将学习如何使用 Optuna。但首先,让我们将列分为分类值和数字特征。

代码语言:javascript复制
category_columns = ['building_id', 'site_id', 'primary_use']
weather_columns = [
    'air_temperature', 'cloud_coverage',
    'dew_temperature', 'precip_depth_1_hr', 'sea_level_pressure',
    'wind_direction', 'wind_speed', 'air_temperature_mean_lag72',
    'air_temperature_max_lag72', 'air_temperature_min_lag72',
    'air_temperature_std_lag72', 'cloud_coverage_mean_lag72',
    'dew_temperature_mean_lag72', 'precip_depth_1_hr_mean_lag72',
    'sea_level_pressure_mean_lag72', 'wind_direction_mean_lag72',
    'wind_speed_mean_lag72', 'air_temperature_mean_lag3',
    'air_temperature_max_lag3',
    'air_temperature_min_lag3', 'cloud_coverage_mean_lag3',
    'dew_temperature_mean_lag3',
    'precip_depth_1_hr_mean_lag3', 'sea_level_pressure_mean_lag3',
    'wind_direction_mean_lag3', 'wind_speed_mean_lag3']
feature_columns = ['square_feet', 'year_built']   [
    'hour', 'weekend', 'dayofweek', # 'month'
    'building_median']   weather_cols
代码语言:javascript复制
def create_X_y(train_df, target_meter):
    target_train_df = train_df[train_df['meter'] == target_meter]
    target_train_df = target_train_df.merge(building_meta_df, on='building_id', how='left')
    target_train_df = target_train_df.merge(weather_train_df, on=['site_id', 'timestamp'], how='left')
    X_train = target_train_df[feature_cols   category_cols]
    y_train = target_train_df['meter_reading_log1p'].values

    del target_train_df
    return X_train, y_train

定义 “目标”函数

使用 Optuna 完成的每个超参数调整项目都从一个目标函数开始,我们必须在其中决定优化所依据的指标。

一个试验对象是一个目标函数的输入,并返回一个分数。

代码语言:javascript复制
def objective(trial, ...):
    # calculate score...
    return score

现在我们将为电表训练 LightGBM 模型,获得最佳验证分数并将该分数作为最终分数返回。让我们开始!

代码语言:javascript复制
import optuna
from optuna import Trial
代码语言:javascript复制
debug = False

train_df_original = train_df
# Only using 10000 data,,, for fast computation for debugging.
train_df = train_df.sample(10000)
代码语言:javascript复制
def objective(trial: Trial, fast_check=True, target_meter=0, return_info=False):
    folds = 5
    seed = 666
    shuffle = False
    kf = KFold(n_splits=folds, shuffle=shuffle, random_state=seed)
    X_train, y_train = create_X_y(train_df, target_meter=target_meter)
    y_valid_pred_total = np.zeros(X_train.shape[0])
    gc.collect()
    print('target_meter', target_meter, X_train.shape)
    L = [X_train.columns.get_loc(cat_col) for cat_col in category_cols]
    categorical_features = L
    print('cat_features', categorical_features)
    models = []
    valid_score = 0
    for train_idx, valid_idx in kf.split(X_train, y_train):
        train_data = X_train.iloc[train_idx,:], y_train[train_idx]
        valid_data = X_train.iloc[valid_idx,:], y_train[valid_idx]
        print('train', len(train_idx), 'valid', len(valid_idx))
        a, b, c = fit_lgbm(trial, train_data, valid_data, cat_features=category_cols,
                                            num_rounds=1000)
        model, y_pred_valid, log = a, b, c
        y_valid_pred_total[valid_idx] = y_pred_valid
        models.append(model)
        gc.collect()
        valid_score  = log["valid/l2"]
        if fast_check:
            break
    valid_score /= len(models)
    if return_info:
        return valid_score, models, y_pred_valid, y_train
    else:
        return valid_score

fit_lgbm函数具有核心训练码,并定义了超参数。

接下来,我们将熟悉“trial” 模块的内部工作原理。

使用“trial”模块动态定义超参数

这是使用 Optuna 代码与传统定义并运行代码之间的比较:

这是Define-by-run 的优势。这使得用户更容易编写直观的代码来获取超参数,而不是预先定义整个搜索空间。

你可以使用这些方法来获取超参数:

代码语言:javascript复制
# Categorical parameter
optimizer = trial.suggest_categorical('optimizer', ['MomentumSGD', 'Adam'])

# Int parameter
num_layers = trial.suggest_int('num_layers', 1, 3)

# Uniform parameter
dropout_rate = trial.suggest_uniform('dropout_rate', 0.0, 1.0)

# Loguniform parameter
learning_rate = trial.suggest_loguniform('learning_rate', 1e-5, 1e-2)

# Discrete-uniform parameter
drop_path_rate = trial.suggest_discrete_uniform('drop_path_rate', 0.0, 1.0, 0.1)

现在让我们创建一个简单的 Lightgbm 模型:

代码语言:javascript复制
def fit_lgbm(trial, train, val, devices=(-1,), seed=None, cat_features=None, num_rounds=1500):
    """Train Light GBM model"""
    X_train, y_train = train
    X_valid, y_valid = val
    metric = 'l2'
    params = {
        'num_leaves': trial.suggest_int('num_leaves', 2, 256),
        'objective': 'regression',
        'max_depth': -1,
        'learning_rate': 0.1,
        "boosting": "gbdt",
        'lambda_l1': trial.suggest_loguniform('lambda_l1', 1e-8, 10.0),
        'lambda_l2': trial.suggest_loguniform('lambda_l2', 1e-8, 10.0),
        "bagging_freq": 5,
        "bagging_fraction": trial.suggest_uniform('bagging_fraction', 0.1, 1.0),
        "feature_fraction": trial.suggest_uniform('feature_fraction', 0.4, 1.0),
        "metric": metric,
        "verbosity": -1,
    }
    device = devices[0]
    if device == -1:
        # use cpu
        pass
    else:
        # use gpu
        print(f'using gpu device_id {device}...')
        params.update({'device': 'gpu', 'gpu_device_id': device})

    params['seed'] = seed

    early_stop = 20
    verbose_eval = 20

    d_train = lgb.Dataset(X_train, label=y_train, categorical_feature=cat_features)
    d_valid = lgb.Dataset(X_valid, label=y_valid, categorical_feature=cat_features)
    watchlist = [d_train, d_valid]

    print('training LGB:')
    model = lgb.train(params,
                      train_set=d_train,
                      num_boost_round=num_rounds,
                      valid_sets=watchlist,
                      verbose_eval=verbose_eval,
                      early_stopping_rounds=early_stop)

    # predictions
    y_pred_valid = model.predict(X_valid, num_iteration=model.best_iteration)

    print('best_score', model.best_score)
    log = {'train/l2': model.best_score['training']['l2'],
           'valid/l2': model.best_score['valid_1']['l2']}
    return model, y_pred_valid, log

现在是优化的时候了!

做“学习”,优化!

在使用“trial”模块定义目标函数并找到超参数后,我们都准备好进行调整了。

只需 2 行代码,所有的超参数调优就可以完成了!

代码语言:javascript复制
study = optuna.create_study()
study.optimize(objective, n_trials=10)

由于 n_trials 的值为 10,因此输出相当大。因此,在下面的屏幕截图中,我将只包括最后一次试验:

就是这样!!你的超参数已调整!!

“trial”与“Study”:总结

Trial通过指定超参数的一次试验来管理模型训练、评估和获得分数的所有单次执行。

Study管理并记录所有已执行的试验。该记录帮助我们了解最佳超参数并建议下一个要搜索的参数空间。

剪枝以加快搜索

optuna 中,有一种先进而有用的技术是对那些没有希望的试验进行剪枝。

对于那些不熟悉什么是剪枝的人来说,它是一种在 ML 搜索算法中压缩数据的技术,它通过消除冗余和不重要的数据来对实例进行分类来减小决策树的大小。

因此剪枝可以提高最终分类器的复杂性并防止过度拟合。Optuna 中提供了对多个流行 ML 框架的集成,用户可以使用它在超参数训练期间尝试剪枝。例子:

  • XGBoost:optuna.integration.XGBoostPruningCallback
  • LightGBM:optuna.integration.LightGBMPruningCallback
  • Chainer:optuna.integration.ChainerPruningExtension
  • keras:optuna.integration.KerasPruningCallback
  • TensorFlow optuna.integration.TensorFlowPruningHook
  • keras optuna.integration.TFKerasPruningCallback
  • MXNet optuna.integration.MXNetPruningCallback

你可以在此处详细了解这些集成:https://optuna.readthedocs.io/en/stable/reference/integration.html

这是一个使用剪枝创建目标函数的简单示例:

代码语言:javascript复制
def objective_with_prune(trial: Trial, fast_check=True, target_meter=0):
    folds = 5
    seed = 666
    shuffle = False
    kf = KFold(n_splits=folds, shuffle=shuffle, random_state=seed)

    X_train, y_train = create_X_y(train_df, target_meter=target_meter)
    y_valid_pred_total = np.zeros(X_train.shape[0])
    gc.collect()
    print('target_meter', target_meter, X_train.shape)
    x = [X_train.columns.get_loc(cat_col) for cat_col in category_cols]
    cat_features = x
    print('cat_features', cat_features)

    models0 = []
    valid_score = 0
    for train_idx, valid_idx in kf.split(X_train, y_train):
        train_data = X_train.iloc[train_idx,:], y_train[train_idx]
        valid_data = X_train.iloc[valid_idx,:], y_train[valid_idx]

        print('train', len(train_idx), 'valid', len(valid_idx))
        model, y_pred_valid, log = fit_lgbm_with_pruning(trial, train_data, valid_data, cat_features=category_cols,
                                                         num_rounds=1000)
        y_valid_pred_total[valid_idx] = y_pred_valid
        models0.append(model)
        gc.collect()
        valid_score  = log["valid/l2"]
        if fast_check:
            break
    valid_score /= len(models0)
    return valid_score

使用 Optuna 进行可视化

Optuna 为我们提供了可视化训练和研究历史的选项,以确定具有最佳性能的超参数。最棒的是,所有这些可视化只需要 1 行代码!

优化历史
代码语言:javascript复制
optuna.visualization.plot_optimization_history(study)
剪枝历史
代码语言:javascript复制
optuna.visualization.plot_intermediate_values(study)

所有不同的颜色都显示了每次试验的损失曲线。

切片图
代码语言:javascript复制
optuna.visualization.plot_slice(study)
等高线图

以目标值作为轮廓绘制参数对。

代码语言:javascript复制
optuna.visualization.plot_contour(study)
平行坐标图
代码语言:javascript复制
optuna.visualization.plot_parallel_coordinate(study)

尾注

本文只是熟悉Optuna的开始,涵盖了有关如何调整 ML 模型的超参数的大部分基础知识。我们学习了 Optuna 库中使用的术语,如trial和Study。我们还学习了如何定义使用 Optuna 调整所必需的目标函数。

接下来,我们讨论并研究了一些高级概念,例如剪枝,这也是 Optuna 的最佳功能之一。然后我们学习了如何使用 Optuna 的可视化并使用它们来评估和选择最佳超参数。

参考

数据集 – https://www.kaggle.com/c/ashrae-energy-prediction/overview

0 人点赞