使用历史平均来预测未来的需求
使用测试集真实数据进行过拟合的结果
名词定义
库存水位
在仓库存数量,用来满足需求。
补货时长(交货时间,lead_time)
从下达补货指令到货物到仓可用的时长。
本赛题初赛时的补货时长为14天,即假设1号A货物的库存水位为0,此时下达A货物补货指令,补货量为10,则1号至14号A货物的库存水位均为0,15号时A货物的库存水位为10。
补货在途
下达补货指令后还未到仓的货物量总和。
上例中1号至14号A货物的补货在途为10,其他时段为0。
若在8号再次下达补货量为10的补货指令,那么1号至7号的补货在途为10,8号至14号的补货在途为20,15号至21号的补货在途为10,其他时段为0。
补货策略
本赛题场景使用周期性盘点的补货策略,每周一为补货决策日,决定货物的补货量。
历史需求
货物历史需求量的时间序列,值得注意的是,因为云产品有购买与释放的概念,所以本赛题场景下需求量会为负数,即云产品被用户释放。
补货单元
货物的唯一标识。
赛题场景
基于给定过去一段时间的历史需求数据,同时结合当前的库存数据、补货时长、补货在途以及补货单元的相关信息(产品维度与地理纬度),参赛者需要自己提出方案,在补货决策日确定每一补货单元的补货量。最直接的方案,可通过历史需求数据,对未来的需求进行预测,结合当前库存水位以及补货在途的货物判断14天后的库存水位能否满足14天后的一系列需求(因为当日补货14天后才能到货),考虑对应的补货量,达到在保障一定服务水平的情况下,实现最低库存成本的效果;当然也可采用end to end的整体优化方案,实现该目标。库存量视角的变化过程如下图所示。
我们会给一个训练集,供参赛选手训练模型并验证模型效果使用。同时,初赛时我们会提供一个测试集,选手需要为按时间读取20210302-20210607的需求数据,并根据历史需求数据以及补货在途,决定补货量,并把决策结果CSV文件输出到指定位置。(由于赛题特殊与评价机制的问题,初赛时给选手透出了未来数据,选手在初赛时将其作为未知数据调整算法,不允许在决策时使用当前日之后的未来数据。)
由于库存控制是一个前后相关的决策过程,两次补货决策并不独立,因此我们会提供一段时间的数据,由选手在时间轴上进行多次补货决策,最后在较长的时间段内评价选手方案的好坏。在复赛时,选手需要提交一个docker镜像,镜像中需要包含用来进行库存管理所需的所有内容,如模型、脚本等,未来的需求数据以及到货时间将通过流评测的方式给出。镜像中的脚本需要能根据所得的需求量,根据历史需求数据以及先前所做的决策,决定补货量,并把决策结果CSV文件输出到指定位置;初赛时测试集文件将线下交给选手。
相关参考
https://web.mit.edu/2.810/www/files/readings/King_SafetyStock.pdf
https://abcsupplychain.com/safety-stock-formula-calculation/
数据描述
训练集包含如下信息:
虚拟资源使用量历史数据
demand_train.csv为虚拟资源使用量历史数据,包含7列。每列的含义如下:
字段 | 说明 |
---|---|
unit | 单元 |
ts | 日期 |
qty | 资源使用量 |
geography | 地理信息 |
geography_level | 地理聚合维度 |
product | 产品信息 |
product_level | 产品聚合维度 |
inventory_info.csv为虚拟资源库存数据,包含7列。每列的含义如下:
字段 | 说明 |
---|---|
unit | 单元 |
ts | 日期 |
qty | 库存量 |
geography | 地理信息 |
geography_level | 地理聚合维度 |
product | 产品信息 |
product_level | 产品聚合维度 |
geography_tuopu.csv为地理拓扑数据,包含3列。每列的含义如下:
字段 | 说明 |
---|---|
geography_level_1 | 地理层级1 |
geography_level_2 | 地理层级2 |
geography_level_3 | 地理层级3 |
product_tuopu.csv为产品层级信息,包含2列。每列的含义如下:
字段 | 说明 |
---|---|
product_level_1 | 产品层级1 |
product_level_2 | 产品层级2 |
unit_weight.csv为库存单元的库存信息,包含2列。每列的含义如下:
字段 | 说明 |
---|---|
unit | 单元 |
weight | 权重 |
复赛阶段,测试集的数据格式和初赛阶段相同,但是测试集的内容不会提供给参赛选手。选手需要在docker代码中从指定的数据集目录中读取测试集内容,进行特征工程和模型预测,最后输出的格式和初赛一致,输出后续一段时间内决策日需要补货的集合(如果有预训练权重则docker代码中需包含本地训练好的模型)。
提交格式
在初赛中,预测结果的提交格式为CSV压缩的ZIP文件。文件中的每一行表示在未来的时间段内每个决策日每个库存单元需要补充的量,具体包含:补货单元、日期和补货量(用逗号隔开),形式如下:
"unit1","20210308",12
"unit1","20210315",11
"unit1","20210322",9
**注:在复赛中,参赛选手需
要提交docker镜像,具体的提交方式及规范请参见到时发布的容器镜像提交说明。**
评价指标
综合指标
评测时按时间顺序更新上述变量,完毕后用上述公式计算指标。
#初赛评测程序伪代码如下
#intransit 是用来记录预计到达的资源的中间变量
init intransit[][] = 0
for i in I:
for d in D:
#初始库存值已给出
qty_inventory[i,d] = qty_inventory[i,d - 1]
#qty_replenish即为选手提交的补货决策
if qty_replenish[i,d] > 0:
#lead_time 为交货时间,在交货时间之后供应会到达
intransit[i,d + lead_time] += qty_replenish[i,d]
# 可用库存加上到达的库存
qty_inventory[i,d] = max(qty_inventory[i,d] + intransit[i,d], 0)
# 需求没有被满足的量,当天需求量和可用库存量进行比较
stockout[i,d] = max(demand[i,d] - qty_inventory[i,d], 0)
if demand[i,d] < 0:
# 净需求为负时候,表示释放,可用库存会增加,释放的量和已使用的量取小
qty_inventory[i,d] = max(qty_inventory[i,d] + min(abs(demand[i,d]),qty_using[i,d - 1]), 0)
else:
# 净需求为正的时候,可用库存进行扣减,>=0
qty_inventory[i,d] = max(qty_inventory[i,d] - demand[i,d], 0)
# 前一天的保有量+今天被满足的量
qty_using[i,d] = max(qty_using[i,d - 1] + demand[i,d] - stockout[i,d], 0)
这次比赛作为首次参加的机器学习竞赛的时间序列预测比赛,由于之前几乎没有相关的基础,这次还是一点点的根据baseline和官方的讲解开展的。很遗憾在初赛就止步了,没有能够进入复赛,可能这次参赛的主要目的还是学习,通过比赛了解最基础的一些时间序列建模方法以及特征工程的构建方式。同时这次的比赛针对云电商的这样一个库存补货策略还是有很高的一个要求的。
本场比赛最后使用的方法是多模的一个融合包括xgb+lgb和经典的arima。特征工程的构建呢会比较多的参考auto-X这个第四范式提供的项目。
其实还有很多没有尝试的过程比如:
人工智能顶会最新时序模型汇总(附原文源码)
90%冠亚军采用的时间序列建模策略
Train_dt = read_csv('train.csv')[['unit','ts','qty']]
Test_dt = read_csv('test.csv')[['unit','ts','qty']]
Train_dt["ts"] = Train_dt["ts"].apply(lambda x: pd.to_datetime(x)) # 日期对应数据标准化为规范时间 这一步比较耗时间!!!!!
Test_dt["ts"] = Test_dt["ts"].apply(lambda x: pd.to_datetime(x)) # 日期对应数据标准化为规范时间
last_dt = pd.to_datetime("20210301") # 用来限定使用的是历史数据而不是未来数据
start_dt = pd.to_datetime("20210301") # 用来划定预测的针对test的起始时间
end_dt = pd.to_datetime("20210607") # 预测需求的截止时间
qty_using = pd.concat([Train_dt, Test_dt])
for num,chunk in enumerate(qty_using.groupby("unit")):
unit = chunk[0]
demand = chunk[1]
eval = demand.copy()
demand["log qty"] = np.log(demand['qty']) #对数处理源数据
由于ARMA和ARIMA需要时间序列满足平稳性和非白噪声的要求,所以要用查分法和平滑法(滚动平均和滚动标准差)来实现序列的平稳性操作。一般情况下,对时间序列进行一阶差分法就可以实现序列的平稳性,有时需要二阶查分。
demand["log qty"] = np.log(demand['qty'])
demand["diff"] = demand["qty"].diff().values
del demand["diff"]
demand = demand[1:]
#滚动平均(平滑法不平稳处理)
demand_log_moving_avg = demand['log qty'].rolling(12).mean()
#滚动标准差
demand_log_std = demand['log qty'].rolling(12).std()
这里采用的是auto_arima这些过程会有自主实现的过程不需要逐个的去实现,这里也为模型的运行效率做了提升
if __name__ == '__main__':
# 加载数据
Train_dt = read_csv('train.csv')[['unit','ts','qty']]
Test_dt = read_csv('test.csv')[['unit','ts','qty']]
Train_dt["ts"] = Train_dt["ts"].apply(lambda x: pd.to_datetime(x)) # 日期对应数据标准化为规范时间 这一步比较耗时间!!!!!
Test_dt["ts"] = Test_dt["ts"].apply(lambda x: pd.to_datetime(x)) # 日期对应数据标准化为规范时间
last_dt = pd.to_datetime("20210301") # 用来限定使用的是历史数据而不是未来数据
start_dt = pd.to_datetime("20210301") # 用来划定预测的针对test的起始时间
end_dt = pd.to_datetime("20210607") # 预测需求的截止时间
qty_using = pd.concat([Train_dt, Test_dt])
for num,chunk in enumerate(qty_using.groupby("unit")):
unit = chunk[0]
demand = chunk[1]
eval = demand.copy()
demand["diff"] = demand["qty"].diff().values
del demand["diff"]
demand = demand[1:]
demand["log qty"] = np.log(demand['qty'])
demand_log_moving_avg = demand['log qty'].rolling(12).mean() # 平滑处理也是针对非平稳性的处理方式
demand_log_std = demand['log qty'].rolling(12).std()
#迭代预测
date_list = pd.date_range(start=start_dt, end=end_dt - datetime.timedelta(days=1))
for date in date_list:
if date.dayofweek != 0:
# 周一为补货决策日,非周一不做决策
pass
else:
demand_his = demand[(demand["ts"] >= date - datetime.timedelta(days=42)) & (demand["ts"] < date)]['log qty'].values.astype('float32')
try:
model = auto_arima(demand_his, trace=True, error_action='ignore', suppress_warnings=True)
model.fit(demand_his)
forecast = model.predict(n_periods=7)
forecast = np.exp(np.array(forecast))
# forecast = diff1_reduction(forecast[1], demand_his)
demand.loc[(demand["ts"] > date) & (demand["ts"] <= date + datetime.timedelta(days=7)), 'qty'] = forecast
except:
print('ARIMA检验存在问题的unit:{}'.format(unit))
#出了问题的用前面42天的均值代替
demand_future = np.mean(demand_his) #这里是针对特殊的unit也就是arima无法预测的不能通过平稳性检验的
if demand_future == -np.inf:
demand_future = 0
demand.loc[(demand["ts"] > date) & (demand["ts"] <= date + datetime.timedelta(days=7)), 'qty'] = np.array([demand_future] * 7)
这里使用try和except是因为有部分unit的历史数据不能满足arima预测的平稳性要求,采用多个历史的前42天也就是我们arima依赖的历史数据周期的均值来代替。
参考github项目:auto-x
完整colab代码:
# -*- coding: utf-8 -*-
# 安装autox
!git clone https://github.com/4paradigm/AutoX.git
!pip install pytorch_tabnet
!pip install ./AutoX
import os
import pandas as pd
"""## 数据预处理"""
data_name = '../../data/'
path = f'./{data_name}'
# 赛题数据demand_test_A中给了标签,我们需要将它删掉。同时我们顺便删掉无用的'Unnamed: 0'列
demand_train_A = pd.read_csv(f'{path}/demand_train_A.csv')
demand_test_A = pd.read_csv(f'{path}/demand_test_A.csv')
demand_train_A.drop('Unnamed: 0', axis=1, inplace=True)
demand_test_A.drop(['Unnamed: 0', 'qty'], axis=1, inplace=True)
# 将 demand_train_A, demand_test_A 保存为train.csv, test.csv
demand_train_A.to_csv(path + '/train.csv', index = False)
demand_test_A.to_csv(path + '/test.csv', index = False)
"""## 导入所需的包"""
from autox import AutoX
"""## 初始化AutoX类"""
# 数据集是多表数据集,需要配置表关系
relations = [
{
"related_to_main_table": "true", # 是否为和主表的关系
"left_entity": "train.csv", # 左表名字
"left_on": ["product"], # 左表拼表键
"right_entity": "product_topo.csv", # 右表名字
"right_on": ["product_level_2"], # 右表拼表键
"type": "1-1" # 左表与右表的连接关系
}, # train.csv和product_topo.csv两张表是1对1的关系,拼接键为train.csv中的product列 和 product_topo.csv中的product_level_2列
{
"related_to_main_table": "true", # 是否为和主表的关系
"left_entity": "test.csv", # 左表名字
"left_on": ["product"], # 左表拼表键
"right_entity": "product_topo.csv", # 右表名字
"right_on": ["product_level_2"], # 右表拼表键
"type": "1-1" # 左表与右表的连接关系
}, # test.csv和product_topo.csv两张表是1对1的关系,拼接键为test.csv中的product列 和 product_topo.csv中的product_level_2列
{
"related_to_main_table": "true", # 是否为和主表的关系
"left_entity": "train.csv", # 左表名字
"left_on": ["geography"], # 左表拼表键
"right_entity": "geo_topo.csv", # 右表名字
"right_on": ["geography_level_3"], # 右表拼表键
"type": "1-1" # 左表与右表的连接关系
}, # train.csv和geo_topo.csv两张表是1对1的关系,拼接键为train.csv中的geography列 和 geo_topo.csv中的geography_level_3列
{
"related_to_main_table": "true", # 是否为和主表的关系
"left_entity": "test.csv", # 左表名字
"left_on": ["geography"], # 左表拼表键
"right_entity": "geo_topo.csv", # 右表名字
"right_on": ["geography_level_3"], # 右表拼表键
"type": "1-1" # 左表与右表的连接关系
} # test.csv和geo_topo.csv两张表是1对1的关系,拼接键为test.csv中的geography列 和 geo_topo.csv中的geography_level_3列
]
autox = AutoX(target = 'qty', train_name = 'train.csv', test_name = 'test.csv',
id = ['unit'], path = path, time_series=True, ts_unit='D',time_col = 'ts',
relations = relations
) #feature_type = feature_type,
sub = autox.get_submit_ts()
# 检查预测结果和真实结果的差距
sub.rename({'qty': 'qty_pre'}, axis=1, inplace=True)
demand_test_A = pd.read_csv(f'{path}/demand_test_A.csv', usecols = ['unit','ts','qty'])
analyze = demand_test_A.merge(sub, on = ['unit', 'ts'], how = 'left')
# 查看mae
from sklearn.metrics import mean_absolute_error
y_true = analyze['qty']
y_pred = analyze['qty_pre']
print(mean_absolute_error(y_true, y_pred))
### 3.2特征工程构建部分
def get_submit_ts(self):
id_ = self.info_['id']
target = self.info_['target']
# 特征工程
log("start feature engineer")
df = self.dfs_['train_test']
feature_type = self.info_['feature_type']['train_test']
# 1-M拼表特征
# one2M拼表特征
log("feature engineer: one2M")
featureOne2M = FeatureOne2M()
featureOne2M.fit(self.info_['relations'], self.info_['train_name'], self.info_['feature_type'])
log(f"featureOne2M ops: {featureOne2M.get_ops()}")
if len(featureOne2M.get_ops()) != 0:
self.dfs_['FE_One2M'] = featureOne2M.transform(df, self.dfs_)
else:
self.dfs_['FE_One2M'] = None
log("ignore featureOne2M")
# 时间特征
log("feature engineer: time")
featureTime = FeatureTime()
featureTime.fit(df, df_feature_type=feature_type, silence_cols=id_ + [target])
log(f"featureTime ops: {featureTime.get_ops()}")
self.dfs_['FE_time'] = featureTime.transform(df)
# lag_ts特征
log("feature engineer: ShiftTS")
featureShiftTS = FeatureShiftTS()
featureShiftTS.fit(df, id_, target, feature_type, self.info_['time_col'], self.info_['ts_unit'])
log(f"featureShiftTS ops: {featureShiftTS.get_ops()}")
log(f"featureShiftTS lags: {featureShiftTS.get_lags()}")
self.dfs_['FE_shift_ts'] = featureShiftTS.transform(df)
# rolling_stat_ts特征
log("feature engineer: RollingStatTS")
featureRollingStatTS = FeatureRollingStatTS()
featureRollingStatTS.fit(df, id_, target, feature_type, self.info_['time_col'], self.info_['ts_unit'])
log(f"featureRollingStatTS ops: {featureRollingStatTS.get_ops()}")
log(f"featureRollingStatTS windows: {featureRollingStatTS.get_windows()}")
self.dfs_['FE_rollingStat_ts'] = featureRollingStatTS.transform(df)
# exp_weighted_mean_ts特征
log("feature engineer: ExpWeightedMean")
featureExpWeightedMean = FeatureExpWeightedMean()
featureExpWeightedMean.fit(df, id_, target, feature_type, self.info_['time_col'], self.info_['ts_unit'])
log(f"featureExpWeightedMean ops: {featureExpWeightedMean.get_ops()}")
log(f"featureExpWeightedMean lags: {featureExpWeightedMean.get_lags()}")
self.dfs_['FE_ewm'] = featureExpWeightedMean.transform(df)
# label_encoder
df = auto_encoder(df, feature_type, id_)
# 特征合并
log("feature combination")
df_list = [df, self.dfs_['FE_One2M'], self.dfs_['FE_time'], self.dfs_['FE_shift_ts'], self.dfs_['FE_rollingStat_ts'], self.dfs_['FE_ewm']]
self.dfs_['FE_all'] = feature_combination(df_list)
# # 内存优化
# self.dfs_['FE_all'] = reduce_mem_usage(self.dfs_['FE_all'])
def feature_filter(train, test, id_, target):
not_used = id_ + [target]
used_features = test.describe().columns
# 过滤掉test中全为nan的特征
for col in tqdm(used_features):
# test中全为Nan的特征
if test.loc[test[col].isnull()].shape[0] == test.shape[0]:
if col not in not_used:
not_used += [col]
# nunique为1的特征
if train[col].nunique() == 1:
if col not in not_used:
not_used += [col]
# test中的值都比train中的值要大(或小)的特征
if test[col].min() > train[col].max() or test[col].max() < train[col].min():
if col not in not_used:
not_used += [col]
log(f"filtered features: {not_used}")
used_features = [x for x in used_features if x not in not_used]
return used_features
# train和test数据切分
train_length = self.info_['shape_of_train']
train, test = train_test_divide(self.dfs_['FE_all'], train_length)
log(f"shape of FE_all: {self.dfs_['FE_all'].shape}, shape of train: {train.shape}, shape of test: {test.shape}")
# 特征过滤
log("feature filter")
used_features = feature_filter(train, test, id_, target)
log(f"used_features: {used_features}")
# 模型训练
log("start training model")
if self.info_['task_type'] == 'regression':
# model_cat = CatboostRegressionTs()
# model_cat.fit(train, test, used_features, target, self.info_['time_col'], self.info_['ts_unit'])
model_lgb = LgbRegressionTs()
model_lgb.fit(train, test, used_features, target, self.info_['time_col'], self.info_['ts_unit'])
model_xgb = XgbRegressionTs()
model_xgb.fit(train, test, used_features, target, self.info_['time_col'], self.info_['ts_unit'])
# 特征重要性
fimp = model_lgb.feature_importances_
log("feature importance")
log(fimp)
# 模型预测
predict_lgb = model_lgb.predict(test, used_features)
predict_xgb = model_xgb.predict(test, used_features)
# predict_cat = model_cat.predict(test, used_features)
# predict_tabnet = model_tabnet.predict(test[used_features])
# predict = (predict_xgb + predict_lgb + predict_cat) / 3
predict = (predict_xgb * 0.5 + predict_lgb * 0.5)
predict_train = model_lgb.predict(train, used_features) * 0.5 + model_xgb.predict(train, used_features) * 0.5
# 预测结果后处理
min_ = self.info_['min_target']
max_ = self.info_['max_target']
predict = clip_label(predict, min_, max_)
predict_train = clip_label(predict_train, min_, max_)
# 获得结果
sub = test[id_ + [self.info_['time_col']]]
sub[target] = predict
sub.index = range(len(sub))
sub_train = train[id_ + [self.info_['time_col']]]
sub_train[target] = predict_train
sub_train.index = range(len(sub_train))
return sub,sub_train,self.dfs_['FE_all']