手把手教寫出XGBoost實戰程式

香橙雲子發表於2017-11-29

簡單介紹:

這是一個真實的比賽。賽題來源是天池大資料的 "商場中精確定位使用者所在店鋪"。原資料有114萬條,計算起來非常困難。為了讓初學者有一個更好的學習體驗,也更加基礎,我將資料集縮小了之後放在這裡,密碼:ndfd。供大家下載。

在我的資料中,資料是這樣子的: train.csv

user_id 使用者的id time_stamp 時間戳
latitude 緯度 wifi_strong 1-10 十個wifi的訊號強度
longitude 經度 wifi_id 1-10 十個wifi的id
shop_id 商店的id con_sta 1-10 十個wifi連線狀態

test.csv

user_id 使用者的id time_stamp 時間戳
latitude 緯度 wifi_id 1-10 十個wifi的id
longitude 經度 con_sta 1-10 十個wifi連線狀態
row_id 行標 wifi_strong 1-10 十個wifi的訊號強度
shop_id 商店的id

這個題目的意思是,我們在商場中,由於不同層數和GPS精度限制,我們並不能僅根據經緯度準確知道某使用者具體在哪一家商店中。我們通過手機與附近10個wifi點的連線情況,來精準判斷出使用者在哪個商店中。方便公司根據使用者的位置投放相應店家的廣告。

開始實戰

準備實戰之前,當然要對整個XGBoost有一個基本瞭解,對這個模型不太熟悉的朋友,建議看我之前的文章《XGBoost》

實戰的流程一般是先將資料預處理,成為我們模型可處理的資料,包括丟失值處理,資料拆解,型別轉換等等。然後將其匯入模型執行,最後根據結果正確率調整引數,反覆調引數達到最優。

我們在機器學習實戰的時候一定要脫離一個思維慣性————一切都得我們思考周全才可以執行。這是一個很有趣的思維慣性,怎麼解釋呢?比如這道賽題,我也是學通訊出身的,看到十個wifi強度值,就想找這中間的關係,然後程式設計來求解人的確切位置。這本質上還是我們的思維停留在顯式程式設計的層面上,覺得程式只有寫清楚才可達到預定的目標。但其實大資料處理並不是這個原理。決策樹不管遇到什麼資料,無論是時間還是地理位置,都是一樣的按照一定規則生成樹,最後讓新資料按照這個樹走一遍得到預測的結果。也就是說我們不必花很多精力去考慮每個資料的具體物理意義,只要把他們放進模型裡面就可以了。(調參需要簡單地考慮物理意義來給各個資料以權重,這個以後再說)

分析一下資料

我們的資料的意義都在上面那張表裡面,我們有使用者的id、經緯度、時間戳、商店id、wifi資訊。我們簡單思考可以知道:

  1. user_id並沒有什麼實際意義,僅僅是一個代號而已
  2. shop_id是我們預測的目標,我們題目要求就是我們根據其他資訊來預測出使用者所在的shop_id,所以 shop_id 是我們的訓練目標
  3. 經緯度跟我們的位置有關,是有用的資訊
  4. wifi_id 讓我們知道是哪個路由器,這個不同的路由器位置不一樣,所以有用
  5. wifi_strong是訊號強度,跟我們離路由器距離有關,有用
  6. con_sta是連線狀態,也就是有沒有連上。本來我看資料中基本都是沒連上,以為沒有用。後來得高人提醒,說如果有人自動連上某商店wifi,不是可以說明他常來麼,這個對於判斷顧客也是有一點用的。
  7. 我們看test.csv總體差不多,就多了個row_id,我們輸出結果要注意對應上就可以

python庫準備

import pandas as pd
import xgboost as xgb
from sklearn import preprocessing
複製程式碼

我們這個XGBoost比較簡單,所以就使用了最必要的三個庫,pandas資料處理庫,xgboost庫,從大名鼎鼎的機器學習庫sklearn中匯入了preprocessing庫,這個pandas庫對資料的基本處理有很多封裝函式,用起來比較順手。想看例子的戳這個連結,我寫的pandas.Dataframe基本拆解資料的方法。

先進行資料預處理

我們得先匯入一份資料:

train = pd.read_csv(r'D:\XGBoost_learn\mall_location\train2.csv')
tests = pd.read_csv(r'D:\XGBoost_learn\mall_location\test_pre.csv')
複製程式碼

