[TOC]

什么是基数

基数(cardinality,也译作势),维基百科中的解释是: cardinality of a set is a measure of the "number of elements of the set"。我们可以理解为一个集合(这里的集合允许存在重复元素)中不重复元素的个数。例如集合{1,2,3,1,2},它有5个元素,但它的基数/Distinct数为3。

应用举例

  1. UV统计:统计日、月的独立用户访问数。
  2. 电商应用中,统计一段时间内查看某个商品的独立用户数,分析产品的受众。
  3. 搜索引擎中,统计一段时间内用户搜索的unique query数量,分析搜索query特征。

问题总结

  1. 输入数据量大:数据不可一次性加入到内存中。
  2. 不具有可累加性:任务不可分割成更小粒度,如天/小时的粒度,然后把特征直接合并计算出月级数据。
  3. 对于某些任务,如上面的2、3,需要对key先GroupBy再统计。因此其中有很多稀疏数据。

因此,我们希望算法具有如下特性:

概率算法

实际上,在大数据场景中准确计算基数十分困难,因此在不追求绝对准确的情况下,使用概率算法算是一个不错的解决方案。常用的算法包括:

基础:Hash函数与Bitmap

HashSet

一个最基本的想法是,使用HashSet,对任何数据进行Hash求值,并放入Hash Set中。一个Hash函数是 H(M)=h (其中M为密文,h为定长的hash值)需要满足:

这种方式可以十分精确的记录集合的基数,对于128bit的Hash算法,需要的空间是128*N bit (N为基数)。在大数据场景下,基数巨大,无法在单机内存中存储所有的值。

Bitmap算法

在Bitmap算法中,我们不存储完整的hash值,而是用bit数组的某一位表示某一数据,从而一个bit数组可以表示海量数据。用0表示某一元素不在集合中,用1表示某一元素在集合中,如0100000011000000可以用来表示集合{1,8,9}。问题的关键成为,如何选择合适的Hash函数,将目标M映射成bitmap中的一位?我只需要选择Hash函数满足抗冲突性。实际算法选择时,以32位的MD5算法为例,需要2128bit的空间,这显然是不可行的,因此,我们只取其结果的前8位(32bit),那么整个Hash函数的映射空间就有168(40多亿,尽量减少冲突)个值,取一个长度为168的bitmap(大约536MB)。每一位对应Hash函数映射空间中的一个值,初始值全为0。每当有新访问产生,对该访客标识进行Hash,并映射到bitmap中的某一位上,若该位置为0,则置1;若为1,则不作处理。最后统计整个bitmap中1的个数即为基数。

如果要精确统计,不使用Hash来映射Bitmap,可以维护一张目标M与index之间的映射表,并增量更新

优点:

缺点

为了进一步减少存储所需要的空间,我们使用基于概率的基数统计算法。

BloomFilter

使用BF实现一个简单的概率统计方法。布隆过滤器可以k个Hash函数来判断一个元素是否在集合中。用误判率Pfp表示判断的准确性。

误判率估算公式:$$P_{fp} \approx (1-e^{-kn/m})^k $$(集合大小n,bitmap位数m,Hash函数个数k)

m=nlnPfp(ln2)2k=mnln2
import breeze.util.BloomFilter

val bf = BloomFilter.optimallySized[Int](5, 0.01)
val arr = Array(1, 3, 4, 5, 1, 2, 6, 3, 1)
var cnt = 0
arr.foreach { t =>
  bf.contains(t) match {
    case false => cnt += 1; bf.+=(t)
    case _ =>
  }
}
println(arr.distinct.length) // 6
println(cnt) // 6

特点

Linear Counting

简介

LC算法是较为简单的概率算法,计算过程与Bitmap方法类似,实验完成后统计bitmap中0的个数即可

核心计算公式如下:

