廣告行業中那些趣事系列51:超牛的kaggle比賽Favorita Grocery Sales Forecasting冠軍方案

語言: CN / TW / HK

導讀:本文是“資料拾光者”專欄的第五十一篇文章,這個系列將介紹在廣告行業中自然語言處理和推薦系統實踐。本篇 分享了kaggle比賽《Corporación Favorita Grocery Sales Forecasting》冠軍方案 , 對商品銷量預測相關問題感興趣的小夥伴可以一起溝通交流。

歡迎轉載,轉載請註明出處以及連結,更多關於自然語言處理、推薦系統優質內容請關注如下頻道。

知乎專欄:資料拾光者

公眾號:資料拾光者

摘要: 本篇分享了kaggle比賽《Corporación Favorita Grocery Sales Forecasting》冠軍方案。因為業務需要所以調研了商品銷量預測比賽,重點學習了冠軍方案的特徵工程和模型構建,其中關於時間滑動視窗特徵的構建非常巧妙,受益匪淺。對商品銷量預測相關問題感興趣的小夥伴可以一起溝通交流。

下面主要按照如下思維導圖進行學習分享:

01

比賽介紹及資料理解

最近因為工作原因需要調研下kaggle比賽《Corporación Favorita Grocery Sales Forecasting》top方案的特徵和模型工作,可以借鑑並應用到實際業務中。很多時候我們的任務可能與kaggle中某個比賽是類似的,想又快又好的完成目標其中一條有效的方法就是參考大牛分享的方案。因為很多大牛在比賽打完之後會分享自己的原始碼,這樣相比於我們自己去從0到1構建模型效率會提升很多。不僅如此,參考大牛的方案可以讓我們瞭解當前業界對於此類問題優秀的方案,快速得到很好的baseline,然後快速迭代更新更容易出成果。

該比賽kaggle地址如下:

https://www.kaggle.com/c/favorita-grocery-sales-forecasting/overview

整體來看該比賽就是預測商品的銷量,官方提供了2013-2017年各商店商品的銷量,參賽隊伍需要根據已有資料預測未來一段時間商店商品的銷量,下面是每年的訓練樣本量:

圖1 每年的訓練樣本量

每年各月的訓練樣本分佈如下:

圖2 每年各月的訓練樣本

對官方提供的資料進行整理,下面是資料說明和示例:

圖3 資料說明和示例

其中id代表唯一key值,實際無用;date代表日期,store_nbr代表商店id,item_nbr代表商品id,預測的粒度也就是某個商店中的某個商品在某一天的銷量;onpromotion代表當天商店的該商品是否在促銷;紫色的四個欄位都是商店的特徵,其中city代表市,state代表州,tpye代表商店層級,一共有A-E五個等級,cluster代表相似商店分組,一共有17個分組;紅色的三個欄位是商品的特徵,其中family是商品分類,總共有33個分類,class代表商品小類,有337個小類別,perishable代表商品是否容易變質;oil_price代表油價,day_type代表假日型別。

02

詳解冠軍方案

冠軍方案介紹地址如下:

https://www.kaggle.com/c/favorita-grocery-sales-forecasting/discussion/47582

2.1 樣本選擇

雖然官方提供了2013-2017的訓練樣本,但是作者僅使用了2017年的訓練樣本。詳細如下:

訓練樣本:20170531 - 20170719 or 20170614 - 20170719

驗證樣本:20170726 - 20170810

測試集:20170816 - 20170820

2.2 特徵工程

整體來看特徵包括兩大塊,第一塊是基本特徵,第二塊是時間滑動視窗特徵。

(1)基本特徵

基本特徵主要包括item_nbr、family、class、perishable、store_nbr、city、state、type、cluster。

(2) 時間滑動視窗特徵

這裡重點研究時間滑動視窗特徵。作者使用num_day個滑動視窗,分別統計item-store粒度、item粒度和store-class粒度的時間滑動視窗特徵,關於時間滑動視窗特徵介紹如下:

圖4 時間滑動視窗特徵介紹如下

