AI考拉技術分享--布隆過濾器實戰

Kalengo發表於2019-02-19

前言

今天是中國傳統佳節“猿宵節”,是程式猿通宵趕程式碼的佳節。
AI考拉的技術小夥伴志在打破傳統,以“我們不加班”為口號,以“我們提早下班”為指導中心,在這裡安利技術知識給大家,祝大家節日快樂,提前下班,過真正的元宵節!

需求

在金融業務系統裡面,判斷使用者是否是黑名單,這種場景應該很常見。

假設我們系統裡面有一百萬個黑名單使用者,用手機號表示,現在有一個人想借款,我們要判斷他是否在黑名單中,怎麼做?

一般方法

最直接的方法,是在資料庫中查詢,目前資料庫上實現的索引,雖然可以做到 O(logn) 或者理論O(1) 的時間複雜度,但畢竟是磁碟操作,跟記憶體操作不是一個數量級的。

於是,我們可以把黑名單中的手機號快取到記憶體中,用一個陣列儲存起來,這種方法有兩個問題,一是查詢時間複雜度是 O(n),非常慢,二是佔用大量記憶體。

查詢速度上可以再優化,將陣列變成Set,內部實現可以選擇平衡二叉樹或者雜湊,這樣子插入和查詢的時間複雜度能做到 O(logn)或者理論O(1),但是帶來的是空間上的災難,比使用陣列會更佔用空間。

現在來看一下程式碼,對比一下這兩種方法:

import random
import sys

def generate_random_phone():
    """
    隨機生成11位的字串
    """
    phone = ''
    for j in range(0, 11):
        phone += str(random.randint(0, 9))
    return phone

# 10萬個黑名單使用者
black_list = []
for i in range(0, 100000):
    black_list.append(generate_random_phone())

# 轉成集合
black_set = set(black_list)
print(len(black_list), len(black_set))
# 看一下兩種資料結構的空間佔用
print("size of black_list: %f M" % (sys.getsizeof(black_list) / 1024 / 1024))
print("size of black_set: %f M" % (sys.getsizeof(black_set) / 1024 / 1024))

def brute_force_find():
    """
    直接列表線性查詢,隨機查一個存在或者不存在的元素, O(n)
    """
    if random.randint(0, 10) % 2:
        target = black_list[random.randint(0, len(black_list))]
        return __brute_force_find(target)
    else:
        return __brute_force_find(generate_random_phone())

def __brute_force_find(target):
    for i in range(0, len(black_list)):
        if target == black_list[i]:
            return True
    return False

def set_find():
    """
    集合查詢,隨機查一個存在或者不存在的元素, O(1)
    """
    if random.randint(0, 10) % 2:
        target = black_list[random.randint(0, len(black_list))]
        return __set_find(target)
    else:
        return __set_find(generate_random_phone())

def __set_find(target):
    return target in black_set

print(brute_force_find())
print(set_find())  
複製程式碼

可以看到,陣列和集合的長度相等,說明元素都是唯一的。列表的空間佔用為0.78M,而集合的空間佔用為4M,主要是因為雜湊表的資料結構需要較多指標連線衝突的元素,空間佔用大概是列表的5倍。這是10w個手機號,如果有1億個手機號,將需要佔用3.9G的空間。

下面來看一下效能測試:

import timeit

print(timeit.repeat('brute_force_find()', number=100, setup="from __main__ import brute_force_find"))
print(timeit.repeat('set_find()', number=100, setup="from __main__ import set_find"))  
複製程式碼
[0.0016423738561570644, 0.0013590981252491474, 0.0014535998925566673]   
複製程式碼

可以看到,直接線性查詢大概需要0.85s, 而集合的查詢僅需要0.0016s,速度上是質的提升,但是空間佔用太多了!

有沒有一種資料結構,既可以做到集體查詢的時間複雜度,又可以省空間呢?

答案是布隆過濾器,只是它有誤判的可能性,當一個手機號經過布隆過濾器的查詢,返回屬於黑名單時,有一定概率,這個手機號實際上並不屬於黑名單。 回到我們的業務中來,如果一個借款人有0.001%的概率被我們認為是黑名單而不借錢給他,其實是可以接受的,用風控的一句話說: 寧可錯殺一百,也不放過一個。說明,利用布隆過濾器來解決這個問題是合適的。

