Probabilistic Data Structures for Web Analytics and Data Mining
对于big data经常需要做如下的查询和统计,
Cardinality Estimation (基数或势), 集合中不同元素的个数, 比如, 独立访客(Unique Visitor,简称UV)统计
Frequency Estimation, 估计某个element重复出现次数, 比如, 某个用户对网站访问次数
Heavy Hitters, top-k elements, 比如, 销量top-100的商铺
Range Query, 比如找出年龄在20~30之间的用户
Membership Query, 是否包含某个element, 比如, 该用户名是否已经被注册.
当然你可以采用精确的数据结构, sorted table或hash table, 结果是需要耗费的空间比较大, 如图中对于40M数据, 需要4~7M的数据.
但是其实在很多情况下, 我们不需要很精确的结果, 可以容忍较小的误差, 那么在这种情况下, 我们就可以使用些基于概率的数据结构来大大提高时空效率.
解读Cardinality Estimation算法(第一部分:基本概念)
Big Data Counting: How to count a billion distinct objects using only 1.5KB of Memory
Linear Counting, 比较简单的一种方法, 类似于Bitmap, 至少在实现上看没有什么不同, 最终通过数有多少'1'来判断个数
区别在于, Bitmap是精确的方法直接用'1'的个数来表示Cardinality, 所以必须要分配足够的空间以避免冲突, 比如Cardinality上限为10000的集合, 就需要分配10000bit的bitmap
而Linear Counting, 是概率近似的方法, 允许冲突, 只要选取合适的m(bitset的大小), 就可以根据'1'的个数来推断出近似的Cardinality.
class LinearCounter { BitSet mask = new BitSet(m) // m is a design parameter void add(value) { int position = hash(value) // map the value to the range 0..m mask.set(position) // sets a bit in the mask to 1 } }
所以接下来的问题就是,
如何根据'1'的个数来推断出近似的Cardinality?
如何选取合适的m? m太大浪费空间, m太小会导致所有bit都被置1从而无法估计, 所以必须根据Cardinality上限n计算出合适的m
参考下面的公式, 第一个公式就是根据m和w(1的个数)来计算近似的Cardinality
优点, 简单, 便于多集合合并(多个bitset直接or即可)
缺点, 空间效率不够理想, m大约为n的十分之一, 空间复杂度仍为O(Nmax)
Case Study, 收到各个网站的用户访问log, 需要支持基于时间范围和网站范围的UV查询
对于每个网站的每个时间单元(比如小时)建立Linear Counting, 然后根据输入的时间和网站范围进行or合并, 最终计算出近似值
这个数据结构和算法比较复杂, 但基于的原理还是可以说的清楚的
首先, 需要将集合里面所有的element进行hash, 这里的hash函数必须要保证服从均匀分布(即使集合里面的element不是均匀的), 这个前提假设是Loglog Counting的基础
在均匀分布的假设下, 产生的hash value就有如下图中的分布比例, 因为每个bit为0或1的概率都是1/2, 所以开头连续出现的0的个数越多, 出现概率越小, 需要尝试伯努利过程的次数就越多
Loglog Counting就是根据这个原理, 根据出现的最大的rank数, 来estimate伯努利过程的次数(即Cardinality)
假设设ρ(a)为a的比特串中第一个"1”出现的位置, 即前面出现连续ρ(a)-1个0, 其实这是个伯努利过程
集合中有n个elements, 而每个element的ρ(a)都小于k的概率为, 当n足够大(>>2^k)的时候接近0
反之, 至少有一个element大于k的概率为, 当n足够小(<<2^k)的时候接近0
所以当在集合中出现ρ(a) = k时, 说明n不可能远大于或远小于2^k(从概率上讲)
故当取得一个集合中的Max(ρ(a))时, 可以将2^Max(ρ(a))作为Cardinality的近似值
但这样的方案的问题是, 偶然性因素影响比较大, 因为小概率事件并不是说不会发生, 从而带来较大的误差
所以这里采用分桶平均的方式来平均误差,
将哈希空间平均分成m份,每份称之为一个桶(bucket)。对于每一个元素,其哈希值的前k比特作为桶编号,其中2^k=m,而后L-k个比特作为真正用于基数估计的比特串。桶编号相同的元素被分配到同一个桶,在进行基数估计时,首先计算每个桶内元素最大的第一个“1”的位置,设为M[i],然后对这m个值取平均后再进行估计,
class LogLogCounter { int H // H is a design parameter, hash value的bit长度 int m = 2^k // k is a design parameter, 划分的bucket数 etype[] estimators = new etype[m] // etype is a design parameter, 预估值的类型(ex,byte), 不同rank函数的实现可以返回不同的类型 void add(value) { hashedValue = hash(value) //产生H bits的hash value bucket = getBits(hashedValue, 0, k) //将前k bits作为桶号 estimators[bucket] = max( //对每个bucket只保留最大的预估值 estimators[bucket], rank( getBits(hashedValue, k, H) ) //用k到H bits来预估Cardinality ) } getBits(value, int start, int end) //取出从start到end的bits段 rank(value) //取出ρ(value) }
优点, 空间效率显著优化, 可以支持多集合合并(对每个bucket的预估值取max)
缺点, n不是特别大时, 计误差过大, HyperLogLog Counting和Adaptive Counting就是这类改进算法
估计某个element的出现次数
正常的做法就是使用sorted table或者hash table, 问题当然就是空间效率
所以我们需要在牺牲一定的准确性的情况下, 优化空间效率
这个方法比较简单, 原理就是, 使用二维的hash table, w是hash table的取值空间, d是hash函数的个数
对某个element, 分别使用d个hash函数计算相应的hash值, 并在对应的bucket上递增1, 每个bucket的值称为sketch, 如图
然后在查询某个element的frequency时, 只需要取出所有d个sketch, 然后取最小的那个作为预估值, 如其名
因为为了节省空间, w*d是远小于真正的element个数的, 所以必然会出现很多的冲突, 而最小的那个应该是冲突最少的, 最精确的那个
这个方法的思路和bloom filter比较类似, 都是通过多个hash来降低冲突带来的影响
class CountMinSketch { long estimators[][] = new long[d][w] // d and w are design parameters long a[] = new long[d] long b[] = new long[d] long p // hashing parameter, a prime number. For example 2^31-1 void initializeHashes() { //初始化hash函数family,不同的hash函数中a,b参数不同 for(i = 0; i < d; i++) { a[i] = random(p) // random in range 1..p b[i] = random(p) } } void add(value) { for(i = 0; i < d; i++) estimators[i][ hash(value, i) ]++ //简单的对每个bucket经行叠加 } long estimateFrequency(value) { long minimum = MAX_VALUE for(i = 0; i < d; i++) minimum = min( //取出最小的估计值 minimum, estimators[i][ hash(value, i) ] ) return minimum } hash(value, i) { return ((a[i] * value + b[i]) mod p) mod w //hash函数,a,b参数会变化 } }
优点, 简单, 空间效率显著优化
缺点, 对于大量重复的element或top的element比较准确, 但对于较少出现的element准确度比较差
实验, 对于Count-Min sketch of size 3×64, i.e. 192 counters total
Dataset1, 10k elements, about 8500 distinct values, 较少重复的数据集, 测试结果准确度很差
Dataset2, 80k elements, about 8500 distinct values, 大量重复的数据集, 测试结果准确度比较高
前面说了Count-Min Sketch只对重度重复的数据集有比较好的效果, 但对于中度或轻度重复的数据集, 效果就很差
因为大量的冲突对较小频率的element的干扰很大, 所以Count-Mean-Min Sketch就是为了解决这个问题
原理也比较简单, 预估sketch上可能产生的noise
怎么预估? 很简单, 比如1000数hash到20个bucket里面, 那么在均匀分布的条件下, 一个bucket会被分配50个数
那么这里就把每个sketchCounter里面的noise减去
最终是取所有sketch的median(中位数), 而不是min
class CountMeanMinSketch { // initialization and addition procedures as in CountMinSketch // n is total number of added elements long estimateFrequency(value) { long e[] = new long[d] for(i = 0; i < d; i++) { sketchCounter = estimators[i][ hash(value, i) ] noiseEstimation = (n - sketchCounter) / (w - 1) e[i] = sketchCounter – noiseEstimator } return median(e) } }
首先top element应该是重度重复的element, 所以使用Count-Min Sketch是没有问题的
方法,
1. 建个Count-Min Sketch不断的给所有的element进行计数
2. 需要取top的时候, 对集合中每个element从Count-Min Sketch取出近似的frequency, 然后放到heap中
其实这里使用Count-Min Sketch只是计算frequency, Top-n问题仍然是依赖heap来解决
use case, 比如网站IP访问数的排名
另外一种获取top的思路,
维护一组固定个数的slots, 比如你要求Top-10, 那么维护10个slots
当elements过来, 如果slots里面有, 就递增, 没有就替换solts中frequency最小的那个
这个算法没有讲清楚, 给的例子也太简单, 不太能理解e(maximum potential error)干吗用的, 为什么4替换3后, 3的frequency作为4的maximum potential error
我的理解是, 因为3的frequency本身就是最小的, 所以4继承3的frequency不会影响实际的排名,
这样避免3,4交替出现所带来的计数问题, 但这里的frequency就不是精确的, 3的frequency被记入4是potential error
The figure below illustrates how Stream-Summary with 3 slots works for the input stream {1,2,2,2,3,1,1,4}.
RangeQuery, 毫无疑问需要类似B-tree这样排序的索引, 对于大部分NoSql都很难支持
这里要实现的是, SELECT count(v) WHERE v >= c1 AND v < c2, 在一定范围内的element的个数和
简单的使用Count-Min Sketch的方法, 就是通过v的索引找出所有在范围内的element, 然后去Count-Min Sketch中取出每个element的近似frequency, 然后相加
这个方法的问题在于, 在范围内的element可能非常多, 并且那么多的近似值相加, 误差会被大大的放大
解决办法就是使用多个Count-Min Sketch, 来提供更粗粒度的统计
如图, sketch1就是初始的, 以element为单位的统计, 没一个小格代表一个element
sketch2, 以2个element为单位统计, 实际的做法就是truncate a one bit of a value, 比如1110111, 前缀匹配111011.
sketch3, 以4个element为单位统计......
最终sketchn, 所有element只会分两类统计, 1开头或0开头
这样再算范围内的count, 就不需要一个个element加了, 只需要从粗粒度开始匹配查询
如下图, 只需要将4个红线部分的值相加就可以了
MADlib (a data mining library for PostgreSQL and Greenplum) implements this algorithm to process range queries and calculate percentiles on large data sets.
查询某个element在不在, 典型的Bloom Filter的应用