時間滑動視窗具體特徵如下:

  • 時間視窗內(最近14/60/140天):促銷天數彙總;

  • 時間視窗內(後3/7/14天):促銷天數彙總。這裡需要介紹下為什麼可以使用之後的促銷天數資料,因為在測試集中官方已經給出了未來一段時間某商店某商品是否會進行促銷,所以我們可以用未來幾天促銷的資料;

  • 時間視窗內(最近3/7/14/30/60/140天):

    • 銷量差值的均值,以時間視窗最近3天為例,用第二天的銷量減去第一天的銷量,再用第三天的銷量減去第二天的銷量,將兩者取均值就可以得到銷量差值的均值,這個特徵可以理解為想檢視每天的銷量增長率;

    • 銷量每天按0.9衰減之後彙總,以時間視窗3天為例,最近一天銷量不變,最近第二天的銷量乘以衰減係數0.9,最近第三天的銷量乘以衰減係數0.81,然後將三天衰減之後的銷量相加;

    • 均值、中位數、最小值、最大值和標準差;

  • 時間視窗內(上一週最近3/7/14/30/60/140天):和前一天銷量差值的均值、銷量每天按0.9衰減之後彙總、均值、中位數、最小值、最大值和標準偏差。這個特徵和上一個特徵是一樣的,只不過計算的是上一週各個特徵值,作者想檢視前一週的銷量各個特徵;

  • 時間視窗內(最近7/14/30/60/140天):

    • 有銷量/促銷的天數,分別檢視時間視窗內有銷量和促銷的天數,以時間視窗3天為例,如果這三天都有銷量,那麼為3;

    • 距離上次有銷量/促銷的天數,以時間視窗3天為例,上一次有銷量是昨天,那麼該值為1。這個特徵主要是檢視上一次有銷量或者促銷對未來商品銷量的影響,以促銷為例,有些商品近期才做過促銷,可能未來幾天的銷量就會受影響;

    • 距離最早有銷量/促銷的天數,以時間視窗3天為例,最早有銷量是最近第三天,那麼該值為3;

  • 時間視窗內(後15天)促銷的天數、距離上次促銷的天數、距離最早促銷的天數,這個特徵和上一個特徵類似,只不過檢視未來15天各個特徵情況;

  • 時間視窗內(最近15天)當天的銷量;

  • 最近4周時間視窗為(每週1-每週日)的銷量均值,比如最近4周每週1的銷量均值;

  • 最近20周時間視窗為(每週1-每週日)的銷量均值,比如最近20周每週1的銷量均值;

  • 時間視窗內(前16到後15天)每天是否促銷。

特徵加工程式碼如下:

# 計算不同時間視窗的特徵
def get_timespan(df, dt, minus, periods,freq='D'):
df_result = df[pd.date_range(dt - timedelta(days=minus),periods=periods, freq=freq)]
return df_result

def prepare_dataset(df, promo_df, t2017,is_train=True, name_prefix=None):
X= {
# 以t2017為起點,最近14/60/140天促銷彙總
"promo_14_2017": get_timespan(promo_df, t2017, 14,14).sum(axis=1).values,
"promo_60_2017": get_timespan(promo_df, t2017, 60,60).sum(axis=1).values,
"promo_140_2017": get_timespan(promo_df, t2017, 140,140).sum(axis=1).values,
# 以t2017為起點,後3/7/14天促銷彙總
"promo_3_2017_aft": get_timespan(promo_df, t2017 +timedelta(days=16), 15, 3).sum(axis=1).values,
"promo_7_2017_aft": get_timespan(promo_df, t2017 +timedelta(days=16), 15, 7).sum(axis=1).values,
"promo_14_2017_aft": get_timespan(promo_df, t2017 +timedelta(days=16), 15, 14).sum(axis=1).values,
}

#t2017為起點
for i in [3, 7, 14, 30, 60, 140]:
tmp = get_timespan(df, t2017, i, i)
# 最近i天裡和前一天銷量差值的均值
X['diff_%s_mean' % i] = tmp.diff(axis=1).mean(axis=1).values
# 最近i天裡銷量每天按0.9衰減之後彙總 *******************************
X['mean_%s_decay' % i] = (tmp * np.power(0.9,np.arange(i)[::-1])).sum(axis=1).values
# 最近i天裡均值、中位數、最小值、最大值和標準偏差
X['mean_%s' % i] = tmp.mean(axis=1).values
X['median_%s' % i] = tmp.median(axis=1).values
X['min_%s' % i] = tmp.min(axis=1).values
X['max_%s' % i] = tmp.max(axis=1).values
X['std_%s' % i] = tmp.std(axis=1).values

