Predict Shipment Time in Supply Chain Using Random Forest Regressor 用随机森林回归预测供应链货运时长

最近参加了公司组织的Hackthon活动。其中一项的任务就是数字化供应量的货运执行与管理。作为切入点,我选择了考察货运的运输时间及其影响因素,并利用机器学习的回归算法train了一个模型,专门用来预测货运的运输时间。下面总结下过程中遇到的问题以及解决方法。

其实,最初我们是打算做一个分类器来预测货运的及时交付与否。然而,在于供应链的一线同事交流之后才发现,实际中的运营是约20%的货运可以保证on time delivery,剩余80%的只是on time delivery。所以他们并不是特别在意货运是否一定要及时交付。既然如此,我们也就改成做货运时间的回归预测。

遇到的第一个问题,就是数据集里面并没有直接给出货运所花的时间。不过,倒是可以找出特定carton的进出站记录。例如,下面截取carton ID 为#5##74@52的记录。1月8号准备运输到1月16号送达总共花费8天。
Screen Shot 2017-07-17 at 09.24.24
#简单起见,我们先对数据集按carton ID分组再取时间戳的最大值与最小值之差作为每个carton ID的运输时间。
def getTimeNMode(group):
try:
result = pd.Series([(max(group[‘DATE’])-min(group[‘DATE’])).days,group[‘MODE’].unique()[0]],index=[‘ShippingDays’,’ShippingMode’])
return result
except:
return pd.Series([0,”],index=[‘ShippingDays’,’ShippingMode’])
#group_keys=False可以将’CONTAINER ID’,’STATUS’,’DATE’,’MODE’任然保留为columns而不是index
df1 = df[[‘CONTAINER ID’,’STATUS’,’DATE’,’MODE’]].groupby(‘CONTAINER ID’,group_keys=False).apply(getTimeNMode)

下图是ShippingDays的分布图。显然,不同时间段的ShippingDays也会略有不同。
ShippingDays

有了lable值,接下来开始选取feature。这里自然少不了对供应链物流环节的业务知识。目前,公司依赖一套OTM系统来完成货运的运输安排,对应数据集里的OTM_ROUTE_CODE。比如,
CC-SJZ,从工厂直接运到客户
COC-SLCHONGKONG,从工厂先运到就近的物流中心,再运到客户
CDC-SLCROERMOND,从工厂先运到客户就近的物流中心,再运到客户
CODC-SLCHONGKONG-SLCROERMOND,从工厂先运到就近的物流中心,再运到客户就近的物流中心,最后运到客户
#提取OTM_ROUTE_CODE的3个字段,作为模型的3个feature
def getInTransitStopOvers(row):
try:
code = row[‘OTM_ROUTE_CODE’]
#code = row
codes = []
if len(code) > 0 and code.count(‘-‘) >0:
codes = code.split(‘-‘)
if codes[0] in [‘COC’,’CDC’]:
codes.append(‘NA’)
elif codes[0] == ‘CC’:
codes[1] = ‘NA’
codes.append(‘NA’)
else:
codes = [”,”,”]
return pd.Series(codes,index=[‘RouteType’,’OriSLC’,’DesSLC’])
except Exception, e:
print code, ‘ failed in parsing In-Transit StopOvers with the error ‘+str(e)
return pd.Series([”,”,”],index=[‘RouteType’,’OriSLC’,’DesSLC’])

df[‘OTM_ROUTE_CODE’].fillna(”, inplace=True)
df[[‘RouteType’,’OriSLC’,’DesSLC’]] = df.apply(getInTransitStopOvers,axis=1)

然后考察运输承运商集运输方式。为此,考察SHIPPING_METHOD_CODE的字段。例如,
000001_DHLC_P_1D中,DHLC表示承运商为DHL,运输方式是P(普通包裹),优先级为1D(1天到达)。
#提取SHIPPING_METHOD_CODE的3个字段,作为模型的3个feature
def getDeliveryData(row):
try:
method = row[‘SHIPPING_METHOD_CODE’]
#method = row
methods = []
if method.count(‘‘) >2:
methods = method.split(‘
‘,3)
else:
methods = [”,”,”,”]
return pd.Series(methods[1:],index=[‘Carrier’,’DeliveryType’,’DeliveryPriority’])
except Exception, e:
print method + ‘ failed in parsing Delivery Data with the error ‘+str(e)
return pd.Series([”,”,”],index=[‘Carrier’,’DeliveryType’,’DeliveryPriority’])

df[‘SHIPPING_METHOD_CODE’].fillna(”, inplace=True)
df[[‘Carrier’,’DeliveryType’,’DeliveryPriority’]] = df.apply(getDeliveryData, axis=1)

