机器学习实战(十二)——使用FP-growth算法来高效发现频繁项集
本章节所介绍的算法FP-growth是一个非常好的频繁项集发现算法,比Apriori算法要快上很多。它基于Apriori构建,但使用了一些不同的技术,具体是将数据集存储在一个特定的称作FP树的结构,之后再挖掘频繁项集或者频繁项对,即常在一块出现的元素项的集合FP树。
该算法的另一个特点是可以更高效地发现频繁项集,但是不能用于发现关联规则。
一、FP树:用于编码数据集的有效技术
我们先了解FP树是一种怎么样的数据结构,再来学习如何构建。
首先,FP树是通过链接来连接相似元素的,被连起来的元素项可以看成一个链表。特殊的是一个元素项可以在一颗FP树中出现多次,同时FP树也会存储项集的出现频率,而每个项集会以路径的形式存储在树中。只有当集合完全不同时,树才会分叉。因此可以理解为一棵树的左右结点的集合是完全不相同的。
如下面这个例子:
上图是由上表所构建的一个FP树。结合表格可以这样理解:
- 首先根节点的左节点为 z = 5 z=5 z=5而右子树中没有 z z z,那么就是说 z z z总共出现了5次
- 在 z = 5 z=5 z=5后开始分叉,说明它的左右两个子树的集合是完全不相同的
- 左子树为 r = 1 r=1 r=1,就是说 z r zr zr 这个子集出现了一次。
- 右子树为: x = 3 , y = 3 x=3,y=3 x=3,y=3,后面再次分叉,分别为 s = 2 , t = 2 s=2,t=2 s=2,t=2,和 r = 1 , t = 1 r=1,t=1 r=1,t=1,那么就是说 z x y s t zxyst zxyst 这个集合出现了两次,而 z x y r t zxyrt zxyrt 这个集合出现了一次
- 但从其左右子树来看 z z z只有和其他元素一起出现了4次,因此我们可以确定 z z z 单独出现了1次。
- 因此也可以理解到根节点的右子树代表集合 x s r xsr xsr 出现了一次。
- 但表格中还有 q , p q,p q,p这两个元素,而FP树却没有,这是因为 q , p q,p q,p这两个集合出现的次数过低,不满足最小支持度的要求被剔除了。
FP-growth算法的工作流程为:
- 构建FP树:需要对原始数据集扫描两次,第一次对所有元素项的出现次数进行计数,第二遍只考虑那些频繁元素
- 利用FP树来挖掘频繁项集
注意:由于存储的是集合,因此如果要处理连续数据则需要将它们转化为离散值。
二、构建FP树
2.1、创建FP树的数据结构
由于FP树比较特殊,因此我们需要一个定制的容器来装这种树。
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)
上述代码比较容易理解
2.2、构建FP树
另外,还需要一个头指针表来指向给定类型的第一个实例,这样才可以快速访问FP树中一个给定类型的所有元素。例如下图:
如果给定了 z z z ,那么根据头指针表就可以快速访问有关 z z z 的所有元素。此外头指针表还可以存储每类元素的出现总数。
构建过程大致如下:
- 第一次遍历数据集,得到每个元素项的出现频率,并且把不满足最小支持度的元素项去除。
- 构建FP树:读取每个项集并将其添加到一条已存在的路径中。如果不存在则创建新路径。
- 在之前项集中的元素都是无序的,但此处由于FP树中相同项只会表达一次,例如 z x y zxy zxy 和 y x r yxr yxr ,只会公用一个 x y xy xy结点后再分叉为 z z z 和 r r r。因此需要对每个集合进行排序,按照元素项出现的频率来进行。
例如对上面表格进行排序后结果如下:
画出构建过程就很容易理解了:
具体的构建代码为:
def createTree(dataSet, minSup = 1):
headerTable = {}
# dataSet也是一个字典,包含每一个事务及该事务的出现次数
for trans in dataSet: # 遍历每一个记录
for item in trans: # 遍历记录中的每一个元素
headerTable[item] = headerTable.get(item,0) + dataSet[trans] # 增加该元素的出现次数
for k in list(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) # 初始化一个只含有空集的tree
for tranSet,count in dataSet.items(): # 返回各个键值对,事务-出现次数
localD = {}
for item in tranSet: # 遍历当前事务的每一个元素
if item in freqItemSet: # 如果该元素是频繁的
localD[item] = headerTable[item][0] # 取出它出现的次数
if len(localD) > 0: # localD中存放着当前这个事务的{各个元素--出现次数}这样的键值对
# 下面一行代码是按照出现频率对当前事务中的元素进行排序
orderedItems = [v[0] for v in sorted(localD.items(),key=lambda p:p[1],reverse=True)]
updataTree(orderedItems,retTree,headerTable,count)
return retTree,headerTable
def updataTree(items,inTree,headerTable,count):
if items[0] in inTree.children: # 如果第一个元素满足已有路径
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: # 头指针表中已有了,那么要形成连接
updataHeader(headerTable[items[0]][1],inTree.children[items[0]])
if len(items) > 1: # 如果长度大于1则继续迭代下去,items[1::]从索引为1的位置取到最后
updataTree(items[1::],inTree.children[items[0]],headerTable,count)
def updataHeader(nodeToTest,targetNode):
while(nodeToTest.nodeLink != None): # 不断循环找到这条链的最后一个结点
nodeToTest = nodeToTest.nodeLink
nodeToTest.nodeLink = targetNode # 最后一个结点连接上刚进来的那个结点
原文运行会报错,在这行代码:
for k in headerTable.keys():
报错为:
RuntimeError: dictionary changed size during iteration
就是说遍历的同时我们还在改变字典中的元素,进行删减,这是不可以的。因此我们将其改为:
for k in list(headerTable.keys()):
转成列表即可。
解释一下这行排序的代码:
orderedItems = [v[0] for v in sorted(localD.items(),key=lambda p:p[1],reverse=True)]
- k e y = l a m b d a p : p [ 1 ] key = lambda\quad p:p[1] key=lambdap:p[1] 是匿名函数用来作为排序的依据,就是对 l o c a l D . i t e m s ( ) localD.items() localD.items()所返回的键值对{元素–出现次数}作为p,而p[1]就是出现次数,那么就是对出现次数进行排序,reverse=True就是按照降序排列
- [ v [ 0 ] f o r v i n s o r t e d ( ) ] [\quad v[0]\quad for\quad v\quad in\quad sorted()\quad] [v[0]forvinsorted()] 是指取出sorted()所返回的序列中的每一个元素的索引为0的部分来组成一个列表,那么就是取出排完序之后的元素部分来组成列表。
再加入初始化数据集的代码:
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
就可以运行看看实际的效果了:
if __name__ == "__main__":
simpDat = loadSimpDat()
print(simpDat)
initSet = createInitSet(simpDat)
print(initSet)
myFPtree, myHeaderTab = createTree(initSet,3)
myFPtree.disp()
[['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']]
{frozenset({'p', 'z', 'r', 'j', 'h'}): 1, frozenset({'y', 'w', 's', 'z', 't', 'u', 'x', 'v'}): 1, frozenset({'z'}): 1, frozenset({'s', 'r', 'x', 'o', 'n'}): 1, frozenset({'y', 'p', 'z', 'r', 't', 'x', 'q'}): 1, frozenset({'y', 's', 'm', 'e', 'z', 't', 'x', 'q'}): 1}
Null Set 1
z 5
r 1
x 3
y 3
s 2
t 2
r 1
t 1
x 1
s 1
r 1
这里在构建树的时候可能会出现一种特殊的情况,不知道是否是我代码的错误,还请各位大佬指点:
就是在构建树的时候,上图中我们可以看到 t t t 是在y的下面的两条路径中,但在我的代码运行时可能会出现 x = 3 , y = 3 , t = 3 x=3,y=3,t=3 x=3,y=3,t=3之后再分叉为 r = 1 , s = 1 r=1,s=1 r=1,s=1 的情况,这也就导致我在后面执行条件基的时候运行结果不一致。
最近的想法是sorted函数有没有可能是不稳定的导致排序后 t t t 在 y y y 在前面,但查阅后发现是稳定的,因此目前没有头绪,希望大佬解答。
三、从一棵FP树中挖掘频繁项集
思路和Apriori相似,也是从利用FP树,单元素项集合开始,逐步构建更大的集合。
从FP树中抽取频繁项集的三个基本步骤为:
- 从FP树中获取条件模式基
- 利用条件模式基,构建一个条件FP数
- 迭代重复1和2,直到树包含一个元素项为止
3.1、抽取条件模式基
条件模式基是以所查找元素项为结尾的路径集合,每一条路径都是介于所查找元素项与树根节点之间的所有内容,称为前缀路径。
例如上述的例子,元素项 r r r 的前缀路径是 {x,s}、{z,x,y}、{z}。并且每条路径都对应一个计数值,代表该路径上 r r r的数目(这路径出现了多少次),如下图:
那么找到给定元素的所有路径的代码为:
def ascendTree(leafNode,prefixPath):
if leafNode.parent != None: # 不等于None说明父节点还没到根节点,还要继续往上迭代
prefixPath.append(leafNode.name) # 将当前节点添加进路径
ascendTree(leafNode.parent,prefixPath) # 继续往上迭代
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
3.2、创建条件FP树
对于每一个频繁项,都要创建一个条件FP树。通过以下例子来了解过程:
由于条件模式基中 s s s 和 r r r 的支持度小于3,也就是说{t,s}和{t,r}这两个集合的频数不够,那么就剔除了。
继续往下迭代,因为这一步我们选出来的频繁项集为{t,y},{t,x},{t,z},因此下一轮我们为{t,z}(因为次数相同,因此与上图对应)来构建条件FP树,即从t的条件FP树中找{t,z}的条件模式基,那么就找到了{x,y},就找出频繁项集{t,z,x}和{t,z,y},那么下一轮就是对{t,z,x}来再{t,y}的条件FP树上找条件模式基,找到{y},那么找到频繁项集{t,z,x,y},此时因为{t,z,x}的条件FP树的头指针表为空,那么返回上一轮迭代下一个。
具体的代码为:
def mineTree(inTree,headerTable,minSup,preFix,freqItemList):
bigL = [v[0] for v in sorted(headerTable.items(),key=lambda p:p[0])]
# 这里排序是按照升序,就是从底部的指针开始
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:
mineTree(myCondTree,myHead,minSup,newFreqSet,freqItemList)
注意在第二行那里应该是p[0]而不是p[1],因此p[1]是一个treeNode无法比较大小的。原文那里是错误的。
五、示例:从新闻网站点击流中挖掘
这里的例子中每一行都是一个用户所访问过的新闻的编号,那么我们通过该算法可以提取出哪些新闻或者新闻的集合曾经被访问的次数到达我们的预期,具体代码如下:
if __name__ == "__main__":
fr = open("kosarak.dat")
parsedDat = [line.split() for line in fr.readlines()]
initSet = createInitSet(parsedDat)
myFPtree,myHeaderTab = createTree(initSet,100000)
myFreqList = []
mineTree(myFPtree,myHeaderTab,100000,set([]),myFreqList)
print(myFreqList)
这里的运行结果与课本中一致。