#t2017上一週,前i天各指標值,和上面是一樣的
for i in [3, 7, 14, 30, 60, 140]:
tmp = get_timespan(df, t2017 + timedelta(days=-7), i, i)
X['diff_%s_mean_2' % i] = tmp.diff(axis=1).mean(axis=1).values
X['mean_%s_decay_2' % i] = (tmp * np.power(0.9,np.arange(i)[::-1])).sum(axis=1).values
X['mean_%s_2' % i] = tmp.mean(axis=1).values
X['median_%s_2' % i] =tmp.median(axis=1).values
X['min_%s_2' % i] = tmp.min(axis=1).values
X['max_%s_2' % i] = tmp.max(axis=1).values
X['std_%s_2' % i] = tmp.std(axis=1).values

#t2017為起點,最近i天內有銷量/促銷的天數、距離上次有銷量的天數、距離最早有銷量的天數
for i in [7, 14, 30, 60, 140]:
tmp = get_timespan(df, t2017, i, i)
# 最近i天內有銷量的天數
X['has_sales_days_in_last_%s' % i] = (tmp > 0).sum(axis=1).values
# 最近i天內距離上次有銷量的天數,如果都沒有銷量則為i
X['last_has_sales_day_in_last_%s' % i] = i - ((tmp > 0) *np.arange(i)).max(axis=1).values
# 最近i天內距離最早有銷量的天數
X['first_has_sales_day_in_last_%s' % i] = ((tmp > 0) * np.arange(i,0, -1)).max(axis=1).values
tmp = get_timespan(promo_df, t2017, i, i)
X['has_promo_days_in_last_%s' % i] = (tmp > 0).sum(axis=1).values
X['last_has_promo_day_in_last_%s' % i] = i - ((tmp > 0) *np.arange(i)).max(axis=1).values
X['first_has_promo_day_in_last_%s' % i] = ((tmp > 0) * np.arange(i,0, -1)).max(axis=1).values

#t2017為起點,未來15天內有促銷的天數、距離上次有促銷的天數、距離最早有促銷的天數
tmp = get_timespan(promo_df, t2017 + timedelta(days=16), 15, 15)
X['has_promo_days_in_after_15_days'] = (tmp > 0).sum(axis=1).values
X['last_has_promo_day_in_after_15_days'] = i - ((tmp > 0) *np.arange(15)).max(axis=1).values
X['first_has_promo_day_in_after_15_days'] = ((tmp > 0) *np.arange(15, 0, -1)).max(axis=1).values

#t2017為起點,前i天當天的銷量
for i in range(1, 16):
X['day_%s_2017' % i] = get_timespan(df, t2017, i, 1).values.ravel()

#t2017為起點,最近4/20周時間視窗為(每週1-每週日)的銷量均值,比如最近4周每週周1的均值;
for i in range(7):
X['mean_4_dow{}_2017'.format(i)] = get_timespan(df, t2017, 28-i, 4,freq='7D').mean(axis=1).values
X['mean_20_dow{}_2017'.format(i)] = get_timespan(df, t2017, 140-i, 20,freq='7D').mean(axis=1).values

#t2017為起點,前後16天當天促銷
for i in range(-16, 16):
# 需要把t2017 + timedelta(days=i) 轉化成str格式,否則會報錯
X["promo_{}".format(i)] = promo_df[str(t2017 +timedelta(days=i))].values.astype(np.uint8)

X= pd.DataFrame(X)
#y是未來16天當天銷量
if is_train:
y = df[pd.date_range(t2017, periods=16)].values
return X, y
if name_prefix is not None:
X.columns = ['%s_%s' % (name_prefix, c) forc in X.columns]
return X

print("Preparing dataset...")
#num_days = 8
num_days = 2
t2017 = date(2017, 5, 31)
X_l, y_l = [], []
for i in range(num_days):
delta = timedelta(days=7 * i)
#store_nbr-item_nbr粒度
X_tmp, y_tmp = prepare_dataset(df_2017, promo_2017, t2017 + delta)
#item_nbr粒度
X_tmp2 = prepare_dataset(df_2017_item, promo_2017_item, t2017 + delta,is_train=False, name_prefix='item')
X_tmp2.index = df_2017_item.index
X_tmp2 =X_tmp2.reindex(df_2017.index.get_level_values(1)).reset_index(drop=True)
#store-class粒度
X_tmp3 = prepare_dataset(df_2017_store_class, df_2017_promo_store_class,t2017 + delta, is_train=False, name_prefix='store_class')
X_tmp3.index = df_2017_store_class.index

