Hyperloglog与大数据统计

转自:https://chenjiehua.me/database/hyperloglog-bigdata.html


这几天在做一些数据统计相关的工作,涉及的东西挺多的,在此顺便做一下笔记以备忘。

大数据统计

大数据应用场景中最常见的一个问题便是基数估算,而在进行基数估算时遇到的问题主要是内存需求以及后期数据合并处理等。

举个例子,假设我们的网站对每一次请求都做如下简单的日志记录:

date=20151104123059&province=广东&network=电信&uid=1087012……
date=20151104123101&province=北京&network=联通&uid=2319802……
date=20151104123108&province=浙江&network=移动&uid=1825023……

现在,如果我们需要统计一天内的独立用户数,我们可以用最简单的方法:

defcount_user():
    ……
    uniq_users=set()
    foruinuids:
        uniq_users.add(u)
 
    total=len(uniq_users)
    ……

然而,假如每天的访问量量级在千万或者亿级别,那么这样的方法显然会占用极大的内存,我们不得不寻找其他的基数计算方法。

Bitmap计数

参考:解读Cardinality Estimation算法(第一部分:基本概念)

bitmap的原理很简单,它的实质就是用一个bit数组来标记用户,比如对于{3, 6, 10}三个用户,我们可以使用0000001000100100(这里用了2byte的bitmap)来标记用户。

对于bitmap,我们可以很容易的进行and、or操作,而且效率也是非常高的。但是,对于bitmap的内存占用则是一个比较大的问题,它取决于基数的上限而非元素的个数。比如你的基数是1000W,那么你将需要分配1.2MB左右的内存空间给bitmap而不管你是否仅存了一个元素。

为此,对于大数据基数估算,一般采用概率统计算法,常见的算法有Linear Counting、LogLog Counting、HyperLogLog Counting等几种。

Liner Counting

参考:解读Cardinality Estimation算法(第二部分:Linear Counting)

Liner Counting的基本思路是:设有一哈希函数H,其哈希结果空间有m个值(最小值0,最大值m-1),并且哈希结果服从均匀分布。使用一个长度为m的bitmap,每个bit为一个桶,均初始化为0,设一个集合的基数为n,此集合所有元素通过H哈希到bitmap中,如果某一个元素被哈希到第k个比特并且第k个比特为0,则将其置为1。当集合所有元素哈希完成后,设bitmap中还有u个bit为0。则: n̂ = -mlog(u/m) 为n的一个估计,且为最大似然估计(MLE)。

LC的空间复杂度与简单bitmap方法是一样的,但是有个常数项级别的降低,不过实际应用仍然比较少单独使用(作为Adaptive Counting的一部分)。

当然,关于误差问题可以参考原作者的分析……

Loglog Counting

参考:解读Cardinality Estimation算法(第三部分:LogLog Counting)

LLC的空间复杂度仅有O(log2(log2(Nmax)))。

LLC基本思路:选取一个哈希函数H应用于所有元素,并且满足:哈希结果等长,哈希碰撞可忽略不计,哈希结果基本服从均匀分布;

1

对于哈希后的结果a,假设其长度为L比特位,由上面哈希函数要求哈希结果基本服从均匀分布,故对哈希结果的每一位也都应服从:P(x=1)=0.5,P(x=0)=0.5;

设ρ(a)为a的比特串中第一个“1”出现的位置,显然1≤ρ(a)≤L;如果我们遍历集合中所有元素的比特串,取ρmax为所有ρ(a)的最大值。

此时我们可以将2**ρmax作为基数的一个粗糙估计,即:n̂ =2**ρmax

至于为什么可以这样估算,参考原作者的分析……

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

Hyperloglog

参考:解读Cardinality Estimation算法(第四部分:HyperLogLog Counting及Adaptive Counting)

HLLC的基本思想是在LLC的基础上做改进,其中一个改进就是使用调和平均数替代几何平均数。

而目前在Redis 2.8.9中就已经实现了Hyperloglog,官方提供了三个操作大大简化了我们的工作:

$pfaddkeyelement[element...]
$pfcountkey[key...]
$pfmergedestkeysourcekey[sourcekey...]

对于单独一个key, 这三个操作的时间复杂度均为O(1); 对于N个key, 时间复杂度为O(N).

在最开始的问题中,应用hyperloglog我们便可以非常方便地对独立用户数进行统计。

交集问题

为了实现各种组合条件下的统计需求,比如我们需要统计“广东电信”的独立用户数,就需要对hyperloglog进行求交集运算。而redis的hyperloglog默认操作只有pfadd,pfcount,pfmerge(并集)。

通过以上对loglog counting和hyperloglog counting原理的分析以及redis的官方说明文档,redis中每个hyperloglog key占用了12K的内存用于标记基数。

测试中我们发现,pfadd命令并不会一次性分配12k内存,而是随着基数的增加而逐渐增加内存分配;而pfmerge操作则会将sourcekey合并后存储在12k大小的key中,这由hyperloglog合并操作的原理(两个hyperloglog合并时需要单独比较每个桶的值)可以很容易理解。

