嫌棄Apriori演算法太慢?使用FP-growth演算法讓你的資料探勘快到飛起
今天是機器學習專題的第20篇文章,我們來看看FP-growth演算法。
這個演算法挺冷門的,至少比Apriori演算法冷門。很多資料探勘的教材還會提一提Apriori,但是提到FP-growth的相對要少很多。原因也簡單,因為從功能的角度上來說,FP-growth和Apriori基本一樣,相當於Apriori的效能優化版本。
但不得不說有時候優化是一件很尷尬的事,因為優化意味著效能要求很高。但是反過來說,對於效能有著更高要求的應用場景,無論是企業也好,還是學術研究也罷,現在早就有了更好的選擇,完全可以用更強大的演算法和模型,沒必要用這麼個古老演算法的優化版。對於那些效能要求不高的場景,簡單的Apriori也就夠了,優化的必要也不是很大。
但是不管這個演算法命運如何,至少從原理和思路理念上來說的確有為人稱道的部分。下面我們就來看看它的具體原理吧。
FP-growth與FP-tree
FP-growth的核心價值在於加速,在之前介紹的Apriori演算法當中,我們每一次從候選集當中篩選出頻繁項集的時候,都需要掃描一遍全量的資料庫來計算支援度,顯然這個開銷是很大的,尤其是我們資料量很大的時候。
FP-growth的精髓是構建一棵FP-tree,它只會掃描完整的資料集兩次,因此整體執行的速度顯然會比Apriori快得多。之所以能做到這麼快,是因為FP-growth演算法對於資料的挖掘並不是針對全量資料集的,而只針對FP-tree上的資料,因此這樣可以省略掉很大一部分資料,從而節省下許多計算資源。
從這裡我們可以看出,FP-tree是整個演算法的精髓。在我們介紹整個樹的構建方法以及一些細節之前,我們先來看下FP這兩個字母的含義。相信很多同學從一開始的時候就開始迷惑了,其實FP這兩個字母是frequent pattern的縮寫,翻譯過來是頻繁模式,其實也可以理解成頻繁項,說白了,FP-tree這棵樹上只會儲存頻繁項的資料,我們每次挖掘頻繁項集和關聯規則都是基於FP-tree,這也就過濾了不頻繁的資料。
這是一棵生成好的FP-tree,我們先來看一下它的樣子,再來詳細解讀其中的細節和原理。
頭指標表
上圖這個結構初看會覺得很混亂,完全不知道應該怎麼理解。這是很正常的,但如果我們把上圖拆開,可以分成兩個部分,左邊的部分是一個連結串列,右邊是一棵樹。只不過左邊的連結串列指到了右邊的樹上,所以整體看起來有些複雜。
我們先忽略右側的樹的部分,以及指標表和樹之間關聯的指標,單純地來看一下左側的頭指標表。僅僅看這一部分就簡單多了,它其實是一個單頻繁項的集合。
前面我們提到我們在使用FP-growth演算法的過程當中,一共只需要遍歷兩次資料集。其中第一次遍歷資料集就在這裡,我們首先遍歷了一遍資料集,求出了所有元素出現的次數。然後根據閾值過濾掉不頻繁的元素,保留下來的結果就是單個頻繁項的集合。
這裡的邏輯非常簡單,只有兩件事,第一件事是統計每個單獨的項出現的次數,第二件事是根據閾值將不頻繁的項過濾掉。
def filter_unfreq_items(dataset, min_freq):
data_dict = defaultdict(int)
# 統計每一項出現的次數
for trans in dataset:
for item in trans:
data_dict[item] += 1
# 根據閾值過濾
ret = {}
for k, v in data_dict.items():
if v >= min_freq:
ret[k] = v
return ret
通過這個方法,我們就生成了頭指標表裡的資料。之後我們要在建立FP-tree的過程當中將這份資料轉化成連結串列,也就是左邊的頭指標表。雖然我們還不瞭解建樹的原理,但至少我們可以把dict轉化成指標表,這個邏輯非常簡單,說白了我們只需要在dict的value當中增加一個引用即可。
def transform_to_header_table(data_dict):
return {k: [v, None] for k, v in data_dict.items()}
這裡的None要存的就是連結串列下一個位置的引用,只是目前我們只有連結串列頭,所以全部設定為None。
建立FP-tree
我們有了頭指標表的資料,也就是高頻的單個元素的資料之後,顯然要將它用起來。很明顯,我們可以用它來過濾整個資料集,過濾掉其中低頻的元素。
其實本質上來說FP-tree的構建過程,其實就是一個將過濾之後的結果插入到樹上的過程。後面的事情後面再說,我們首先來看過濾。
單純的過濾當然非常簡單,我們只需要判斷資料集中的元素是否在頭指標表中出現即可。但是不僅如此,由於我們之後想要將它插入到樹中。這裡利用了huffman樹的思想,我們希望頻次越高的元素存放的位置距離根節點越近。頻次越低的離根節點越遠,從而優化我們查詢的效率。要做到這點,需要我們對資料進行排序。
我們來實現這部分內容,這部分內容分為兩塊,第一塊是根據頭指標表進行過濾,第二塊是根據頭指標表中出現的頻次進行排序。
def rank_by_header(data, header_dict):
rank_dict = {}
for item in data:
# 如果元素是高頻的則保留,否則則丟棄
if item in header_dict:
rank_dict[item] = header_dict[item]
# 對元素按照整體出現的頻次排序
item_list = sorted(rank_dict.items(), lambda x: x[1], reverse=True)
return [i[0] for i in item_list]
有了這份資料之後,我們距離建樹只有一步之遙。FP-tree的構建剛才也提到過,非常簡單粗暴,就是將元素按照出現的頻次進行排序之後從樹根開始依次插入。在插入的過程當中,對路徑上的節點進行更新,我這麼說肯定很費解,但是看一張圖就肯定明白了:
一開始的時候樹為空,什麼也沒有,接著插入第一條資料{r, z}。由於z出現的次數大於r,所以先插入z再插入r,之後插入了一條{z,x,y,s,t},同樣是按照整體出現的頻次排序的。由於z已經插入了,所以我們將它出現的次數更新成2,之後發現沒有重複的元素,那麼就構建出一條新的分支。
本質上來說就是按照頻次排序之後,由淺到深依次插入,如果相同的元素之前已經出現過了,那麼就更新它出現的次數。
這個邏輯應該很好理解,在我們實現邏輯之前,我們先來建立樹節點的類。
class Node:
def __init__(self, item, freq, father):
self.item = item
self.freq = freq
# 父節點指標
self.father = father
# 定義指標
self.ptr = None
# 孩子節點,用dict儲存,方便根據item查詢
self.children = {}
# 更新頻次
def update_freq(self):
self.freq += 1
# 新增孩子
def add_child(self, node):
self.children[node.item] = node
這個類我們只需要看程式碼就好了,應該完全沒有難度,當然如果你願意你也可以給它加上一個視覺化方法用來debug。但老實說樹結構單憑列印很難顯示得很清楚,所以我就不加了。
樹節點的定義寫好了之後,接下來就到了最重要的實現更新FP-tree的環節了。其實如果對上面的邏輯都理解了,這部分程式碼也非常簡單,我們只需要套用剛才的程式碼將生成的資料按照順序插入進樹上即可。
這種在樹上傳入一個序列依次插入的做法非常常見,在很多資料結構當中有類似的操作,最經典的就是Trie樹了。如果你學過,會發現這個插入操作真的和Trie幾乎一模一樣,如果你沒學過,也沒有關係,這也不難理解。
def create_FP_tree(dataset, min_freq=3):
header_dict = filter_unfreq_items(dataset, min_freq)
root = Node('Null', 0, None)
for data in dataset:
# 根據整體出現次數進行排序
item_list = rank_by_header(data, header_dict)
print(item_list)
head = root
# 按照排序順序依次往樹上插入
for item in item_list:
if item in head.children:
head = head.children[item]
else:
new_node = Node(item, 0, head)
head.add_child(new_node)
head = new_node
head.update_freq()
return root
更新頭指標表
FP-tree已經完成了,接下來我們要把更新頭指標表的邏輯加上,使得對於每一個項來說,我們都可以根據頭指標表找到這個元素在FP-tree上所有的位置。
我們仔細觀察一下上面的那張圖,我們選擇其中的一條鏈路進行高亮:
從上圖當中我們會發現,頭指標表的作用就是建立一個連結串列,將元素所有出現的位置全部串聯起來。
那麼什麼情況下我們需要向這個連結串列當中新增值呢?
稍微分析一下就會發現,其實就是我們在樹上建立新節點的時候。想通了這點就很簡單了,我們只需要在上面的程式碼當中增加幾行,使得在樹上建立新的節點的時候,用同樣的值更新一下頭指標連結串列。
def create_FP_tree(dataset, min_freq=3):
header_dict = filter_unfreq_items(dataset, min_freq)
root = Node('Null', 0, None)
for data in dataset:
# 根據整體出現次數進行排序
item_list = rank_by_header(data, header_dict)
print(item_list)
head = root
# 按照排序順序依次往樹上插入
for item in item_list:
if item in head.children:
head = head.children[item]
else:
new_node = Node(item, 0, head)
head.add_child(new_node)
# 頭指標指向的位置為空,那麼我們直接讓頭指標指向當前位置
if head_table[item][1] is None:
head_table[item][1] = new_node
else:
# 否則的話,我們將當前元素新增到連結串列的末尾
insert_table(head_table[item][1], new_node)
head = new_node
head.update_freq()
return root
這裡新增到末尾的操作,我們可以再建立一個dict來維護頭指標表的末尾節點,但我這裡偷懶了一下,就用最簡單的辦法,先遍歷到連結串列的結尾,再進行新增:
def insert_table(head_node, node):
while head_node.ptr is not None:
head_node = head_node.ptr
head_node.ptr = node
通過FP-tree快速查詢資料
現在,我們已經完整實現了FP-tree的構建,接下來就到了重頭戲,也就是頻繁項集的挖掘。我們有了這棵FP-tree之後應該怎麼用呢?
如果我們仔細觀察一下FP-tree的話,會發現這棵樹其實是資料的濃縮。可以認為是之前完整的資料集去除了非頻繁的元素之後提煉得到的資料。根據APriori演算法的原理,我們接下來要做的就是用長度為1的頻繁項集去構建長度為2的頻繁項集,以此類推,直到找出所有的頻繁項集為止。
但是在FP-growth演算法當中,我們對這個邏輯稍稍做了一點點修正,我們每次固定一個元素,查詢所有它構成的頻繁項集。我們要查詢頻繁項集,首先需要獲取資料集,原始的資料包含了太多無關的資訊,我們已經用不到了,我們可以通過FP-tree高效地獲取我們需要的資料。
由於我們之前在插入FP-tree的時候,是嚴格按照元素出現的次數排序的,出現頻次高的元素放置的位置越高。這樣樹上某一個鏈路在資料集中出現的次數,就等於鏈路中最底層的節點的數字。
我們來看個例子:
紅框當中s的位置最低,所以整個鏈路上{z, x, y, s}在整個資料集當中出現的次數就是2,那麼我們確定了s之後,通過向上FP-tree,就可以還原出所有包含s的頻繁項構成的資料。
我們首先實現一個輔助方法,用來向上遍歷整個鏈路所有的元素:
def up_forward(node):
path = []
while node.father is not None:
path.append(node)
node = node.father
# 過濾樹根
return path
第二個輔助方法是固定某個元素之後,還原所有這個元素的資料。要還原所有資料,只拿到一個節點是不夠的,我們需要知道這個元素在FP-tree上所有出現的位置。這個時候就需要用到頭指標表了,利用頭指標表,我們可以找到所有元素在FP-tree中的位置,我們只需要呼叫上面的方法,就可以還原出資料集了。
這段邏輯同樣並不困難,其實就是遍歷一個連結串列,然後再呼叫上面向上遍歷樹的方法,獲取所有的資料。
def regenerate_dataset(head_table, item):
dataset = {}
if item not in head_table:
return dataset
# 通過head_table找到連結串列的起始位置
node = head_table[item][1]
# 遍歷連結串列
while node is not None:
# 對連結串列中的每個位置,呼叫up_forward,獲取FP-tree上的資料
path = up_forward(node)
if len(path) > 1:
# 將元素的set作為key
# 這裡去掉了item,為了使得新構建的資料當中沒有item
# 從而挖掘出以含有item為前提的新的頻繁項
dataset[frozenset(path[1:])] = node.freq
node = node.ptr
# 將kv格式的資料還原成陣列形式
ret = []
for k, v in dataset.items():
for i in range(v):
ret.append(list(k))
return ret
遞迴建樹,挖掘頻繁項集
到這裡,我們對FP-tree應該有一個比較清晰的認識了,它的功能就是可以快速地查詢某些元素組合的集合的頻次,因為相同元素構成的集合都被儲存在同一條樹鏈上了。
那麼我們怎麼根據FP-tree來挖掘頻繁項集呢?
這裡才是真正的演算法的精髓。
我們還是看下上面的例子:
我們假設我們固定的元素是r,我們通過FP-tree可以快速找到和r共同出現的頻繁項有z,x,y,s。通過剛才上面的方法,我們可以得到一個新的必須包含r和頻繁項的新的資料集。我們把r從這份資料當中去除,然後對剩下的資料構建新的FP-tree,如果新的FP-tree當中還有其他元素,那麼這個元素必然可以和r構成二元的頻繁項集。假設這個元素是x,那麼我們繼續重複上面的操作,再將x從資料中去除,再次構建FP-tree來挖掘包含x和r的三元頻繁項集,直到構成的FP-tree當中沒有元素了為止。
這就成了一個遞迴呼叫的過程,也就是說FP-tree本身並不能挖掘頻繁項集,只能挖掘頻繁項。但是我們可以人為加上前提條件,當我們以必須包含x的資料為前提挖掘出來的頻繁項,其實就是包含x的二元頻繁項集。我們加上的前提越來越多,也就是挖掘的頻繁項集的元素越來越多。
如果你還有點蒙,沒有關係,我們來看下程式碼:
def mine_freq_lists(root, head_table, min_freq, base, freq_lists):
# 對head_table排序,按照頻次從小到大排
frequents = [i[0] for i in sorted(head_table.items(), key=lambda x: x[0])]
for freq in frequents:
# base是被列為前提的頻繁項集
new_base = base.copy()
# 把當前元素加入頻繁項集
new_base.add(freq)
# 加入答案
freq_lists.append(new_base)
# 通過FP-tree獲取當前頻繁項集(new_base)為基礎的資料
new_dataset = regenerate_dataset(head_table, freq)
# 生成新的head_table
new_head_table = transform_to_header_table(filter_unfreq_items(new_dataset, min_freq))
# 如果為空,說明沒有更長的頻繁項集了
if len(new_head_table) > 0:
# 如果還有,就構建新的FP-tree
new_root = create_FP_tree(new_dataset, new_head_table, min_freq)
# 遞迴挖掘
mine_freq_lists(new_root, new_head_table, min_freq, new_base, freq_lists)
if __name__ == "__main__":
dataset = create_dataset()
data_dict = filter_unfreq_items(dataset, 3)
head_table = transform_to_header_table(data_dict)
root = create_FP_tree(dataset, head_table, 3)
data = regenerate_dataset(head_table, 'r')
freq_lists = []
mine_freq_lists(root, head_table, 3, set([]), freq_lists)
結尾
到這裡,整個FP-growth挖掘頻繁項集的演算法就結束了,相比於Apriori,它的技術細節要多得多,如果初學者覺得不太好理解,這也是正常的,可以抓大放小,先從核心思路開始理解。
Apriori的核心思路是用兩個長度為l的頻繁項集去構建長度為l+1的頻繁項集,而FP-growth則稍有不同。它是將一個長度為l的頻繁項集作為前提,篩選出包含這個頻繁項集的資料集。用這個資料集構建新的FP-tree,從這個FP-tree當中尋找新的頻繁項。如果能找到,那麼說明它可以和長度為l的頻繁項集構成長度為l+1的頻繁項集。然後,我們就重複這個過程。
這個核心思路理解了,怎麼構建FP-tree,怎麼維護頭指標表都是很簡單的問題了。
各位看官大人,請關注我吧~