GBDT+LR演算法解析及Python實現
1. GBDT + LR 是什麼
2. GBDT + LR 用在哪
GBDT+LR 使用最廣泛的場景是CTR點選率預估,即預測當給使用者推送的廣告會不會被使用者點選。
一個典型的CTR流程如下圖所示:
如上圖,主要包括兩大部分:離線部分、線上部分,其中離線部分目標主要是訓練出可用模型,而線上部分則考慮模型上線後,效能可能隨時間而出現下降,弱出現這種情況,可選擇使用Online-Learning來線上更新模型:
2.1 離線部分
- 資料收集:主要收集和業務相關的資料,通常會有專門的同事在app位置進行埋點,拿到業務資料
- 預處理:對埋點拿到的業務資料進行去髒去重;
- 構造資料集:經過預處理的業務資料,構造資料集,在切分訓練、測試、驗證集時應該合理根據業務邏輯來進行切分;
- 特徵工程:對原始資料進行基本的特徵處理,包括去除相關性大的特徵,離散變數one-hot,連續特徵離散化等等;
- 模型選擇:選擇合理的機器學習模型來完成相應工作,原則是先從簡入深,先找到baseline,然後逐步優化;
- 超參選擇:利用gridsearch、randomsearch或者hyperopt來進行超參選擇,選擇在離線資料集中效能最好的超參組合;
- 線上A/B Test:選擇優化過後的模型和原先模型(如baseline)進行A/B Test,若效能有提升則替換原先模型;
2.2 線上部分
- Cache & Logic:設定簡單過濾規則,過濾異常資料;
- 模型更新:當Cache & Logic 收集到合適大小資料時,對模型進行pretrain+finetuning,若在測試集上比原始模型效能高,則更新model server的模型引數;
- Model Server:接受資料請求,返回預測結果;
3. GBDT + LR 的結構
正如它的名字一樣,GBDT+LR 由兩部分組成,其中GBDT用來對訓練集提取特徵作為新的訓練輸入資料,LR作為新訓練輸入資料的分類器。
具體來講,有以下幾個步驟:
3.1 GBDT首先對原始訓練資料做訓練,得到一個二分類器,當然這裡也需要利用網格搜尋尋找最佳引數組合。
3.2 與通常做法不同的是,當GBDT訓練好做預測的時候,輸出的並不是最終的二分類概率值,而是要把模型中的每棵樹計算得到的預測概率值所屬的葉子結點位置記為1,這樣,就構造出了新的訓練資料。
舉個例子,下圖是一個GBDT+LR 模型結構,設GBDT有兩個弱分類器,分別以藍色和紅色部分表示,其中藍色弱分類器的葉子結點個數為3,紅色弱分類器的葉子結點個數為2,並且藍色弱分類器中對0-1 的預測結果落到了第二個葉子結點上,紅色弱分類器中對0-1 的預測結果也落到了第二個葉子結點上。那麼我們就記藍色弱分類器的預測結果為[0 1 0],紅色弱分類器的預測結果為[0 1],綜合起來看,GBDT的輸出為這些弱分類器的組合[0 1 0 0 1] ,或者一個稀疏向量(陣列)。
這裡的思想與One-hot獨熱編碼類似,事實上,在用GBDT構造新的訓練資料時,採用的也正是One-hot方法。並且由於每一弱分類器有且只有一個葉子節點輸出預測結果,所以在一個具有n個弱分類器、共計m個葉子結點的GBDT中,每一條訓練資料都會被轉換為1*m維稀疏向量,且有n個元素為1,其餘m-n 個元素全為0。
3.3 新的訓練資料構造完成後,下一步就要與原始的訓練資料中的label(輸出)資料一併輸入到Logistic Regression分類器中進行最終分類器的訓練。思考一下,在對原始資料進行GBDT提取為新的資料這一操作之後,資料不僅變得稀疏,而且由於弱分類器個數,葉子結點個數的影響,可能會導致新的訓練資料特徵維度過大的問題,因此,在Logistic Regression這一層中,可使用正則化來減少過擬合的風險,在Facebook的論文中採用的是L1正則化。
4. RF + LR ? Xgb + LR?
有心的同學應該會思考一個問題,既然GBDT可以做新訓練樣本的構造,那麼其它基於樹的模型,例如Random Forest以及Xgboost等是並不是也可以按類似的方式來構造新的訓練樣本呢?沒錯,所有這些基於樹的模型都可以和Logistic Regression分類器組合。至於效果孰優孰劣,我個人覺得效果都還可以,但是之間沒有可比性,因為超引數的不同會對模型評估產生較大的影響。下圖是RF+LR、GBT+LR、Xgb、LR、Xgb+LR 模型效果對比圖(來自。。。。。),然而這隻能做個參考,因為模型超引數的值的選擇這一前提條件都各不相同,但仍不妨礙我們能得到一些結論:GBDT+LR的效果要比 RF+LR的效果好。
5. GBDT + LR 程式碼分析
在網上找到了兩個版本的GBDT+LR的程式碼實現,通過閱讀分析,認為裡面有一些細節還是值得好好學習一番的,所以接下來這一小節會針對程式碼實現部分做一些總結。
首先,目前我所瞭解到的GBDT的實現方式有兩種:一是利用Scikit-learn中的ensemble.GradientBoostingClassifier ,二是利用lgb裡的params={ 'boosting_type': 'gbdt' }引數。接下里分別對這兩種實現方式進行分析。
5.1 Scikit-learn的實現:
from sklearn.preprocessing import OneHotEncoder from sklearn.ensemble import GradientBoostingClassifier gbm1 = GradientBoostingClassifier(n_estimators=50, random_state=10, subsample=0.6, max_depth=7, min_samples_split=900) gbm1.fit(X_train, Y_train) train_new_feature = gbm1.apply(X_train) train_new_feature = train_new_feature.reshape(-1, 50) enc = OneHotEncoder() enc.fit(train_new_feature) # # 每一個屬性的最大取值數目 # print('每一個特徵的最大取值數目:', enc.n_values_) # print('所有特徵的取值數目總和:', enc.n_values_.sum()) train_new_feature2 = np.array(enc.transform(train_new_feature).toarray())
劃重點:
5.1.1 model.apply(X_train)的用法
model.apply(X_train)返回訓練資料X_train在訓練好的模型裡每棵樹中所處的葉子節點的位置(索引)
5.1.2 sklearn.preprocessing 中OneHotEncoder的使用
雖然我更喜歡pandas中的 get_dummies(),但是 get_dummies(data)中的data 必須為1-dimensional,即每次只能對一列資料進行One-hot 。
而OneHotEncoder() 首先fit() 過待轉換的資料後,再次transform() 待轉換的資料,就可實現對這些資料的所有特徵進行One-hot 操作。
由於transform() 後的資料格式不能直接使用,所以最後需要使用.toarray() 將其轉換為我們能夠使用的陣列結構。
enc.transform(train_new_feature).toarray()
5.1.3 sklearn中的GBDT 能夠設定樹的個數,每棵樹最大葉子節點個數等超引數,但不能指定每顆樹的葉子節點數。
5.2 lightgbm 的實現
params = { 'task': 'train', 'boosting_type': 'gbdt', 'objective': 'binary', 'metric': {'binary_logloss'}, 'num_leaves': 64, 'num_trees': 100, 'learning_rate': 0.01, 'feature_fraction': 0.9, 'bagging_fraction': 0.8, 'bagging_freq': 5, 'verbose': 0 } # number of leaves,will be used in feature transformation num_leaf = 64 print('Start training...') # train gbm = lgb.train(params=params, train_set=lgb_train, valid_sets=lgb_train, ) print('Start predicting...') # y_pred分別落在100棵樹上的哪個節點上 y_pred = gbm.predict(x_train, pred_leaf=True) y_pred_prob = gbm.predict(x_train) result = [] threshold = 0.5 for pred in y_pred_prob: result.append(1 if pred > threshold else 0) print('result:', result) print('Writing transformed training data') transformed_training_matrix = np.zeros([len(y_pred), len(y_pred[1]) * num_leaf], dtype=np.int64) # N * num_tress * num_leafs for i in range(0, len(y_pred)): # temp表示在每棵樹上預測的值所在節點的序號(0,64,128,...,6436 為100棵樹的序號,中間的值為對應樹的節點序號) temp = np.arange(len(y_pred[0])) * num_leaf + np.array(y_pred[i]) # 構造one-hot 訓練資料集 transformed_training_matrix[i][temp] += 1 y_pred = gbm.predict(x_test, pred_leaf=True) print('Writing transformed testing data') transformed_testing_matrix = np.zeros([len(y_pred), len(y_pred[1]) * num_leaf], dtype=np.int64) for i in range(0, len(y_pred)): temp = np.arange(len(y_pred[0])) * num_leaf + np.array(y_pred[i]) # 構造one-hot 測試資料集 transformed_testing_matrix[i][temp] += 1
劃重點:
5.2.1 params 字典裡超引數的設定
因為是二分類問題,所以設定 {'boosting_type': 'gbdt','objective': 'binary','metric': {'binary_logloss'}},然後設定樹的個數及每棵樹的葉子結點個數{'num_leaves': 64,'num_trees': 100}
5.2.2 model.predict(x_train, pred_leaf=True)
使用
model.predict(x_train, pred_leaf=True)
返回訓練資料在訓練好的模型裡預測結果所在的每棵樹中葉子節點的位置(索引),形式為7999*100的二維陣列。
5.2.3 構造Ont-hot陣列作為新的訓練資料
這裡並沒有使用sklearn中的OneHotEncoder(),也沒有使用pandas中的get_dummies(),而是手工建立一個One-hot陣列。(當然也可以像5.1.2 那樣操作)
- 首先,建立一個二維零陣列用於存放one-hot的元素;
- 然後,獲取第2步得到的二維數組裡每個葉子節點在整個GBDT模型裡的索引號,因為一共有100棵樹,每棵樹有64個葉子節點,所以索引範圍是0~6400;(這裡有一個技巧,通過把每棵樹的起點索引組成一個列表,再加上由落在每棵樹葉子節點的索引組成的列表,就得到了往二維零數組裡插入元素的索引資訊)
- 最後,
temp = np.arange(len(y_pred[0])) * num_leaf + np.array(y_pred[i])
5.2.4 對二維陣列填充資訊,採用"+=" 的方法
# 構造one-hot 訓練資料集
transformed_training_matrix[i][temp] += 1
6. GBDT + LR 模型提升
現在,我們思考這樣一個問題,Logistic Regression是一個線性分類器,也就是說會忽略掉特徵與特徵之間的關聯資訊,那麼是否可以採用構建新的交叉特徵這一特徵組合方式從而提高模型的效果?
其次,我們已經在2.3小節中瞭解到GBDT很有可能構造出的新訓練資料是高維的稀疏矩陣,而Logistic Regression使用高維稀疏矩陣進行訓練,會直接導致計算量過大,特徵權值更新緩慢的問題。
針對上面可能出現的問題,可以翻看我之前的文章:FM演算法解析及Python實現 ,使用FM演算法代替LR,這樣就解決了Logistic Regression的模型表達效果及高維稀疏矩陣的訓練開銷較大的問題。然而,這樣就意味著可以高枕無憂了嗎?當然不是,因為採用FM對本來已經是高維稀疏矩陣做完特徵交叉今後,新的特徵維度會更加多,並且由於元素非0即1,新的特徵資料可能也會更加稀疏,那麼怎麼辦?
所以,我們需要再次回到GBDT構造新訓練資料這裡。當GBDT構造完新的訓練樣本後,我們要做的是對每一個特徵做與輸出之間的特徵重要度評估並篩選出重要程度較高的部分特徵,這樣,GBDT構造的高維的稀疏矩陣就會減少一部分特徵,也就是說得到的稀疏矩陣不再那麼高維了。之後,對這些篩選後得到的重要度較高的特徵再做FM演算法構造交叉項,進而引入非線性特徵,繼而完成最終分類器的訓練資料的構造及模型的訓練。