布隆過濾器原理

原理非常簡單,維護一個非常大的點陣圖,設長度為m,選取k個雜湊函式。

初始時,這個點陣圖,所有元素都置為0。 對於黑名單中的每一個手機號,用k個雜湊函式計算出來k個索引值,把點陣圖中這k個位置都置為1。 當查詢某個元素時,用k個雜湊函式計算出來k個索引值,如果點陣圖中k個位置的值都為1,說明這個元素可能存在,如果有一個位置不為1,則一定不存在。

這裡的查詢,說的可能存在,是因為雜湊函式可能會出現衝突,一個不存在的元素,通過k個雜湊函式計算出來索引,可能跟另外一個存在的元素相同,這個時間就出現了誤判。所以,要降低誤判率,明顯是通過增大點陣圖的長度和雜湊函式的個數來實現的。

687474703a2f2f696d61676573323031352e636e626c6f67732e636f6d2f626c6f672f313033303737362f3230313730312f313033303737362d32303137303130363134333134313738342d313437353033313030332e706e67.png

來看一下程式碼:

from bitarray import bitarray
import mmh3

class BloomFilter:
    def __init__(self, arr):
        # 點陣圖長度暫定為20倍黑名單庫的大小
        self.SIZE = 20 * len(arr)
        self.bit_array = bitarray(self.SIZE)
        self.bit_array.setall(0)
        for item in arr:
            for pos in self.get_positions(item):
                self.bit_array[pos] = 1
        
    def get_positions(self, val):
        # 使用10個雜湊函式,murmurhash演算法,返回索引值
        return [mmh3.hash(val, i) % self.SIZE for i in range(40, 50)]
            
    def find(self, val):
        for pos in self.get_positions(val):
            if self.bit_array[pos] == 0:
                return False
        return True
    
bloomFilter = BloomFilter(black_list)
print("size of bloomFilter's bit_array: %f M" % (sys.getsizeof(bloomFilter.bit_array) / 1024 / 1024))

def get_error_rate():
    # 用1w個隨機手機號,測試布隆過濾器的錯誤率
    size = 10000
    error_count = 0
    for i in range(0, size):
        phone = generate_random_phone()
        bloom_filter_result = bloomFilter.find(phone)
        set_result = __set_find(phone)
        if bloom_filter_result != set_result:
            error_count += 1
    return error_count / size

print(get_error_rate())  
複製程式碼
size of bloomFilter's bit_array: 0.000092 M
0.0001  
複製程式碼

可以看到,雖然點陣圖的長度是原資料的20倍,但是佔用的空間卻很小,這是因為點陣圖的8個元素才佔用1個位元組,而原資料列表中1個元素就佔用了將近11個位元組。

錯誤率大約為0.0001,可以嘗試不同的點陣圖長度,比如改成30倍,錯誤率就會降低到0。

最後來看一下3種演算法的效能測試:

def bloom_filter_find():
    if random.randint(0, 10) % 2:
        target = black_list[random.randint(0, len(black_list))]
        return bloomFilter.find(target)
    else:
        return bloomFilter.find(generate_random_phone())

print(timeit.repeat('brute_force_find()', number=100, setup="from __main__ import brute_force_find"))
print(timeit.repeat('set_find()', number=100, setup="from __main__ import set_find"))
print(timeit.repeat('bloom_filter_find()', number=100, setup="from __main__ import bloom_filter_find")) 
複製程式碼
[0.70748823415488, 0.7686979519203305, 0.7785645266994834]
[0.001686999574303627, 0.002007704693824053, 0.0013333242386579514]
[0.001962156966328621, 0.0018132571130990982, 0.0023592300713062286]      
複製程式碼

可以看到,布隆過濾器的查詢速度接近集合的查詢速度,有時候甚至更快,在很低的誤判率可以接受的情況下,選用布隆過濾器是即省時間又省空間的,是最佳的選擇。

參考連結

  1. 布隆過濾器的原理和實現

著作權歸本文作者所有,未經授權,請勿轉載,謝謝。

相關文章