前言
看官可以先拿出大学《概率论与数理统计》教材翻一翻,反正我是复习过才写的(逃
基数统计简介
什么是基数?
一个(有限)集合S里不同的元素个数就称为该集合的基数(cardinality),也叫做“势”,记为|S|。例如,S={"西红柿", "土豆", "胡萝卜", "土豆", "洋葱", "西红柿"},那么|S|=4。
在我们的日常工作中,经常碰到需要统计基数的情境。最常见的就是日活跃用户数(daily active users, DAU)。比如,在一天内登录某App的独立访客(unique visitor, UV)总数,或者在一天内进入到某商品详情页面的UV总数等等。DAU是衡量互联网产品活跃度最直接的指标,少不了要与它们打交道。如果只考虑最naive的基数统计手段,很简单:
- 基于平衡查找树实现的集合。容易想到Java HashSet(底层是HashMap,亦即链表+红黑树),或者B树。它们都能方便地查找元素是否在集合中,以及插入新元素,时间复杂度稳定为O(logn)。
- 基于位数组(即bitmap)实现的集合。位数组中的第i个bit即代表第i个元素,为1表示存在,为0表示不存在,整个位数组中1的数量就是该集合的基数。位运算的效率自然无需赘言。
上面的两个方法都是精确统计,在数据量适中时常用。但是在海量数据面前,它们的空间(内存)占用和时间效率都会变得不可控。用大数据的思维来考虑,我们是否可以稍微牺牲一点准确率,换来效率的大幅提升呢?答案自然是肯定的,基数估计(cardinality estimation)已经有了多种成熟的实现,应用比较广泛的就是HyperLogLog,熟悉Redis的看官肯定已经见怪不怪了。不过,本文研究的是它的父辈们,即两种早期的基数估计算法——Linear Counting算法与Flajolet-Martin算法。
Linear Counting算法
Linear Counting(线性计数)算法由Kyu-Young Whang等人在1990年的论文《A Linear-Time Probabilistic Counting Algorithm for Database Applications》中提出。它不是最早的基数估计算法,但它的思路比较直接,并且不涉及什么高深的东西,所以我可以尽量叙述得详细一点。
算法描述
算法流程如下,不难理解。
- 创建一个有m个bit的位数组,初始状态是0填充的。
- 设定一个哈希函数H,它的结果空间正好落在上述位数组中,尽量服从均匀分布,并且按每bit分桶。
- 将集合C的元素依次输入H,散列到位数组中,将其散列到的bit置为1。
- 所有元素输入完成后,设n=|C|,且位数组中仍然为0的bit(即空桶)共有u个,那么基数n的一个估计为:ñ = -m·ln(u/m)。
下图是论文中给出的哈希过程示例。
证明过程
由于H按每bit分了m个桶,并且n个哈希结果服从均匀分布,可得:
P{第j个bit为0} = (1 - 1/m)n
空桶数u是个随机变量,我们就可以计算出它的期望:
E(u) = m(1 - 1/m)n
当n和m都趋向无穷大时,可得:
lim E(u) = lim {m[(1 - 1/m)-m]-n/m} = me-n/m
即得出这种情况下,基数n与m、E(u)的关系:
n = -m · ln[E(u)/m]
因为每个bit的值都服从0-1分布,故u服从二项分布。又因为n和m都趋向无穷大,所以u渐近地服从正态分布,即:
u ~ N(μ, σ2), μ = E(u)
对正态分布而言,μ的最大似然估计(MLE)是样本均值,其证明过程可以参考这里。
而u正是从正态分布总体中随机抽取的样本,故u就是E(u)的MLE。
根据MLE的不变性,因为函数f(x) = -m · ln(x/m)可逆,即得出基数n的MLE:
ñ = -m · ln(u/m)
算法得证。
算法误差与误差控制
由于计算过程太长,所以直接给出论文中的结论:
Bias(ñ/n) = E(ñ/n) - 1 = (et - t - 1) / 2n;
StdError(ñ/n) = [m(et - t - 1)]1/2 / n;
其中t = n/m。
可见,Bias指估计值与精确值的期望相对偏差,StdError指“标准误差”,即n的估计值与精确值的比值的标准差。
如果我们限定标准误差,即StdError < ε,容易推导出位数组长度m要满足以下条件:
m > (et - t - 1) / (εt)2
但这样还不够。由上面的算法证明过程可知,一旦u=0(就是所有桶都满了),算法就失效了,因此我们还得保证在t<1的情况下,u=0的概率足够小,可以控制空桶数u的期望E(u)与其标准差SD(u)之间满足如下关系:
E(u) - α · SD(u) > 0
当n、m趋向无穷大时,又可以推导出标准差(计算过程略去):
SD(u) = [E(u2) - E(u)2]1/2 ≈ [me-t(1 - (1 + t)e-t)]1/2
解得:
m > α2(et - t - 1)
也就是说,m最终要满足:
m > β(et - t - 1), β = max[α2, 1/(εt)2]
在上一节中,我们已经说过u渐近地服从正态分布,这是二项分布逼近连续型的情况。如果仍然考虑离散型,那么在u~B(n,p)的n较大而p较小时,u就会近似服从泊松分布:
lim P{u=k} = λk · e-λ / k!
当k=0时,就是位数组被填满的概率,即e-λ。
现在我们给α赋一个值,论文中是√5。又因为泊松分布的期望和方差都是λ,易得:
E(u) > α · SD(u) => λ > √5λ => λ > 5
P{u=0} < e-5 ≈ 0.007
也就是说,就算不考虑ε,我们只要保证u的期望值偏离标准差的√5倍以上,就可以保证算法失效的概率低于0.7%了。文中提供了在α=√5且ε=0.01或0.10的情况下,随着n增大的m取值表。
复杂度
由上一节的表中可以看出,当n达到比较大的规模时,Linear Counting算法的空间复杂度为O(n/C),C是个常数。以n=108为例,位数组的大小不到107 bit(1MB多点),相当于只占用了原生位数组方法的1/12。如果想要计算两个集合的并集的基数,只需要O(1)的按位或就可以,简单方便。
但是,这个算法只能保证空间占用有常数级别的降低,因此仍然主要用于小数据量的场景,仍然不适用于大数据。下面我们来看更“聪明”一些的Flajolet-Martin算法。
Flajolet-Martin算法
这个算法由Philippe Flajolet和G.Nigel Martin在1984年的论文《Probabilistic Counting Algorithms for Data Base Applications》中提出,因此得名,并且是基数估计算法真正的始祖。它的论文就比较难啃了,我毕竟不是数学系毕业的,所以数学方面的细节会写得粗糙一点,但保证贴合原文的思路。
原始算法描述
定义哈希函数:
H(x): -> [0, 1, 2, ..., 2L-1]
该函数能够保证哈希结果尽量服从均匀分布。换句话说,H(x)的哈希结果空间为长度固定L的二进制串的集合。
对任意一个非负整数y,将y的二进制表示中第k(k≥0)个bit的值记为bit(y,k),那么可得:
y = Σ bit(y,k) · 2k
然后,定义ρ(y),代表y的二进制表示中,从末尾开始出现的第一个1(least significant set bit)的位置,即:
ρ(y) = min{k≥0 | bit(y,k) ≠ 0}, ρ(0)=L
也就是说,y的二进制表示的低位有连续ρ(y)个0。
Flajolet-Martin算法的流程如下:
- 初始化一个长度为L的位数组,记为bitmap[0...L-1],用0填充。
- 设要计算基数的集合为M,对每个x∈M,将bitmap[ρ(H(x))]设为1。
- 令R表示哈希过后bitmap中所有0的最小的下标,那么|M|的估计量为2R/φ,φ≈0.77351。
是不是感觉有些云里雾里的?下面来简单证明一下它。
简单证明
如果bitmap[j]=1,就表示M中有一个值经过哈希后,其二进制串末尾有连续j个0。由于H(x)的结果尽量符合均匀分布,所以哈希结果二进制串中的每个比特都服从0-1分布且相互独立。
若我们将二进制串视为抛硬币的结果,0代表反面,1代表正面,那么很显然,“从二进制串的末尾开始扫描1”就相当于“连续抛硬币直到出现正面为止”。进一步说,它是个参数为p=1/2的伯努利实验。我们可以得出:
P{bitmap[j]=1} = 1 / 2j+1
记集合的基数|M|为n,易得:
- 进行n重上述伯努利实验,每重实验进行的次数都不大于k的概率为:
(1 - 1/2k)n
- 进行n重上述伯努利实验,每重实验进行的次数至少有一次大于等于k的概率为:
1 - (1 - 1/2k)n
也就是说,如果j>>log2n,那么bitmap[j]=0的概率极大;反过来,如果j<<log2n,那么bitmap[j]=1的概率极大。参考算法流程中R的定义,它其实就是所有哈希结果中最大的ρ(y),因此它可以代替前述的j值,使得2R成为基数n的一个粗糙的估计量。
φ≈0.77351是经过复杂的计算得出的修正因子,就不提了。
改进方案:PCSA
在H(x)保证均匀分布的情况下,可以得出结论:
E(R) ≈ log2φn, D(R) ≈ 1.12
又因为估计值是2的整数次幂,显然它是非常不精确的。论文中提出了一个解决方案,叫做PCSA(Probabilistic counting with stochastic averaging),即“基于离散平均值的概率性计数”。思路如下:
将原始算法中的bitmap扩展为m个组,每次哈希时,以H(x) mod m为组编号,在每组中再用floor[H(x) / m]的方法确定下标。这样每个组都会有n/m个元素,并且计算出的R值就不再是一个,而是m个。n的估计量就可以表示为:
ñ = 2E(R)m / φ, E(R) = (R1 + R2 + ... + Rm) / m
详细的伪码描述如下。
论文中还给出了m的取值与偏差值和标准误差的对应关系表。
除PCSA的思路之外,也有其他方法,比如采用多个哈希函数,或者用中位数来代替均值。当然我们很容易想明白,多个哈希函数的方法并不现实,因为设计多个均匀分布并且尽量少冲突的哈希函数很难,并且计算哈希值也是需要耗费CPU的。
参数的选择
PCSA方法的偏差与标准误差的计算极其复杂,论文中靠计算机得出了近似值:
Bias = 1 + 0.31 / m, StdError = 0.78 / √m
可见都只与m的选择有关。如果m > 256,标准误差会缩小到5%以内。
位数组长度L的理想取值范围为:
L > log2(n / m) + 4
当m=64时,若L=16,可估算的基数可达十万数量级;若L=24,可估算的基数可达千万数量级。