集算示例:10 行代码解决漏斗转换计算

销售过程是一个多环节的过程,哪个步骤有了过大瑕疵,都会导致业绩急剧下滑。而诊断出哪个步骤有瑕疵,除了无形的经验,还有量化的诊断方式,就是今天要讨论的主角:转化漏斗模型

示例数据

为了详细讨论这个漏斗的实现过程,我们举一个具体的网上商城的例子,被分析的数据也不复杂,只有一个事件表

用户 ID:用户编码

事件 ID:事件编码

事件属性:不同事件有不同属性;json 格式,{“content”:”computer”,”page_num”:1}

时间:事件发生的时间

事件类型和事件属性如下所示

ld-1

需求定义

目标结果是获得某个操作流程在每个操作的客户流失率,如下图,登录的用户有 20000 人;其中有 12000 人进行了:登录 -> 搜索商品;其中有 8000 人进行了:登录 -> 搜索商品 -> 查看商品。如下图所示:

ld-2

每个事件后都可能流失一些用户,整个图示就象一个漏斗形状,所以被称为漏斗转换分析。

我们来研究这个运算的一些需求点:

针对同一个用户,我们观察下面这两组数据,因为事件顺序关系,我们认为 1000001 用户只发生了登录行为,而 1000002 的三个事件符合目标顺序,到查看商品事件才流失

用户 ID 事件 ID 事件名称 时间
1000001 10002 登录 2017/2/3 0:01
1000001 10004 浏览产品 2017/2/3 0:03
1000001 10003 搜索产品 2017/2/3 0:08
1000001 10007 生成订单 2017/2/3 0:12
用户 ID 事件 ID 事件名称 时间
1000002 10002 登录 2017/2/3 0:01
1000002 10003 搜索产品 2017/2/3 0:03
1000002 10004 浏览产品 2017/2/3 0:08

上面这些事件,有一些事件有必然的前后关系,比如退订商品肯定发生在订单付款之后,订单付款肯定发生在生成订单之后;而收藏商品和加入购物车就不一定谁先谁后了,退订商品前也不一定发生评价商品的事件。这些不稳定性背后隐藏着用户行为,通过对一组有序事件的漏斗分析,就找到了这组行为用户在各个阶段的流失率。这是第一个需求点:事件要顺序发生,且能灵活定义

第二个需求点是能对事件属性自由定义条件,如 brand=’APPLE’,price>10。

第三个需求点是定义时间段,这段时间之外的数据不在考察范围内,如 2017-02-012017-0228。

第四个需求点仍然是和时间有关,窗口时间,只有在窗口时间以内顺序发生的事件才符合要求,比如 5 分钟、3 个自然天;上面图中的 1000002 用户在 5 分钟的窗口时间条件下,那只有前两个事件符合要求,因为浏览产品事件迟于登录 7 分钟而超出窗口时间了。

第五个需求点是每个用户只记录一次符合要求的最长事件序列

额外多提一句,现实业务中发生的漏斗分析不一定和上面这些需求细节完全一致,完全有可能更复杂,更个性化,我们这里是为了容易说明问题而假定了这些需求细节。

算法详述

因为事件要求顺序发生,所以我们第一步应该把数据先按照时间排序了,这样同一个窗口期的数据就汇聚到比较集中的一块了,而且窗口期内数据的位置也能表示先后次序了。数据量大大超出内存,用可以借用集算器的 sortx 对原始数据游标进行外存排序。

A B
1 =file(fPath+”event.data”).cursor@t(用户 ID, 事件 ID, 事件属性, 时间) / 以游标方式加载文件数据
2 =A1.sortx(时间) / 用时间字段外存排序
3 =file(fPath+”eventOrdered.data”).export@b(A2) / 排序结果存入目标文件

最终的结果只需要每个用户最长符合条件的事件序列的长度:代表该用户发生流失时的最后一个事件。因为查找的过程中,不确定哪个事件序列最长,所以聚合信息里会保持住多个事件序列的信息。

events:定义目标事件顺序数组:[A,B,C,D,E],事件序号分别为 1,2,3,4,5