接着将carton的尺寸拆分为长宽高3个地段,作为另外3个feature。
def getCarbonDimensions(row):
try:
dimension = str(row[‘CART_DIM’])
#dimension = row
dimensions = []
if dimension.count(‘X’) >1:
dimensions = [float(d.strip()) for d in dimension.split(‘X’,3)]
else:
dimensions = [0.0,0.0,0.0]
return pd.Series(dimensions,index=[‘Length’,’Width’,’Height’])
except Exception, e:
print dimension, ‘ failed in parsing dimensions with the error ‘+str(e)
return pd.Series([0.0,0.0,0.0],index=[‘Length’,’Width’,’Height’])

df[‘CART_DIM’].fillna(0.0, inplace=True)
df[[‘Length’,’Width’,’Height’]] = df.apply(getCarbonDimensions, axis=1)

最终我们选取下面12个feature。
SHIP_FROM_ORG_ID
TO_CITY
WEIGHT
ORDERED_ITEM
Width
Length
Height
RouteType
OriSLC
DesSLC
Carrier
DeliveryType

我们选取的RandomForestRegressor模型要求所有的feature必须是数值型的。为此,需要先对Categorical类型的feature进行数值编码。这里,我也遇到本次Hackthon最大的难点。我发现sklearn自带的labelEncoder虽然好用,但是,接下来的难题着实耗费了很多时间。

  • 由于我们做的是offline training,需要把模型序列化成一个可导入的文件。这样的话,每个做了转化的faeture的labelEncoder都要单独序列化,甚是麻烦。
  • 在做预测的时候,我的理解是输入的值需要经由同样的labelEncoder做转化。然而,此时labelEncoder永远输出0。一阵吐槽纠结后才发现,labelEncoder的fit或者fit_transform好像只能针对一个array起作用。换句话说,labelEncoder把预测数据当成一个新的一维数组进行重现转化,所以输出值永远为0。最终,我写了一个小方法,可能存在问题,但是能达到我需要的目的。
    from sklearn.externals import joblib
    def categoryEncoding(col,ColName):
    colDict = {}
    colSet = set([i for i in col])
    i = 1
    for j in colSet:
    colDict[j] = i
    i += 1
    joblib.dump(colDict, ‘/home/felix/jupyter/data/dict_’+ColName)
    return colDictdef encodingLookup(ColName, val):
    colDict = joblib.load(‘/home/felix/jupyter/data/dict_’+ColName)
    if val in colDict:
    return colDict[val]
    else:
    return colDict[max(colDict)] +1

接下来就是水到渠成的training和tuning。
from sklearn.cross_validation import train_test_split
from sklearn.model_selection import cross_val_score
from sklearn.ensemble import RandomForestRegressor
from sklearn.grid_search import GridSearchCV
cols = [‘TO_CITY’,’DeliveryType’,’RouteType’,’OriSLC’,’DesSLC’,’Carrier’,’ORDERED_ITEM’]
for colName in cols:
#le = LabelEncoder()
#df_model[colName] = le.fit_transform(df_model[colName])
colDict = categoryEncoding(df_model[colName],colName)
df_model[colName] = df_model[colName].apply(lambda x:colDict[x])

Split into Training and Testing set

train_set, test_set = train_test_split(df_model, test_size=0.2)
X_train = train_set.iloc[:,1:]
y_train = train_set.iloc[:,0]

X_test = test_set.iloc[:,1:]
y_test = test_set.iloc[:,0]

param_grid = [{‘n_estimators’: [400,600,800], ‘max_depth’:[3,5,7]}]
forest_reg = RandomForestRegressor()
grid_search = GridSearchCV(forest_reg, param_grid, cv=5)
grid_search.fit(X_train, y_train)

最终,我选取n_estimators=500,性能如下。
R2 = 0.62546409754312426
相应的Feature Importances排序如下。
TO_CITY
0.26507
WEIGHT
0.191827
ORDERED_ITEM
0.148944
Width
0.075103
Length
0.065954
Height
0.065687
OriSLC
0.050146
Carrier
0.041274
DesSLC
0.040447
SHIP_FROM_ORG_ID
0.038849
DeliveryType
0.015408
RouteType
0.001292

完整的代码参加我的GitHub。经历这次Hackthon,以下几点思考:

  • 你以为的不一定就是业界关心的问题。比如,我们从最初的分类转成回归分析。
  • 字符转数值的labelEncoder在真正的线上系统是如何应用的?
  • 随机森林回归需要考虑regularization吗?
  • 从Feature Importances来看,长宽高貌似对货运时间的影响还比较大,值得再做深入挖掘。
Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s