神奇的HyperLogLog算法

原文連接:http://rainybowe.com/blog/2017/07/13/%E7%A5%9E%E5%A5%87%E7%9A%84HyperLogLog%E7%AE%97%E6%B3%95/index.html?utm_source=tuicool&utm_medium=referralhtml

神奇的HyperLogLog算法

 

基數計數基本概念

基數計數(cardinality counting)一般用來統計一個集合中不重複的元素個數,例如統計某個網站的UV,或者用戶搜索網站的關鍵詞數量。數據分析、網絡監控及數據庫優化等領域都會涉及到基數計數的需求。 要實現基數計數,最簡單的作法是記錄集合中全部不重複的元素集合S_uS​u​​,當新來一個元素x_ix​i​​,若S_uS​u​​中不包含元素x_ix​i​​,則將x_ix​i​​加入S_uS​u​​,不然不加入,計數值就是S_uS​u​​的元素數量。這種作法存在兩個問題:git

  1. 當統計的數據量變大時,相應的存儲內存也會線性增加
  2. 當集合S_uS​u​​變大,判斷其是否包含新加入元素x_ix​i​​的成本變大

大數據量背景下,要實現基數計數,首先須要肯定存儲統計數據的方案,以及如何根據存儲的數據計算基數值;另外還有一些場景下須要融合多個獨立統計的基數值,例如對一個網站分別統計了三天的UV,如今須要知道這三天的UV總量是多少,怎麼融合多個統計值。github

基數計數方法

B樹

B樹最大的優點是插入和查找效率很高,若是用B樹存儲要統計的數據,能夠快速判斷新來的數據是否已經存在,並快速將元素插入B樹。要計算基數值,只須要計算B樹的節點個數。 將B樹結構維護到內存中,能夠快速統計和計算,但依然存在問題,B樹結構只是加快了查找和插入效率,並無節省存儲內存。例如要同時統計幾萬個連接的UV,每一個連接的訪問量都很大,若是把這些數據都維護到內存中,實在是夠嗆。redis

bitmap

bitmap能夠理解爲經過一個bit數組來存儲特定數據的一種數據結構,每個bit位都能獨立包含信息,bit是數據的最小存儲單位,所以能大量節省空間,也能夠將整個bit數據一次性load到內存計算。 若是定義一個很大的bit數組,基數統計中每個元素對應到bit數組的其中一位,例如bit數組 001101001001101001表明實際數組[2,3,5,8][2,3,5,8]。新加入一個元素,只須要將已有的bit數組和新加入的數字作按位或 (or)(or)計算。bitmap中1的數量就是集合的基數值。算法

bitmap有一個很明顯的優點是能夠輕鬆合併多個統計結果,只須要對多個結果求異或就能夠。也能夠大大減小存儲內存,能夠作個簡單的計算,若是要統計1億個數據的基數值,大約須要內存: 100000000/8/1024/1024 \approx≈ 12M
若是用32bit的int表明每一個統計數據,大約須要內存:
32*100000000/8/1024/1024 \approx≈ 381M數據庫

bitmap對於內存的節約量是顯而易見的,但仍是不夠。統計一個對象的基數值須要12M,若是統計10000個對象,就須要將近120G了,一樣不能普遍用於大數據場景。數組

機率算法

實際上目前尚未發現更好的在大數據場景中準確計算基數的高效算法,所以在不追求絕對準確的狀況下,使用機率算法算是一個不錯的解決方案。機率算法不直接存儲數據集合自己,經過必定的機率統計方法預估基數值,這種方法能夠大大節省內存,同時保證偏差控制在必定範圍內。目前用於基數計數的機率算法包括:網絡

  • Linear Counting(LC):早期的基數估計算法,LC在空間複雜度方面並不算優秀,實際上LC的空間複雜度與上文中簡單bitmap方法是同樣的(可是有個常數項級別的下降),都是O(N_{max})O(N​max​​);
  • LogLog Counting(LLC):LogLog Counting相比於LC更加節省內存,空間複雜度只有O(log_2(log_2(N_{max})))O(log​2​​(log​2​​(N​max​​)))
  • HyperLogLog Counting(HLL):HyperLogLog Counting是基於LLC的優化和改進,在一樣空間複雜度狀況下,可以比LLC的基數估計偏差更小。

下面將着重講HLL的原理和計算過程。數據結構

HyperLogLog的驚人表現

上面咱們計算過用bitmap存儲1一億個統計數據大概須要12M內存;而在HLL中,只須要不到1K內存就能作到;redis中實現的HyperLogLog,只須要12K內存,在標準偏差0.81%的前提下,可以統計2^{64}2​64​​個數據。首先容我感嘆一下數學的強大和魅力,那麼機率算法是怎樣作到如此節省內存的,又是怎樣控制偏差的呢?app

首先簡單展現一下HLL的基本作法,HLL中實際存儲的是一個長度爲mm的大數組SS,將待統計的數據集合劃分紅mm組,每組根據算法記錄一個統計值存入數組中。數組的大小mm由算法實現方本身肯定,redis中這個數組的大小是16834,mm越大,基數統計的偏差越小,但須要的內存空間也越大。

