解读Cardinality Estimation算法(第一部分:基本概念)
基数的定义
简单来说,基数(cardinality,也译作势),是指一个集合(这里的集合允许存在重复元素,与集合论对集合严格的定义略有不同,如不做特殊说明,本文中提到的集合均允许存在重复元素)中不同元素的个数。例如看下面的集合:
{1,2,3,4,5,2,3,9,7}
这个集合有9个元素,但是2和3各出现了两次,因此不重复的元素为1,2,3,4,5,9,7,所以这个集合的基数是7。
如果两个集合具有相同的基数,我们说这两个集合等势。基数和等势的概念在有限集范畴内比较直观,但是如果扩展到无限集则会比较复杂,一个无限集可能会与其真子集等势(例如整数集和偶数集是等势的)。不过在这个系列文章中,我们仅讨论有限集的情况,关于无限集合基数的讨论,有兴趣的同学可以参考实变分析相关内容。
容易证明,如果一个集合是有限集,则其基数是一个自然数。
基数的应用实例
下面通过一个实例说明基数在电商数据分析中的应用。
假设一个淘宝网店在其店铺首页放置了10个宝贝链接,分别从Item01到Item10为这十个链接编号。店主希望可以在一天中随时查看从今天零点开始到目前这十个宝贝链接分别被多少个独立访客点击过。所谓独立访客(Unique Visitor,简称UV)是指有多少个自然人,例如,即使我今天点了五次Item01,我对Item01的UV贡献也是1,而不是5。
用术语说这实际是一个实时数据流统计分析问题。
要实现这个统计需求。需要做到如下三点:
1、对独立访客做标识
2、在访客点击链接时记录下链接编号及访客标记
3、对每一个要统计的链接维护一个数据结构和一个当前UV值,当某个链接发生一次点击时,能迅速定位此用户在今天是否已经点过此链接,如果没有则此链接的UV增加1
下面分别介绍三个步骤的实现方案
对独立访客做标识
客观来说,目前还没有能在互联网上准确对一个自然人进行标识的方法,通常采用的是近似方案。例如通过登录用户+cookie跟踪的方式:当某个用户已经登录,则采用会员ID标识;对于未登录用户,则采用跟踪cookie的方式进行标识。为了简单起见,我们假设完全采用跟踪cookie的方式对独立访客进行标识。
记录链接编号及访客标记
这一步可以通过javascript埋点及记录accesslog完成,具体原理和实现方案可以参考我之前的一篇文章:网站统计中的数据收集原理及实现。(该链接已经失效了)
实时UV计算
可以看到,如果将每个链接被点击的日志中访客标识字段看成一个集合,那么此链接当前的UV也就是这个集合的基数,因此UV计算本质上就是一个基数计数问题。
在实时计算流中,我们可以认为任何一次链接点击均触发如下逻辑(伪代码描述):
cand_counting(item_no, user_id) {
if (user_id is not in the item_no visitor set) {
add user_id to item_no visitor set;
cand[item_no]++;
}
}
逻辑非常简单,每当有一个点击事件发生,就去相应的链接被访集合中寻找此访客是否已经在里面,如果没有则将此用户标识加入集合,并将此链接的UV加1。
虽然逻辑非常简单,但是在实际实现中尤其面临大数据场景时还是会遇到诸多困难,下面一节我会介绍两种目前被业界普遍使用的精确算法实现方案,并通过分析说明当数据量增大时它们面临的问题。
传统的基数计数实现
基于B树的基数计数
对上面的伪代码做一个简单分析,会发现关键操作有两个:查找-迅速定位当前访客是否已经在集合中,插入-将新的访客标识插入到访客集合中。因此,需要为每一个需要统计UV的点(此处就是十个宝贝链接)维护一个查找效率较高的数据结构,又因为实时数据流的关系,这个数据结构需要尽量在内存中维护,因此这个数据结构在空间复杂度上也要比较适中。综合考虑一种传统的做法是在实时计算引擎采用了B树来组织这个集合。
之所以选用B树是因为B树的查找和插入相关高效,同时空间复杂度也可以接受
这种实现方案为一个基数计数器维护一棵B树,由于B树在查找效率、插入效率和内存使用之间非常平衡,所以算是一种可以接受的解决方案。但是当数据量特别巨大时,例如要同时统计几万个链接的UV,如果要将几万个链接一天的访问记录全部维护在内存中,这个内存使用量也是相当可观的(假设每个B树占用1M内存,10万个B树就是100G!)。一种方案是在某个时间点将内存数据结构写入磁盘(双十一和双十二大促时一淘数据部的效果平台是每分钟将数据写入HBase)然后将内存中的计数器和数据结构清零,但是B树并不能高效的进行合并,这就使得内存数据落地成了非常大的难题。
另一个需要数据结构合并的场景是查看并集的基数,例如在上面的例子中,如果我想查看Item1和Item2的总UV,是没有办法通过这种B树的结构快速得到的。当然可以为每一种可能的组合维护一棵B树。不过通过简单的分析就可以知道这个方案基本不可行。N个元素集合的非空幂集数量为 2 N − 1 2^N-1 2N−1,因此要为10个链接维护1023棵B树,而随着链接的增加这个数量会以幂指级别增长。
基于bitmap的基数计数
为了克服B树不能高效合并的问题,一种替代方案是使用bitmap表示集合。也就是使用一个很长的bit数组表示集合,将bit位顺序编号,bit为1表示此编号在集合中,为0表示不在集合中。例如“00100110”表示集合 {2,5,6}。bitmap中1的数量就是这个集合的基数。
显然,与B树不同bitmap可以高效的进行合并,只需进行按位或(or)运算就可以,而位运算在计算机中的运算效率是很高的。但是bitmap方式也有自己的问题,就是内存使用问题。
很容易发现,bitmap的长度与集合中元素个数无关,而是与基数的上限有关。例如在上面的例子中,假如要计算上限为1亿的基数,则需要12.5M字节的bitmap,十个链接就需要125M。关键在于,这个内存使用与集合元素数量无关,即使一个链接仅仅有一个1UV,也要为其分配12.5M字节。
由此可见,虽然bitmap方式易于合并,却由于内存使用问题而无法广泛用于大数据场景。
解读Cardinality Estimation算法(第二部分:Linear Counting)
传统的精确基数计数算法在数据量大时会存在一定瓶颈,瓶颈主要来自于数据结构合并和内存使用两个方面。因此出现了很多基数估计的概率算法,这些算法虽然计算出的结果不是精确的,但误差可控,重要的是这些算法所使用的数据结构易于合并,同时比传统方法大大节省内存。
简介
Linear Counting(以下简称LC)在1990年的一篇论文“A linear-time probabilistic counting algorithm for database applications”中被提出。作为一个早期的基数估计算法,LC在空间复杂度方面并不算优秀,实际上LC的空间复杂度与上文中简单bitmap方法是一样的(但是有个常数项级别的降低),都是O(Nmax),因此目前很少单独使用LC。不过作为Adaptive Counting等算法的基础,研究一下LC还是比较有价值的。
思路
LC的基本思路是:设有一哈希函数H,其哈希结果空间有m个值(最小值0,最大值m-1),并且哈希结果服从均匀分布。使用一个长度为m的bitmap,每个bit为一个桶,均初始化为0,设一个集合的基数为n,此集合所有元素通过H哈希到bitmap中,如果某一个元素被哈希到第k个比特并且第k个比特为0,则将其置为1。当集合所有元素哈希完成后,设bitmap中还有u个bit为0。则:
n
^
=
−
m
log
u
m
\hat{n}=-m \log \frac{u}{m}
n^=−mlogmu
为
n
n
n的一个最大似然估计,证明见论文。
算法应用
在应用LC算法时,主要需要考虑的是bitmap长度m的选择。这个选择主要受两个因素的影响:基数n的量级以及容许的误差。这里假设估计基数n的量级大约为N,允许的误差为ϵ,则m的选择需要遵循如下约束。
误差控制
这里以标准差作为误差。由上面标准差公式可以推出,当基数的量级为N,容许误差为ϵ时,有如下限制:
m
>
e
t
−
t
−
1
(
e
t
)
2
m>\frac{e^{t}-t-1}{(e t)^{2}}
m>(et)2et−t−1
将量级和容许误差带入上式,就可以得出m的最小值,其中
t
=
n
/
m
t=n/m
t=n/m
满桶控制
由LC的描述可以看到,如果m比n小太多,则很有可能所有桶都被哈希到了,此时u的值为0,LC的估计公式就不起作用了(变成无穷大)。因此m的选择除了要满足上面误差控制的需求外,还要保证满桶的概率非常小。
数学证明省略
当基数量级为N,可接受误差为ϵ,则m的选取应该遵从
m
>
β
(
e
t
−
t
−
1
)
m>\beta\left(e^{t}-t-1\right)
m>β(et−t−1)
其中
β
=
max
(
5
,
1
/
(
ϵ
t
)
2
)
\beta=\max \left(5,1 /(\epsilon t)^{2}\right)
β=max(5,1/(ϵt)2)
下图是论文作者预先计算出的关于不同基数量级和误差情况下,m的选择表:
可以看出精度要求越高,则bitmap的长度越大。随着m和n的增大,m大约为n的十分之一。因此LC所需要的空间只有传统的bitmap直接映射方法的1/10,但是从渐进复杂性的角度看,空间复杂度仍为O(Nmax)。
合并
LC非常方便于合并,合并方案与传统bitmap映射方法无异,都是通过按位或的方式。
小结
这篇文章主要介绍了Linear Counting。LC算法虽然由于空间复杂度不够理想已经很少被单独使用,但是由于其在元素数量较少时表现非常优秀,因此常被用于弥补LogLog Counting在元素较少时误差较大的缺陷,实际上LC及其思想是组成HyperLogLog Counting和Adaptive Counting的一部分。
解读Cardinality Estimation算法(第三部分:LogLog Counting)
Linear Counting算法相较于直接映射bitmap的方法能大大节省内存(大约只需后者1/10的内存),但毕竟只是一个常系数级的降低,空间复杂度仍然为O(Nmax)。而本文要介绍的LogLog Counting却只有O(log2(log2(Nmax)))。例如,假设基数的上限为1亿,原始bitmap方法需要12.5M内存,而LogLog Counting只需不到1K内存(640字节)就可以在标准误差不超过4%的精度下对基数进行估计,效果可谓十分惊人。
简介
LogLog Counting(以下简称LLC)出自论文“Loglog Counting of Large Cardinalities”。LLC的空间复杂度仅有O(log2(log2(Nmax))),使得通过KB级内存估计数亿级别的基数成为可能,因此目前在处理大数据的基数计算问题时,所采用算法基本为LLC或其几个变种。下面来具体看一下这个算法。
设a为待估集合(哈希后)中的一个元素,由上面对H的定义可知,a可以看做一个长度固定的比特串(也就是a的二进制表示),设H哈希后的结果长度为L比特,我们将这L个比特位从左到右分别编号为1、2、…、L:
又因为a是从服从均与分布的样本空间中随机抽取的一个样本,因此a每个比特位服从如下分布且相互独立。
P
(
x
=
k
)
=
{
0.5
(
k
=
0
)
0.5
(
k
=
1
)
P(x=k)=\left\{\begin{array}{l}{0.5(k=0)} \\ {0.5(k=1)}\end{array}\right.
P(x=k)={0.5(k=0)0.5(k=1)
通俗说就是a的每个比特位为0和1的概率各为0.5,且相互之间是独立的。
设ρ(a)为a的比特串中第一个“1”出现的位置,显然1≤ρ(a)≤L,这里我们忽略比特串全为0的情况(概率为1/2L)。如果我们遍历集合中所有元素的比特串,取ρmax为所有ρ(a)的最大值。
此时我们可以将2ρmax作为基数的一个粗糙估计,即:
n
^
=
2
ρ
max
\hat{n}=2^{\rho_{\max }}
n^=2ρmax
从二次分布和伯努利过程的角度来分析了为什么可以这么估计,忽略。
误差分析
S
t
d
E
r
r
o
r
(
n
^
/
n
)
≈
1.30
m
StdError(\hat{n} / n) \approx \frac{1.30}{\sqrt{m}}
StdError(n^/n)≈m1.30
其中m是桶数,总之误差就很小啦
算法应用
误差控制
在应用LLC时,主要需要考虑的是分桶数m,而这个m主要取决于误差。根据上面的误差分析,如果要将误差控制在ϵ之内,则: m > ( 1.30 ϵ ) 2 m>\left(\frac{1.30}{\epsilon}\right)^{2} m>(ϵ1.30)2
内存使用分析
内存使用与m的大小及哈希值得长度(或说基数上限)有关。设H的值为32bit,由于ρmax≤32,因此每个桶需要5bit空间存储这个桶的ρmax,m个桶就是5×m/8字节。例如基数上限为一亿(约 2 27 2^{27} 227),当分桶数m为1024时,每个桶的基数上限约为 2 27 / 2 10 = 2 17 2^{27} / 2^{10}=2^{17} 227/210=217,而 log 2 ( log 2 ( 2 17 ) ) = 4.09 \log _{2}\left(\log _{2}\left(2^{17}\right)\right)=4.09 log2(log2(217))=4.09,因此每个桶需要5bit,需要字节数就是5×1024/8=640。
合并
与LC不同,LLC的合并是以桶为单位而不是bit为单位,由于LLC只需记录桶的ρmax,因此合并时取相同桶编号数值最大者为合并后此桶的数值即可。
小结
本文主要介绍了LogLog Counting算法,相比LC其最大的优势就是内存使用极少。不过LLC也有自己的问题,就是当n不是特别大时,其估计误差过大,因此目前实际使用的基数估计算法都是基于LLC改进的算法,这些改进算法通过一定手段抑制原始LLC在n较小时偏差过大的问题。后面要介绍的HyperLogLog Counting和Adaptive Counting就是这类改进算法。
第四弹居然打不开了
503 (服务不可用) 服务器目前无法使用(由于超载或停机维护)。 通常,这只是暂时状态。
刚好也觉得自己是不是有点太深入了,溜了溜了。(明明都没怎么仔细看公式推导就好意思说自己太深入😒😑)