UserList:定义空序表,每个用户的信息聚合成一条信息存入这个序表,单个用户聚合后的信息用 JSON 格式说明如下

{

用户 ID:1000001

maxLen当前已找到的最大事件序列长度:3

seqs多个符合要求的事件序列数组:[

{// 第一个事件序列

该事件序列的开始时间:2017-02-03 21:18:18

, 该事件序列最后事件序号:2

},

{// 第二个事件序列

该事件序列的开始时间:2017-02-04 08:08:08

, 该事件序列最后事件序号:3

},

……

]

}

在定义了上面变量的基础上,写段伪代码来描述算法过程:

for ( 按时间排好序的原始数据,循环逐条处理){

// 当前记录里四个个变量:当前用户、当前事件、当前时间,当前事件属性

if (当前时间不在查询时间段内 || 当前事件属性不符合要求 ) continue;

if ( 在UserList里找到当前用户){

if (maxLen == events长度 ) {

continue; // 已经找到当前用户完整的目标事件序列,不用处理了,直接跳过

} else {

if (当前事件 == events第一个事件 ) {

新建一个事件序列追加入events。

} else {

for(events){

// 变量:事件序列=events [i]

if (当前事件序号 == 事件序列最大序号+1 AND 当前时间事件序列的窗口期内 ) {

事件序列最大序号增加 1

if(事件序列最大序号>maxLen){

maxLen增加 1

}

}

}

}

}

} else {// 没找到

新建当前用户的聚合信息,然后追加到UserList

}

}

解决方案

  1. sql 或存储过程。虽然这个计算针对单表,但过程复杂,还对数据有序性有要求,这特点就正好是 sql 的软肋。 能用 sql 写出来的人,估计是凤毛麟角,理论上能不能写出来也存疑。退一步讲,即便是写出来了,性能的可控又是一大难题。反正我是没有去细研究了,假如有研究出来的同学,可以反馈给我学习下。
  2. UDF?那相当于直接用高级语言硬编码了,代码量可想而知(比如用 Java 不会少于 200 行),不光写出来难度很大,以后再修改维护都是头疼的事,这种 UDF 又没什么通用性,需求变了就得重写。
  3. MapReduce 以及 Spark 之类的东西?MapReduce 对付这种有序的运算还真不好想。只是这个算法用 Scala 确实也写得出来,也不算太长,不过,其中的问题却是……,算了,这次先不提,以后专门细说,总之 Scala 是不合适。
  4. 想要一种能精确描述这个计算过程,并且描述方法符合人类自然思维习惯,并且能清楚知道每个步骤结果,并且能对步骤里的性能优化也能精确控制,并且代码量不大,并且代码容易复用……! 这么多贪心的“并且”,那就只能推荐这个专门处理数据的集算器脚本语言了。直接看代码
A B C D E
1 >begin=date(string(begin),”yyyyMMdd”) >end=date(string(end),”yyyyMMdd”) >dateWindow=eval(dateWindow)
2 =create(用户 ID,maxLen,seqs).keys(用户 ID) =now()
3 =file(fPath+”event30.txt”).cursor@t() =A3.select(时间 >=begin&& 时间 <end && events.pos( 事件 ID)>0 && ${filter})
4 for C3 >user=A2.find@b(A4. 用户 ID)
5 if user==null >if (A4. 事件 ID==events(1),A2.insert(,A4. 用户 ID: 用户 ID,1:maxLen,[[A4. 时间,1]]:seqs))
6 next
7 if user.maxLen==events.len() next
8 for user.seqs >nextXh=B8(2)+1,time1=long(A4. 时间), time2=long(B8(1))+dateWindow, outWindow=time1>time2,nextEvt=A4. 事件 ID==events(nextXh) if(outWindow||!nextEvt) next
9 >B8(2)=nextXh,if(nextXh>user.maxLen,user.maxLen=nextXh),if(nextXh==events.len(),user.seqs=null) next A4
10 if A4. 事件 ID==events(1) >user.seqs.insert(0,[[A4. 时间,1]]),if(user.maxLen==0,user.maxLen=1)
11 =[A2.len()] for events.len()-1 >A11.insert(0,A11(B11)-(A2.select(maxLen==B11).len()))
12 =interval@ms(C2,now())

