导读:本文是“数据拾光者”专栏的第五十一篇文章,这个系列将介绍在广告行业中自然语言处理和推荐系统实践。本篇分享了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 时间滑动窗口特征介绍如下
时间滑动窗口具体特征如下:
特征加工代码如下:
# 计算不同时间窗口的特征
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) 单模型效果
其中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的方案,感兴趣的小伙伴可以好好学习下:
04
总结及反思
本篇分享了kaggle比赛《Corporación Favorita Grocery Sales Forecasting》冠军方案。因为业务需要所以调研了商品销量预测比赛,重点学习了冠军方案的特征工程和模型构建,其中关于时间滑动窗口特征的构建非常巧妙,受益匪浅。对商品销量预测相关问题感兴趣的小伙伴可以一起沟通交流。