我們使用pandas裡面的read_csv函式直接讀取csv檔案。csv檔案全名是Comma-Separated Values檔案,就是每個資料之間都以逗號隔開,比較簡潔,也是各個資料比賽常用的格式。 我們需要注意的是路徑問題,windows下是\,linux下是/,這個有區別。並且我們寫的路徑經常會與庫裡的函式欄位重合,所以在路徑最前加一個r來禁止與庫裡匹配,重合報錯。r是raw的意思,生的,大家根據名字自行理解一下。

我們的time_stamp原來是一個str型別的資料,計算機是不會知道它是什麼東西的,只知道是一串字串。所以我們進行轉化成datetime處理:

train['time_stamp'] = pd.to_datetime(pd.Series(train['time_stamp']))
tests['time_stamp'] = pd.to_datetime(pd.Series(tests['time_stamp']))
複製程式碼

train和tests都要處理。這也體現了pandas的強大。接下來我們看time_stamp資料的樣子:2017/8/6 21:20,看資料集可知,是一個十分鐘為精確度(粒度)的資料,感覺這個資料包含太多資訊了呢,放一起很浪費(其實是容易過擬合,因為一個結點會被分的很細),我們就將其拆開吧:

train['Year'] = train['time_stamp'].apply(lambda x: x.year)
train['Month'] = train['time_stamp'].apply(lambda x: x.month)
train['weekday'] = train['time_stamp'].dt.dayofweek
train['time'] = train['time_stamp'].dt.time
tests['Year'] = tests['time_stamp'].apply(lambda x: x.year)
tests['Month'] = tests['time_stamp'].apply(lambda x: x.month)
tests['weekday'] = tests['time_stamp'].dt.dayofweek
tests['time'] = tests['time_stamp'].dt.time
複製程式碼

細心的朋友可能會發現,這裡採用了兩種寫法,一種是.apply(lambda x: x.year),這是什麼意思呢?這其實是採用了一種叫匿名函式的寫法.匿名函式就是我們相要寫一個函式,但並不想費神去思考這個函式該如何命名,這時候我們就需要一個匿名函式,來實現一些小功能。我們這裡採用的是.apply(lambda x: x.year)實際上是呼叫了apply函式,是加這一列的意思,加的列的內容就是x.year。我們要是覺得這樣寫不直觀的話,也可以這樣寫:

YearApply(x):
   return x.year
   
train['Year'] = train['time_stamp'].apply(YearApply)
複製程式碼

這兩種寫法意義都是一樣的。 在呼叫weekday和datetime的時候,我們使用的是numpy裡面的函式dt,用法如程式碼所示。其實這weekday也可以這樣寫: train['weekday'] = train['time_stamp'].apply(lambda x: x.weekday()),注意多了個括號,因為weekday需要計算一下才可以得到,所以還呼叫了一下內部的函式。 為什麼採用weekday呢,因為星期幾比幾號對於購物來說更加有特徵性。 接下來我們將這個time_stamp丟掉,因為已經有了year、month那些:

train = train.drop('time_stamp', axis=1)
tests = tests.drop('time_stamp', axis=1)
複製程式碼

再丟掉缺失值,或者補上缺失值。

train = train.dropna(axis=0)
tests = tests.fillna(method='pad')
複製程式碼

我們看到我對訓練集和測試集做了兩種不同方式的處理。訓練集資料比較多,而且缺失值比例比較少,於是就將所有缺失值使用dropna函式,tests檔案因為是測試集,不能丟失一個資訊,哪怕資料很多缺失值很少,所以我們用各種方法來補上,這裡採用前一個非nan值補充的方式(method=“pad”),當然也有其他方式,比如用這一列出現頻率最高的值來補充。

class DataFrameImputer(TransformerMixin):
   def fit(self, X, y=None):
       for c in X:
           if X[c].dtype == np.dtype('O'):
               fill_number = X[c].value_counts().index[0]
               self.fill = pd.Series(fill_number, index=X.columns)
           else:
               fill_number = X[c].median()
               self.fill = pd.Series(fill_number, index=X.columns)
       return self
       
       def transform(self, X, y=None):
           return X.fillna(self.fill)
       
train = DataFrameImputer().fit_transform(train)
複製程式碼