\hat n=−m\log\dfrac{u}{m}$$(这里的m为bitmap的大小;u为0的个数;n̂为n的一个估计,且为最大似然估计) 算法过程如下 - 选择一个哈希函数h,其结果服从**均匀分布** - 开一个长度为m的bitmap,均初始化为0(m设为多大后面有讨论) - 数据流每来一个元素,计算其哈希值并对m取模,然后将该位置为1 - 查询时,设bitmap中还有u个bit为0,则不同元素的总数近似为$ -m\log\dfrac{u}{m}$ ![](https://fodi.limuzhi.us.kg/images/IMG-ee19ea265655aabc.webp) ### 基本思想 直观上,随着Hash不断的映射,使得bitmap中为空桶不断减少(即u值不断减少),对于固定长度m的bitmap,其满足与某种关系,使得我们求解的目标基数值满足随着u的减少,单调增加。 我们可以从概率上推导出上述公式,由于选择的Hash函数满足均匀分布,也就是说集合A中的**每一个元素映射到bitmap中的每一位都是等可能的**。设$C_j$为“经过$n$次元素Hash后,bitmap上第$j$位为0的概率”: $$C_j=(1-\frac{1}{m})^n

把整个试验过程看做伯努利过程,即每一bit是一次实验且每个bit间相互独立,由于bitmap上每一位都是独立的,所以u=C1+C2+...Cn的期望为(独立同分布的期望之和)

E(u)=j=1mP(Cj)=m(11m)n=m[(1+1m)m]nm

mn都趋于无穷时有

E(u)=menm

n=mlnE(u)m

因此,可以推导

应用

Hash 函数选择

Hash函数的选择,在Bitmap方法中因为其过于追求Hash函数的抗冲突性,进而导致映射空间m过大。在LC算法中,我们只需要Hash函数具有分布均匀性即可。

参数选择

选择合适的参数m,减少u为0情况(从上面的公式看错当u为0时候,值为无穷的,算法失效)

因此我们要选择足够大小的参数m。主要需要考虑的是bitmap长度mm主要由两个因素决定,基数大小以及容许的误差。假设基数大约为n,允许的误差为ϵ,则m需要满足如下约束,

m>ϵtt1(ϵt)2, 其中 t=nm

为了减少u为0情况

m>β(ett1)$$$$β=max(5,1/(ϵt)2)

我们可以参考作者论文中给出的实验结果分布图

合并特性

LC非常方便于合并,合并方案与传统bitmap映射方法无异,都是通过按位或的方式,合并后重新使用上述公式计算即可。

空间复杂度

可以看出精度要求越高,则bitmap的长度越大。可以看出对于N是几万以内这张元素数量较少是很有优势的。但是,随着m和n的增大,m大约为n的十分之一。。因此,LC所需要的空间只有传统的bitmap直接映射方法的1/10,但是从渐进复杂性的角度看,空间复杂度仍为$$O(N_{max})$$。

误差分析

StdError(\dfrac{\hat n}{n})=\dfrac{\sqrt{m}(e^t−t−1)^{1/2}}{n}$$,其中$t=\dfrac{n}{m}$ ### 总结 该算法改进了Bitmap所需空间,但是需要的空间依然是线性增加的。在**基数较少的情况下表现不错**,一般不会单独使用,往往与下面的算法结合使用。 ## LogLog Counting 使用LC算法,对于n(基数)很大情况下,依然需要极大的空间(相比与Bitmap已经是他的十分之一了)。对于上万/亿基数这种情况,我需要更小的空间复杂度的算法。假设基数的上限为1亿,原始bitmap方法需要12.5M内存,而LogLog Counting(以下简称LLC)只需不到1K内存(640字节)就可以在标准误差不超过4%的精度下对基数进行估计,效果可谓十分惊人。 ### 简介 LLC算法不再使用Bitmap数据结构,我们依旧需要对对象进行Hash求值,但是,只需关心Hash比特串h中**第一个1出现的位置ρ(h)**(例如h为`098950fc`,则ρ(h)=5),并记录所有ρ(h)中的最大值$\rho_{max}$。 核心计算公式如下 $$\hat n=2^{\rho_{max}}$$($\rho_{max}$是所有元素中首个“1”的位置最大的那个元素的“1”的位置,$\hat n$是n的一个粗糙估计) 算法过程如下 - 选择一个固定长度m的哈希函数h,其结果服从**均匀分布**,且**碰撞几率极小**。 - 分配一个变量$\rho_{max}$,来存储最大值,大小是:$p=\log_2m$ 即p bit - 数据流每来一个元素,计算其哈希值并算出第一个1出现的位置$\rho$,判断是否有$\rho>\rho_{max}$,然后更新$\rho_{max}$。 - 查询时,计算$\hat n=2^{\rho_{max}}$,这里$\hat n$是n的一个**粗糙估计**。 ### 基本思想 设a为待估集合中的一个元素,h=H(a),这里把h表示为<u>长度为L的比特串</u>(如h为`098950fc`,写为`00001001100010010101000011111100`),将这L个比特串从左至右依次编号为1、2、……、L。因为Hash函数是均匀分布的,所以这L个比特服从如下分布且相互独立 $$ P(x=k) = \lbrace_{0.5(k=1)}^{0.5(k=0)}

我们可以把上述寻找比特串中第一个1的过程看作一个投硬币试验:当硬币为反面时,记为0;当硬币为正面时,记为1,试验停止,记录投掷次数。设n次试验中,最大投掷数为k。

现在考虑如下两个事件:

注意到,

转换为自然语言描述就是,当n远远大于2k时,每次试验投掷次数都不大于k的概率几乎为0,当n远远小于2k时,至少有一次试验投掷次数大于等于k的概率也为0。而这些均与我们观察到的试验结果不符,即存在至少一次试验的投掷次数等于k,且不存在比k更多的投掷次数(即最大投掷那一次)

假设一、二均无法满足,所以唯一合理的推断是n2k

应用

平均分桶

实际应用时,上述方法由于偶然性而存在较大误差。因此,LLC采用了分桶平均的思想来消减误差。具体来说,就是将哈希空间平均分成m份,每份称之为一个桶(bucket)。对于每一个元素,其哈希值的前k比特作为桶编号,其中2k=m,而后L-k个比特作为真正用于基数估计的比特串。桶编号相同的元素被分配到同一个桶,在进行基数估计时,首先计算每个桶内元素最大的第一个“1”的位置,设为M[i],然后对这m个值取算数平均后再进行估计,即:

n^=21mi=1mM[i]

偏差修正

通过数学分析可以知道这并不是基数n的无偏估计。因此需要修正成无偏估计

n^=αm21mi=1mM[i]

其中αm为修正量,它是一个关于m分桶数的公式,计算参考公式,推导过程(略),实际使用过程中会根据m预先算好修正量。

需要注意的是其无偏性是渐近的,只有当n远远大于m时,其估计值才近似无偏

Hash 函数选择

  1. H的结果具有很好的均匀性,也就是说无论原始集合元素的值分布如何,其哈希结果的值几乎服从均匀分布。
  2. H的碰撞几乎可以忽略不计。也就是说我们认为对于不同的原始值,其哈希结果相同的概率非常小以至于可以忽略不计。
  3. H的哈希结果是固定长度的。(原论文使用32bit Bash)

参数选择

因为算法精度与分桶的数量有关,所以主要考虑的是分桶数m,而这个m主要取决于误差。

这里不证明推导过程,如果要将误差控制在ϵ之内,则:

m>(1.30ϵ)2

合并特性

合并时取相同桶编号数值最大者为合并后此桶的数值即可。

空间复杂度

空间复杂度与Hash长度L分桶数m有关。

可以看到,LLC算法需要的空间仅仅是基数空间n的两次log的大小。这也是loglogCounting算法的命名来源。

假设m为1024,H的值为32bit,每个桶需要5bit空间存储,一共需要5×1024 = 5120 bit = 640字节。此时误差为1.301024=0.040625,也就是约为4%。

误差分析

公式与参数m选择相同,这里不证明,渐近标准误差为:

StdError(n^n)1.30m

小结

LLC在基数大的情况下占用空间极少。但是,就是当n不是特别大时,其估计误差过大,因此HLL算法在次基础上进一步优化。

HyperLogLog Counting

在了解LC和LLC之后,我们可以进一步学习实用的算法了。HLLC优化了LLC对离群值敏感的问题,并结合了LC,解决LLC算法在基数较少时误差较大的问题。

优化

使用调和平均代替几何平均数

调和平均数:H=ni=1n1xi

n^=αmm2j=1m2M[j] 其中,重新修正后 αm=(m0(log2(2+u1+u))mdu)1

根据论文中的分析结论,与LLC一样HLLC是渐近无偏估计。渐近标准差

StdError(n^n)1.04m

分段偏差修正

HLLC中,为了解决LLC在基数较小时偏差大的问题,在小基数时,选择LC估计。设E为估计值:

HyperLogLog++

在HLLC的理论基础上,Google对其进行了工程上的优化,解决了实际运用的一些问题。我们把HLLC中不使用分段修正的原始算法记作HLLC_orign。

优化

####使用64位Hash代替32位

优化基数小时偏差抖动问题

由于HLLC使用渐进偏差进行估计,在基数较小的情况下,HLLC在实践中表现为偏差偏大。不考虑使用LC的分段修正,在n为0时,具有约0.7m的固定误差。

我们以m=16384(p=14)为例,偏差随基数数目变化的实验结果如下图所示。

得出一下结论与修正过程如下:

在HLLC中为了修正这个问题我们比较LC,HLLC ,HLLC_nobias的平均误差(p=14):

可以发现如下结论

综合这些结论,我们结合LC,HLLC,HLLC_nobias,来优化我们的算法

对比,LC+HLLC的算法和LC+HLLC_nobias+HLLC修正偏差效果如下 ,可以发现新加入的HLLC_nobias优化了突变值,使偏差变得平缓:

使用稀疏数组(空间优化)

HLLC(64bit Hash)算法中使用了6m的固定空间存储数据,但是在基数较少的情况下(n<<m),大部分桶的值为0,这是一个典型的稀疏数组。因此,

通过稀疏表示法,我们通过一点计算开销实现了大量空间的节约。

稀疏数组中使用动态精度(准确性优化)

在对精度要求更高的场合,我们可以利用稀疏数组节约的空间提高在基数较小的情况下的精度p,本质是临时提高分桶数m来实现。(实际算法中我们可以设置动态的范围p'),称此算法为HLL_sparse1

使用动态精度后(p'=25),对比HLLC_nobias 效果如图,可以看到在使用LC算法的阶段(基数小)的时候精度大大增加:

备注:在Google提供的算法代码中,默认在稀疏数组表示中使用高精度p',降级为p时自动切换为正常表示方法。 若设置的p'过大,会导致过早切换。参考代码EncodeHash/DecodeHash

压缩稀疏数组(空间优化)

由于在稀疏数组表示时我们可以使用更高的精度p',所以进一步压缩稀疏数组的存储,可以尽可能的提高效果。主要利用这两个特性

  1. 之前我们直接使用Integer来存储pair (idx,σ(w))。在大部分编程语言中整型占用32bit,但是实际上size(pair)大小为(p+6)/(p'+6)。其余的空间浪费了。
  2. 稀疏表示的list中,pair是顺序存储的,我们可以利用这一点优化空间。

对应的优化点是

  1. 方案一:使用变长编码的integer,对较小的值的整数占用较少的空间bit。称此算法为HLL_sparse2
  2. 方案二σ(w)中不存储绝对值,而利用有序性,存储差值。对于a1,a2,a3,... 存储为a1,a2a1,a3a2,。这样存储的值又小了,理论上需要的空间更小。称此算法为HLL_sparse3

此外,还有一个比较隐蔽的优化点(详细描述参考论文 5.3.3 Encoding Hash Values以及代码EncodeHash/DecodeHash):在使用稀疏数组的高精度p'表示时,都只会用LC算法(此时基数显然很小),是用不到后面的6bit的σ(w)值,我们只关心,桶出现在稀疏数组中,表示他为非空的。这6bit的作用是方便我们在转换为原始表示方法的时候恢复桶的值。

方案三:在某些情况下,我们可以不存储这个σ(w)值。通过前面对p'降级到p的过程的分析,我们知道,只有当p...p'的这几位都是0时,才会用到6bit的σ(w)值。因此我们有很大的概率(112pp)可以不存储这个值,而是是要增加1bit来表示这种存储格式即可:0表示省略了6bit,1表示存储了6bit。称此算法为HLL++,伪代码入下:

if ⟨x63−p,...,x64−p′⟩ = 0 then
	return ⟨x63,...,x64−p′⟩ || ⟨ρ(⟨x63−p′,...,x0⟩)⟩ || ⟨1⟩
else
	return ⟨x63,...,x64−p′⟩ || ⟨0⟩
end if

Google论文中提供了一个实践的建议:选择p'=25,则存储6bit的情况下,使用32bit的int是合适的选择

上述几种算法在稀疏数组表示时可以存储的pair数量(p'=25)对比如下

序列化相关

总结

HLL++算法最终的效果对比原始HLL实现(p=14,p'=25):

参考文章

实现