FP-growth
FP-growth 算法能够更有效地挖掘数据,但不能用于发现关联规则。
FP-growth 基于 Apriori 算法构建,但在完成相同任务时采用了一些不同的技术。
- Apriori:在每次循环的连接步中都要扫描数据集,来计算当前组合而成的项集的支持度。
- FP-growth:只需要对数据库进行两次扫描,并将数据集存储在一个特定的称作 FP 树的数据结构。这种做法能够使得算法的执行速度要快于 Apriori,通常性能要好两个数量级以上。
那么什么是 FP 树呢?
FP 树介绍
FP 树是一种用于编码数据集的有效方式,看上去与普通的树结构类似,但不同的是:
- 它通过链接(link)来连接相似元素,被连起来的元素项可以看成一个链表。相似项之间的链接即节点链接(node link),用于快速发现相似项的位置。
- 同搜索树不同的是,一个元素项可以在 FP 树中出现多次;
- FP 树会存储项集的出现频率,而每个项集会以路径的方式存储在树中;因此,树节点上给出项集中的单个元素及其在序列中的出现次数,路径会给出该序列的出现次数。
- 存在相似元素的项集会共享树的一部分。只有当项集之间完全不同时,树才会分叉。
假设我们现在有如下数据:
TID | Items |
---|---|
001 | r, z, h, j, p |
002 | z, y, x, w, v, u, t, s |
003 | z |
004 | r, x, n, o, s |
005 | y, r, x, z, q, t, p |
006 | y, z, x, e, q, s, t, m |
根据上表的数据我们可以生成下图所示的 FP 树。
观察上图,我们从元素项 z 开始。元素项 z 共出现了 5 次,其中项集 {r, z} 出现了 1 次,{z, x} 出现了 3 次,那么 z 本身单独出现了 1 次,这样相加刚好等于 5。我们从表中也可以得出相同的结论,TID 003 只有 z 这一元素项。
我们接着往下看,沿着 z->x->y 路径,我们可以得到项集 {z, x, y} 的出现次数为 3,此时有两个分叉,往左边走,得到项集 {z, x, y, s},其出现次数为 2;往右边走,得到项集 {z, x, y, r},其出现次数为 1。
仔细观察 FP 树我们可以发现其中没有元素项 p 和 q,这是为什么呢?根据 Apriori 算法的原理,假设最小支持度为 3,因为 p 和 q 不满足最小支持度的要求,所以没有出现在 FP 树中。也就是说当前这一颗 FP 树的 1-项集全部为频繁项集。
我们再来看节点链接,从最左边的 r 开始(红色箭头处),该节点 r 的值为 1,难道 r 在数据库中只出现一次吗?并不是,我们沿着当前节点的链接可以找到其他两个 r 节点,并把这些 r 节点的值相加,就可以得到项集 {r} 的出现次数。因为当前的 r 节点是 z 节点的子节点,它表示的是项集 {z, r} 出现次数为 1。如果我们将 r 作为根节点的子节点(与 z 互换位置),那么 r 的值或许就会发生变化(变成 3),这说明 FP 树的结构是可变的。
通过 FP 树,我们就可以获得每一个项集(1-项集非频繁的除外)的出现次数。
有了 FP 树之后,我们就可以得出 FP-growth 算法的工作流程:
- 首先对数据库扫描两遍,第一遍对所有项集的出现次数进行计数,第二遍只考虑频繁元素,并构建一棵 FP 树;
- 然后利用构建的 FP 树来挖掘频繁项集。
构建 FP 树
在第二次扫描数据库(数据集)时会构建一棵 FP 树。为构建一棵树,我们需要一个容器来保存树。
FP 树的数据结构
class treeNode:
def __init__(self, name_value, num_occur, parent_node):
self.name = name_value
self.count = num_occur
self.node_link = None
self.parent = parent_node
self.children = {}
def inc(self, num_occur):
self.count += num_occur
def disp(self, ind=1):
print(' ' * ind, self.name, ' ', self.count)
for child in self.children.values():
child.disp(ind + 1)
上面代码给出了 FP 树中节点类的定义。
- name:存放节点名字;
- count:记录当前节点的计数值;
- node_link 变量用于链接相似的元素项;
- parent:指向当前节点的父节点;
- children:存放子节点的字典。
以上图红色圈中的 r 节点为例:
- name:r
- count:1
- node_link:指向下一个 r 节点
- parent:指向 y 节点
- children:存放子节点 t 的字典
inc() 方法用以给 count 变量增加给定值,disp() 方法用于将树以文本形式显示,该方法对于树构建来说并不是必要的,但是它对于调试非常有用。
我们先测试一下代码是否可行,先创建一个根节点。
root_node = treeNode('pyramid', 9, None)
接下来为根节点增加一个子节点。
root_node.children['eye'] = treeNode('eye', 13, None)
输出 FP 树。
>>> root_node.disp()
pyramid 9
eye 13
再添加一个节点,查看两个子节点的展示效果。
>>> root_node.children['phoenix'] = treeNode('phoenix', 3, None)
>>> root_node.disp()
pyramid 9
eye 13
phoenix 3
可以看到,同一层的节点的缩进层级是相同的。现在 FP 树所需的数据结构已经建好,下面就可以构建 FP 树了。
事实上,在 FP 树构建的过程中,还需要一个头指针表来指向给定元素的第一个实例。利用头指针表,可以快速访问 FP 树中一个给定元素的所有实例。
这里可以使用一个字典作为数据结构来保存头指针表。除了存放指针外,头指针表还可以用来保存 FP 树中每类元素的总数。
构建 FP 树
首先,我们先遍历一遍数据集以获得每个元素项的出现频率(出现次数 / 数据集的长度)。接下来去掉不满足最小支持度的元素项。继续沿用先前的数据集,我们可以得到每个元素项的出现次数。
Items | count | Items | count | Items | count |
---|---|---|---|---|---|
e | 1 | p | 2 | v | 1 |
h | 1 | q | 2 | w | 1 |
j | 1 | r | 3 | x | 4 |
m | 1 | s | 3 | y | 3 |
n | 1 | t | 3 | z | 5 |
o | 1 | u | 1 |
令最小支持度为 0.5,那么将出现次数小于 3 的元素项过滤,最后可得 1-频繁项集 L1,并且观察 L1 的结果可以发现与头指针表的数据相同。
Items | count |
---|---|
r | 3 |
s | 3 |
t | 3 |
x | 4 |
y | 3 |
z | 5 |
接着,我们将数据集中的非频繁项移除,并且按照出现次数从高到低进行排序。为什么要这么做呢?因为数据集中的每一条记录都是一个无序集合,假设有集合 {z, x, y} 和 {y, z, x},在 FP 树中,相同项会只表示一次。如果不进行排序的话,在构建 FP 树的过程中就会因为集合的无序性而创建两条路径 z->x->y 和 y->z->x,但实际上这两条路径表示同一个项集,这违背了 FP 树中相同项只会表示一次的条件,所以我们需要提前对集合进行排序。
TID | Items | NewItems |
---|---|---|
001 | r, z, h, j, p | z r |
002 | z, y, x, w, v, u, t, s | z x y t s |
003 | z | z |
004 | r, x, n, o, s | x s r |
005 | y, r, x, z, q, t, p | z x y r t |
006 | y, z, x, e, q, s, t, m | z x y s t |
此时,我们就可以根据过滤后数据集(NewItems)构建 FP 树。在构建时,读入每个项集并将其添加到一条已经存在的路径中。如果该路径不存在,则创建一条新路径。
例如,从根节点(空集)开始,读入项集 {z, r},为根节点增加一个子节点 z,然后给 z 节点创建一个子节点 r,形成一条 z->r 的路径。
- 接着读入项集 {z, x, y, s, t},z 节点已经存在,那么就从根节点走到 z 节点,然而 z 节点不存在子节点 x,于是创建子节点 x,重复上述过程,直到创建一条 z->x->y->s->t 的路径。
- 接着读入项集 {z},当前项集有存在的路径,于是就不需要创建新路径。
- 需要注意的是,在遍历路径的过程中,如果现有元素存在,则增加现有元素的值。例如项集 {z, r} 创建 z 节点,并将 z 节点的 count 设置为 1,接着读入项集 {z, x, y, s, t},由于 z 节点已经存在,那么就为 z 节点的 count 加 1。
通过上面的叙述,我们大致了解了根据数据集构建 FP 树的基本思想,接下来我们通过代码来实现上述过程。
update_tree()
update_tree() 函数用以让 FP 树“生长”,输入参数为项集 items,FP 树 in_tree,头指针表 header_table,以及项集的个数 count。
def update_tree(items, in_tree, header_table, count):
if items[0] in in_tree.children:
in_tree.children[items[0]].inc(count)
else:
in_tree.children[items[0]] = treeNode(items[0], count, in_tree)
if header_table[items[0]][1] == None:
header_table[items[0]][1] = in_tree.children[items[0]]
else:
update_header(header_table[items[0]][1], in_tree.children[items[0]])
if len(items) > 1:
# 对剩下的项集迭代调用 update_tree 函数
update_tree(items[1::], in_tree.children[items[0]], header_table, count)
- 该函数首先测试项集中的第一个元素项是否作为子节点存在。如果存在的话,则更新该元素项的计数;如果不存在,则创建一个新的 treeNode 并将其作为一个子节点添加到树中。这时,头指针表也要更新以指向新的节点。更新头指针表需要调用函数 update_header()。
if items[0] in in_tree.children:
in_tree.children[items[0]].inc(count)
else:
in_tree.children[items[0]] = treeNode(items[0], count, in_tree)
if header_table[items[0]][1] == None:
header_table[items[0]][1] = in_tree.children[items[0]]
else:
update_header(header_table[items[0]][1], in_tree.children[items[0]])
- update_tree() 函数不断迭代调用自身,每次调用时从项集中去除第一个元素,例如项集 {z, r}。第一次调用后去除 z,第二次调用后去除 r,由于此时项集的长度已经为 0,则不再迭代调用。
if len(items) > 1:
# 对剩下的项集迭代调用 update_tree 函数
update_tree(items[1::], in_tree.children[items[0]], header_table, count)
update_header()
update_header() 函数确保节点链接指向树中该元素项的每一个实例。它接收两个参数,分别是对应元素项的头指针 node_to_test 和目标节点 target_node。
def update_header(node_to_test, target_node):
while node_to_test.node_link != None:
node_to_test = node_to_test.node_link
node_to_test.node_link = target_node
从头指针表的 node_link 开始,不断循环直到当前链表的尾部,然后将目标节点添加到链表的尾部。
【注意】:在处理树的时候通常都会用递归来完成相应的操作。如果在处理树的同时,也以递归的方式来处理链表可能会遇到一些问题——链表很长可能会遇到递归调用的次数限制。所以在这里我们采用迭代(循环)的方式去遍历链表,直到链表的尾部。
create_tree()
create_tree() 接受两个输入参数,分别是数据集 dataset 和最小支持度 min_sup。
def create_tree(dataset, min_sup=1):
header_table = {}
for trans in dataset:
for item in trans:
header_table[item] = header_table.get(item, 0) + dataset[trans]
temp_table = {}
# 移除不满足最小支持度的项集
for k in header_table.keys():
if header_table[k] >= min_sup:
temp_table[k] = header_table[k]
del(header_table)
header_table = temp_table
freq_item_set = set(header_table.keys())
# 如果没有项集满足要求,则退出
if len(freq_item_set) == 0:
return None, None
for k in header_table:
header_table[k] = [header_table[k], None]
ret_tree = treeNode('Null Set', 1, None)
for tran_set, count in dataset.items():
# 根据全局频率对每个事务中的元素进行排序
local_D = {}
for item in tran_set:
if item in freq_item_set:
local_D[item] = header_table[item][0]
if len(local_D) > 0:
ordered_items = [v[0] for v in sorted(local_D.items(), key=lambda p:p[1], reverse=True)]
update_tree(ordered_items, ret_tree, header_table, count)
return ret_tree, header_table
- 第一次遍历扫描数据集并统计每个元素项出现的频度,这些信息被存储在头指针表中。
header_table = {}
for trans in dataset:
for item in trans:
header_table[item] = header_table.get(item, 0) + dataset[trans]
- 接下来,扫描头指针表选择出现次数大于 min_sup 的元素项。
temp_table = {}
for k in header_table.keys():
if header_table[k] >= min_sup:
temp_table[k] = header_table[k]
del(header_table)
header_table = temp_table
- 获取频繁项集,如果所有项集都不频繁,则直接返回 None。
freq_item_set = set(header_table.keys())
if len(freq_item_set) == 0:
return None, None
- 接下来,对头指针表稍加扩展以便可以保存计数值及指向每种类型第一个元素项的指针。
for k in header_table:
header_table[k] = [header_table[k], None]
- 然后创建只包含空集合的根节点。
ret_tree = treeNode('Null Set', 1, None)
- 最后,再一次遍历数据集,这次只考虑那些频繁项。先根据全局频率对每个事务中的元素进行排序,然后调用 update_tree() 函数来“生长” FP 树。
for tran_set, count in dataset.items():
local_D = {}
# 根据全局频率对每个事务中的元素进行排序
for item in tran_set:
if item in freq_item_set:
local_D[item] = header_table[item][0]
if len(local_D) > 0:
ordered_items = [v[0] for v in sorted(local_D.items(), key=lambda p:p[1], reverse=True)]
update_tree(ordered_items, ret_tree, header_table, count)
return ret_tree, header_table
【注意】:create_tree() 函数接受的数据集不是列表,而是一个字典。我们再编写一个简单的获取数据函数以及数据转换函数。
def load_simp_data():
simp_data = [
['r', 'z', 'h', 'j', 'p'],
['z', 'y', 'x', 'w', 'v', 'u', 't', 's'],
['z'],
['r', 'x', 'n', 'o', 's'],
['y', 'r', 'x', 'z', 'q', 't', 'p'],
['y', 'z', 'x', 'e', 'q', 's', 't', 'm']
]
return simp_data
def create_init_set(dataset):
ret_dict = {}
for trans in dataset:
ret_dict[frozenset(trans)] = 1
return ret_dict
【测试代码】:
>>> simp_dat = load_simp_data()
>>> simp_dat
[['r', 'z', 'h', 'j', 'p'],
['z', 'y', 'x', 'w', 'v', 'u', 't', 's'],
['z'],
['r', 'x', 'n', 'o', 's'],
['y', 'r', 'x', 'z', 'q', 't', 'p'],
['y', 'z', 'x', 'e', 'q', 's', 't', 'm']]
>>> init_set = create_init_set(simp_dat)
>>> init_set
{frozenset({'h', 'j', 'p', 'r', 'z'}): 1,
frozenset({'s', 't', 'u', 'v', 'w', 'x', 'y', 'z'}): 1,
frozenset({'z'}): 1,
frozenset({'n', 'o', 'r', 's', 'x'}): 1,
frozenset({'p', 'q', 'r', 't', 'x', 'y', 'z'}): 1,
frozenset({'e', 'm', 'q', 's', 't', 'x', 'y', 'z'}): 1}
>>> my_fptree, my_header_tab = create_tree(init_set, 3)
>>> my_fptree.disp()
Null Set 1
z 5
r 1
x 3
t 3
y 3
s 2
r 1
x 1
r 1
s 1
上面给出的是元素项及其对应的频率计数值,其中每个缩进表示所处的树的深度。现在我们已经构建 FP 树,接下来就使用它来进行频繁项集挖掘。
挖掘频繁项集
FP-growth 算法抽取频繁项集的过程与 Apriori 算法大致类似,首先从单元素项集开始,然后在此基础上逐步构建更大的项集。与 Apriori 不同的是,FP-growth 利用 FP 树实现上述过程。
【从 FP 树中抽取频繁项集的三个基本步骤】:
- 从 FP 树中获得条件模式基;
- 利用条件模式基,构建一个条件 FP 树;
- 迭代重复步骤(1)和步骤(2),直到树包含一个元素项为止。
接下来重点关注第(1)步,即寻找条件模式基的过程。之后,为每个条件模式基创建对应的条件 FP 树。
什么是条件模式基(conditional pattern base)?条件模式基是以所查找元素项为结尾的路径集合。每一条路径其实都是一条前缀路径(prefix path)。简单地说,一条前缀路径是介于所查找元素项与根节点之间的所有内容。例如项集 {z, x, y, s, t},我们要查找元素项 t,在路径 z->x->y->s 即为当前元素项 t 的前缀路径。
那么我们就可以写出 1-频繁项的前缀路径。
频繁项 | 前缀路径 |
---|---|
z | {}5 |
r | {x, s}1, {z, x, yy}1, {z}1 |
x | {z}3, {}1 |
y | {z, x}3 |
s | {z, x, y}2, {x}1 |
t | {z, x, y, s}2, {z, x, y, r}1 |
抽取条件模式基
从先前已经保存在头指针表中的单个频繁元素项开始。对于每一个元素项,上溯到 FP 树的根节点,从而获得其对应的条件模式基。
ascend_tree()
ascend_tree() 函数用以上溯 FP 树,并收集所有遇到的元素项的名称。
def ascend_tree(leaf_node, prefix_path):
if leaf_node.parent != None:
prefix_path.append(leaf_node.name)
ascend_tree(leaf_node.parent, prefix_path)
find_prefix_path()
find_prefix_path() 函数通过访问树中所有包含给定元素项的节点来为给定元素项生成一个条件模式基。
def find_prefix_path(base_pat, tree_node):
cond_pats = {}
while tree_node != None:
prefix_path = []
ascend_tree(tree_node, prefix_path)
if len(prefix_path) > 1:
cond_pats[frozenset(prefix_path[1:])] = tree_node.count
tree_node = tree_node.node_link
return cond_pats
【测试代码】:可以用先前构建的树来查看一下实际的运行效果。
>>> find_prefix_path('x', my_header_tab['x'][1])
{frozenset({'z'}): 3}
>>> find_prefix_path('z', my_header_tab['z'][1])
{}
>>> find_prefix_path('r', my_header_tab['r'][1])
{frozenset({'z'}): 1, frozenset({'x'}): 1, frozenset({'x', 'z'}): 1}
创建条件 FP 树
对于每一个频繁项,都要创建一棵条件 FP 树。可以使用上一步发现的条件模式基作为输入数据,并通过相同的构建 FP 树的代码来构建条件 FP 树。然后,我们会递归地发现频繁项、发现条件模式基,以及发现另外的条件树。
假定为频繁项 t 创建一个条件 FP 树,最小支持度的频数为 3。根据先前得到的 1-频繁项的前缀路径,可知 t 的前缀路径为 {z, x, y, s}2, {z, x, y, r}1。
- 因为最小支持度的频数为 3,所以 {z, x, y, s} 和 {z, x, y, r} 都不满足条件,分别去掉 s 和 r。
- 去掉 s 和 r 后发现,这两条前缀路径重合了,{z, x, y} 的计数值变为 3,即满足条件。根据 Apriori 原理,频繁项集的超集自然也是频繁项集。
这样,我们就可以得到 {t, z},{t, x} 以及 {t, y}。接下来对上面得到的 2-频繁项集继续执行刚才的操作,直到条件树中没有元素为止。
mine_tree()
mine_tree() 函数接受五个输入参数,分别是:
- in_tree:FP 树;
- header_table:头指针表;
- min_sup:最小支持度频数;
- prefix:前缀路径;
- freq_item_list:频繁项集。
def mine_tree(in_tree, header_table, min_sup, prefix, freq_item_list):
big_l = [v[0] for v in sorted(header_table.items(), key=lambda p:p[1])]
for base_pat in big_l:
new_freq_set = prefix.copy()
new_freq_set.add(base_pat)
freq_item_list.append(new_freq_set)
cond_patt_bases = find_prefix_path(base_pat, header_table[base_pat][1])
my_cond_tree, my_head = create_tree(cond_patt_bases, min_sup)
if my_head != None:
mine_tree(my_cond_tree, my_head, min_sup, new_freq_set, freq_item_list)
- 首先对头指针表中的元素项按照其出现频率进行升序排序。
big_l = [v[0] for v in sorted(header_table.items(), key=lambda p:p[1])]
- 然后,将每一个频繁项添加到频繁项集列表 freq_item_list 中。
for base_pat in big_l:
new_freq_set = prefix.copy()
new_freq_set.add(base_pat)
freq_item_list.append(new_freq_set)
// ...
- 接着,递归调用 find_prefix_path() 函数来创建条件基。该条件基被当成一个新数据集用以创造 FP 树。
cond_patt_bases = find_prefix_path(base_pat, header_table[base_pat][1])
my_cond_tree, my_head = create_tree(cond_patt_bases, min_sup)
- 最后,如果树中仍然有元素项的话,递归调用 mine_tree() 函数。
if my_head != None:
mine_tree(my_cond_tree, my_head, min_sup, new_freq_set, freq_item_list)
【测试代码】:为了更好地观察 mine_tree() 函数的运行过程,我们在语句 if my_head != None
之后添加两行代码:
print('conditional tree for: ', new_freq_set)
my_cond_tree.disp(1)
接着运行下述命令:
>>> # 先建立空列表来存储所有的频繁项集
<<< freq_items = []
>>> mine_tree(my_fptree, my_header_tab, 3, set([]), freq_items)
conditional tree for: {'y'}
Null Set 1
z 3
x 3
conditional tree for: {'y', 'x'}
Null Set 1
z 3
conditional tree for: {'s'}
Null Set 1
x 3
conditional tree for: {'t'}
Null Set 1
y 3
z 3
x 3
conditional tree for: {'t', 'z'}
Null Set 1
y 3
conditional tree for: {'t', 'x'}
Null Set 1
y 3
z 3
conditional tree for: {'t', 'z', 'x'}
Null Set 1
y 3
conditional tree for: {'x'}
Null Set 1
z 3
至此,完整的 FP-growth 算法已经可以运行。
【完整代码】:传送门
class FPGrowth():
def __init__(self):
pass
def create_tree(self, dataset, min_sup=1):
header_table = {}
for trans in dataset:
for item in trans:
header_table[item] = header_table.get(item, 0) + dataset[trans]
temp_table = {}
# 移除不满足最小支持度的项集
for k in header_table.keys():
if header_table[k] >= min_sup:
temp_table[k] = header_table[k]
del(header_table)
header_table = temp_table
freq_item_set = set(header_table.keys())
# 如果没有项集满足要求,则退出
if len(freq_item_set) == 0:
return None, None
for k in header_table:
header_table[k] = [header_table[k], None]
ret_tree = treeNode('Null Set', 1, None)
for tran_set, count in dataset.items():
# 根据全局频率对每个事务中的元素进行排序
local_D = {}
for item in tran_set:
if item in freq_item_set:
local_D[item] = header_table[item][0]
if len(local_D) > 0:
ordered_items = [v[0] for v in sorted(local_D.items(), key=lambda p:p[1], reverse=True)]
self._update_tree(ordered_items, ret_tree, header_table, count)
return ret_tree, header_table
def _update_tree(self, items, in_tree, header_table, count):
if items[0] in in_tree.children:
in_tree.children[items[0]].inc(count)
else:
in_tree.children[items[0]] = treeNode(items[0], count, in_tree)
if header_table[items[0]][1] == None:
header_table[items[0]][1] = in_tree.children[items[0]]
else:
self._update_header(header_table[items[0]][1], in_tree.children[items[0]])
if len(items) > 1:
# 对剩下的项集迭代调用 update_tree 函数
self._update_tree(items[1::], in_tree.children[items[0]], header_table, count)
def _update_header(self, node_to_test, target_node):
while node_to_test.node_link != None:
node_to_test = node_to_test.node_link
node_to_test.node_link = target_node
def _ascend_tree(self, leaf_node, prefix_path):
if leaf_node.parent != None:
prefix_path.append(leaf_node.name)
self._ascend_tree(leaf_node.parent, prefix_path)
def _find_prefix_path(self, base_pat, tree_node):
cond_pats = {}
while tree_node != None:
prefix_path = []
self._ascend_tree(tree_node, prefix_path)
if len(prefix_path) > 1:
cond_pats[frozenset(prefix_path[1:])] = tree_node.count
tree_node = tree_node.node_link
return cond_pats
def mine_tree(self, in_tree, header_table, min_sup, prefix, freq_item_list):
big_l = [v[0] for v in sorted(header_table.items(), key=lambda p:p[1][0])]
for base_pat in big_l:
new_freq_set = prefix.copy()
new_freq_set.add(base_pat)
freq_item_list.append(new_freq_set)
cond_patt_bases = self._find_prefix_path(base_pat, header_table[base_pat][1])
my_cond_tree, my_head = self.create_tree(cond_patt_bases, min_sup)
if my_head != None:
# print('conditional tree for: ', new_freq_set)
# my_cond_tree.disp(1)
self.mine_tree(my_cond_tree, my_head, min_sup, new_freq_set, freq_item_list)
参考
- 《机器学习实战》
- 《数据挖掘:概念与技术第三版》