1. 需求分析
数据处理需求是从2千万的客户特征工程数据集(customerfeature)中,抽取部分数据建立训练集进行客户流失模型监督学习,输出目标训练集(traindatas)预计5百万,处理过程如下。
(1)提取流失数据集(churndatas),也就是最后一次统计标记为流失的记录;
(2)客户特征工程数据集(customerfeature)与流失数据集(churndatas)通过“carduser_id”关联,提取流失前8个月的统计特征,构建流失训练集;
(3)提取当前(本月统计有交易)活跃用户两个月前的统计交易特征数据进流失训练集;
(4)计算同一客户交易特征时间间隔,以及计算相关交易次数等数据;
(5)标注数据。
本文,重点分享MongoDB多表关联查询,完成第(2)段工作任务,涉及多表关联、按(carduser_id)分组获取从行1开始的8组数据。
2. 多表关联遇坑记
2.1. 关于多表关联
多表关联使用"$lookup"聚合管道操作,是对同一数据库中的未分片集合执行左外部联接,以从“联接”集合中筛选文档进行处理。$lookup阶段向每个输入文档添加一个新的数组字段,其元素是来自“joined”集合的匹配文档。并且$lookup阶段将这些重新成形的文档传递到下一阶段。
$lookup阶段具有以下语法:
具有单个联接条件的相等匹配:
{
$lookup:
{
from: <外部连接数据集>,
localField: <本文档的关联关键字段>,
foreignField: <外部关联文档关键连接字段 "from" 集合>,
as: <输出数组字段>
}
}
2.2. 遇坑
为了便于交流,简化churndatas文档内容:
/* 1 */
{
"_id" : ObjectId("61a5b31c3bc7a47e1e98dedd"),
"carduser_id" : 1313943,
"yearmonth" : "202105"
}
/* 2 */
{
"_id" : ObjectId("61a5b3403bc7a47e1e98dede"),
"carduser_id" : 1492855,
"yearmonth" : "202106"
}
第一次写的关联查询,缩略版(为了简化说明问题):
db.getCollection('customerfeature').aggregate([
{'$lookup':{
'from': 'churndatas',
'localField': 'carduser_id',
'foreignField': 'carduser_id',
'as': 'newdata'
}}
],
{'allowDiskUse':true})
返回数据集如下所示:
很惨的结果,将返回客户特征工程数据集(customerfeature)全部记录,并关联上所对应的流失客户集。
而实际目标是只返回流失客户所对应的历史数据,这样差个数量级的结果是大量消耗了时间,长时间才能返回结果。
怎么办呢?
以客户流失数据集(churndatas)为主文档,外部关联客户特征工程数据集(customerfeature)进行多表关联查询。
db.getCollection('churndatas').aggregate([
{'$lookup':{
'from': 'customerfeature',
'localField': 'carduser_id',
'foreignField': 'carduser_id',
'as': 'newdata'
}}
],
{'allowDiskUse':true})
很快,返回结果如下:
按预期只返回流失历史数据。
但是,历史数据是以数组方式存在,如何转化成文档,取出规定行的数据呢?
3. 数组转文档及分组提取多行数据记录关键技术
3.1. 数组转文档
$unwind 扩展数组,为每个数组入口生成一个输出文档。
案例中,表关联返回“as”输出有一次数组,需要转换为文档,分组过程中,添加文档(或具体字段)到数组中,也需要转换一次文档。
3.2. 分组提取多行数据记录
在分组聚合过程中,使用$push 聚合操作符,先把分组后的文档记录添加到数组中,用于传递到下一段;再使用$slice返回数组中的子串:
- $push 添加值到数组中
- $slice 返回数据中的子串,与$push和$each一起使用来缩小更新后数组的大小。
3.3 组合后的案例
方案一:直接表关联,获取流失历史数据集后,再分组筛选指定多行数据。
db.getCollection('churndatas').aggregate([
{'$lookup':{
'from': 'customerfeature',
'localField': 'carduser_id',
'foreignField': 'carduser_id',
'as': 'newdata'
}},
{'$unwind': '$newdata'},
{'$replaceRoot':{'newRoot': '$newdata'}},
{'$sort':{'carduser_id':1,'occurtime':1}},
{'$group':{'_id': '$carduser_id','data':{'$push': '$$ROOT'}}},
{'$project':{'data':{'$slice' :['$data',1 ,3]}}},
{'$unwind': '$data'},
{'$replaceRoot':{'newRoot': '$data'}}
],
{'allowDiskUse':true})
方法二:在表关联过程中,先对关联到的历史数据分组筛选指定多行数据,然后再进行文档转换。
其中,“$lookup”中的“pipeline”是针对外联表“customerfeature”聚合,两个表的关联是通过“let”定义关联及其表达式,其中“user_id”是表“churndatas”中“carduser_id”变量定义,而且“let”定义变量只能在“pipeline:[{$match:{$expr:”中使用,而pipeline其他地方则是外联表“customerfeature”的操作内容。
db.getCollection('churndatas').aggregate([
{'$lookup':{
'from': 'customerfeature',
'let':{'user_id':'$carduser_id','ym':'$yearmonth'},
'pipeline':
[{'$match':
{'$expr':
{'$and': [
{ '$eq': [ '$$user_id', '$carduser_id' ] },
{ '$gt': [ '$$ym', '$yearmonth' ] }
]}
}},
{'$sort':{'carduser_id':1,'yearmonth':-1}},
{'$group':{'_id':'$carduser_id','data':{'$push':'$$ROOT'}}},
{'$project':{'data':{'$slice' :['$data',1 ,3]}}},
{'$unwind': '$data'},
{'$replaceRoot':{'newRoot': '$data'}}
],
'as': 'newdata'
}},
{'$unwind': '$newdata'},
{'$replaceRoot':{'newRoot': '$newdata'}},
{'$project':{'_id':0}},
{'$sort':{'carduser_id':1,'yearmonth':1}}
],
{'allowDiskUse':true})
输出结果如下:
注:为了易于文档表达,缩减文档指定行数为3行数据。
4. 总结
MongoDB数据库聚合功能强大,变化较多。开发中时刻需要注意待处理数据集的大小,尽量压缩待处理数据集,节省处理时间。
经过初步试验验证,逆向使用小数据集关联大数据的方法二的效率相对高些,后续将继续完善,欢迎讨论研究。
参考:
[1]. 东山絮柳仔. 详解MongoDB中的多表关联查询($lookup). 博客园. 2018.12
[2]. ghosind. MongoDB查询分组并获取TopN数据. CSDN博客. 2020.11
[3]. 肖永威. MongoDB高级查询聚合应用实践案例. CSDN博客. 2021.04
[4]. MongoDB Manual/aggregation