因此,在进行一次pfmerge操作保证每个key的长度均为12k后,我们尝试使用bitop进行and、or操作。

对于hyperloglog类型的key:hyll1,hyll2,对应set类型的key:set1,set2:

$sunionstoreset_unionset1set2
$sinterstoreset_interset1set2
$pfmergehyll_mergehyll1hyll2
$bitopandhyll_andhyll1hyll2
$bitoporhyll_orhyll1hyll2
$pfcounthyll_and
$pfcounthyll_or
# 测试的结果, ~=(约等于)
set_union~=hyll_merge==hyll_or
# hyll_and与set_inter误差很大
set_inter<>hyll_and

在测试数据中,set1,set2的数量在1W左右,set_inter在5000左右,而hyll_and则在4300,计算的误差在10%~20%.

德·摩根律

既然这种方法不可靠,我们便需要寻找将交集运算转换为并集运算的方法。

根据德·摩根律

屏幕快照 2015-11-04 23.25.36

在这里,我们可以将交集转换为并集与补集的操作。我们继续把上面的例子简化,假设我们存储了6个hyperloglog key:

# redis-cli
$keysuv:total
uv:total
 
$keysuv:province:*
uv:province:广东
uv:province:北京
uv:province:浙江
 
$keysuv:network:*
uv:network:移动
uv:network:联通
uv:network:电信
那么: “广东n电信” = not(not广东 U not电信) = not((北京U浙江)U(联通U移动)) = TOTAL – (北京U浙江)U(联通U移动)

当然这里存在一个隐藏条件:那就是province内的每一个key都必须是独立,否则not广东 = 北京U浙江 就不会成立了;同理network。

PS:这里或许有人会说:广东n电信 = 广东 + 电信 – 广东U电信,即 A n B = A + B – A u B。是的,这属于容斥原理中最简单的一种情况,但假如有N个条件呢,比如“广东n电信n男性 n Android n 4G网络 n 手机系统版本号 n ……”,那么应用德·摩根律仍不失为一个不错的方法。

容斥原理

但是假设我们需要统计“12~18时n广东n电信”的独立用户呢?假如我们存储了24个hyerloglog key用来表示每个小时的独立用户数:


$keysuv:hour:*
uv:hour:00
uv:hour:01
uv:hour:02
uv:hour:03
uv:hour:04
……
uv:hour:21
uv:hour:22
uv:hour:23

这时候德·摩根律就无法使用了,因为 not(uv:hour:12~18) != (uv:hour:00~11)U(uv:hour:19~23)。此时,我们则必须采用容斥原理屏幕快照 2015-11-05 00.26.43

对于其代码实现,可以采用递归方式:

假如有集合 A, B, C, D, 那么根据容斥原理, 我们首先需要求出各种组合:

n=1: A, B, C, D
n=2: AnB, AnC, AnD, BnC, BnD, CnD
n=3: AnBnC, AnBnD, AnCnD, BnCnD

所以: AuBuCuD = A + B + C + D – (AnB + AnC + AnD + BnC + BnD + CnD) + (AnBnC + AnBnD + AnCnD + BnCnD) – AnBnCnD, 这样就可以算出 AnBnCnD 了

"""容斥原理, 递归求交集
 
其中, ckeys=[A, B, C, D],
当然, 为了减少重复计算量, 这里可以加入一个 lru_cache() 的修饰器
"""
# @lru_cache
defrecurse_intersect(ckeys):
    iflen(ckeys)==1:
        result=r.execute_command('pfcount',ckeys[0])
 
    eliflen(ckeys)==2:
        A=r.execute_command('pfcount',ckeys[0])
        B=r.execute_command('pfcount',ckeys[1])
        AuB=r.execute_command('pfcount',*ckeys)
        result=A+B-AuB
 
    else:
        result=0
        foriinrange(len(ckeys))[1:]:
            # 这里对元素求组合
            combines=recurse_combine(ckeys,i)
            total=0
            forcombineincombines:
                total+=recurse_intersect(combine)
 
            result+=totalifi%2==1else-total
 
        union=r.execute_command('pfcount',*ckeys)
        if(i+1)%2==1:
            result=union-result
        else:
            result=result-union
 
    returnresult
 
 
"""递归求组合
 
其中, keys=list, n为组合元素个数, i为递归参数用
例如, keys=[A, B, C, D],
n=1, result=[[A],[B],[C],[D]]
n=2, result=[[A,B],[A,C],[A,D],[B,C], [B,D],[C,D]]
n=3, result=[[A,B,C], [A,B,D],[A,C,D], [B,C,D]]
"""
defrecurse_combine(keys,n,i=0):
    result=[]
    forvalinkeys[i:]:
        iflen(keys)-keys.index(val)<n:
            break
 
        ifn==1:
            result.append([val])
        else:
            children=recurse_combine(keys,n-1,keys.index(val)+1)
            forchildinchildren:
                child.append(val)
                # 由于list是有序的, 这里进行排序可以减少后面重复计算
                child.sort()
                result.append(child)
 
    returnresult