這裏有個HLL demo能夠看一下HLL究竟是怎麼作到這種超乎想象的事情的。

hyperloglog

  1. 經過hash函數計算輸入值對應的比特串
  2. 比特串的低 t(t=log_2^m)t(t=log​2​m​​)位對應的數字用來找到數組SS中對應的位置 ii
  3. t+1t+1位開始找到第一個1出現的位置 kk,將 kk 記入數組S_iS​i​​位置
  4. 基於數組SS記錄的全部數據的統計值,計算總體的基數值,計算公式能夠簡單表示爲:\hat{n}=f(S)​n​^​​=f(S)

看到這裏內心應該有無數個問號,這樣真的就能統計到上億條數據的基數了嗎?我總結一下,先拋出三個疑問:

  1. 爲何要記錄第一個1出現的位置?
  2. 爲何要有分桶數組 SS ?
  3. 經過分桶數組 SS 計算基數的公式是什麼?

hyperloglog原理理解

舉一個咱們最熟悉的拋硬幣例子,出現正反面的機率都是1/2,一直拋硬幣直到出現正面,記錄下投擲次數kk,將這種拋硬幣屢次直到出現正面的過程記爲一次伯努利過程,對於nn次伯努利過程,咱們會獲得nn個出現正面的投擲次數值k_1k​1​​,k_2k​2​​……k_nk​n​​,其中最大值記爲k_{max}k​max​​,那麼能夠獲得下面結論:

  1. nn次伯努利過程的投擲次數都不大於k_{max}k​max​​
  2. nn次伯努利過程,至少有一次投擲次數等於k_{max}k​max​​

對於第一個結論,nn次伯努利過程的拋擲次數都不大於k_{max}k​max​​的機率用數學公式表示爲: 
P_n(X \le k_{max})=(1-1/2^{k_{max}})^nP​n​​(X≤k​max​​)=(1−1/2​k​max​​​​)​n​​

第二個結論至少有一次等於k_{max}k​max​​的機率用數學公式表示爲: 
P_n(X \ge k_{max})=1-(1-1/2^{k_{max}-1})^nP​n​​(X≥k​max​​)=1−(1−1/2​k​max​​−1​​)​n​​

當n\ll 2^{k_{max}}n≪2​k​max​​​​時,P_n(X \ge k_{max})\approx0P​n​​(X≥k​max​​)≈0,即當nn遠小於2^{k_{max}}2​k​max​​​​時,上述第一條結論不成立; 
當n\gg 2^{k_{max}}n≫2​k​max​​​​時,P_n(X \le k_{max})\approx0P​n​​(X≤k​max​​)≈0,即當nn遠大於2^{k_{max}}2​k​max​​​​時,上述第二條結論不成立。 所以,咱們彷佛就能夠用2^{k_{max}}2​k​max​​​​的值來估計nn的大小。

以上結論能夠總結爲:進行了nn次進行拋硬幣實驗,每次分別記錄下第一次拋到正面的拋擲次數kk,那麼能夠用n次實驗中最大的拋擲次數k_{max}k​max​​來預估實驗組數量nn: \hat{n} = 2^{k_{max}}​n​^​​=2​k​max​​​​原型圖 (1).png-18.1kB能夠經過一組小實驗驗證一下這種估計方法是否基本合理。

回到基數統計的問題,咱們須要統計一組數據中不重複元素的個數,集合中每一個元素的通過hash函數後能夠表示成0和1構成的二進制數串,一個二進制串能夠類比爲一次拋硬幣實驗,1是拋到正面,0是反面。二進制串中從低位開始第一個1出現的位置能夠理解爲拋硬幣試驗中第一次出現正面的拋擲次數kk,那麼基於上面的結論,咱們能夠經過屢次拋硬幣實驗的最大拋到正面的次數來預估總共進行了多少次實驗,一樣能夠能夠經過第一個1出現位置的最大值k_{max}k​max​​來預估總共有多少個不一樣的數字(總體基數)。

這種經過局部信息預估總體數據流特性的方法彷佛有些超出咱們的基本認知,須要用機率和統計的方法才能推導和驗證這種關聯關係。HyperLogLog核心在於觀察集合中每一個數字對應的比特串,經過統計和記錄比特串中最大的出現1的位置來估計集合總體的基數,能夠大大減小內存耗費。

如今回到第二節中關於HyperLogLog的第一個疑問,爲何要統計hash值中第一個1出現的位置?
第一個1出現的位置能夠類比爲拋硬幣實驗中第一次拋到正面的拋擲次數,根據拋硬幣實驗的結論,記錄每一個數據的第一個出現的位置kk,就能夠經過其中最大值{k_{max}}k​max​​推導出數據集合的基數:\hat{n} = 2^{k_{max}}​n​^​​=2​k​max​​​​。

hyperloglog算法講解

分桶平均