這一段程式碼有一點拗口,意思是對於X中的每一個c,如果X[c]的型別是object‘O’表示object)的話就將[X[c].value_counts().index[0]傳給空值,[X[c].value_counts().index[0]表示的是重複出現最多的那個數,如果不是object型別的話,就傳回去X[c].median(),也就是這些數的中位數。

在這裡我們可以使用print來輸出一下我們的資料是什麼樣子的。

print(train.info())
複製程式碼
<class 'pandas.core.frame.DataFrame' at 0x0000024527C50D08>
Int64Index: 467 entries, 0 to 499
Data columns (total 38 columns):
user_id          467 non-null object
shop_id          467 non-null object
longitude        467 non-null float64
latitude         467 non-null float64
wifi_id1         467 non-null object
wifi_strong1     467 non-null int64
con_sta1         467 non-null bool
wifi_id2         467 non-null object
wifi_strong2     467 non-null int64
con_sta2         467 non-null object
wifi_id3         467 non-null object
wifi_strong3     467 non-null float64
con_sta3         467 non-null object
wifi_id4         467 non-null object
wifi_strong4     467 non-null float64
con_sta4         467 non-null object
wifi_id5         467 non-null object
wifi_strong5     467 non-null float64
con_sta5         467 non-null object
wifi_id6         467 non-null object
wifi_strong6     467 non-null float64
con_sta6         467 non-null object
wifi_id7         467 non-null object
wifi_strong7     467 non-null float64
con_sta7         467 non-null object
wifi_id8         467 non-null object
wifi_strong8     467 non-null float64
con_sta8         467 non-null object
wifi_id9         467 non-null object
wifi_strong9     467 non-null float64
con_sta9         467 non-null object
wifi_id10        467 non-null object
wifi_strong10    467 non-null float64
con_sta10        467 non-null object
Year             467 non-null int64
Month            467 non-null int64
weekday          467 non-null int64
time             467 non-null object
dtypes: bool(1), float64(10), int64(5), object(22)
memory usage: 139.1+ KB
None
複製程式碼

我們可以清晰地看出我們程式碼的結構,有多少列,每一列下有多少個值等等,有沒有空值我們可以根據值的數量來判斷。 我們在缺失值處理之前加入這個print(train.info())就會得到:

<class 'pandas.core.frame.DataFrame' at 0x000001ECFA6D6718>
RangeIndex: 500 entries, 0 to 499
複製程式碼

這裡面就有500個值,處理後就只剩467個值了,可見丟棄了不少。同樣的我們也可以將test的資訊輸出一下:

<class 'pandas.core.frame.DataFrame' at 0x0000019E13A96F48>
RangeIndex: 500 entries, 0 to 499
複製程式碼

500個值一個沒少。都給補上了。這裡我只取了輸出資訊的標題,沒有全貼過來,因為全資訊篇幅很長。 我們注意到這個資料中有boolfloatintobject四種型別,我們XGBoost是一種迴歸樹,只能處理數字類的資料,所以我們要轉化。對於那些字串型別的資料我們該如何處理呢?我們採用LabelEncoder方法:

for f in train.columns:
    if train[f].dtype=='object':
        if f != 'shop_id':
            print(f)
            lbl = preprocessing.LabelEncoder()
            train[f] = lbl.fit_transform(list(train[f].values))
for f in tests.columns:
    if tests[f].dtype == 'object':
        print(f)
        lbl = preprocessing.LabelEncoder()
        tests[f] = lbl.fit_transform(list(tests[f].values))
複製程式碼

這段程式碼的意思是呼叫sklearn中preprocessing裡面的LabelEncoder方法,對資料進行標籤編碼,作用主要就是使其變成數字類資料,有的進行歸一化處理,使其執行更快等等。 我們看這段程式碼,lbl只是LabelEncoder的簡寫,lbl = preprocessing.LabelEncoder(),這段程式碼只有一個代換顯得一行不那麼長而已,沒有實際執行什麼。第二句lbl.fit_transform(list(train[f].values))是將train裡面的每一個值進行編碼,我們在其前後輸出一下train[f].values就可以看出來:

print(train[f].values)
train[f] = lbl.fit_transform(list(train[f].values))
print(train[f].values)
複製程式碼

我加上那一串0/的目的是分隔開輸出資料。我們得到:

user_id
['u_376' 'u_376' 'u_1041' 'u_1158' 'u_1654' 'u_2733' 'u_2848' 'u_3063'
 'u_3063' 'u_3063' 'u_3604' 'u_4250' 'u_4508' 'u_5026' 'u_5488' 'u_5488'
 'u_5602' 'u_5602' 'u_5602' 'u_5870' 'u_6429' 'u_6429' 'u_6870' 'u_6910'
 'u_7037' 'u_7079' 'u_7869' 'u_8045' 'u_8209']
[ 7  7  0  1  2  3  4  5  5  5  6  8  9 10 11 11 12 12 12 13 14 14 15 16 17
 18 19 20 21]
複製程式碼

我們可以看出,LabelEncoder將我們的str型別的資料轉換成數字了。按照它自己的一套標準。 對於tests資料,我們可以看到,我單獨將shop_id給避開了。這樣處理的原因就是shop_id是我們要提交的資料,不能有任何編碼行為,一定要保持這種str狀態。

接下來需要將train和tests轉化成matrix型別,方便XGBoost運算:

feature_columns_to_use = ['Year', 'Month', 'weekday',
'time', 'longitude', 'latitude',
'wifi_id1', 'wifi_strong1', 'con_sta1',
 'wifi_id2', 'wifi_strong2', 'con_sta2',
'wifi_id3', 'wifi_strong3', 'con_sta3',
'wifi_id4', 'wifi_strong4', 'con_sta4',
'wifi_id5', 'wifi_strong5', 'con_sta5',
'wifi_id6', 'wifi_strong6', 'con_sta6',
'wifi_id7', 'wifi_strong7', 'con_sta7',
'wifi_id8', 'wifi_strong8', 'con_sta8',
'wifi_id9', 'wifi_strong9', 'con_sta9',
'wifi_id10', 'wifi_strong10', 'con_sta10',]
train_for_matrix = train[feature_columns_to_use]
test_for_matrix = tests[feature_columns_to_use]
train_X = train_for_matrix.as_matrix()
test_X = test_for_matrix.as_matrix()
train_y = train['shop_id']
複製程式碼

待訓練目標是我們的shop_id,所以train_yshop_id

匯入模型生成決策樹

gbm = xgb.XGBClassifier(silent=1, max_depth=10, n_estimators=1000, learning_rate=0.05)
gbm.fit(train_X, train_y)
複製程式碼

這兩句其實可以合併成一句,我們也就是在XGBClassifier裡面設定好引數,其所有引數以及其預設值(預設值)我寫在這,內容來自XGBoost原始碼

  • max_depth=3, 這代表的是樹的最大深度,預設值為三層。max_depth越大,模型會學到更具體更區域性的樣本。
  • learning_rate=0.1,學習率,也就是梯度提升中乘以的係數,越小,使得下降越慢,但也是下降的越精確。
  • n_estimators=100,也就是弱學習器的最大迭代次數,或者說最大的弱學習器的個數。一般來說n_estimators太小,容易欠擬合,n_estimators太大,計算量會太大,並且n_estimators到一定的數量後,再增大n_estimators獲得的模型提升會很小,所以一般選擇一個適中的數值。預設是100。
  • silent=True,是我們訓練xgboost樹的時候後臺要不要輸出資訊,True代表將生成樹的資訊都輸出。
  • objective="binary:logistic",這個引數定義需要被最小化的損失函式。最常用的值有:
  1. binary:logistic 二分類的邏輯迴歸,返回預測的概率(不是類別)。
  2. multi:softmax 使用softmax的多分類器,返回預測的類別(不是概率)。在這種情況下,你還需要多設一個引數:num_class(類別數目)。
  3. multi:softprob和multi:softmax引數一樣,但是返回的是每個資料屬於各個類別的概率。
  • nthread=-1, 多執行緒控制,根據自己電腦核心設,想用幾個執行緒就可以設定幾個,如果你想用全部核心,就不要設定,演算法會自動識別
  • `gamma=0,在節點分裂時,只有分裂後損失函式的值下降了,才會分裂這個節點。Gamma指定了節點分裂所需的最小損失函式下降值。 這個引數的值越大,演算法越保守。這個引數的值和損失函式息息相關,所以是需要調整的。
  • min_child_weight=1,決定最小葉子節點樣本權重和。 和GBM的 min_child_leaf 引數類似,但不完全一樣。XGBoost的這個引數是最小樣本權重的和,而GBM引數是最小樣本總數。這個引數用於避免過擬合。當它的值較大時,可以避免模型學習到區域性的特殊樣本。 但是如果這個值過高,會導致欠擬合。這個引數需要使用CV來調整
  • max_delta_step=0, 決定最小葉子節點樣本權重和。 和GBM的 min_child_leaf 引數類似,但不完全一樣。XGBoost的這個引數是最小樣本權重的和,而GBM引數是最小樣本總數。這個引數用於避免過擬合。當它的值較大時,可以避免模型學習到區域性的特殊樣本。 但是如果這個值過高,會導致欠擬合。這個引數需要使用CV來調整。
  • subsample=1, 和GBM中的subsample引數一模一樣。這個引數控制對於每棵樹,隨機取樣的比例。減小這個引數的值,演算法會更加保守,避免過擬合。但是,如果這個值設定得過小,它可能會導致欠擬合。典型值:0.5-1
  • colsample_bytree=1, 用來控制每棵隨機取樣的列數的佔比(每一列是一個特徵)。典型值:0.5-1
  • colsample_bylevel=1,用來控制樹的每一級的每一次分裂,對列數的取樣的佔比。其實subsample引數和colsample_bytree引數可以起到相似的作用。
  • reg_alpha=0,權重的L1正則化項。(和Lasso regression類似)。可以應用在很高維度的情況下,使得演算法的速度更快。
  • reg_lambda=1, 權重的L2正則化項這個引數是用來控制XGBoost的正則化部分的。這個引數越大就越可以懲罰樹的複雜度
  • scale_pos_weight=1,在各類別樣本十分不平衡時,把這個引數設定為一個正值,可以使
  • base_score=0.5, 所有例項的初始化預測分數,全域性偏置;為了足夠的迭代次數,改變這個值將不會有太大的影響。
  • seed=0, 隨機數的種子設定它可以復現隨機資料的結果,也可以用於調整引數

資料通過樹生成預測結果

predictions = gbm.predict(test_X)
複製程式碼

將tests裡面的資料通過這生成好的模型,得出預測結果。

submission = pd.DataFrame({'row_id': tests['row_id'],
                            'shop_id': predictions})
print(submission)
submission.to_csv("submission.csv", index=False)
複製程式碼

將預測結果寫入到csv檔案裡。我們注意寫入檔案的格式,row_id在前,shop_id在後。index=False的意思是不寫入行的名稱。改成True就把每一行的行標也寫入了。



附錄

參考資料

  1. 機器學習系列(12)_XGBoost引數調優完全指南(附Python程式碼)http://blog.csdn.net/han_xiaoyang/article/details/52665396
  2. Kaggle比賽:泰坦尼克之災: https://www.kaggle.com/c/titanic

完整程式碼

import pandas as pd
import xgboost as xgb
from sklearn import preprocessing


train = pd.read_csv(r'D:\mall_location\train.csv')
tests = pd.read_csv(r'D:\mall_location\test.csv')

train['time_stamp'] = pd.to_datetime(pd.Series(train['time_stamp']))
tests['time_stamp'] = pd.to_datetime(pd.Series(tests['time_stamp']))

print(train.info())

train['Year'] = train['time_stamp'].apply(lambda x:x.year)
train['Month'] = train['time_stamp'].apply(lambda x: x.month)
train['weekday'] = train['time_stamp'].apply(lambda x: x.weekday())
train['time'] = train['time_stamp'].dt.time
tests['Year'] = tests['time_stamp'].apply(lambda x: x.year)
tests['Month'] = tests['time_stamp'].apply(lambda x: x.month)
tests['weekday'] = tests['time_stamp'].dt.dayofweek
tests['time'] = tests['time_stamp'].dt.time
train = train.drop('time_stamp', axis=1)
train = train.dropna(axis=0)
tests = tests.drop('time_stamp', axis=1)
tests = tests.fillna(method='pad')
for f in train.columns:
    if train[f].dtype=='object':
        if f != 'shop_id':
            print(f)
            lbl = preprocessing.LabelEncoder()
            train[f] = lbl.fit_transform(list(train[f].values))
for f in tests.columns:
    if tests[f].dtype == 'object':
        print(f)
        lbl = preprocessing.LabelEncoder()
        lbl.fit(list(tests[f].values))
        tests[f] = lbl.transform(list(tests[f].values))


feature_columns_to_use = ['Year', 'Month', 'weekday',
'time', 'longitude', 'latitude',
'wifi_id1', 'wifi_strong1', 'con_sta1',
 'wifi_id2', 'wifi_strong2', 'con_sta2',
'wifi_id3', 'wifi_strong3', 'con_sta3',
'wifi_id4', 'wifi_strong4', 'con_sta4',
'wifi_id5', 'wifi_strong5', 'con_sta5',
'wifi_id6', 'wifi_strong6', 'con_sta6',
'wifi_id7', 'wifi_strong7', 'con_sta7',
'wifi_id8', 'wifi_strong8', 'con_sta8',
'wifi_id9', 'wifi_strong9', 'con_sta9',
'wifi_id10', 'wifi_strong10', 'con_sta10',]

big_train = train[feature_columns_to_use]
big_test = tests[feature_columns_to_use]
train_X = big_train.as_matrix()
test_X = big_test.as_matrix()
train_y = train['shop_id']

gbm = xgb.XGBClassifier(silent=1, max_depth=10,
                    n_estimators=1000, learning_rate=0.05)
gbm.fit(train_X, train_y)
predictions = gbm.predict(test_X)

submission = pd.DataFrame({'row_id': tests['row_id'],
                            'shop_id': predictions})
print(submission)
submission.to_csv("submission.csv",index=False)
複製程式碼

相關文章