针对餐饮订单数据进行关联规则提取,关联规则的数据量大的时候计算非常耗时,主要是在频繁项集产生的过程中,目前比较经典的两种算法分别是Apriori和Fp-growth(也是基于apriori),两种算法都尝试了,测试fp-growth的计算速度比apriori的速度要快不少,那么显然选择fp-growth进行操作;
FP-growth算法主要包括以下步骤(参考:https://www.cnblogs.com/pinard/p/6307064.html,解释的非常通俗易懂,非常棒):
1. FP Tree数据结构
为了减少I/O次数,FP Tree算法引入了一些数据结构来临时存储数据。这个数据结构包括三部分,如下图所示:
第一部分是一个项头表。里面记录了所有的1项频繁集出现的次数,按照次数降序排列。比如上图中B在所有10组数据中出现了8次,因此排在第一位,这部分好理解。第二部分是FP Tree,它将我们的原始数据集映射到了内存中的一颗FP树,这个FP树比较难理解,它是怎么建立的呢?这个我们后面再讲。第三部分是节点链表。所有项头表里的1项频繁集都是一个节点链表的头,它依次指向FP树中该1项频繁集出现的位置。这样做主要是方便项头表和FP Tree之间的联系查找和更新,也好理解。
下面我们讲项头表和FP树的建立过程。
2. 项头表的建立
FP树的建立需要首先依赖项头表的建立。首先我们看看怎么建立项头表。
我们第一次扫描数据,得到所有频繁一项集的的计数。然后删除支持度低于阈值的项,将1项频繁集放入项头表,并按照支持度降序排列。接着第二次也是最后一次扫描数据,将读到的原始数据剔除非频繁1项集,并按照支持度降序排列。
上面这段话很抽象,我们用下面这个例子来具体讲解。我们有10条数据,首先第一次扫描数据并对1项集计数,我们发现O,I,L,J,P,M, N都只出现一次,支持度低于20%的阈值,因此他们不会出现在下面的项头表中。剩下的A,C,E,G,B,D,F按照支持度的大小降序排列,组成了我们的项头表。
接着我们第二次扫描数据,对于每条数据剔除非频繁1项集,并按照支持度降序排列。比如数据项ABCEFO,里面O是非频繁1项集,因此被剔除,只剩下了ABCEF。按照支持度的顺序排序,它变成了ACEBF。其他的数据项以此类推。为什么要将原始数据集里的频繁1项数据项进行排序呢?这是为了我们后面的FP树的建立时,可以尽可能的共用祖先节点。
通过两次扫描,项头表已经建立,排序后的数据集也已经得到了,下面我们再看看怎么建立FP树。
3. FP Tree的建立
有了项头表和排序后的数据集,我们就可以开始FP树的建立了。开始时FP树没有数据,建立FP树时我们一条条的读入排序后的数据集,插入FP树,插入时按照排序后的顺序,插入FP树中,排序靠前的节点是祖先节点,而靠后的是子孙节点。如果有共用的祖先,则对应的公用祖先节点计数加1。插入后,如果有新节点出现,则项头表对应的节点会通过节点链表链接上新节点。直到所有的数据都插入到FP树后,FP树的建立完成。
似乎也很抽象,我们还是用第二节的例子来描述。
首先,我们插入第一条数据ACEBF,如下图所示。此时FP树没有节点,因此ACEBF是一个独立的路径,所有节点计数为1, 项头表通过节点链表链接上对应的新增节点。
接着我们插入数据ACG,如下图所示。由于ACG和现有的FP树可以有共有的祖先节点序列AC,因此只需要增加一个新节点G,将新节点G的计数记为1。同时A和C的计数加1成为2。当然,对应的G节点的节点链表要更新
同样的办法可以更新后面8条数据,如下8张图。由于原理类似,这里就不多文字讲解了,大家可以自己去尝试插入并进行理解对比。相信如果大家自己可以独立的插入这10条数据,那么FP树建立的过程就没有什么难度了。
4. FP Tree的挖掘
我们辛辛苦苦,终于把FP树建立起来了,那么怎么去挖掘频繁项集呢?看着这个FP树,似乎还是不知道怎么下手。下面我们讲如何从FP树里挖掘频繁项集。得到了FP树和项头表以及节点链表,我们首先要从项头表的底部项依次向上挖掘。对于项头表对应于FP树的每一项,我们要找到它的条件模式基。所谓条件模式基是以我们要挖掘的节点作为叶子节点所对应的FP子树。得到这个FP子树,我们将子树中每个节点的的计数设置为叶子节点的计数,并删除计数低于支持度的节点。从这个条件模式基,我们就可以递归挖掘得到频繁项集了。
实在太抽象了,之前我看到这也是一团雾水。还是以上面的例子来讲解。我们看看先从最底下的F节点开始,我们先来寻找F节点的条件模式基,由于F在FP树中只有一个节点,因此候选就只有下图左所示的一条路径,对应{A:8,C:8,E:6,B:2, F:2}。我们接着将所有的祖先节点计数设置为叶子节点的计数,即FP子树变成{A:2,C:2,E:2,B:2, F:2}。一般我们的条件模式基可以不写叶子节点,因此最终的F的条件模式基如下图右所示。
通过它,我们很容易得到F的频繁2项集为{A:2,F:2}, {C:2,F:2}, {E:2,F:2}, {B:2,F:2}。递归合并二项集,得到频繁三项集为{A:2,C:2,F:2},{A:2,E:2,F:2},...还有一些频繁三项集,就不写了。当然一直递归下去,最大的频繁项集为频繁5项集,为{A:2,C:2,E:2,B:2,F:2}
F挖掘完了,我们开始挖掘D节点。D节点比F节点复杂一些,因为它有两个叶子节点,因此首先得到的FP子树如下图左。我们接着将所有的祖先节点计数设置为叶子节点的计数,即变成{A:2, C:2,E:1 G:1,D:1, D:1}此时E节点和G节点由于在条件模式基里面的支持度低于阈值,被我们删除,最终在去除低支持度节点并不包括叶子节点后D的条件模式基为{A:2, C:2}。通过它,我们很容易得到D的频繁2项集为{A:2,D:2}, {C:2,D:2}。递归合并二项集,得到频繁三项集为{A:2,C:2,D:2}。D对应的最大的频繁项集为频繁3项集。
同样的方法可以得到B的条件模式基如下图右边,递归挖掘到B的最大频繁项集为频繁4项集{A:2, C:2, E:2,B:2}。
继续挖掘G的频繁项集,挖掘到的G的条件模式基如下图右边,递归挖掘到G的最大频繁项集为频繁4项集{A:5, C:5, E:4,G:4}。
E的条件模式基如下图右边,递归挖掘到E的最大频繁项集为频繁3项集{A:6, C:6, E:6}。
C的条件模式基如下图右边,递归挖掘到C的最大频繁项集为频繁2项集{A:8, C:8}。
至于A,由于它的条件模式基为空,因此可以不用去挖掘了。
至此我们得到了所有的频繁项集,如果我们只是要最大的频繁K项集,从上面的分析可以看到,最大的频繁项集为5项集。包括{A:2, C:2, E:2,B:2,F:2}。
通过上面的流程,相信大家对FP Tree的挖掘频繁项集的过程也很熟悉了。
5. FP Tree算法归纳
这里我们对FP Tree算法流程做一个归纳。FP Tree算法包括三步:
1)扫描数据,得到所有频繁一项集的的计数。然后删除支持度低于阈值的项,将1项频繁集放入项头表,并按照支持度降序排列。
2)扫描数据,将读到的原始数据剔除非频繁1项集,并按照支持度降序排列。
3)读入排序后的数据集,插入FP树,插入时按照排序后的顺序,插入FP树中,排序靠前的节点是祖先节点,而靠后的是子孙节点。如果有共用的祖先,则对应的公用祖先节点计数加1。插入后,如果有新节点出现,则项头表对应的节点会通过节点链表链接上新节点。直到所有的数据都插入到FP树后,FP树的建立完成。
4)从项头表的底部项依次向上找到项头表项对应的条件模式基。从条件模式基递归挖掘得到项头表项项的频繁项集。
5)如果不限制频繁项集的项数,则返回步骤4所有的频繁项集,否则只返回满足项数要求的频繁项集。
6. FP tree算法总结
FP Tree算法改进了Apriori算法的I/O瓶颈,巧妙的利用了树结构,这让我们想起了BIRCH聚类,BIRCH聚类也是巧妙的利用了树结构来提高算法运行速度。利用内存数据结构以空间换时间是常用的提高算法运行时间瓶颈的办法。
在实践中,FP Tree算法是可以用于生产环境的关联算法,而Apriori算法则做为先驱,起着关联算法指明灯的作用。除了FP Tree,像GSP,CBA之类的算法都是Apriori派系的。
fp-growth.py:
# encoding: utf-8
"""
fp-growth算法是一个生成频繁项集的算法,其主要利用了FP树的数据结构,
整个生成过程只需要遍历数据集2次
"""
from collections import defaultdict, namedtuple
"""
collections模块中的defaultdict继承自dict,namedtuple继承自tuple
defaultdict会构建一个类似dict的对象,该对象具有默认值
当dict不存在的key时会报KeyError错误,调用defaultdict时遇到KeyError错误会用默认值填充
namedtuple主要用来产生可以使用名称来访问元素的数据对象,通常用来增强代码的可读性
"""
def find_frequent_itemsets(transactions, minimum_support, include_support=False):
"""
挖掘频繁项集,生成频繁项集和对应支持度(频数)
"""
items = defaultdict(lambda: 0) # mapping from items to their supports
# Load the passed-in transactions and count the support that individual
# items have.
for transaction in transactions:
for item in transaction:
items[item] += 1
# Remove infrequent items from the item support dictionary.
items = dict((item, support) for item, support in items.items()
if support >= minimum_support)
# Build our FP-tree. Before any transactions can be added to the tree, they
# must be stripped of infrequent items and their surviving items must be
# sorted in decreasing order of frequency.
def clean_transaction(transaction):
transaction = filter(lambda v: v in items, transaction)
transaction_list = list(transaction) # 为了防止变量在其他部分调用,这里引入临时变量transaction_list
transaction_list.sort(key=lambda v: items[v], reverse=True)
return transaction_list
master = FPTree()
for transaction in map(clean_transaction, transactions):
master.add(transaction)
def find_with_suffix(tree, suffix):
for item, nodes in tree.items():
support = sum(n.count for n in nodes)
if support >= minimum_support and item not in suffix:
# New winner!
found_set = [item] + suffix
yield (found_set, support) if include_support else found_set
# Build a conditional tree and recursively search for frequent
# itemsets within it.
cond_tree = conditional_tree_from_paths(tree.prefix_paths(item))
for s in find_with_suffix(cond_tree, found_set):
yield s # pass along the good news to our caller
# Search for frequent itemsets, and yield the results we find.
for itemset in find_with_suffix(master, []):
yield itemset
class FPTree(object):
"""
构建FP树
所有的项必须作为字典的键或集合成员
"""
Route = namedtuple('Route', 'head tail')
def __init__(self):
# The root node of the tree.
self._root = FPNode(self, None, None)
# A dictionary mapping items to the head and tail of a path of
# "neighbors" that will hit every node containing that item.
self._routes = {}
@property
def root(self):
"""The root node of the tree."""
return self._root
def add(self, transaction):
"""Add a transaction to the tree."""
point = self._root
for item in transaction:
next_point = point.search(item)
if next_point:
# There is already a node in this tree for the current
# transaction item; reuse it.
next_point.increment()
else:
# Create a new point and add it as a child of the point we're
# currently looking at.
next_point = FPNode(self, item)
point.add(next_point)
# Update the route of nodes that contain this item to include
# our new node.
self._update_route(next_point)
point = next_point
def _update_route(self, point):
"""Add the given node to the route through all nodes for its item."""
assert self is point.tree
try:
route = self._routes[point.item]
route[1].neighbor = point # route[1] is the tail
self._routes[point.item] = self.Route(route[0], point)
except KeyError:
# First node for this item; start a new route.
self._routes[point.item] = self.Route(point, point)
def items(self):
"""
Generate one 2-tuples for each item represented in the tree. The first
element of the tuple is the item itself, and the second element is a
generator that will yield the nodes in the tree that belong to the item.
"""
for item in self._routes.keys():
yield (item, self.nodes(item))
def nodes(self, item):
"""
Generate the sequence of nodes that contain the given item.
"""
try:
node = self._routes[item][0]
except KeyError:
return
while node:
yield node
node = node.neighbor
def prefix_paths(self, item):
"""Generate the prefix paths that end with the given item."""
def collect_path(node):
path = []
while node and not node.root:
path.append(node)
node = node.parent
path.reverse()
return path
return (collect_path(node) for node in self.nodes(item))
def inspect(self):
print('Tree:')
self.root.inspect(1)
print('Routes:')
for item, nodes in self.items():
print(' %r' % item)
for node in nodes:
print(' %r' % node)
def conditional_tree_from_paths(paths):
"""从给定的前缀路径构建一个条件fp树."""
tree = FPTree()
condition_item = None
items = set()
# Import the nodes in the paths into the new tree. Only the counts of the
# leaf notes matter; the remaining counts will be reconstructed from the
# leaf counts.
for path in paths:
if condition_item is None:
condition_item = path[-1].item
point = tree.root
for node in path:
next_point = point.search(node.item)
if not next_point:
# Add a new node to the tree.
items.add(node.item)
count = node.count if node.item == condition_item else 0
next_point = FPNode(tree, node.item, count)
point.add(next_point)
tree._update_route(next_point)
point = next_point
assert condition_item is not None
# Calculate the counts of the non-leaf nodes.
for path in tree.prefix_paths(condition_item):
count = path[-1].count
for node in reversed(path[:-1]):
node._count += count
return tree
class FPNode(object):
"""FP树节点"""
def __init__(self, tree, item, count=1):
self._tree = tree
self._item = item
self._count = count
self._parent = None
self._children = {}
self._neighbor = None
def add(self, child):
"""Add the given FPNode `child` as a child of this node."""
if not isinstance(child, FPNode):
raise TypeError("Can only add other FPNodes as children")
if not child.item in self._children:
self._children[child.item] = child
child.parent = self
def search(self, item):
"""
Check whether this node contains a child node for the given item.
If so, that node is returned; otherwise, `None` is returned.
"""
try:
return self._children[item]
except KeyError:
return None
def __contains__(self, item):
return item in self._children
@property
def tree(self):
"""The tree in which this node appears."""
return self._tree
@property
def item(self):
"""The item contained in this node."""
return self._item
@property
def count(self):
"""The count associated with this node's item."""
return self._count
def increment(self):
"""Increment the count associated with this node's item."""
if self._count is None:
raise ValueError("Root nodes have no associated count.")
self._count += 1
@property
def root(self):
"""True if this node is the root of a tree; false if otherwise."""
return self._item is None and self._count is None
@property
def leaf(self):
"""True if this node is a leaf in the tree; false if otherwise."""
return len(self._children) == 0
@property
def parent(self):
"""The node's parent"""
return self._parent
@parent.setter
def parent(self, value):
if value is not None and not isinstance(value, FPNode):
raise TypeError("A node must have an FPNode as a parent.")
if value and value.tree is not self.tree:
raise ValueError("Cannot have a parent from another tree.")
self._parent = value
@property
def neighbor(self):
"""
The node's neighbor; the one with the same value that is "to the right"
of it in the tree.
"""
return self._neighbor
@neighbor.setter
def neighbor(self, value):
if value is not None and not isinstance(value, FPNode):
raise TypeError("A node must have an FPNode as a neighbor.")
if value and value.tree is not self.tree:
raise ValueError("Cannot have a neighbor from another tree.")
self._neighbor = value
@property
def children(self):
"""The nodes that are children of this node."""
return tuple(self._children.itervalues())
def inspect(self, depth=0):
print((' ' * depth) + repr(self))
for child in self.children:
child.inspect(depth + 1)
def __repr__(self):
if self.root:
return "<%s (root)>" % type(self).__name__
return "<%s %r (%r)>" % (type(self).__name__, self.item, self.count)
# 主函数:
def generate_association_rules(patterns, total, min_confidence):
"""
生成关联规则,计算支持度、置信度和提升度
"""
antecedent_list = []
consequent_list = []
support_list = []
confidence_list = []
lift_list = []
count_antecedent = []
p_antecedent = []
count_consequent = []
p_consequent = []
count_ant_con = []
num = len(patterns)
for idx, itemset in enumerate(patterns.keys(), 1):
print('总共有{}个频繁项集,开始处理第{}个频繁项集'.format(num, idx))
upper_support = patterns[itemset] # A & B
for i in range(1, len(itemset)):
for antecedent in itertools.combinations(itemset, i):
"""
itertools.combinations()用于创建一个迭代器,
返回iterable中所有长度为r的子序列,
返回的子序列中的项按输入iterable中的顺序排序
"""
antecedent = tuple(sorted(antecedent))
consequent = tuple(sorted(set(itemset) - set(antecedent)))
if antecedent in patterns and consequent in patterns:
lower_support = patterns[antecedent] # A
consequent_support = patterns[consequent] # B
p_lower_support = lower_support / total # P(A)
p_consequent_support = consequent_support / total # P(B)
support = round(float(upper_support) / total, 6) # 支持度Support = P(A & B)
confidence = float(upper_support) / lower_support # 置信度Confidence = P(A & B)/ P(A)
lift = confidence / p_consequent_support # 提升度Lift = ( P(A & B)/ P(A) ) / P(B) = P(A & B)/ P(A) / P(B)
if confidence >= min_confidence:
antecedent_list.append(list(antecedent))
consequent_list.append(list(consequent))
support_list.append(support)
confidence_list.append(confidence)
lift_list.append(lift)
count_antecedent.append(lower_support) # count(A)
p_antecedent.append(p_lower_support) # P(A)
count_consequent.append(consequent_support) # count(B)
p_consequent.append(p_consequent_support) # P(B)
count_ant_con.append(upper_support) # count(AB)
rules_col = {'antecedent': antecedent_list,
'consequent': consequent_list,
'count_antecedent': count_antecedent,
'antecedent_support': p_antecedent,
'count_consequent': count_consequent,
'consequent_support': p_consequent,
'count_ant_con': count_ant_con,
'support': support_list,
'confidence': confidence_list,
'lift': lift_list}
rules = pd.DataFrame(rules_col)
# col = ['antecedent','consequent','count_antecedent','antecedent_support',
# 'count_consequent','consequent_support','count_ant_con','support',
# 'confidence','lift']
col = ['antecedent', 'consequent', 'support', 'confidence', 'lift']
rules = rules[col]
rules.sort_values(by=['support', 'confidence'], ascending=False, inplace=True)
return rules
if __name__ == '__main__':
min_sup = 10
min_conf = 0.8
delta_days = -100
df, lst = connect_mongodb_get_meituan_huitiao_task_data(delta_days)
order_name_list_dict = get_order_dict(lst, arg=2)
dataset = list(order_name_list_dict.values())
total = len(dataset)
print('总订单数:', total)
'''
find_frequent_itemsets()调用函数生成频繁项集和频数
minimum_support表示设置最小支持度(频数),即频数大于等于minimum_support,保存此频繁项,否则删除
include_support表示返回结果是否包含支持度(频数),若include_support=True,返回结果中包含itemset和support,否则只返回itemset
'''
frequent_itemsets = fpg.find_frequent_itemsets(dataset, minimum_support=min_sup, include_support=True)
result = []
for itemset, support in frequent_itemsets: # 将generator结果存入list
result.append((itemset, support))
result = sorted(result, key=lambda i: i[0]) # 排序后输出
item_list = []
itemset_list = []
support_list = []
for itemset, support in result:
# print(str(itemset) + ' ' + str(support)) #频繁项集和出现次数
item_list.append(itemset) # 保存为列表,用于输出频繁项集结果
itemset = tuple(sorted(itemset)) # 先转换为元组,用于后续生成关联规则
itemset_list.append(itemset)
support_list.append(support)
# 构建字典
patterns = dict(zip(itemset_list, support_list))
# 生成关联规则,计算支持度、置信度和提升度
# min_confidence代表最小置信度
rules = generate_association_rules(patterns, total, min_confidence=min_conf)
print('关联规则:\n', rules.head())
print('结果总数:', len(rules))
rules = rules[(rules.antecedent.apply(lambda x:len(x) >= 1)) & (rules.consequent.apply(lambda x:len(x) >= 1))]
## 输出结果,输出到同一份excel文件不同的工作表中
# 输出频繁集
sup = {'item_id': item_list, 'frequency': support_list}
sup = pd.DataFrame(sup)
sup['support'] = round(sup['frequency'] / float(total), 6)
sup.sort_values(by=['support'], ascending=False, inplace=True)
sup_col = ['item_id', 'frequency', 'support']
sup = sup[sup_col]
print(type(rules))
writer = pd.ExcelWriter('tmp_data/fp-growth-result.xlsx')
print(type(writer))
sup.to_excel(excel_writer=writer, sheet_name='support', encoding="utf-8", index=False)
# 输出关联规则
rules.to_excel(excel_writer=writer, sheet_name='rules', encoding="utf-8", index=False)
writer.save()
writer.close()
# sup.to_csv('tmp_data/fp-sup.txt', index=None)
# rules.to_csv('tmp_data/fp-rules.txt', index=None)
end = time.time()
print('Running time: %s Seconds' % (end - start))
这里面有个问题,就是使用fp算出来的结果居然和apriori算法得到的结果是不一致的(同一支持度和置信度),并且计算得到的置信度会大于1,然后最终排查出来是因为fp-growth用到的原始数据中一个事务中的物品是要删除重复商品的,切记啊
结果:
参考:https://zhuanlan.zhihu.com/p/76357500;https://www.cnblogs.com/pinard/p/6307064.html