HLL的基本思想是利用集合中數字的比特串第一個1出現位置的最大值來預估總體基數,可是這種預估方法存在較大偏差,爲了改善偏差狀況,HLL中引入分桶平均的概念。 
一樣舉拋硬幣的例子,若是隻有一組拋硬幣實驗,運氣較好,第一次實驗過程就拋了10次才第一次拋到正面,顯然根據公式推導獲得的實驗次數的估計偏差較大;若是100個組同時進行拋硬幣實驗,同時運氣這麼好的機率就很低了,每組分別進行屢次拋硬幣實驗,並上報各自實驗過程當中拋到正面的拋擲次數的最大值,就能根據100組的平均值預估總體的實驗次數了。

分桶平均的基本原理是將統計數據劃分爲mm個桶,每一個桶分別統計各自的{k_{max}}k​max​​並能獲得各自的基數預估值 \hat{n}​n​^​​ ,最終對這些 \hat{n}​n​^​​ 求平均獲得總體的基數估計值。LLC中使用幾何平均數預估總體的基數值,可是當統計數據量較小時偏差較大;HLL在LLC基礎上作了改進,採用調和平均數,調和平均數的優勢是能夠過濾掉不健康的統計值,具體的計算公式爲:

回到第二節中關於HLL的第二個疑問,爲何要有分桶數組  ?分桶數組是爲了消減因偶然性帶來的偏差,提升預估的準確性。那麼分桶數組的大小怎麼肯定呢? 
這是由算法實現方本身設定的,例如上面HLL demo中,設定統計數組的大小,若是函數獲得的比特串是32位,須要其中6()位定位分桶數組中的桶的位置,還剩下26位(須要記錄的出現1的位置的最大值是26),那麼數組中每一個桶須要5()位記錄1第一次出現的位置,整個統計數組須要花費的內存爲: 
 
也就是用32bit的內存可以統計的基數數量爲。

誤差修正

上述通過分桶平均後的估計量看似已經很不錯了,不過經過數學分析能夠知道這並非基數n的無偏估計。所以須要修正成無偏估計。這部分的具體數學分析在「Loglog Counting of Large Cardinalities」中。

其中係數由統計數組的大小  決定,具體的公式爲:

根據論文中分析結論,HLL與LLC同樣是漸進無偏估計,漸進標準偏差表示爲:

所以,統計數組大小  越大,基數統計的標準偏差越小,但須要的存儲空間也越大,在 的狀況下,HLL的標準偏差爲1.1%。

雖然調和平均數可以適當修正算法偏差,但做者給出一種分階段修正算法。當HLL算法開始統計數據時,統計數組中大部分位置都是空數據,而且須要一段時間才能填滿數組,這種階段引入一種小範圍修正方法;當HLL算法中統計數組已滿的時候,須要統計的數據基數很大,這時候hash空間會出現不少碰撞狀況,這種階段引入一種大範圍修正方法。最終算法用僞代碼能夠表示爲以下。

 
  1. m = 2^b # with b in [4...16]

  2.  
  3. if m == 16:

  4. alpha = 0.673

  5. elif m == 32:

  6. alpha = 0.697

  7. elif m == 64:

  8. alpha = 0.709

  9. else:

  10. alpha = 0.7213/(1 + 1.079/m)

  11.  
  12. registers = [0]*m # initialize m registers to 0

  13.  
  14. ###########################################################################

  15. # Construct the HLL structure

  16. for h in hashed(data):

  17. register_index = 1 + get_register_index( h,b ) # binary address of the rightmost b bits

  18. run_length = run_of_zeros( h,b ) # length of the run of zeroes starting at bit b+1

  19. registers[ register_index ] = max( registers[ register_index ], run_length )

  20.  
  21. ##########################################################################

  22. # Determine the cardinality

  23. DV_est = alpha * m^2 * 1/sum( 2^ -register ) # the DV estimate

  24.  
  25. if DV_est < 5/2 * m: # small range correction

  26. V = count_of_zero_registers( registers ) # the number of registers equal to zero

  27. if V == 0: # if none of the registers are empty, use the HLL estimate

  28. DV = DV_est

  29. else:

  30. DV = m * log(m/V) # i.e. balls and bins correction

  31.  
  32. if DV_est <= ( 1/30 * 2^32 ): # intermediate range, no correction

  33. DV = DV_est

  34. if DV_est > ( 1/30 * 2^32 ): # large range correction

  35. DV = -2^32 * log( 1 - DV_est/2^32)

redis中hyperloglog實現

redis正是基於以上的HLL算法實現的HyperLogLog結構,用於統計一組數據集合中不重複的數據個數。 redis中統計數組大小設置爲,hash函數生成64位bit數組,其中  位用來找到統計數組的位置,剩下50位用來記錄第一個1出現的位置,最大位置爲50,須要 位記錄。

那麼統計數組須要的最大內存大小爲:  基數估計的標準偏差爲。能夠學習一下redis中HyperLogLog的源碼實現


參考閱讀
Redis new data structure: the HyperLogLog
HyperLogLog — Cornerstone of a Big Data Infrastructure
解讀Cardinality Estimation算法(第四部分:HyperLogLog Counting及Adaptive Counting)

相關文章
相關標籤/搜索