详情请参见原书
《机器学习算法竞赛实战(图灵出品)》(王贺,刘鹏,钱乾)【摘要 书评 试读】- 京东图书
比赛链接:
https://www.kaggle.com/competitions/elo-merchant-category-recommendation/overview
想象一下,当你在一个不熟悉的地方饿着肚子想要找好吃的东西时,你是不是会得到基于你的个人喜好而被专属推荐的餐馆,且该推荐还附带着你的信用卡提供商为你提供的附近餐馆的折扣信息。
目前,巴西最大的支付品牌之一Elo已经与商家建立了合作关系,以便向顾客提供促销或折扣活动。但这些促销活动对顾客和商家都有益吗?顾客喜欢他们的活动体验吗?商家能够看到重复交易吗?要回答这些问题,个性化是关键。
Elo 建立了机器学习模型,以了解顾客生命周期中从食品到购物等最重要方面的偏好。但到目前为止,那些学习模型都不是专门为个人或个人资料量身定做的,这也就是这场竞赛举办的原因。
在这场竞赛中,需要参赛者开发算法,通过发现顾客忠诚度的信号,识别并为个人提供最相关的机会。你的意见将改善顾客的生活,帮助Elo减少不必要的活动,为顾客创造精准正确的体验。
为了保证隐私与信息安全,本次竞赛的所有数据都是模拟与虚构数据或经过脱敏的数据,并非真实的顾客数据。具体包含下列数据文件。
通过顾客的历史交易记录以及顾客和商家的信息数据进行模型训练,最终预测测试集里面所有信用卡的忠诚度分数
其中为是参赛者对每个信用卡预测的忠诚度分数,而是对应信用卡的真实忠诚度分数。
Q:竞赛提供了这么多数据文件,至少需要哪些才能完成建模?
A:至少需要train.csv 和test.csv,这两个文件包含所有将会被用来进行训练与测试的信用卡card_id。另外 historical_transactions.csv 和 new_merchant_transactions.csv 包含每张信用卡的交易记录。
Q:参赛者如何能够将其余的数据利用上呢?
A:train.csv 和 test.csv 包含所有信用卡的 card_id 和信用卡本身的信息(比如卡激活的第一个月是何时等)。此外 train.csv 还包含部分顾客的目标值,即提供了这部分顾客确定的忠诚度分值。historical_transactions.csv 和 new_merchant_transactions.csv 设计为与 train.csv,test.csv 和merchants.csv 结合在一起,因为如上所述,这两个文件包含每张信用卡的交易记录所以将交易记录与商家结合在一起可以提供额外的商家级别等信息
点击赛题主页中的data可以直接查看数据信息
在进行数据探索前,参赛者首先应该明确对各数据文件的介绍以及文件中字段的含义,以便理解赛题和拾建分析逻辑。参考赛题主办方提供的字段信息表Data_Dictionary.xlsx可知,五个数据文件中的子段及含义如下。
train.csv 与 test.csv 中的字段及含义
字段 | 含义 | 举例 |
---|---|---|
card_id | 独一无二的信用卡标识,即信用卡id | C_ID_92a2005557 |
first_active_month | 首次使用信用卡购物的月份(注册时间),格式为YYYY-MM | 2017-04 |
feature_1/2/3 | 匿名的信用卡离散特征1/2/3 | 3 |
target | Loyalty numerical score calculated 2 months after historical and evaluation period. 忠诚度分数目标列 | 0.392913 |
查看上述字段的含义可知,三个feature都是匿名的信用卡离散字段,还有一个首次购物的月份,而target是在历史和评估时期后的两个月进行量化计算得到的忠诚度分数。需要注意的是,这里的 evaluation period 应该是指 new_merchant_transactions.csv 中的信息,同时也是对应Data_Dictionary.xlsx 里面的 new_merchant_period 字段。同时校验一下数据的正确性就发现训练集与测试集的 card_id 均为唯一值,且训练集与测试集中的card_id不重复。
historical_transactions.csv 和 new_merchant_transaction.csv 中的字段及含义
字段 | 含义 | 举例 | 说明 |
---|---|---|---|
authorized_flag | 2 | ||
card_id | 独一无二的信用卡标识,即信用卡id | C_ID_415bb3a509 | 3 |
month_lag | 距离参考日期的月份 | [-12,-1]、[0,2] | 2 |
purchase_date | 购物日期(时间) | 2018-03-11 14:57:36 | |
category_3 | 匿名类别特征3 | A/B/C/D/E | 2 |
installments | 购买商品的数量 | 1 | 1 |
category_1 | 匿名类别特征1 | Y/N | |
merchant_category_id | 商品种类id(经过了匿名处理) | 307 | 2 |
subsector_id | 商品种类群id(经过了匿名处理) | 19 | |
merchant_id | 商品 id(经过了匿名处理) | M_ID_bec793002c | 3 |
purchase_amount | 标准化的购物金额 | -0.557574 | 1 |
city_id | 城市id(经过了匿名处理) | 300 | |
state_id | 州id(经过了匿名处理) | 9 | |
category_2 | 匿名类别特征2 | 1 |
merchants.csv 中的字段与含义
黄色背景:重复特征;加粗:离散型;加粗斜体:离散型非数值型;橙色字体:连续型
查看每个字段的含义及取值情况:离散与否、取值类型、大小关系(独立还是有顺序含义)
缺失值,字段的取值范围和分布:
离散:数量分布;
连续:异常值、离群点。可采用pandas.series的describe方法;若采用value_counts方法,可以发现极端值-33.2,占比约1%
以上可以通过kaggle直接查看(较为粗略)
数据集划分依据:训练集、测试集、验证集的数据分布要相似,尤其是特征和标签的联合分布要一致。
下面对train.csv和test.csv中的first_active_month、feature_1、feature_2、feature_3几个字段进行单变量分布对比展示。
kaggle网站可以直接展示数据分布情况。
绝对数量分布
结论:训练集与测试集在所有单变量上的绝对数量分布形状极其相似,需要进一步查看相对占比分布
相对占比分布
结论:训练集与测试集在所有单变量上的相对占比分布形状基本一致,猜想训练集与测试集的生成方式一样,继续验证联合分布以加强猜想的事实依据
TODO:这里画图分析有一不严谨之处,即训练集与测试集的单变量取值范围可能不完全一样,由此两根线画在同一张图上有可能会出错,如发生偏移等,请参赛者自行验证二者的横坐标是否完全一样?如果不一样,运行这段代码会发生什么?在下面的联合分布验证中,我们将会填补这一遗漏之处
多变量联合分布
可以使用散点图。但是散点图适用于连续特征。因此可以将两个单变量拼接,再用上述方法。查看结果发现依然保持一致。(参见eda.ipynb)
这里只给出详细步骤,具体代码请见本书附带资源中的eda.ipynb。
这两个表格只有test.csv中的first_active_month字段有一个缺失值,总体来说只有一个缺失值的影响不大,且这个字段是字符型,因此需要对其进行编码处理,考虑到其实质上具有先后顺序关系,采用字典排序进行编码即可。
处理步骤如下:
(1)根据业务含义划分离散字段category_cols与连续字段numeric_cols;
(2)对字符型的离散字段进行字典排序编码;
(3)为了更方便统计,对缺失值进行处理,对离散字段统一用-1进行填充;
(4)探查离散字段发现有正无穷值,这是特征提取以及模型不能接受的,因此需要对无穷值进行处理,此处采用最大值进行替换;
(5)对离散字段的缺失值进行处理的方式有很多种,这里先使用平均值进行填充,后续有需要再进行优化处理;
(6)去除与交易记录表格重复的列以及对merchant_id的重复记录。
处理步骤如下:
(1)为了统一处理,首先将这两张表格拼接起来,后续可以通过month_lag>=0这个条件进行区分
(2)划分离散字段、连续字段以及时间字段:
(3)可仿照merchants.csv的处理方式对字符型离散字段进行字典排序编码以及对缺失值进行填充
(4)对时间段进行处理,简单起见,提取月份、星期几(工作日与周末)以及时间段(上午、下午、晚上、凌晨)信息
(5)对新生成的购买月份离散字段进行字典排序编码;
(6)处理完商家信息和交易记录的表格后,为了方便特征的统一计算将这几个表格合并,然后重新划分相应的字段种类。
本赛题的重点便是挖掘用户的各种交易行为与目标列的关系,进而达到良好的模型学习效果,使模型能够准确预测测试集用户的忠诚度分数。因此这是一个关注信用卡用户局部消费偏好画像的题目,通过找到相似的训练集用户来类推测试集用户的忠诚度分数,进而对高价值人群进行区分,给商家与信用卡银行提供决策支持,同时也能够提升消费者的购物体验,因此特征工程可集中于用户的交易行为画像,即用户在各个维度上购物行为的量化,比如最近一个月的消费金额与购买数量等。
在评估用户价值的画像领域,有个经典的RFM理论,即Recent,Frequency(频次)和Money(金钱)。结合前面的数据探索,能够明确这一理论的可行性。这里将用购买数量模拟Frequency,把消费金额作为Money。本赛题不仅在建模目标上具有广泛性,其数据结构也具有典型的特点,即主要利用用户的行为记录表格(historical_transactions.csv,merchants.csv.以及new_merchant transactions.csv)进行信息挖掘。
接下来将分别介绍特征提取的两种办法,一种是借助python的原生字典结构进行通用特征的提取,另一种则借助pandas这一强大的数据处理工具的统计函数进行业务特征的提取。
字典的键值结构很好地提供了便于使用的映射关系,这里的特征提取可以把用户作为第一层键值,把特征字段作为第二层键值,统计完成后再将字典转换成pandas.DataFrame格式;简单来说,就是想知道用户在每个类别字段的每个取值下的购买数量与消费金额。
首先,创建一个字典以存储生成的统计特征,并给每个card_id赋值:
features = {}
card_all = train['card_id'].append(test['card_id']).values.tolist()
for card in card_all:
features[card] = {}
其次,记录好每个字段的索引以便按行处理的时候直接获取目标值:
columns = transaction.columns.tolist()
idx = columns.index('card_id')
category_cols_index = [columns.index(col) for col in category_cols]
numeric_cols_index = [columns.index(col) for col in numeric_cols]
然后,按行进行相应字段的特征提取和更新:
# 记录运行时间
s = time.time()
num = 0
for i in range(transaction.shape[0]):
va = transaction.loc[i].values
card = va[idx]
for cate_ind in category_cols_index:
for num_ind in numeric_cols_index:
col_name = '&'.join([columns[cate_ind], va[cate_ind], columns[num_ind]])
features[card][col_name] = features[card].get(col_name, 0) + va[num_ind]
num += 1
if num%1000000==0:
print(time.time()-s, "s")
del transaction
gc.collect()
最后,将字典转换成特征DateFrame表格结构,并且重置表格的列名。
# 字典转dataframe
df = pd.DataFrame(features).T.reset_index()
del features
cols = df.columns.tolist()
df.columns = ['card_id'] + cols[1:]
在表格生成后就可以拼接训练集和测试集,进行后续的模型训练。为区别于 后续特征,将该特征集命名为dixt。(具体参见dict.ipynb)
# 生成训练集与测试集
train = pd.merge(train, df, how='left', on='card_id')
test = pd.merge(test, df, how='left', on='card_id')
del df
train.to_csv("preprocess/train_dict.csv", index=False)
test.to_csv("preprocess/test_dict.csv", index=False)
基于字典结构的通用特征提取,其优势在于可以按行读取及处理,无论速度还是内存都有一定的保障,还可以面面俱到地量化到每个子类下的用户行为。但其缺点也比较明显,即需要固定的数据结构,同时会产生较高维度的结果。另一种方案是使用pandas工具的groupby方法
进行统计,这种方式简单很多,但对内存性能要求较高,因为需要加载全部数据。需要注意的是,这里为了符合pandas的统计需要,不再对缺失值以及离散型字段进行转化。
同时增加两个特征,这两个特征与用户两次购买行为之间的时间间隔有关,分别从日和月方面进行刻画,代码如下;
transaction['purchase_day_diff'] = transaction.groupby("card_id")['purchase_day'].diff()
transaction['purchase_month_diff'] = transaction.groupby("card_id")['purchase_month'].diff()
首先,根据字段的种类设置相应想获取的统计量,并给定相应的字段列表,为后续的计算做准备,这种方式逻辑清晰,特征构造更加全面:
aggs = {}
for col in numeric_cols:
aggs[col] = ['nunique', 'mean', 'min', 'max','var','skew', 'sum']
for col in categorical_cols:
aggs[col] = ['nunique']
aggs['card_id'] = ['size', 'count']
cols = ['card_id']
for key in aggs.keys():
cols.extend([key+'_'+stat for stat in aggs[key]])
然后,针对new_merchant_transactions.csv,historical_transactions.csv 以及全时间段分别进行计算和统计,获取多角度下的统计特征:
df = transaction[transaction['month_lag']<0].groupby('card_id').agg(aggs).reset_index()
df.columns = cols[:1] + [co+'_hist' for co in cols[1:]]
df2 = transaction[transaction['month_lag']>=0].groupby('card_id').agg(aggs).reset_index()
df2.columns = cols[:1] + [co+'_new' for co in cols[1:]]
df = pd.merge(df, df2, how='left',on='card_id')
df2 = transaction.groupby('card_id').agg(aggs).reset_index()
df2.columns = cols
df = pd.merge(df, df2, how='left',on='card_id')
可以看出,利用groupby方法统计出的特征数量会少很多,集中为用户各种行为的统计量,为区别于后续特征,将此处特征集命名为groupby。
除去上述常规的特征之外,本赛题还可以对一类特征进行提取,就是基于CountVector和NLP 领域的TF-IDF向量特征,不同于前面的dict和groupby,这里只针对部分离散字段进行词频统计。CountVector与dict部分的特征比较像,而TF-IDF则是对多变量联合分布的补充。
首先将相应字段处理成标准的输入格式,然后调用sklearn中的相关方法进行计算,需要注意这部分特征采用的是scipy的sparse稀疏矩阵结构,因此在处理上与dict和 groupby有所不同。
常见的特征选择方法主要分两种,一种是过滤式选择,另一种是特征重要性选择。前者利用一些统计学上的相关性系数进行过滤,后者通过模型评估过程中的特征重要性进行选择。一般来讲,特征选择的功能主要出于提升模型训练速度与精度两个方面的考虑,在8.4节将会针对不同的特征选择方法进行模型训练,并对比最终的线下,线上结果。
在准备好基础特征后,参赛者就可以开始尝试模型训练与预测的全流程,为尽可能多地给参赛者介绍一些处理技巧,本节将会介绍三种模型(随机森林、LightGBM和XGBoost)的全流程,同时组合不同的特征选择与参数调优方法。
首先是sklearm库里的随机森林模型,本模型的全流程分为四个模块:读取数据、特征选取、参数调优以及训练预测。模型的要素组成为8.3.4节中的dict和groupby两部分,特征选取方面采用基于皮尔逊相关系数计算的Filter方法取前300个特征,参数调优方面使用skleam库的网格搜索(GridSearch)。
首先,读取已经提前构造好的指定特征集和测试集并且进行数据集的拼接,具体代码如下:
def read_data(debug=True):
"""
读取数据
:param debug:是否调试版,可以极大节省debug时间
:return:训练集,测试集
"""
print("read_data...")
NROWS = 10000 if debug else None
train_dict = pd.read_csv("preprocess/train_dict.csv", nrows=NROWS)
test_dict = pd.read_csv("preprocess/test_dict.csv", nrows=NROWS)
train_groupby = pd.read_csv("preprocess/train_groupby.csv", nrows=NROWS)
test_groupby = pd.read_csv("preprocess/test_groupby.csv", nrows=NROWS)
# 去除重复列
for co in train_dict.columns:
if co in train_groupby.columns and co!='card_id':
del train_groupby[co]
for co in test_dict.columns:
if co in test_groupby.columns and co!='card_id':
del test_groupby[co]
# 拼接特征
train = pd.merge(train_dict, train_groupby, how='left', on='card_id').fillna(0)
test = pd.merge(test_dict, test_groupby, how='left', on='card_id').fillna(0)
print("done")
return train, test
然后采用基于皮尔逊相关系数计算的Filter方法取前300个特征进行选取,这里的300是随意取的一个数字,参赛者可以多试几个数字以选出效果最佳的,具体代码如下:
def feature_select_pearson(train, test):
"""
利用pearson系数进行相关性特征选择
:param train:训练集
:param test:测试集
:return:经过特征选择后的训练集与测试集
"""
print('feature_select...')
features = train.columns.tolist()
features.remove("card_id")
features.remove("target")
featureSelect = features[:]
# 去掉缺失值比例超过0.99的
for fea in features:
if train[fea].isnull().sum() / train.shape[0] >= 0.99:
featureSelect.remove(fea)
# 进行pearson相关性计算
corr = []
for fea in featureSelect:
corr.append(abs(train[[fea, 'target']].fillna(0).corr().values[0][1]))
# 取top300的特征进行建模,具体数量可选
se = pd.Series(corr, index=featureSelect).sort_values(ascending=False)
feature_select = ['card_id'] + se[:300].index.tolist()
print('done')
return train[feature_select + ['target']], test[feature_select]
接着就是基于网格搜索的参数调优。网格搜索实际上是不同参数、不同取值的排列集合,有可能需要根据调优结果多次手动选代参数空间,当然每次选代都是在上一次最佳参数的基础上增加未搜索过的参数区域,具体代码如下:
def param_grid_search(train):
"""
网格搜索参数寻优
:param train:训练集
:return:最优的分类器模型
"""
print('param_grid_search')
features = train.columns.tolist()
features.remove("card_id")
features.remove("target")
parameter_space = {
"n_estimators": [80],
"min_samples_leaf": [30],
"min_samples_split": [2],
"max_depth": [9],
"max_features": ["auto", 80]
}
print("Tuning hyper-parameters for mse")
clf = RandomForestRegressor(
criterion="mse",
min_weight_fraction_leaf=0.,
max_leaf_nodes=None,
min_impurity_decrease=0.,
min_impurity_split=None,
bootstrap=True,
oob_score=False,
n_jobs=4,
random_state=2020,
verbose=0,
warm_start=False)
grid = GridSearchCV(clf, parameter_space, cv=2, scoring="neg_mean_squared_error")
grid.fit(train[features].values, train['target'].values)
print("best_params_:")
print(grid.best_params_)
means = grid.cv_results_["mean_test_score"]
stds = grid.cv_results_["std_test_score"]
for mean, std, params in zip(means, stds, grid.cv_results_["params"]):
print("%0.3f (+/-%0.03f) for %r"
% (mean, std * 2, params))
return grid.best_estimator_
最后根据参数调优的最佳结果进行模型训练与预测,这里选择五折交叉验证,注意保存训练集的交叉预测结果以及测试集的预测结果,便于8.5节使用。
def train_predict(train, test, best_clf):
"""
进行训练和预测输出结果
:param train:训练集
:param test:测试集
:param best_clf:最优的分类器模型
:return:
"""
print('train_predict...')
features = train.columns.tolist()
features.remove("card_id")
features.remove("target")
prediction_test = 0
cv_score = []
prediction_train = pd.Series()
kf = KFold(n_splits=5, random_state=2020, shuffle=True)
for train_part_index, eval_index in kf.split(train[features], train['target']):
best_clf.fit(train[features].loc[train_part_index].values, train['target'].loc[train_part_index].values)
prediction_test += best_clf.predict(test[features].values)
eval_pre = best_clf.predict(train[features].loc[eval_index].values)
score = np.sqrt(mean_squared_error(train['target'].loc[eval_index].values, eval_pre))
cv_score.append(score)
print(score)
prediction_train = prediction_train.append(pd.Series(best_clf.predict(train[features].loc[eval_index]),
index=eval_index))
print(cv_score, sum(cv_score) / 5)
pd.Series(prediction_train.sort_index().values).to_csv("preprocess/train_randomforest.csv", index=False)
pd.Series(prediction_test / 5).to_csv("preprocess/test_randomforest.csv", index=False)
test['target'] = prediction_test / 5
test[['card_id', 'target']].to_csv("result/submission_randomforest.csv", index=False)
return
这里最后一步采用的是五折交叉验证,一方面可以避免模型对训练集的过拟合,另一方面可使模型对测试集的预测结果更具健壮性,还有一个顺带的好处是可生成用于Stacking融合的特征,即训练集的交叉预测结果和测试集的模型预测结果,将这两者保留下来为后续模型融合做准备,总共需要保存三个文件:train_randomforest.csv,test_randomforest.csv和 submission randomforest.csv.
预测结果出来以后,提交测试,得到具体分数,交叉验证分数为3.68710936,其中提交得分为Public Score(公开榜,俗称A榜)是3.75283(2867/4127),Private Score(隐藏榜,俗称B榜)是3.65493(2814/4127).
if __name__ == "__main__":
# 获取训练集与测试集
train, test = read_data(debug=False)
# 获取特征选择结果
train, test = feature_select_pearson(train, test)
# 获取最优分类器模型
best_clf = param_grid_search(train)
# 获取结果
train_predict(train, test, best_clf)
# [3.6952175995861753, 3.653405245049519, 3.711542672510601, 3.78859477721067, 3.586786511640954] 3.687109361199584
重要性前300
Hyperopt是一个 sklearn的Python库,它在搜索空间上进行串行和并行优化,搜索空间可以是实值、离散值和条件维度,提供了传递参数空间和评估函数的接口,目前支持的优化算法有随机搜索(random search)、模拟退火(simulated annealing)和TPE(Tree of Parzen Estimators)算法。相较于网格搜索,hyperopt往往能够在相对较短的时间内获取更优的参数结果。具体代码如下:
对于结果的输出,网格搜索输出含最佳参数的结果,hyperopt输出最佳参数字典
读取数据时,需要把之前的特征集与nlp特征合并成sparse稀疏矩阵;参数调优阶段,最大化评估分数,即均方误差最小
将模型的结果按照分数和排名分配权重
使用最好的xgboost模型产生的stacking特征(训练集和测试集的预测结果)
以card_id为key进行聚合(groupby)统计
分别对new_transactions.csv,historical_transactions.csv(authorized_flag=1)和historical transactions.csv(authorized_flag=0)的数据集提取此部分特征。
主要包含与用户行为时间相关的统计,比如
(3)最近一次交易与首次交易的时间差、信用卡激活日与首次交易的时间差;
最近两月的card_id仅对historical_transactions.csv的数据集提取此部分特征。此部分与全局card_id特征有很多类似特征,主要差别在于时间范围不同,此处更加注重用户近期的行为变化情况。
仅对historical_transactions.csv的数据集提取此部分特征,前提是要先构建一阶特征(nunique、count、sum等),具体提取结构如下;
for col_level1,col_level2 in tqdm_notebook(level12_nunique):
level1 = df.groupby(['card_id',col_level1])[col_level2].nunique().to_frame(col_level2 + '_nunique')
level1.reset_index(inplace =True)
level2 = level1.groupby('card_id')[col_level2 + '_nunique'].agg(['mean', 'max', 'std'])
level2 = pd.DataFrame(level2)
level2.columns = [col_level1 + '_' + col_level2 + '_nunique_' + col for col in level2.columns.values]
level2.reset_index(inplace = True)
cardid_features = cardid_features.merge(level2, on='card_id', how='left')