针对单用户的聚合代码是第 4~10 行,规模和上面的伪代码相当,基本上就是按自然思路去写出算法。如果用 Java 类语言起码是 10 倍长度了,代码长了就要翻好几页,看到后面就会忘了前面,而集算器的代码很短,一屏就能呈现出来,整个算法过程一目了然。

如果以前没接触过集算器的话,可能会看不懂这些代码,不过没关系了,掌握任何一门语言的语法都需要一个学习过程,我稍微解释一些关键点:

变量说明:开始事件 begin,结束时间 end,窗口期毫秒数 dateWindow,目标事件序列 events,事件属性过滤条件 filter。

A2:定义一个空序表,也就是伪代码中的UserList。用户 ID为主键。

A3:定义被分析数据文件的游标,这样多大的文件都能分批加载入内存进行计算了。

C3:对数据游标进行条件过滤,效果类比 SQL 语句里的 where 子句。

A4:从 C3 游标里循环取数据,每次取一行记录做处理。

B4:用二分法从UserList里找当前记录的用户。

第 5、6 行:UserList里不存在当前用户的处理分支,按照主键用户 ID顺序自动找正确的位置插入。

第 7 行:当前用户已经找到完整目标事件序列,直接跳过。

第 8~9 行:已找到的多个事件序列进行循环处理,试图把当前用户信息融入某个符合条件的事件序列。融入成功,跳出到 A4 执行下一条;融入失败,执行第 10 行。

第 11 行:用UserList计算出每个目标事件存留的用户数,也就是漏斗需要的各层数据了。

A12:以秒为单位计算出 C2 执行到 A12 的耗时。

实现上面这个功能,无论用哪门语言,程序逻辑应该没多大变化,关键就是看方便程度。这段流程还算繁杂的程序,写完之后执行,只改了两三处小毛病就跑通了,运行到哪个格子发生什么错误;哪个格子运算后的结果是啥都会一目了然。

ld-4

ld-5

为了验证这段程序是否正确,只剩下 1000001 用户如下的 9 条数据:

用户 ID 事件 ID 时间 事件属性
1000001 10001 2017/2/3 8:11 {}
1000001 10002 2017/2/3 8:12 {}
1000001 10003 2017/2/3 8:13 {“content”: “watch”, “page_num”: 3}
1000001 10004 2017/2/3 8:14 {“brand”: “Apple”, “price”: 2500}
1000001 10005 2017/2/3 8:15 {}
1000001 10006 2017/2/3 8:16 {}
1000001 10007 2017/2/3 8:17 {“price_all”: 3500}
1000001 10008 2017/2/3 8:18 {}
1000001 10009 2017/2/3 8:19 {“how”: -1}

不同条件的执行结果:

窗口期 1 分钟,事件序列 [10003,10004,10005],算出来最大事件序号是 2

ld-6

增长窗口期到 5 分钟,结果是 3,找到了完整的目标事件序列。

ld-7

目标事件序列反序 [10005,10004,10003] 测试,结果是 1,因为不存在这种顺序。

ld-8

修改事件属性 Huawei,结果同样为 1,因为没有符合条件的 10004 事件

ld-9

结语及预告

6 万条符合条件的记录,聚合出 3 万个用户的事件数据,耗时1.8 秒。目标数据 6 亿条时,性能即便是线性的也需要 5 个小时,还很可能不是线性的,这就不能容忍了。

理论上能完成的任务在性能不达标的情况下,等同于不能完成。实际上好些生产中的业务就因为耗不起时间和计算资源,不得不作罢。可以预告下我们已经验证了更多优化办法,不仅限于修改这段程序的逻辑,还有发生在数据预处理阶段的。正是在逐步优化、反复试错的过程中才真切体会到一个顺手工具的重要性。敬请关注!