#構建多重索引必須從這裡pd.MultiIndex.from_frame,原始碼會報錯
X_tmp3 =X_tmp3.reindex(pd.MultiIndex.from_frame(df_2017_store_class_index)).reset_index(drop=True)
X_tmp3

#將不同粒度的訓練資料合併
X_tmp = pd.concat([X_tmp, X_tmp2, X_tmp3, items.reset_index(),stores.reset_index()], axis=1)

X_l.append(X_tmp)
y_l.append(y_tmp)

2.3 模型構建

作者分別使用lgb和nn構建模型,最後通過加權求和的方式得到最終結果。

(1) 單模型效果

  • model_1 : 0.506 / 0.511 , 16 lgb modelstrained for each day source code;

  • model_2 : 0.507 / 0.513 , 16 nn modelstrained for each day source code;

  • model3 : 0.512 / 0.515,1 lgb model for 16 days with almost same features as model1;

  • model_4 : 0.517 / 0.519,1 nn model based on @sjv's code

其中mode1和model3使用的是傳統lgb模型,model2和model4使用的是神經網路模型,下面是神經網路模型結構:

圖5 神經網路模型結構

作者使用LSTM作為特徵抽取器,後面再加全連線層。因為當時比賽時間比較早,Transformer還沒被使用,如果現在要應用到實際業務中,將LSTM替換為Transformer可能會提升模型效果。

神經網路模型構建原始碼如下:

def build_model():
model = Sequential()
model.add(LSTM(512, input_shape=(X_train.shape[1],X_train.shape[2])))
model.add(BatchNormalization())
model.add(Dropout(.2))

model.add(Dense(256))
model.add(PReLU())
model.add(BatchNormalization())
model.add(Dropout(.1))

model.add(Dense(256))
model.add(PReLU())
model.add(BatchNormalization())
model.add(Dropout(.1))

model.add(Dense(128))
model.add(PReLU())
model.add(BatchNormalization())
model.add(Dropout(.05))

model.add(Dense(64))
model.add(PReLU())
model.add(BatchNormalization())
model.add(Dropout(.05))

model.add(Dense(32))
model.add(PReLU())
model.add(BatchNormalization())
model.add(Dropout(.05))

model.add(Dense(16))
model.add(PReLU())
model.add(BatchNormalization())
model.add(Dropout(.05))
model.add(Dense(1))
return model

(2) 模型融合

作者通過加權求和的方式將多模型結果進行融合,這也是kaggle比賽提分的套路了,最終提交的結果是:

finalmodel=0.42*model1 + 0.28 * model2 +0.18 * model3 + 0.12 * model4

03

其他top方案

整理了該比賽其他top2-top6的方案,感興趣的小夥伴可以好好學習下:

  • 2st方案:https://www.kaggle.com/c/favorita-grocery-sales-forecasting/discussion/47568#latest-278474

  • 3st方案:https://www.kaggle.com/c/favorita-grocery-sales-forecasting/discussion/47560#latest-302253

  • 4st方案:https://www.kaggle.com/c/favorita-grocery-sales-forecasting/discussion/47529#latest-271077

  • 5st 方案:https://github.com/LenzDu/Kaggle-Competition-Favorita

  • 6st方案:https://www.kaggle.com/c/favorita-grocery-sales-forecasting/discussion/47575#latest-269568

04

總結及反思

本篇分享了kaggle比賽《Corporación Favorita Grocery Sales Forecasting》冠軍方案。因為業務需要所以調研了商品銷量預測比賽,重點學習了冠軍方案的特徵工程和模型構建,其中關於時間滑動視窗特徵的構建非常巧妙,受益匪淺。對商品銷量預測相關問題感興趣的小夥伴可以一起溝通交流。

最新最全的文章請關注我的微信公眾號或者知乎專欄:資料拾光者。

碼字不易,歡迎小夥伴們點贊和分享。

「其他文章」