我们的问题也就解决啦。

在后续的测试中我们发现,通过利用容斥原理来计算多个hyperloglog的交集,其计算耗时主要在 recurse_intersect 上。Hyperloglog操作的复杂度基本都是O(1),即使merge多个key耗时也只是在ms级别。

性能测试: 容斥原理性能分析

另外有一个值得注意的问题,那就是在用户基数比较小的情况下,hyperloglog通过容斥原理进行交集计算有可能产生比较大的误差。在我自己的几次测试中发现,一般情况下误差在1~2%,然而当基数比较少,而组合条件又比较复杂的时候,统计出来的用户数(量级1W)误差最大竟然有18%。关于这个问题,可以阅读下面MinHash的两篇文章,其中便提到了关于容斥原理的极端误差问题。

MinHash

当然,除了容斥原理,还有一些其他的方法统计hyperloglog交集,其中有一种是通过计算Jaccard系数的MinHash算法。有兴趣的话可以参考:

 

扩展阅读:


---------------------------------------------------------------------

转自: http://www.feellin.com/hyperloglogde-he-xin-si-xiang-yuan-li/

什么是HyperLogLog

首先,HyperLogLog是一个基数估计算法,并不是统计算法,而且不是数据估计算法,而是基数估计算法。其空间效率非常高,1.5K内存可以在误差不超过2%的前提下,用于超过10亿的数据集合基数估计。如果了解到HyperLogLog算法的空间效率优势后,就急着用其去实现大数据统计需求,经常会得到失望的结果。

什么是基数统计呢,要明白这个词本来就区别于个数。比如说一个集合{0, 1, 3, 3, 4, 5},其基数是5,而个数是6。因为3重复出现了两次。基数是个去重统计。 
也就是说,有些场景,诸如用redis的HyperLogLog算PV,如果不作处理直接用链接直接pfadd(url),那么出来的结果肯定是不尽人意,除非每个链接都只访问了一次。不过,可以通过每次把url拼接一个数字来统计。UV通常是非常直接的基数统计。

统计算法原理

最直接的实现通常是利用哈希,或者B权统计,但是空间效率一般,对于大数据统计有直接的弊端。最优的大数据基数统计算法是用位图,参考《编程珠玑》里面提到的。我们知道,一个bit非0即1,也就是说bit是最小的计量单位,对于建立一对一映射,不可能有比位图统计空间效率更高的统计方法。当然,位图统计的弊端是,其空间效率取决于统计区间的上限。也就是说,如果基数是1亿,那么,位图统计每次都要开辟12.5M内存,如果说统计具体URL的UV,那么对于访问量较少的URL,其内存浪费明显,虽然稀疏位图可以压缩,但是,极限也是统计次数个位。

在允许一定的误差范围下,基数统计追求更优的空间效率,必定需要概率统计算法,而HyperLogLog就是为了满足这样需求产生的一种概率统计算法。

HyperLogLog核心原理

把位图从一对一映射中解放出来,设想成一次不断投硬币的过程,非正面即反面(每一面的概率为0.5)。 在这个过程中,投掷次数大于k的概率是0.5^k(连续投掷出k个反面),在一次过程中,投掷次数小于k的概率是(1-0.5)^k。 
因此,在n次投掷过程中,投掷次数均小于k的概率是

P(x<=k)=(1-0.5^k)^n  
P(x>=k)=1-(1-0.5^k)^n  

从以上公式,可以看出,当n<=k)的概率,接近为0。而当n>>k时,P(x<=k)的概率接近为0。所以,当n>>k时,没有一次投掷次数大于k的概率几乎为0。

将一次过程,理解成一个比特子串,反面为0,正面为1, 投掷次数k对应第一个1出现的位置,当统计子串足够多时,其最大的第一个1的位置为j,那么当n>>2^j时,P(x<=k)接近为0,当n<<2^j时,P(x>=0)也趋向为0。也就是说,在得到x=k的前提下,我们可以认为n=2^j,那么,得出以下概率统计结论:

n=2^j

再通俗点说明: 假设我们为一个数据集合生成一个8位的哈希串,那么我们得到00000111的概率是很低的,也就是说,我们生成大量连续的0的概率是很低的。生成连续5个0的概率是1/32,那么我们得到这个串时,可以估算,这个数据集的基数是32。

当然,从以上过程中可以看出,如果仅仅用上面这样的单一估量来估计必然会存在因为偶然性而误差较大,实际应用中,会利用分桶平均原理来消除误差,并且进行偏差修正。在这里就不铺开细讲。在应用中,通常会设定接受误差范围,而这个误差范围在实现上会决定其分桶数。

结论

可以看出,HyperLogLog是一个基于统计原理的基数统计方法。在理解其原理后,在适当的场景利用,在不能直接利用的场景下,需要进行一定的处理利用。

feellin




发布了226 篇原创文章 · 获赞 19 · 访问量 28万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览