FP-growth算法基于Apriori构建,但采用了高级的数据结构减少扫描次数,大大加快了算法速度。FP-growth算法只需要对数据库进行两次扫描,而Apriori算法对于每个潜在的频繁项集都会扫描数据集判定给定模式是否频繁,因此FP-growth算法的速度要比Apriori算法快。
FP-growth算法发现频繁项集的基本过程如下:
FP-growth算法
- 优点:一般要快于Apriori。
- 缺点:实现比较困难,在某些数据集上性能会下降。
- 适用数据类型:离散型数据。
FP-growth算法将数据存储在一种称为FP树的紧凑数据结构中。FP代表频繁模式(Frequent Pattern)。一棵FP树看上去与计算机科学中的其他树结构类似,但是它通过链接(link)来连接相似元素,被连起来的元素项可以看成一个链表。图5给出了FP树的一个例子。
图5 一棵FP树,和一般的树结构类似,包含着连接相似节点(值相同的节点)的连接
与搜索树不同的是,一个元素项可以在一棵FP树种出现多次。FP树辉存储项集的出现频率,而每个项集会以路径的方式存储在数中。存在相似元素的集合会共享树的一部分。只有当集合之间完全不同时,树才会分叉。 树节点上给出集合中的单个元素及其在序列中的出现次数,路径会给出该序列的出现次数。
相似项之间的链接称为节点链接(node link),用于快速发现相似项的位置。
举例说明,下表用来产生图5的FP树:
事务ID | 事务中的元素项 |
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树的解读:
图5中,元素项z出现了5次,集合{r, z}出现了1次。于是可以得出结论:z一定是自己本身或者和其他符号一起出现了4次。集合{t, s, y, x, z}出现了2次,集合{t, r, y, x, z}出现了1次,z本身单独出现1次。就像这样,FP树的解读方式是读取某个节点开始到根节点的路径。路径上的元素构成一个频繁项集,开始节点的值表示这个项集的支持度。根据图5,我们可以快速读出项集{z}的支持度为5、项集{t, s, y, x, z}的支持度为2、项集{r, y, x, z}的支持度为1、项集{r, s, x}的支持度为1。FP树中会多次出现相同的元素项,也是因为同一个元素项会存在于多条路径,构成多个频繁项集。但是频繁项集的共享路径是会合并的,如图中的{t, s, y, x, z}和{t, r, y, x, z}
和之前一样,我们取一个最小阈值,出现次数低于最小阈值的元素项将被直接忽略。图5中将最小支持度设为3,所以q和p没有在FP中出现。
FP-growth算法的工作流程如下。首先构建FP树,然后利用它来挖掘频繁项集。为构建FP树,需要对原始数据集扫描两遍。第一遍对所有元素项的出现次数进行计数。数据库的第一遍扫描用来统计出现的频率,而第二遍扫描中只考虑那些频繁元素。
由于树节点的结构比较复杂,我们使用一个类表示。创建文件fpGrowth.py并加入下列代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class
treeNode:
def
__init__(
self
, nameValue, numOccur, parentNode):
self
.name
=
nameValue
self
.count
=
numOccur
self
.nodeLink
=
None
self
.parent
=
parentNode
self
.children
=
{}
def
inc(
self
, numOccur):
self
.count
+
=
numOccur
def
disp(
self
, ind
=
1
):
print
' '
*
ind,
self
.name,
' '
,
self
.count
for
child
in
self
.children.values():
child.disp(ind
+
1
)
|
每个树节点由五个数据项组成:
成员函数:
测试代码:
1
2
3
4
5
|
>>>
import
fpGrowth
>>> rootNode
=
fpGrowth.treeNode(
'pyramid'
,
9
,
None
)
>>> rootNode.children[
'eye'
]
=
fpGrowth.treeNode(
'eye'
,
13
,
None
)
>>> rootNode.children[
'phoenix'
]
=
fpGrowth.treeNode(
'phoenix'
,
3
,
None
)
>>> rootNode.disp()
|
头指针表
FP-growth算法还需要一个称为头指针表的数据结构,其实很简单,就是用来记录各个元素项的总出现次数的数组,再附带一个指针指向FP树中该元素项的第一个节点。这样每个元素项都构成一条单链表。图示说明:
图6 带头指针表的FP树,头指针表作为一个起始指针来发现相似元素项
这里使用Python字典作为数据结构,来保存头指针表。以元素项名称为键,保存出现的总次数和一个指向第一个相似元素项的指针。
第一次遍历数据集会获得每个元素项的出现频率,去掉不满足最小支持度的元素项,生成这个头指针表。
元素项排序
上文提到过,FP树会合并相同的频繁项集(或相同的部分)。因此为判断两个项集的相似程度需要对项集中的元素进行排序(不过原因也不仅如此,还有其它好处)。排序基于元素项的绝对出现频率(总的出现次数)来进行。在第二次遍历数据集时,会读入每个项集(读取),去掉不满足最小支持度的元素项(过滤),然后对元素进行排序(重排序)。
对示例数据集进行过滤和重排序的结果如下:
事务ID | 事务中的元素项 | 过滤及重排序后的事务 |
001 | r, z, h, j, p | z, r |
002 | z, y, x, w, v, u, t, s | z, x, y, s, t |
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 |
构建FP树
在对事务记录过滤和排序之后,就可以构建FP树了。从空集开始,将过滤和重排序后的频繁项集一次添加到树中。如果树中已存在现有元素,则增加现有元素的值;如果现有元素不存在,则向树添加一个分支。对前两条事务进行添加的过程:
图7 FP树构建过程示意(添加前两条事务)
算法:构建FP树
输入:数据集、最小值尺度
输出:FP树、头指针表
1. 遍历数据集,统计各元素项出现次数,创建头指针表
2. 移除头指针表中不满足最小值尺度的元素项
3. 第二次遍历数据集,创建FP树。对每个数据集中的项集:
3.1 初始化空FP树
3.2 对每个项集进行过滤和重排序
3.3 使用这个项集更新FP树,从FP树的根节点开始:
3.3.1 如果当前项集的第一个元素项存在于FP树当前节点的子节点中,则更新这个子节点的计数值
3.3.2 否则,创建新的子节点,更新头指针表
3.3.3 对当前项集的其余元素项和当前元素项的对应子节点递归3.3的过程
代码(在fpGrowth.py中加入下面的代码):
1 总函数:createTree
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
def
createTree(dataSet, minSup
=
1
):
''' 创建FP树 '''
# 第一次遍历数据集,创建头指针表
headerTable
=
{}
for
trans
in
dataSet:
for
item
in
trans:
headerTable[item]
=
headerTable.get(item,
0
)
+
dataSet[trans]
# 移除不满足最小支持度的元素项
for
k
in
headerTable.keys():
if
headerTable[k] < minSup:
del
(headerTable[k])
# 空元素集,返回空
freqItemSet
=
set
(headerTable.keys())
if
len
(freqItemSet)
=
=
0
:
return
None
,
None
# 增加一个数据项,用于存放指向相似元素项指针
for
k
in
headerTable:
headerTable[k]
=
[headerTable[k],
None
]
retTree
=
treeNode(
'Null Set'
,
1
,
None
)
# 根节点
# 第二次遍历数据集,创建FP树
for
tranSet, count
in
dataSet.items():
localD
=
{}
# 对一个项集tranSet,记录其中每个元素项的全局频率,用于排序
for
item
in
tranSet:
if
item
in
freqItemSet:
localD[item]
=
headerTable[item][
0
]
# 注意这个[0],因为之前加过一个数据项
if
len
(localD) >
0
:
orderedItems
=
[v[
0
]
for
v
in
sorted
(localD.items(), key
=
lambda
p: p[
1
], reverse
=
True
)]
# 排序
updateTree(orderedItems, retTree, headerTable, count)
# 更新FP树
return
retTree, headerTable
|
(代码比较宽,大家的显示器都那么大,应该没关系吧……)
需要注意的是,参数中的dataSet的格式比较奇特,不是直觉上得集合的list,而是一个集合的字典,以这个集合为键,值部分记录的是这个集合出现的次数。于是要生成这个dataSet还需要后面的createInitSet()函数辅助。因此代码中第7行中的dataSet[trans]实际获得了这个trans集合的出现次数(在本例中均为1),同样第21行的“for tranSet, count in dataSet.items():”获得了tranSet和count分别表示一个项集和该项集的出现次数。——这样做是为了适应后面在挖掘频繁项集时生成的条件FP树。
2 辅助函数:updateTree
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
def
updateTree(items, inTree, headerTable, count):
if
items[
0
]
in
inTree.children:
# 有该元素项时计数值+1
inTree.children[items[
0
]].inc(count)
else
:
# 没有这个元素项时创建一个新节点
inTree.children[items[
0
]]
=
treeNode(items[
0
], count, inTree)
# 更新头指针表或前一个相似元素项节点的指针指向新节点
if
headerTable[items[
0
]][
1
]
=
=
None
:
headerTable[items[
0
]][
1
]
=
inTree.children[items[
0
]]
else
:
updateHeader(headerTable[items[
0
]][
1
], inTree.children[items[
0
]])
if
len
(items) >
1
:
# 对剩下的元素项迭代调用updateTree函数
updateTree(items[
1
::], inTree.children[items[
0
]], headerTable, count)
|
3 辅助函数:updateHeader
1
2
3
4
|
def
updateHeader(nodeToTest, targetNode):
while
(nodeToTest.nodeLink !
=
None
):
nodeToTest
=
nodeToTest.nodeLink
nodeToTest.nodeLink
=
targetNode
|
这个函数其实只做了一件事,就是获取头指针表中该元素项对应的单链表的尾节点,然后将其指向新节点targetNode。
生成数据集
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
def
loadSimpDat():
simpDat
=
[[
'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
simpDat
def
createInitSet(dataSet):
retDict
=
{}
for
trans
in
dataSet:
retDict[
frozenset
(trans)]
=
1
return
retDict
|
生成的样例数据同文中用得一样。这个诡异的输入格式就是createInitSet()函数中这样来得。
测试代码
1
2
3
4
5
|
>>>
import
fpGrowth
>>> simpDat
=
fpGrowth.loadSimpDat()
>>> initSet
=
fpGrowth.createInitSet(simpDat)
>>> myFPtree, myHeaderTab
=
fpGrowth.createTree(initSet,
3
)
>>> myFPtree.disp()
|
结果是这样的(连字都懒得打了,直接截图……):
得到的FP树也和图5中的一样。
到现在为止大部分比较困难的工作已经处理完了。有了FP树之后,就可以抽取频繁项集了。这里的思路与Apriori算法大致类似,首先从单元素项集合开始,然后在此基础上逐步构建更大的集合。
从FP树中抽取频繁项集的三个基本步骤如下:
(这个翻译是什么鬼……英文是conditional pattern base)
首先从头指针表中的每个频繁元素项开始,对每个元素项,获得其对应的条件模式基(conditional pattern base)。条件模式基是以所查找元素项为结尾的路径集合。每一条路径其实都是一条前缀路径(prefix path)。简而言之,一条前缀路径是介于所查找元素项与树根节点之间的所有内容。
将图5重新贴在这里:
则每一个频繁元素项的所有前缀路径(条件模式基)为:
频繁项 | 前缀路径 |
z | {}: 5 |
r | {x, s}: 1, {z, x, y}: 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 |
发现规律了吗,z存在于路径{z}中,因此前缀路径为空,另添加一项该路径中z节点的计数值5构成其条件模式基;r存在于路径{r, z}、{r, y, x, z}、{r, s, x}中,分别获得前缀路径{z}、{y, x, z}、{s, x},另添加对应路径中r节点的计数值(均为1)构成r的条件模式基;以此类推。
前缀路径将在下一步中用于构建条件FP树,暂时先不考虑。如何发现某个频繁元素项的所在的路径?利用先前创建的头指针表和FP树中的相似元素节点指针,我们已经有了每个元素对应的单链表,因而可以直接获取。
下面的程序给出了创建前缀路径的代码:
1 主函数:findPrefixPath
1
2
3
4
5
6
7
8
9
10
|
def
findPrefixPath(basePat, treeNode):
''' 创建前缀路径 '''
condPats
=
{}
while
treeNode !
=
None
:
prefixPath
=
[]
ascendTree(treeNode, prefixPath)
if
len
(prefixPath) >
1
:
condPats[
frozenset
(prefixPath[
1
:])]
=
treeNode.count
treeNode
=
treeNode.nodeLink
return
condPats
|
该函数代码用于为给定元素项生成一个条件模式基(前缀路径),这通过访问树中所有包含给定元素项的节点来完成。参数basePet表示输入的频繁项,treeNode为当前FP树种对应的第一个节点(可在函数外部通过headerTable[basePat][1]获取)。函数返回值即为条件模式基condPats,用一个字典表示,键为前缀路径,值为计数值。
2 辅助函数:ascendTree
1
2
3
4
|
def
ascendTree(leafNode, prefixPath):
if
leafNode.parent !
=
None
:
prefixPath.append(leafNode.name)
ascendTree(leafNode.parent, prefixPath)
|
这个函数直接修改prefixPath的值,将当前节点leafNode添加到prefixPath的末尾,然后递归添加其父节点。最终结果,prefixPath就是一条从treeNode(包括treeNode)到根节点(不包括根节点)的路径。在主函数findPrefixPath()中再取prefixPath[1:],即为treeNode的前缀路径。
测试代码:
1
2
3
|
>>> fpGrowth.findPrefixPath(
'x'
, myHeaderTab[
'x'
][
1
])
>>> fpGrowth.findPrefixPath(
'z'
, myHeaderTab[
'z'
][
1
])
>>> fpGrowth.findPrefixPath(
'r'
, myHeaderTab[
'r'
][
1
])
|
对于每一个频繁项,都要创建一棵条件FP树。可以使用刚才发现的条件模式基作为输入数据,并通过相同的建树代码来构建这些树。例如,对于r,即以“{x, s}: 1, {z, x, y}: 1, {z}: 1”为输入,调用函数createTree()获得r的条件FP树;对于t,输入是对应的条件模式基“{z, x, y, s}: 2, {z, x, y, r}: 1”。
代码(直接调用createTree()函数):
1
2
|
condPattBases
=
findPrefixPath(basePat, headerTable[basePat][
1
])
myCondTree, myHead
=
createTree(condPattBases, minSup)
|
示例:t的条件FP树
图8 t的条件FP树的创建过程
在图8中,注意到元素项s以及r是条件模式基的一部分,但是它们并不属于条件FP树。因为在当前的输入中,s和r不满足最小支持度的条件。
有了FP树和条件FP树,我们就可以在前两步的基础上递归得查找频繁项集。
递归的过程是这样的:
输入:我们有当前数据集的FP树(inTree,headerTable)
1. 初始化一个空列表preFix表示前缀
2. 初始化一个空列表freqItemList接收生成的频繁项集(作为输出)
3. 对headerTable中的每个元素basePat(按计数值由小到大),递归:
3.1 记basePat + preFix为当前频繁项集newFreqSet
3.2 将newFreqSet添加到freqItemList中
3.3 计算t的条件FP树(myCondTree、myHead)
3.4 当条件FP树不为空时,继续下一步;否则退出递归
3.4 以myCondTree、myHead为新的输入,以newFreqSet为新的preFix,外加freqItemList,递归这个过程
函数如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
def
mineTree(inTree, headerTable, minSup, preFix, freqItemList):
bigL
=
[v[
0
]
for
v
in
sorted
(headerTable.items(), key
=
lambda
p: p[
1
])]
for
basePat
in
bigL:
newFreqSet
=
preFix.copy()
newFreqSet.add(basePat)
freqItemList.append(newFreqSet)
condPattBases
=
findPrefixPath(basePat, headerTable[basePat][
1
])
myCondTree, myHead
=
createTree(condPattBases, minSup)
if
myHead !
=
None
:
# 用于测试
print
'conditional tree for:'
, newFreqSet
myCondTree.disp()
mineTree(myCondTree, myHead, minSup, newFreqSet, freqItemList)
|
输入参数:
测试代码:
1
2
3
|
>>> freqItems
=
[]
>>> fpGrowth.mineTree(myFPtree, myHeaderTab,
3
,
set
([]), freqItems)
>>> freqItems
|
[set(['y']), set(['y', 'x']), set(['y', 'z']), set(['y', 'x', 'z']), set(['s']), set(['x', 's']), set(['t']), set(['z', 't']), set(['x', 'z', 't']), set(['y', 'x', 'z', 't']), set(['y', 'z', 't']), set(['x', 't']), set(['y', 'x', 't']), set(['y', 't']), set(['r']), set(['x']), set(['x', 'z']), set(['z'])]
想这一段代码解释清楚比较难,因为中间涉及到很多递归。直接举例说明,我们在这里分解输入myFPtree和myHeaderTab后,“for basePat in bigL:”一行当basePat为’t’时的过程:
图9 mineTree函数解构图(basePat = ‘t’)
图中红色加粗的部分即实际添加到freqItemList中的频繁项集。
至此,完整的FP-growth算法已经可以运行。封装整个过程如下:
1
2
3
4
5
6
|
def
fpGrowth(dataSet, minSup
=
3
):
initSet
=
createInitSet(dataSet)
myFPtree, myHeaderTab
=
createTree(initSet, minSup)
freqItems
=
[]
mineTree(myFPtree, myHeaderTab, minSup,
set
([]), freqItems)
return
freqItems
|
注意,这里直接使用了上节(4.2)中的createInitSet()函数,这里有个问题:上节中的loadSimpDat()函数返回了一组简单的样例数据,没有相同的事务,所以createInitSet()函数中直接赋值“retDict[frozenset(trans)] = 1”没有问题。但是如果要封装成一个通用的FP-growth算法,就还需要处理输入数据有相同事务的情形,createInitSet()函数中需要累加retDict[frozenset(trans)]。(谢谢@xanxuslam的回复)
测试代码:
1
2
3
4
|
>>>
import
fpGrowth
>>> dataSet
=
fpGrowth.loadSimpDat()
>>> freqItems
=
fpGrowth.fpGrowth(dataSet)
>>> freqItems
|
和之前的输出相同。
FP-growth算法是一种用于发现数据集中频繁模式的有效方法。FP-growth算法利用Apriori原则,执行更快。Apriori算法产生候选项集,然后扫描数据集来检查它们是否频繁。由于只对数据集扫描两次,因此FP-growth算法执行更快。在FP-growth算法中,数据集存储在一个称为FP树的结构中。FP树构建完成后,可以通过查找元素项的条件基及构建条件FP树来发现频繁项集。该过程不断以更多元素作为条件重复进行,直到FP树只包含一个元素为止。
FP-growth算法还有一个map-reduce版本的实现,它也很不错,可以扩展到多台机器上运行。Google使用该算法通过遍历大量文本来发现频繁共现词,其做法和我们刚才介绍的例子非常类似(参见扩展阅读:FP-growth算法)。
书中的这两章有不少精彩的示例,这里只选取比较有代表性的一个——从新闻网站点击流中挖掘热门新闻报道。这是一个很大的数据集,有将近100万条记录(参见扩展阅读:kosarak)。在源数据集合保存在文件kosarak.dat中。该文件中的每一行包含某个用户浏览过的新闻报道。新闻报道被编码成整数,我们可以使用Apriori或FP-growth算法挖掘其中的频繁项集,查看那些新闻ID被用户大量观看到。
首先,将数据集导入到列表:
1
|
>>> parsedDat
=
[line.split()
for
line
in
open
(
'kosarak.dat'
).readlines()]
|
接下来需要对初始集合格式化:
1
2
|
>>>
import
fpGrowth
>>> initSet
=
fpGrowth.createInitSet(parsedDat)
|
然后构建FP树,并从中寻找那些至少被10万人浏览过的新闻报道。
1
|
>>> myFPtree, myHeaderTab
=
fpGrowth.createTree(initSet,
100000
)
|
下面创建一个空列表来保存这些频繁项集:
1
2
|
>>> myFreqList
=
[]
>>> fpGrowth.mineTree(myFPtree, myHeaderTab,
100000
,
set
([]), myFreqList)
|
接下来看下有多少新闻报道或报道集合曾经被10万或者更多的人浏览过:
1
|
>>>
len
(myFreqList)
|
9
总共有9个。下面看看都是那些:
1
|
>>> myFreqList
|
[set(['1']), set(['1', '6']), set(['3']), set(['11', '3']), set(['11', '3', '6']), set(['3', '6']), set(['11']), set(['11', '6']), set(['6'])]