Hyperband是機器學習中一個相當實用的超參數調優算法,核心思路是用逐次減半來分配計算資源。説白了就是讓一堆配置先跑幾輪,表現差的直接踢掉,剩下的繼續訓練更多輪次。
這個方法的巧妙之處在於平衡了探索和利用。你既要試足夠多的配置組合(探索),又要給有潛力的配置足夠的訓練時間(利用)。傳統方法要麼試得不夠多,要麼每個都試要很久浪費時間。
本文我們來通過調優一個lstm來展示Hyperband的工作機制,並和貝葉斯優化、隨機搜索、遺傳算法做了對比。結果挺有意思的。
Hyperband的工作原理
Hyperband結合了多臂老虎機策略和逐次減半算法(SHA)。多臂老虎機問題其實就是在探索新選擇和利用已知好選擇之間做權衡。
SHA則是具體的資源分配策略如下:給隨機採樣的配置分配固定預算(比如訓練輪數),每輪評估後踢掉表現最差的,把剩餘預算分給剩下的。Hyperband更進一步,用不同的初始預算跑多次SHA,這樣既能快速篩選,又不會遺漏那些需要長時間訓練才能顯現優勢的配置。
相比其他調優方法,Hyperband在處理大搜索空間時速度和效率優勢明顯。
下圖展示了Hyperband如何逐步給獲勝配置(#4)分配更多資源,雖然最開始的預算分配是隨機的:
Hyperband工作流程
整個過程從Bracket 1開始,創建很多超參數配置,每個分配少量預算。然後逐步減少配置數量,同時增加倖存者的預算。到了Bracket 2,只給Bracket 1的倖存者(配置#1和#4)更多預算。最終在Bracket 3把全部預算給最優配置#4。
這種做法能有效探索廣泛配置範圍,同時快速淘汰表現差的,在探索和利用間找到平衡。
算法的四個關鍵步驟
定義預算和減半因子
首先要定義最大資源預算R(單個模型能訓練的總輪數)和減半因子η(決定淘汰激進程度的預設因子)。減半因子常用2、3或4。每步都用η來減少配置數量,用η來增加倖存者預算。
計算Bracket數量
算法跑一系列bracket,每個bracket是用不同起始預算的完整SHA運行。最大bracket索引s_max的計算公式是:
其中η是減半因子,R是最大資源預算。算法從s_max個bracket迭代到零。
運行逐次減半
對每個bracket s,Hyperband確定起始的超參數配置數量n_s。有意思的是,初始預算小的bracket配置數量大,初始預算大的bracket配置數量小。
配置數量的數學定義:
其中n_s是當前bracket要評估的配置數量,R是最大資源預算,η是減半因子,s_max是最大bracket數,s是當前bracket索引。
每個bracket的初始預算r_s計算公式:
Hyperband先採樣n_s個隨機超參數配置,用初始預算r_s輪訓練每個。然後根據性能選出前n_s/η個配置。這些"倖存者"繼續訓練更多輪,總共r_s⋅η輪。
這個減半候選數量、增加預算的過程持續進行,直到bracket中只剩一個配置或達到最大預算。
選擇最終配置
所有bracket跑完後,選擇表現最好的配置作為最終結果。Hyperband的效率就來自快速丟棄表現差的配置,把資源用來訓練更有前景的配置。
演示:支持向量分類器
我們用SVC來演示具體工作過程,調優正則化參數C和核係數gamma。
搜索空間:C取[0.1, 1, 10, 100],gamma取['scale', 'auto', 0.1, 1, 10]
設置最大預算R = 81,減半因子η = 3。
最大bracket索引計算得出:
所以Hyperband會為s = 4, 3, 2, 1, 0運行bracket。每個bracket有不同的起始配置數量和初始預算:
- Bracket 1 (s = 4):1個配置,初始預算9
- Bracket 2 (s = 3):3個配置,初始預算3
- Bracket 3 (s = 2):9個配置,初始預算1
- Bracket 4 (s = 1):27個配置,初始預算1/3
- Bracket 5 (s = 0):81個配置,初始預算1/9
以Bracket 3為例説明SHA過程:
初始運行時,Hyperband隨機採樣9個超參數配置,用1輪小預算訓練每個,記錄性能,保留前3個最佳配置丟棄其餘6個。
第二輪,3個倖存者用3輪更大預算訓練,保留前1個最佳配置。
最終輪,剩餘配置用9輪最終預算訓練,記錄最終性能。
總預算R = 81就這樣分佈在各個bracket中,高效找到最佳配置。
實際用例:LSTM股價預測實驗
我們用更復雜的LSTM網絡來驗證Hyperband效果,目標是預測NV股票收盤價。
從Alpha Vantage API獲取歷史日線數據,加載到Pandas DataFrame並預處理。訓練集用於模型訓練和驗證,測試集單獨保存避免數據泄漏。
import torch
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
# create target and input vals
target_col = 'close'
y = df.copy()[target_col].shift(-1) # avoid data leakage
y = y.iloc[:-1] # drop the last row (as y = nan)
input_cols = [col for col in df.columns if col not in [target_col, 'dt']] # drop dt as year, month, date can capture sequence
X = df.copy()[input_cols]
X = X.iloc[:-1] # drop the last row
# create trainning and test dataset (trianing will split into train and val for wfv)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=800, shuffle=False, random_state=42
)
# preprocess
cat_cols = ['year', 'month', 'date']
num_cols = list(set(input_cols) - set(cat_cols))
preprocessor = ColumnTransformer(
transformers=[
('num', StandardScaler(), num_cols),
('cat', OneHotEncoder(handle_unknown='ignore'), cat_cols)
]
)
X_train = preprocessor.fit_transform(X_train)
X_test = preprocessor.transform(X_test)
# convert the dense numpy arrays to pytorch tensors
X_train = torch.from_numpy(X_train.toarray()).float()
y_train = torch.from_numpy(y_train.values).float().unsqueeze(1)
X_test = torch.from_numpy(X_test.toarray()).float()
y_test = torch.from_numpy(y_test.values).float().unsqueeze(1)
原始數據包含6,501個NV歷史股價記錄樣本:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 6501 entries, 0 to 6500
Data columns (total 15 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 dt 6501 non-null datetime64[ns]
1 open 6501 non-null float32
2 high 6501 non-null float32
3 low 6501 non-null float32
4 close 6501 non-null float32
5 volume 6501 non-null int32
6 ave_open 6501 non-null float32
7 ave_high 6501 non-null float32
8 ave_low 6501 non-null float32
9 ave_close 6501 non-null float32
10 total_volume 6501 non-null int32
11 30_day_ma_close 6501 non-null float32
12 year 6501 non-null object
13 month 6501 non-null object
14 date 6501 non-null object
dtypes: datetime64[ns](1), float32(9), int32(2), object(3)
memory usage: 482.6+ KB
基於多對一架構在PyTorch上定義LSTMModel類:
import torch
import torch.nn as nn
class LSTMModel(nn.Module):
def __init__(self, input_dim, hidden_dim, layer_dim, output_dim, dropout):
super(LSTMModel, self).__init__()
self.hidden_dim = hidden_dim
self.layer_dim = layer_dim
self.dropout = dropout
self.lstm = nn.LSTM(
input_dim, hidden_dim, layer_dim, batch_first=True, dropout=dropout
)
self.fc = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
h0 = torch.zeros(self.layer_dim, x.size(0), self.hidden_dim).to(x.device)
c0 = torch.zeros(self.layer_dim, x.size(0), self.hidden_dim).to(x.device)
o_t, _ = self.lstm(x, (h0.detach(), c0.detach()))
o_final = self.fc(o_t[:, -1, :])
return o_final
Hyperband在更廣搜索空間中表現更好,定義以下搜索空間:
import random
def search_space():
return {
'lr': 10**random.uniform(-6, -1),
'hidden_dim': random.choice([16, 32, 64, 128, 256]),
'layer_dim': random.choice([1, 2, 3, 4, 5]),
'dropout': random.uniform(0.1, 0.6),
'batch_size': random.choice([16, 32, 64, 128, 256])
}
為時間序列數據定義滑動窗口驗證的train_and_val_wfv函數:
def train_and_val_wfv(hyperparams, budget, X, y, train_window, val_window):
total_val_loss = 0
all_loss_histories = []
num_folds = (X.size(0) - train_window - val_window) // val_window + 1
for i in range(num_folds):
train_start = i * val_window
train_end = train_start + train_window
val_start = train_end
val_end = val_start + val_window
# ensure not to go past the end of the dataset
if val_end > X.size(0):
break
# create folds
X_train_fold = X[train_start:train_end]
y_train_fold = y[train_start:train_end]
X_val_fold = X[val_start:val_end]
y_val_fold = y[val_start:val_end]
# train and validate on the current fold
fold_val_loss, fold_loss_history = train_and_val(
hyperparams=hyperparams,
budget=budget,
X_train=X_train_fold,
y_train=y_train_fold,
X_val=X_val_fold,
y_val=y_val_fold
)
total_val_loss += fold_val_loss
all_loss_histories.append(fold_loss_history)
# compute ave. loss
avg_val_loss = total_val_loss / num_folds
return avg_val_loss, all_loss_histories
run_hyperband函數接受搜索空間函數、驗證函數、總預算R和減半因子eta四個參數。代碼中R設為100,eta為3,滑動窗口交叉驗證的訓練和驗證窗口分別為3,000和500。
def run_hyperband(search_space_fn, val_fn, R, eta):
s_max = int(log(R, eta))
overall_best_config = None
overall_best_loss = float('inf')
all_loss_histories = []
# outer loop: iterate through all brackets
for s in range(s_max, -1, -1):
n = int(R / eta**s)
r = int(R / n)
main_logger.info(f'... running bracket s={s}: {n} configurations, initial budget={r} ...')
# geerate n random hyperparameter configurations
configs = [get_hparams_fn() for _ in range(n)]
# successive halving
for i in range(s + 1):
budget = r * (eta**i)
main_logger.info(f'... training {len(configs)} configurations for budget {budget} epochs ...')
evaluated_results = []
for config in configs:
loss, loss_history = train_val_fn(config, budget)
evaluated_results.append((config, loss, loss_history))
# record loss histories for plotting
all_loss_histories.append((evaluated_results, budget))
# sort and select top configurations
evaluated_results.sort(key=lambda x: x[1])
# keep track of the best configuration found so far
if evaluated_results and evaluated_results[0][1] < overall_best_loss:
overall_best_loss = evaluated_results[0][1]
overall_best_config = evaluated_results[0][0]
num_to_keep = floor(len(configs) / eta)
configs = [result[0] for result in evaluated_results[:num_to_keep]]
if not configs:
break
return overall_best_config, overall_best_loss, all_loss_histories, s_max
# define budget, halving factor
R = 100
eta = 3
# wfv setting
train_window = 3000
val_window = 500
# run sha
best_config, best_loss, all_loss_histories, s_max = run_hyperband(
search_space_fn=search_space,
val_fn=lambda h, b: train_and_val_wfv(h, b, X_train, y_train, train_window=train_window, val_window=val_window),
R=R,
eta=eta
)
實驗結果
最佳超參數配置:
- lr: 0.0001614172022855225
- hidden_dim: 128
- layer_dim: 3
- dropout: 0.5825758700895215
- batch_size: 16
最佳驗證損失(MSE):0.0519
下圖的實線跟蹤訓練過程中的平均驗證損失,垂直虛線表示Hyperband算法修剪表現差模型的時點:
早期停止的線條(主要是紫色)是表現差的配置,因損失過高被修剪掉。少數持續到100輪的線條(主要是青綠色和藍色)是最成功的配置,損失開始時快速下降然後穩定在很低值,説明性能優異。這就是Hyperband的高效之處:快速淘汰差配置,不用浪費時間長期訓練它們。
與其他調優方法的對比
為了客觀比較,這裏用相同搜索空間、模型和訓練驗證窗口,對貝葉斯優化、隨機搜索、遺傳算法各跑了20次試驗。
貝葉斯優化
貝葉斯優化用概率模型(如高斯過程)建模驗證誤差,選擇下一個最優超參數配置評估。
最佳配置:lr 0.00016768631941614767, hidden_dim 256, layer_dim 3, dropout 0.3932769195043036, batch_size 64
最佳驗證損失(MSE):0.0428
貝葉斯優化損失歷史
隨機搜索
隨機搜索從搜索空間隨機採樣固定數量配置,不利用過去試驗結果。
最佳配置:lr 0.0004941205117774383, hidden_dim 128, layer_dim 2, dropout 0.3398469430820351, batch_size 64
最佳驗證損失(MSE):0.03620
隨機搜索損失歷史
遺傳算法
受生物進化啓發,遺傳算法維護超參數配置羣體,用變異和交叉概念生成新的潛在更優配置。
最佳配置:lr 0.006441170552290832, hidden_dim 128, layer_dim 3, dropout 0.2052570911345997, batch_size 128
最佳驗證損失(MSE):0.1321
遺傳算法損失歷史
結果分析
有意思的是,隨機搜索(0.0362)和貝葉斯優化(0.0428)在最終驗證損失上略優於Hyperband(0.0519)。這説明效率和找到全局最優間存在權衡。
Hyperband的效率來自早期積極修剪表現差配置,這樣能節省大量時間,但風險是可能意外淘汰"大器晚成"的配置,也就是那些需要長時間訓練才能顯現優勢的配置。
在這個案例中,隨機搜索和貝葉斯優化更成功。隨機搜索給每個模型完整訓練預算,讓高性能配置達到全部潛力。貝葉斯優化的智能搜索在找最佳超參數集方面也比Hyperband的早停方法更有效。
改進Hyperband性能的策略
想要改善Hyperband性能,可以調整其參數或與其他調優方法結合。
調整關鍵參數方面,設置大的R(總預算)能讓更多"大器晚成"模型證明價值,減少過早修剪好配置的機會。設置小的eta(減半因子)允許更温和的修剪過程,讓更多配置進入下一bracket(eta=3丟棄三個配置,eta=1只丟棄一個)。
而更有前景的是將Hyperband與貝葉斯優化結合。BOHB(Bayesian Optimization and HyperBand)是這樣的混合方法,用Hyperband的逐次減半作框架,但用貝葉斯優化的概率模型替換隨機採樣。BOHB用貝葉斯優化選擇最有前景的候選者輸入Hyperband的bracket中。
這種方法結合了兩者優點:Hyperband的快速結果加上貝葉斯優化的強最終性能。
總結
Hyperband是個挺實用的超參數優化算法,能有效平衡廣泛搜索空間的探索和有前景配置的利用。其快速修剪差配置的能力使其比傳統網格搜索和隨機搜索明顯更快更可擴展。
雖然貝葉斯優化等方法可能在樣本效率上更高,但Hyperband的簡單性和可並行性讓它成為很多機器學習任務的有力選擇,特別是訓練成本昂貴時。
還是那句話沒有銀彈。選擇哪種調優方法還得看具體場景:如果你有足夠計算資源且更在乎最終性能,貝葉斯優化可能更合適;如果你需要快速得到不錯結果,Hyperband是個好選擇;如果預算有限,隨機搜索也不失為簡單有效的baseline。
關鍵是理解每種方法的權衡,根據實際需求做選擇。
https://avoid.overfit.cn/post/08d708548fdd4c19b4d9ff7973e9e612
作者:Kuriko IWAI