多标签用户画像分析跑得快的关键在哪里?
用户画像分析需要使用众多标签来描述用户属性,通常有两类标签。一类用户标签的值可能有多个,比如用户学历是中学、大学、研究生、博士等,年龄段是 children、juvenile、youth、middle age、old age,这类标签称为枚举标签。另一类用户标签的值只有两个,比如用户是否注册、是否活跃、是否白领、是否某种促销的目标用户等等,这类标签称为二值标签。
在用户画像分析场景中,往往要对这两类标签的组合条件做过滤计算,例如:查询出中年、大学学历、注册、活跃用户,并且是去年黑五大促的目标用户。
总数据量很巨大时,运算性能的瓶颈常常集中在这个条件过滤上。这些条件非常随意,无法预先计算或指望索引,必须要有高效的硬遍历能力。这时候,对枚举标签和二值标签采用的存储和计算方法就非常关键了。
在关系数据库、数据仓库中,枚举标签也就是个普通字段,相应的过滤计算是在 WHERE 子句中用 IN 来完成,一般是 d IN (d1,…,dn) 的形式,即字段 d 取值包含于值集合 {di,..} 时为真。IN 计算的性能较差,主要由于其中有太多的比较运算。要判断字段 d 是否包含在值集合中,如果采用顺序查找,需用 d 与值集合中的成员做 1 到 n 次的比较计算。即使在值集合有序的情况下用二分法查找,也要比较数次。数据量较大时比较次数会非常多,判断 IN 的速度就会很慢,而且值集合越大速度越慢。
枚举标签过滤性能优化的关键是消除其中的比较运算。首先,确定 IN 字段(即写成 IN 条件前面的字段)可能取值的列表。可能值通常不会太多,这个列表也不会太长。然后转换原数据,把 IN 字段值替换为列表中对应记录的序号(位置),另存成一份新数据。
对替换后的新数据做 IN 判断时,先要生成一个与列表等长的布尔值集合,其第 i 个值由列表的第 i 个成员是否在 IN 字段的值集合中决定,在其中就是 true,不在就是 false。遍历时,用 IN 字段值(列表的序号)去取布尔值集合中的成员,是 true 就符合过滤条件,否则就不符合。
这种方法本质上是将“集合值比较”转换为“序号引用”,省去了比较计算,性能会大幅提升。而且计算时间和值集合大小无关,不会随着 IN 条件中枚举值的增多而增加。
SQL 中一般不支持通过序号(位置)直接取集合成员的方法,要用关联表过渡,会导致更复杂的 JOIN 运算,不能直接实现这种优化方法。
二值标签在数据库中一般用布尔型字段来存储。如果只有几个或几十个,那就简单地把过滤条件写在 WHERE 中就可以了。但标签总数有可能达到成百上千个。很多数据库表不支持这么多字段,还要分成多个表再做 JOIN。在数据量很大时,大表连接的性能非常差。
为了避免大表连接,还可以把几千个布尔字段转列为行,用一个“标签号”字段存储,计算时先分组再过滤、统计。但这个分组结果集很大,需要外存缓存,性能还是很差。
如果用整数的二进制位来存储二值标签(0,1 各代表一个取值),那么 16 位短整数就能存 16 个标签,100 个整型字段就能存 1600 个标签,可以有效减少字段数量,避免大表连接。
但是,很多数据库还不支持这种位相关的计算,也就不能实现这种性能优化方法。
开源数据计算引擎 SPL 支持序号引用和位式运算,可以很方便地实现上述优化方法。相应的 SPL 代码也很简单,例如原数据表 T_ordinary 中的字段包括:用户 id、枚举标签字段 dName(比如年龄段:children、juvenile、youth、middle age、old age)、16 个布尔标签字段 flag1 到 flag16,以及金额字段 amt。其中,dName 的取值范围在选项表 dim 中。下面的代码可以完成序号引用和位式存储的转换:
A |
|
1 |
=file("T_ordinary.ctx").open().cursor(id,dName,flag1,flag2,…,flag16,amt) |
2 |
=T("dim.btx") |
3 |
=A1.new(id,dim.pos@b(dName):d,bits(flag1,flag2,…,flag16):b,amt) |
4 |
=file("T.ctx").create(id,d,b,amt) |
5 |
=A4.append(A3) |
A3 用 pos 函数将 dName 的值替换为 dim 中的序号,存成新字段 d。dim 预先按照 dName 有序,这里用二分法查找速度更快。同时使用 bits 函数把 16 个标签字段转换成一个 16 位整数字段 b。
转换好的表 T 就可以做高性能的标签过滤和统计了。例如,过滤条件是 dName 取值在前端传入的集合 ["middle age","old age"] 中,且标签 flag4、flag8 为 1。 完成过滤之后,按照 d 分组汇总金额和记录数,代码大致是这样的:
A |
|
1 |
=bits(0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0) |
2 |
=T("dim.btx").(["middle age","old age"].contain@b(~)) |
3 |
=file("T.ctx").open().cursor(amt;A2(d) && and(b,A1)==A1) |
4 |
=A3.groups(d;sum(amt),count(~)) |
A1 用 bits 函数生成 16 位小整数,第 4、8 位值是 1,对应标签 flag4、flag8。A2 生成布尔值集合。A3 利用布尔值集合和小整数做过滤计算。
在使用 SPL 的虚表后,还可以把这些变换过的字段透明化,直接像普通字段一样使用。比如:基于表 T 定义虚表 T_pseudo 后,上述代码大致会变成这样:
A |
|
1 |
=T_pseudo.select(flag4 && flag8 && ["middle age","old age"].contain@b(dName)) |
2 |
=A3.groups(dName;sum(amt),count(~)) |
flag4、flag8 是虚表中定义的位维度字段,对应 T 表中 b 字段的第 4、8 位。dName 则是虚表中的枚举维度字段,其值是 T 表中的 d 字段序号对应的名称。
有了虚表后,实际的存储和计算方法不变,SPL 会自动完成上述算法。而且,过滤条件中可以使用普通的布尔值,结果集中分组值也会变成容易阅读的字符串,不必再做序号和名称的转换。虚表的具体使用方法参见SPL 虚表的数据类型优化。
英文版