从大规模数据集中寻找物品间的隐含关系被称作关联分析(association analysis)或者关联规则学习(association rule learning)。
那么什么是关联分析呢?关联分析是一种在大规模数据集中寻找有趣关系的任务。这些任务包括两项:发现频繁项集和从频繁项集中发现关联规则。
那么如何定量地衡量一物品集合是否频繁的呢?以及如何定量地衡量两种物品之间的关系?在这里就需要引入一些新的概念:
下面我们结合实例来说明如何进行关联分析。
【示例】:杂货店的交易清单。
交易号码 | 商品 |
---|---|
0 | 豆奶,莴苣 |
1 | 莴苣,尿布,葡萄酒,甜菜 |
2 | 豆奶,尿布,葡萄酒,橙汁 |
3 | 莴苣,豆奶,尿布,葡萄酒 |
4 | 莴苣,豆奶,尿布,橙汁 |
从数据集中可以得到,{豆奶} 分别在交易号码(以下简称编号 0、2、3、4)中出现,根据支持度的定义“数据集中包含该项集的记录所占的比例”,数据集中包含 {豆奶} 的记录共有 4 条,总数据数为 5 ,因此可求出 {豆奶} 的支持度为 0.8。同理,我们可求出 {甜菜} 的支持度为 0.2。
我们已经计算出 {豆奶} 和 {甜菜} 这两个项集的支持度,那么它们都是频繁的吗?这时候就需要我们设置一个阈值,如果项集的支持度大于等于该阈值,则我们认为该项集是一个频繁项集,例如设置阈值为 0.6,那么 {豆奶} 就是一个频繁项集,而 {甜菜} 就不是频繁项集。上面我们设置的阈值通常被称为最小支持度。有了最小支持度之后,我们就可以保留满足最小支持度要求的项集。
在最小支持度为 0.6 的前提下,项集 {尿布,葡萄酒} 是一个频繁项集,我们可以根据该频繁项集找出关联规则,例如 {尿布}->{葡萄酒}。根据可信度的定义“表示某项集在指定条件下的出现概率”,在这里“指定条件”指的是 {尿布} 的支持度,那么这条规则的可信度可被定义为“支持度 {尿布,葡萄酒} / 支持度 {尿布}”。从数据集中可以看到,{尿布,葡萄酒} 的支持度为 0.6,尿布的支持度为 0.8,所以 {尿布->葡萄酒} 的可信度度为 0.75。这意味着对于包含“尿布”的所有记录,我们的规则对其中 75% 的记录都适用,也就是说如果有人买了尿布,那么他很可能也会买葡萄酒。
使用频繁项集和关联规则,商家就可以更好地理解他们的顾客。
通过上面的分析可以知道,支持度和可信度是用来量化关联分析是否成功的方法。假设想找到支持度大于 0.8 的所有项集,应该如何去做?一个办法是生成一个物品所有可能组合的清单,然后对每一种组合统计它出现的频繁程度,但当物品成千上万时,上述做法非常非常慢,且所需的计算代价很高,蛮力搜索方法并不能解决这个问题,所以需要用更智能的方法在合理的时间范围内找到频繁项集。此时可采用 Apriori 原理,以减少关联规则学习时所需的计算量。
假设我们在经营一家商品种类并不多的杂货店,我们对那些经常在一起被购买的商品非常感兴趣。我们只有 4 种商品:商品 0,商品 1,商品 2 和商品 3。我们不关系客户购买某一件商品多少件,我们只关心客户购买不同种类商品。
下图显示了物品之间所有可能的组合。图中从上往下的第一个集合是空集,表示不包含任何物品的集合。物品集合之间的连线表明两个或者更多集合可以组合形成一个更大的集合。
在计算项集支持度时,我们需要遍历每条记录并检查该记录是否包含项集中的元素。在扫描完所有数据之后,使用统计得到的项集的记录总数除以总的交易记录数,就可以得到该项集的支持度。观察上图可以发现,即使对于仅有 4 种物品的集合,也需要遍历数据 15 次。而随着物品数目的增加遍历次数会急剧增长。对于包含 N 种物品的数据集共有 2 N − 1 2^N - 1 2N−1 种项集组合。当物品的数量增加时,可能的项集组合也随着称指数级增长,对于现代的计算机而言,需要很长的时间才能完成运算。
为了降低所需的计算时间,研究人员发现一种所谓的 Apriori 原理,该原理可以帮我们减少可能感兴趣的项集。
该原理是如何推导得出的?
这就是取名为 Apriori 算法的原因。Apriori 在拉丁语中指“来自以前”。当定义问题时,通常会使用先验知识或者假设,这被称作“一个先验”(a priori)。先验知识可能来自领域知识,先前的一些测量结果等等。在关联分析中,我们运用先验知识去判断后续的项集是否频繁。
如何将 Apriori 原理应用于算法中?难点在于我们该如何根据已有的项集去组合新的项集。
观察上面的 Apriori 算法的工作流程我们可以发现,Apriori 算法首先扫描一遍数据集,从中生成 1-项集 C1。接着调用 Scan 函数扫描 C1,过滤不满足最小支持度的项集,最后留下的项集就是频繁项集 L1。根据 Apriori 原理可知,非频繁项集的所有超集也都是非频繁的,那么我们就没有必要球这些非频繁项集的组合。因此,第二轮迭代中,只需要对上一轮迭代产生的频繁项集进行新的组合即可,然后接着调用 Scan 函数检查新组合的支持度是否满足最小支持度要求,将不满足的新组合给过滤。如此循环,直到没有新组合可生成为止。
假设现在数据库中有 4 条交易记录,其中有 5 件不同种类的商品,分别用编号 1、2、3、4、5 表示,最小支持度为 0.5。
TID | Items |
---|---|
100 | 1 3 4 |
200 | 2 3 5 |
300 | 1 2 3 5 |
400 | 2 5 |
Items | support |
---|---|
{1} | 0.50 |
{2} | 0.75 |
{3} | 0.75 |
{4} | 0.25 |
{5} | 0.75 |
Items | support |
---|---|
{1} | 0.50 |
{2} | 0.75 |
{3} | 0.75 |
{5} | 0.75 |
Items | support |
---|---|
{1, 2} | 0.25 |
{1, 3} | 0.50 |
{1, 5} | 0.25 |
{2, 3} | 0.50 |
{2, 5} | 0.75 |
{3, 5} | 0.50 |
Items | support |
---|---|
{1, 3} | 0.50 |
{2, 3} | 0.50 |
{2, 5} | 0.75 |
{3, 5} | 0.50 |
Items | support |
---|---|
{1, 2, 3} | 0.25 |
{1, 2, 5} | 0.25 |
{1, 3, 5} | 0.25 |
{2, 3, 5} | 0.50 |
Items | support |
---|---|
{2, 3, 5} | 0.50 |
从上面的过程中我们可以发现,Apriori 算法工作过程中的连接步,在每次执行时都需要扫描一遍数据库,来计算每个新组合(项集)的支持度。借此,我们可以发现 Apriori 算法的优缺点以及适用的数据类型。
Apriori 算法是发现频繁项集的一种方法。Apriori 算法的两个输入参数分别是最小支持度和数据集。
【过程】:该算法首先会生成所有单个物品的项集列表。接着扫描交易记录来查看哪些项集满足最小支持度要求,那些不满足最小支持度的集合会被去掉。然后,对剩下来的集合进行组合以生成包含两个元素的项集。接下来,再重新扫描交易记录,去掉不满足最小支持度的项集。该过程重复进行直到所有项集都被去掉。
Apriori 算法首先构建集合 c1,然后扫描数据集来判断这些只有一个元素的项集是否满足最小支持度的要求。那些满足要求的项集构成集合 L1。而 L1 中的元素相互组合构成 c2,c2 再进一步过滤变为 L2。
【伪代码】:
对数据集中的每条交易记录 tran
对每个候选项集 can:
检查一下 can 是否是 tran 的子集:
如果是,则增加 can 的计数值
对每个候选项集:
如果其支持度不低于最小值,则保留该项集
返回所有频繁项集列表
create_c1() 函数构建大小为 1 的所有候选项集的集合。
def create_c1(dataset):
c1 = []
for transaction in dataset:
for item in transaction:
if not [item] in c1:
c1.append([item])
c1.sort()
# 对 c1 中每个项构建一个不变集合
return list(np.map(frozenset, c1))
for transaction in dataset:
for item in transaction:
// ...
if not [item] in c1:
c1.append([item])
c1.sort()
return list(map(frozenset, c1)
scan_D() 有三个参数,分别是数据集 D、候选项集列表 ck 以及感兴趣项集的最小支持度 min_support,用于从 c1 生成 L1。另外,该函数返回 L1 和包含支持度值的字典以备后用。
def scan_D(D, ck, min_support):
ss_cnt = {}
for tid in D:
for can in ck:
if can.issubset(tid):
if not ss_cnt.__contains__(can):
ss_cnt[can] = 1
else:
ss_cnt[can] += 1
num_items = float(len(D))
ret_list = []
support_data = {}
for key in ss_cnt:
support = ss_cnt[key] / num_items
if support >= min_support:
ret_list.append(key)
support_data[key] = support
return ret_list, support_data
__contains__()
方法代替。ss_cnt = {}
for tid in D:
for can in ck:
if can.issubset(tid):
if not ss_cnt.__contains__(can):
ss_cnt[can] = 1
else:
ss_cnt[can] += 1
num_items = float(len(D))
ret_list = []
support_data = {}
for key in ss_cnt:
support = ss_cnt[key] / num_items
if support >= min_support:
ret_list.append(key)
support_data[key] = support
return ret_list, support_data
>>> dataset = [[1, 3, 4], [2, 3, 5], [1, 2, 3, 5], [2, 5]]
>>> c1 = create_c1(dataset)
>>> c1
[frozenset([1]), frozenset([2]), frozenset([3]), frozenset([4]), frozenset([5])]
>>> D = list(map(set, dataset))
>>> D
[set([1, 3, 4]), set([2, 3, 5]), set([1, 2, 3, 5]), set([2, 5])]
>>> L1, supp_data = scan_D(D, c1, 0.5)
>>> L1
[frozenset([1]), frozenset([3]), frozenset([2]), frozenset([5])]
上述 4 个项集构成了 L1 列表,该列表中的每个单物品项集至少出现在 50% 以上的记录中。由于物品 4 并没有达到最小支持度,所以没有包含在 L1 中。通过去掉这件物品,减少了查找物品项集的工作量。
【伪代码】:
当集合中项的个数大于 0 时:
构建一个 k 个项组成的候选项集的列表
检查数据以确认每个项集都是频繁的
保留频繁项集并构建 k + 1 项组成的候选项集的列表
apriori_gen() 函数有两个输入参数,分别为频繁项集列表 lk 与项集元素个数 k,输出为 ck。例如,该函数以 {0}、{1}、{2} 作为输入,会生成 {0,1}、{0,2} 以及 {1,2}。
def apriori_gen(lk, k):
ret_list = []
len_lk = len(lk)
for i in range(len_lk):
for j in range(i + 1, len_lk):
l1 = list(lk[i])[:k-2]
l2 = list(lk[j])[:k-2]
l1.sort()
l2.sort()
if l1 == l2:
ret_list.append(lk[i] | lk[j])
return ret_list
ret_list = []
len_lk = len(lk)
for i in range(len_lk):
for j in range(i + 1, len_lk):
l1 = list(lk[i])[:k-2]
l2 = list(lk[j])[:k-2]
l1.sort()
l2.sort()
if l1 == l2:
ret_list.append(lk[i] | lk[j])
return ret_list
【问】:为什么要取列表(l1、l2)的前 k - 2 个数据?
【答】:假设现在要将 {0,1}、{0,2}、{1,2} 来创建三元素项集,如果仅仅将两个项集合并,就会得到三个 {0,1,2}。也就是说,同样的结果会重复 3 次,我们要做的是确保遍历列表的次数最少。现在,如果只比较集合的第 1 个元素,并且只对第 1 个元素相同的集合求并操作,同样可以得到 {0,1,2},且只需要一次操作。
apriori() 函数有两个输入参数,数据集以及支持度,函数会生成候选项集的列表以及支持度数据并返回。
def apriori(dataset, min_support=0.5):
c1 = create_c1(dataset)
D = list(map(set, dataset))
l1, support_data = scan_D(D, c1, min_support)
L = [l1]
k = 2
while len(L[k-2]) > 0:
ck = apriori_gen(L[k-2], k)
lk, supk = scan_D(D, ck, min_support)
support_data.update(supk)
L.append(lk)
k += 1
return L, support_data
c1 = create_c1(dataset)
D = list(map(set, dataset))
l1, support_data = scan_D(D, c1, min_support)
L = [l1]
k = 2
while len(L[k-2]) > 0:
ck = apriori_gen(L[k-2], k)
lk, supk = scan_D(D, ck, min_support)
support_data.update(supk)
L.append(lk)
k += 1
return L, support_data
现在需要解决的问题是如何找出关联规则?要找到关联规则,我们首先从一个频繁项集开始。我们知道集合中的元素是不重复的,但我们想知道基于这些元素能否获得其他内容。某个元素或者某个元素集合可能会推导出另一个元素。从杂货店的例子可以得到,如果有一个频繁项集 {豆奶,莴苣},那么就可能有一条关联规则“豆奶->莴苣”。这意味着如果有人购买了豆奶,那么在统计上他会购买莴苣的概率较大。但是,这一条反过来并不总是成立。也就是说,即使“豆奶->莴苣”统计上显著,那么“莴苣->豆奶”也不一定成立。
最小支持度要求是频繁项集的量化定义;对于关联规则,这种量化指标称为可信度。现在要获得可信度,所需要做的只是取出那些支持度值做一次除法运算。
从一个频繁项集中可以产生多少条关联规则?下图显示从频繁项集 {0,1,2,3} 产生的所有关联规则,阴影区域给出的是低可信度的规则。为找到感兴趣的规则,我们先生成一个可能的规则列表,然后测试每条规则的可信度。如果可信度不满足最小要求,则去掉该规则。
观察上图可一发现,如果某条规则并不满足最小可信度要求,那么该规则的所有子集也不会满足最小可信度要求。例如,{0,1,2} -> 3 不满足最小可信度要求,那么任何左部为 {0,1,2} 子集的规则也不会满足最小可信度要求。
【证明】:令最小可信度要求为 C
P(3|012) = \frac{P(0123)}{P(012)} \quad P(13|02) = \frac{P(0123)}{P(02)}
P(02) \geq P(012) \quad C \geq P(3|012) \geq P(13|02)
因此我们可以利用关联规则的这条性质来减少需要测试的规则数目。
【做法】:
cal_conf() 函数计算规则的可信度以及找到满足最小可信度要求的规则。函数接受五个参数:
函数会返回一个满足最小可信度要求的规则列表。
def calc_conf(freq_set, h, support_data, br1, min_conf=0.7):
pruned_h = []
for conseq in h:
conf = support_data[freq_set] / support_data[freq_set - conseq]
if conf >= min_conf:
print(freq_set - conseq, '-->', conseq, 'conf:', conf)
br1.append((freq_set - conseq, conseq, conf))
pruned_h.append(conseq)
return pruned_h
pruned_h = []
for conseq in h:
conf = support_data[freq_set] / support_data[freq_set - conseq]
// ...
if conf >= min_conf:
print(freq_set - conseq, '-->', conseq, 'conf:', conf)
br1.append((freq_set - conseq, conseq, conf))
pruned_h.append(conseq)
rules_from_conseq() 函数从最初的项集中生成更多的关联规则。该函数接受的参数同 cal_conf() 函数。
def rules_from_conseq(freq_set, h, support_data, br1, min_conf=0.7):
m = len(h[0])
if len(freq_set) > (m + 1):
hmp1 = apriori_gen(h, m + 1)
hmp1 = calc_conf(freq_set, hmp1, support_data, br1, min_conf)
if len(hmp1) > 1:
rules_from_conseq(freq_set, hmp1, support_data, br1, min_conf)
【说明】:先计算 h 中的频繁项集大小 m,然后查看该频繁项集是否大到可以移除大小为 m 的子集。如果不可以的话,则生成 h 中元素的无重复组合,将结果存储在 hmp1 中,这也是下一次迭代的 h 列表。怎么理解呢?例如频繁项集 {1, 2, 3},此时 h 为 [{1}, {2}, {3}],除了可以生成 {1, 2} -> {3},{1, 3} -> {2},{2, 3} -> {1} 之外,也可以生成 {1} -> {2, 3}。所以我们需要递归调用 rules_from_conseq() 函数来生成新的 h,并判断当前规则是否满足最小可信度要求。
generate_rules() 函数有三个输入参数,频繁项集列表 l,包含频繁项集支持度数据的字典 support_data,最小可信度阈值 min_conf。函数最后返回一个包含可信度的规则列表。
def generate_rules(l, support_data, min_conf=0.7):
big_rule_list = []
for i in range(1, len(l)):
for freq_set in l[i]:
h1 = [frozenset([item]) for item in freq_set]
if i > 1:
rules_from_conseq(freq_set, h1, support_data, big_rule_list, min_conf)
else:
calc_conf(freq_set, h1, support_data, big_rule_list, min_conf)
return big_rule_list
# 循环频繁项集列表,依次对 X-频繁项集执行操作
for i in range(1, len(l)):
# 循环当前 X-频繁项集的各项集
for freq_set in l[i]:
h1 = [frozenset([item]) for item in freq_set]
if i > 1:
rules_from_conseq(freq_set, h1, support_data, big_rule_list, min_conf)
else:
calc_conf(freq_set, h1, support_data, big_rule_list, min_conf)
大家可以自行对代码进行测试,观察不同可信度下得出的规则,并且可以看到规则互换前件和后件,规则不一定会成立。
【完整代码】:传送门
class Apriori:
def __init__(self):
pass
def _create_c1(self, dataset):
c1 = []
for transaction in dataset:
for item in transaction:
if not [item] in c1:
c1.append([item])
c1.sort()
return list(map(frozenset, c1))
def _scan_D(self, D, ck, min_support):
ss_cnt = {}
for tid in D:
for can in ck:
if can.issubset(tid):
if not ss_cnt.__contains__(can):
ss_cnt[can] = 1
else:
ss_cnt[can] += 1
num_items = float(len(D))
ret_list = []
support_data = {}
for key in ss_cnt:
support = ss_cnt[key] / num_items
if support >= min_support:
ret_list.insert(0, key)
support_data[key] = support
return ret_list, support_data
def _apriori_gen(self, lk, k):
# creates CK
ret_list = []
len_lk = len(lk)
for i in range(len_lk):
for j in range(i + 1, len_lk):
l1 = list(lk[i])[:k-2]
l2 = list(lk[j])[:k-2]
l1.sort()
l2.sort()
if l1 == l2:
ret_list.append(lk[i] | lk[j])
return ret_list
def apriori(self, dataset, min_support=0.5):
c1 = self._create_c1(dataset)
D = list(map(set, dataset))
l1, support_data = self._scan_D(D, c1, min_support)
l = [l1]
k = 2
while len(l[k-2]) > 0:
ck = self._apriori_gen(l[k-2], k)
lk, supk = self._scan_D(D, ck, min_support)
support_data.update(supk)
l.append(lk)
k += 1
return l, support_data
def generate_rules(self, l, support_data, min_conf=0.7):
big_rule_list = []
for i in range(1, len(l)):
for freq_set in l[i]:
h1 = [frozenset([item]) for item in freq_set]
if i > 1:
self._rules_from_conseq(freq_set, h1, support_data, big_rule_list, min_conf)
else:
self._calc_conf(freq_set, h1, support_data, big_rule_list, min_conf)
return big_rule_list
def _calc_conf(self, freq_set, h, support_data, br1, min_conf=0.7):
pruned_h = []
for conseq in h:
conf = support_data[freq_set] / support_data[freq_set - conseq]
if conf >= min_conf:
print(freq_set - conseq, '-->', conseq, 'conf:', conf)
br1.append((freq_set - conseq, conseq, conf))
pruned_h.append(conseq)
return pruned_h
def _rules_from_conseq(self, freq_set, h, support_data, br1, min_conf=0.7):
m = len(h[0])
if len(freq_set) > (m + 1):
hmp1 = self._apriori_gen(h, m + 1)
hmp1 = self._calc_conf(freq_set, hmp1, support_data, br1, min_conf)
if len(hmp1) > 1:
self._rules_from_conseq(freq_set, hmp1, support_data, br1, min_conf)
如前面所介绍的,Apriori 算法在产生频繁模式完全集前需要对数据库进行多次扫描,同时产生大量的候选频繁项集,而且每次增加频繁项集的大小,Apriori 算法都会重新扫描整个数据集,这就使得 Apriori 算法时间和空间复杂度较大。当数据集很大时这会显著降低频繁项集的发现速度。
可以看出,Apriori 算法的主要时间和空间开销集中于数据集的多次全部访问,以及产生大量的频繁候选集。那么基于此有没有更好的方法用于改进 Apriori 算法,从而提高算法的效率呢?
一些学者在基于 Apriori 算法思想的条件下,主要提出了 FP-growth,GSP,CBA 等算法,事实上,在实际使用当中,也很少直接使用 Apriori 算法,但是理解 Apriori 算法是理解其他 Apriori 类算法的前提。