关联分析算法(Association Analysis)Apriori算法和FP-growth算法初探

1. 关联分析是什么?

关联分析,也叫关联规则挖掘,属于无监督算法的一种,它用于从数据中挖掘出潜在的关联关系,例如经典的啤酒与尿布的关联关系。

本文将要重点介绍的Apriori和FP-growth算法就是一种关联算法,,它们可以高效自动地从数据集中挖掘出潜在的属性关联组合规则。

0x1:从一个购物篮交易的例子说起

许多商业企业在日复一日的运营中积聚了大量的交易数据。例如,超市的收银台每天都收集大量的顾客购物数据。

例如,下表给出了一个这种数据集的例子,我们通常称其为购物篮交易(market basket transaction)。表中每一行对应一个交易,包含一个唯一标识TID和特定顾客购买的商品集合。

零售商对分析这些数据很感兴趣,以便了解其顾客的购买行为。可以使用这种有价值的信息来支持各种商业中的实际应用,如市场促销,库存管理和顾客关系管理等等。

现在,零售商希望从这些交易记录中发现“某种商业规律”,所谓的商业规律,是一个经济学术语,简单来说是因为某些事物间存在的彼此关联和依赖的关系,从而导致这些事物成对或者按照某种确定的先后关系成对出现的情况。

例如:

  • if 豆奶 then 莴苣
  • if 豆奶 and 尿布 then 莴苣
  • .....

理论上说,任何属性都可以伴随着任何可能的属性值出现在右边,而一个单独的关联规则经常能够预测出不止一个属性的值。

要找出这些规则,就必须对右边的每一种可能的属性组合,用每种可能的属性值的组合执行一次规则归纳过程。但这是理论上的分析,实际上学者们已经研究出很多更高效的算法,大大加速了这个搜索过程,我们接下来就来讨论它们。

但是在讨论具体算法之前,笔者需要先介绍一下关联分析算法的两个基本分析元素。

0x2:事物之间关联关系的两种递进抽象形式

关联分析是在大规模数据集中寻找关联关系的任务。这些关系可以有两种形式,它们是2种递进的抽象形式,并且前者是后者的抽象基础:

  • 代表共现关系的频繁项集频繁项集(frequent item sets)是经常出现在一块儿的物品的集合,它暗示了某些事物之间总是结伴或成对出现。本质上来说,不管是因果关系还是相关关系,都是共现关系,所以从这点上来讲,频繁项集是覆盖量(coverage)这个指标的一种度量关系。
  • 代表因果/相关关系的关联规则关联规则(association rules)暗示两种物品之间可能存在很强的关系,它更关注的是事物之间的互相依赖条件先验关系。它暗示了组内某些属性间不仅共现,而且还存在明显的相关和因果关系,关联关系一种更强的共现关系。所以从这点上来将,关联规则是准确率(accuracy)这个指标的一种度量关系。

下面用一个例子来说明这两种概念:下图给出了某个杂货店的交易清单。

从表中可以看出:

  • 频繁项集是指那些经常出现在一起的商品集合,图中的集合{葡萄酒,尿布,豆奶}就是频繁项集的一个例子;
  • 从这个数据集中也可以找到诸如“葡萄酒->尿布”的关联规则,即如果有人买了葡萄酒,那么他很可能也会买尿布。

这里我们注意,为什么是说葡萄酒->尿布的关联规则,而不是尿布->葡萄酒的关联规则呢?因为我们注意到,在第4行,出现了尿布,但是没有出现葡萄酒,所以这个关联推导是不成立的,反之却成立(至少在这个样本数据集里是成立的)。

在实际的关联分析中,常常会分成两部分进行:

  • 第一阶段:产生一个达到指定最小覆盖量的项集
  • 第二阶段:从每一个项集中找出能够达到指定最小准确率的规则

0x3:如何定量度量事物之间的关联关系

我们用支持度和可信度来度量事物间的关联关系,虽然事物间的关联关系十分复杂,但是我们基于统计规律以及贝叶斯条件概率理论的基础进行抽象,得到一种数值化的度量描述。

1. 项与项集

是购物篮数据中所有项的集合,而是所有交易的集合。包含0个或多个项的集合被称为项集(itemset)

如果一个项集包含 k 个项,则称它为 k-项集。显然,每个交易包含的项集都是 I 的子集,每个频繁项集都是一个k-项集。

2. 支持度(support)- 定量评估频繁项集(k-项集)的频繁共现度(即覆盖度)的统计量

关联规则的支持度定义如下:

其中表示事务包含集合A和B的并(即包含A和B中的每个项)的概率。这里的支持度也可以理解为项集A和项集B的共现概率。

通俗的说,一个项集的支持度(support)被定义数据集中包含该项集(多个项的组合集合)的记录所占的比例,也即覆盖度。

如上图中,

  • {豆奶}的支持度为4/5
  • {豆奶,尿布}的支持度为3/5。

在实际的业务场景中,支持度可以帮助我们发现潜在的规则集合

例如在异常进程检测中,当同时出现{ java->bash、bash->bash }这种事件序列集合会经常在发生了反弹shell恶性入侵的机器日志中出现(即这种组合的支持度会较高),这种频繁项集暗示了我们这是一个有代表性的序列标志,很可能是exploited IOC标志。

3. 置信度(confidence)- 定量评估一个频繁项集的置信度(即准确度)的统计量

关联规则是形如 X→Y 的蕴涵表达式,其中 X 和 Y 是不相交的项集,即 X∩Y=∅。

关联规则的置信度定义如下:

通俗地说,可信度置信度(confidence)是针对关联规则来定义的。

例如我们定义一个规则:

{尿布}➞{葡萄酒},即购买尿布的顾客也会购买啤酒

这是一个关联规则,这个关联规则的可信度被定义为:

"支持度({尿布,葡萄酒}) / 支持度({尿布})"

由于{尿布,葡萄酒}的支持度为3/5,尿布的支持度为4/5,所以"尿布➞葡萄酒"的可信度为3/4。

从训练数据的统计角度来看,这意味着对于包含"尿布"的所有记录,我们的规则对其中75%的记录都适用,或者说“{尿布}➞{葡萄酒},即购买尿布的顾客也会购买啤酒”这条规则的准确度有75%。

从关联规则的可信程度角度来看,“购买尿布的顾客会购买葡萄酒”这个商业推测,有75%的可能性是成立的,也可以理解为做这种商业决策,可以获得75%的回报率期望。

笔者思考

这个公式还暗示了另一个非常质朴的道理,如果一个事件A出现概率很高,那么这个事件对其他事件是否出现的推测可信度就会降低,很简单的道理,例如夏天今天气温大于20°,这是一个非常常见的事件,可能大于0.9的可能性,事件B是今天你会中彩票一等奖。confidence(A => B)的置信度就不会很高,因为事件A的出现概率很高,这种常见事件对事件B的推导关联几乎没有实际意义

0x4:关联分析算法过程

纯粹的项集是一个指数级的排列组合过程,每个数据集都可以得到一个天文数字的项集,而其实大多数的项集都是我们不感兴趣的,因此,分析的过程需要加入阈值判断,对搜索进行剪枝,具体来说:

  • 频繁项集发现阶段:按照“support ≥ minsup threshold”的标准筛选满足最小支持度的频繁项集(frequent itemset)。
  • 关联规则发现阶段:按照“confidence ≥ minconf threshold”的标准筛选满足最小置信度的强规则(strong rule)。

满足最小支持度和最小置信度的关联规则,即待挖掘的最终关联规则。也是我们期望模型产出的业务结果。

这实际上是在工程化项目中需要关心的,因为我们在一个庞大的数据集中,频繁项集合关联规则是非常多的,我们不可能采纳所有的这些关系,特别是在入侵检测中,我们往往需要提取TOP N的关联,并将其转化为规则,这个过程也可以自动化完成。

0x5:怎么去挖掘数据集中潜在的关系呢?暴力搜索可以吗?

一种最直接的进行关联关系挖掘的方法或许就是暴力搜索(Brute-force)的方法,实际上,如果算力足够,理论上所有机器学习算法都可以暴力搜索,也就不需要承担启发式搜索带来的局部优化损失问题。

1. List all possible association rules
2. Compute the support and confidence for each rule
3. Prune rules that fail the minsup and minconf thresholds

然而,由于Brute-force的计算量过大,所以采样这种方法并不现实!

格结构(Lattice structure)常被用来枚举所有可能的项集。如下图所示为 I={a,b,c,d,e} 的项集格。

一般来说,排除空集后,一个包含k个项的数据集最大可能产生个频繁项集。由于在实际应用中k的值可能非常大,需要探查的项集搜索空集可能是指数规模的。

Relevant Link:

https://blog.csdn.net/baimafujinji/article/details/53456931
https://www.cnblogs.com/qwertWZ/p/4510857.html
https://www.cnblogs.com/llhthinker/p/6719779.html

 

2. Apriori算法

0x1:Apriori算法中对频繁项集的层级迭代搜索思想

在上一小节的末尾,我们已经讨论说明了Brute-force在实际中并不可取。我们必须设法降低产生频繁项集的计算复杂度。

此时我们可以利用支持度对候选项集进行剪枝,它的核心思想是在上一轮中已经明确不能成功频繁项集的项集就不要进入下一轮浪费时间了,只保留上一轮中的频繁项集,在本轮继续进行统计。

Apriori定律1:如果一个集合是频繁项集,则它的所有子集都是频繁项集

假设一个集合{A,B}是频繁项集,即A、B同时出现在一条记录的次数大于等于最小支持度min_support,则它的子集{A},{B}出现次数必定大于等于min_support,即它的子集都是频繁项集。

Apriori定律2:如果一个集合不是频繁项集,则它的所有超集都不是频繁项集

假设集合{A}不是频繁项集,即A出现的次数小于 min_support,则它的任何超集如{A,B}出现的次数必定小于min_support,因此其超集必定也不是频繁项集

下图表示当我们发现{A,B}是非频繁集时,就代表所有包含它的超集也是非频繁的,即可以将它们都剪除(剪纸)

一般而言,关联规则的挖掘是一个两步的过程:

1. 找出所有的频繁项集
2. 由频繁项集产生强关联规则

0x2:挖掘频繁项集

1. 伪码描述

    • Let k=1:最开始,每个项都是候选1-项集的集合C1的成员
      • Generate frequent itemsets of length k, and Prune candidate itemsets that are infrequent:计算C1每个1-项集的频率,在第一步就要根据支持度阈值对不满足阈值的项集进行剪枝,得到第一层的频繁项
    • Repeat until no new frequent itemsets are identified:迭代过程
      • Generate length (k+1) candidate itemsets from length k frequent itemsets:在上一步k-项集的基础上,算法扫描所有的记录,获得项集的并集组合,生成所有(k+1)-项集。
      • Prune candidate itemsets containing subsets of length k+1 that are infrequent:(k+1)-项集每个项进行计数(根据该项在全量数据集中的频数进行统计)。然后根据最小支持度从(k+1)-项集中删除不满足的项,从而获得频繁(k+1)-项集,Lk+1
    • the finnal k-items:因为Apriori每一步都在通过项集之间的并集操作,以此来获得新的候选项集,如果在某一轮迭代中,候选项集没有新增,则可以停止迭代。因为这说明了在这轮迭代中,通过支持度阈值的剪枝,非频繁项集已经全部被剪枝完毕了,则根据Apriori先验定理2,迭代没有必要再进行下去了。

下面是一个具体的例子,最开始数据库里有4条交易,{A、C、D},{B、C、E},{A、B、C、E},{B、E},使用min_support=2作为支持度阈值,最后我们筛选出来的频繁集为{B、C、E}。

2. 一个频繁项集生成的python代码示例

# coding=utf-8
from numpy import *


def loadDataSet():
    return [[1, 3, 4], [2, 3, 5], [1, 2, 3, 5], [2, 5]]

def createC1(dataSet):
    C1 = []
    for transaction in dataSet:
        for item in transaction:
            if not [item] in C1:
                C1.append([item])
    C1.sort()
    return map(frozenset, C1)


# 其中D为全部数据集,
# # Ck为大小为k(包含k个元素)的候选项集,
# # minSupport为设定的最小支持度。
# # 返回值中retList为在Ck中找出的频繁项集(支持度大于minSupport的),
# # supportData记录各频繁项集的支持度
def scanD(D, Ck, minSupport):
    ssCnt = {}
    for tid in D:
        for can in Ck:
            if can.issubset(tid):
                ssCnt[can] = ssCnt.get(can, 0) + 1
    numItems = float(len(D))
    retList = []
    supportData = {}
    for key in ssCnt:
        support = ssCnt[key] / numItems     # 计算频数
        if support >= minSupport:
            retList.insert(0, key)
        supportData[key] = support
    return retList, supportData


# 生成 k+1 项集的候选项集
# 注意其生成的过程中,首选对每个项集按元素排序,然后每次比较两个项集,只有在前k-1项相同时才将这两项合并。
# # 这样做是因为函数并非要两两合并各个集合,那样生成的集合并非都是k+1项的。在限制项数为k+1的前提下,只有在前k-1项相同、最后一项不相同的情况下合并才为所需要的新候选项集。
def aprioriGen(Lk, k):
    retList = []
    lenLk = len(Lk)
    for i in range(lenLk):
        for j in range(i + 1, lenLk):
            # 前k-2项相同时,将两个集合合并
            L1 = list(Lk[i])[:k-2]; L2 = list(Lk[j])[:k-2]
            L1.sort(); L2.sort()
            if L1 == L2:
                retList.append(Lk[i] | Lk[j])
    return retList


def apriori(dataSet, minSupport=0.5):
    C1 = createC1(dataSet)
    D = map(set, dataSet)
    L1, supportData = scanD(D, C1, minSupport)
    L = [L1]
    k = 2
    while (len(L[k-2]) > 0):
        Ck = aprioriGen(L[k-2], k)
        Lk, supK = scanD(D, Ck, minSupport)
        supportData.update(supK)
        L.append(Lk)
        k += 1
    return L, supportData


dataSet = loadDataSet()
D = map(set, dataSet)
print dataSet
print D

C1 = createC1(dataSet)
print C1    # 其中C1即为元素个数为1的项集(非频繁项集,因为还没有同最小支持度比较)

L1, suppDat = scanD(D, C1, 0.5)
print "L1: ", L1
print "suppDat: ", suppDat


# 完整的频繁项集生成全过程
L, suppData = apriori(dataSet)
print "L: ",L
print "suppData:", suppData

最后生成的频繁项集为:

suppData: 
frozenset([5]): 0.75, 
frozenset([3]): 0.75, 
frozenset([2, 3, 5]): 0.5,
frozenset([1, 2]): 0.25,
frozenset([1, 5]): 0.25,
frozenset([3, 5]): 0.5,
frozenset([4]): 0.25, 
frozenset([2, 3]): 0.5, 
frozenset([2, 5]): 0.75, 
frozenset([1]): 0.5, 
frozenset([1, 3]): 0.5, 
frozenset([2]): 0.75 

需要注意的是,阈值设置的越小,整体算法的运行时间就越短,因为阈值设置的越小,剪纸会更早介入。

0x3:从频繁集中挖掘关联规则

解决了频繁项集问题,下一步就可以解决相关规则问题。

1. 关联规则来源自所有频繁项集

从前面对置信度的形式化描述我们知道,关联规则来源于每一轮迭代中产生的频繁项集(从C1开始,因为空集对单项集的支持推导是没有意义的)

从公式中可以看到,计算关联规则置信度的分子和分母我们都有了,就是上一步计算得到的频繁项集。所以,关联规则的搜索就是围绕频繁项集展开的。

一条规则 S➞H 的可信度定义为:

P(H | S)= support(P 并 S) / support(S)

可见,可信度的计算是基于项集的支持度的。

2. 关联规则的搜索过程

既然关联规则来源于所有频繁项集 ,那要怎么搜索呢?所有的组合都暴力穷举尝试一遍吗?

显然不是的,关联规则的搜索一样可以遵循频繁项集的层次迭代搜索方法,即按照频繁项集的层次结构,进行逐层搜索

3. 关联规则搜索中的剪枝策略

下图给出了从项集{0,1,2,3}产生的所有关联规则,其中阴影区域给出的是低可信度的规则。可以发现:

如果{0,1,2}➞{3}是一条低可信度规则,那么所有其他以3作为后件(箭头右部包含3)的规则均为低可信度的。即如果某条规则并不满足最小可信度要求,那么该规则的所有子集也不会满足最小可信度要求。

反之,如果{0,1,3}->{2},则说明{2}这个频繁项作为后件,可以进入到下一轮的迭代层次搜索中,继续和本轮得到的规则列表的右部进行组合。直到搜索一停止为止

可以利用关联规则的上述性质属性来减少需要测试的规则数目,类似于Apriori算法求解频繁项集的剪纸策略。

4. 从频繁项集中寻找关联规则的python示例代码

# coding=utf-8
from numpy import *

def loadDataSet():
    return [[1, 3, 4], [2, 3, 5], [1, 2, 3, 5], [2, 5]]

def createC1(dataSet):
    C1 = []
    for transaction in dataSet:
        for item in transaction:
            if not [item] in C1:
                C1.append([item])
    C1.sort()
    return map(frozenset, C1)


# 其中D为全部数据集,
# # Ck为大小为k(包含k个元素)的候选项集,
# # minSupport为设定的最小支持度。
# # 返回值中retList为在Ck中找出的频繁项集(支持度大于minSupport的),
# # supportData记录各频繁项集的支持度
def scanD(D, Ck, minSupport):
    ssCnt = {}
    for tid in D:
        for can in Ck:
            if can.issubset(tid):
                ssCnt[can] = ssCnt.get(can, 0) + 1
    numItems = float(len(D))
    retList = []
    supportData = {}
    for key in ssCnt:
        support = ssCnt[key] / numItems     # 计算频数
        if support >= minSupport:
            retList.insert(0, key)
        supportData[key] = support
    return retList, supportData


# 生成 k+1 项集的候选项集
# 注意其生成的过程中,首选对每个项集按元素排序,然后每次比较两个项集,只有在前k-1项相同时才将这两项合并。
# # 这样做是因为函数并非要两两合并各个集合,那样生成的集合并非都是k+1项的。在限制项数为k+1的前提下,只有在前k-1项相同、最后一项不相同的情况下合并才为所需要的新候选项集。
def aprioriGen(Lk, k):
    retList = []
    lenLk = len(Lk)
    for i in range(lenLk):
        for j in range(i + 1, lenLk):
            # 前k-2项相同时,将两个集合合并
            L1 = list(Lk[i])[:k-2]; L2 = list(Lk[j])[:k-2]
            L1.sort(); L2.sort()
            if L1 == L2:
                retList.append(Lk[i] | Lk[j])
    return retList


def apriori(dataSet, minSupport=0.5):
    C1 = createC1(dataSet)
    D = map(set, dataSet)
    L1, supportData = scanD(D, C1, minSupport)
    L = [L1]
    k = 2
    while (len(L[k-2]) > 0):
        Ck = aprioriGen(L[k-2], k)
        Lk, supK = scanD(D, Ck, minSupport)
        supportData.update(supK)
        L.append(Lk)
        k += 1
    return L, supportData


# 频繁项集列表L
# 包含那些频繁项集支持数据的字典supportData
# 最小可信度阈值minConf
def generateRules(L, supportData, minConf=0.7):
    bigRuleList = []
    # 频繁项集是按照层次搜索得到的, 每一层都是把具有相同元素个数的频繁项集组织成列表,再将各个列表组成一个大列表,所以需要遍历Len(L)次, 即逐层搜索
    for i in range(1, len(L)):
        for freqSet in L[i]:
            H1 = [frozenset([item]) for item in freqSet]    # 对每个频繁项集构建只包含单个元素集合的列表H1
            print "\nfreqSet: ", freqSet
            print "H1: ", H1
            rulesFromConseq(freqSet, H1, supportData, bigRuleList, minConf)     # 根据当前候选规则集H生成下一层候选规则集
    return bigRuleList


# 根据当前候选规则集H生成下一层候选规则集
def rulesFromConseq(freqSet, H, supportData, brl, minConf=0.7):
    m = len(H[0])
    while (len(freqSet) > m):  # 判断长度 > m,这时即可求H的可信度
        H = calcConf(freqSet, H, supportData, brl, minConf)     # 返回值prunedH保存规则列表的右部,这部分频繁项将进入下一轮搜索
        if (len(H) > 1):  # 判断求完可信度后是否还有可信度大于阈值的项用来生成下一层H
            H = aprioriGen(H, m + 1)
            print "H = aprioriGen(H, m + 1): ", H
            m += 1
        else:  # 不能继续生成下一层候选关联规则,提前退出循环
            break

# 计算规则的可信度,并过滤出满足最小可信度要求的规则
def calcConf(freqSet, H, supportData, brl, minConf=0.7):
    ''' 对候选规则集进行评估 '''
    prunedH = []
    for conseq in H:
        print "conseq: ", conseq
        print "supportData[freqSet]: ", supportData[freqSet]
        print "supportData[freqSet - conseq]: ", supportData[freqSet - conseq]
        conf = supportData[freqSet] / supportData[freqSet - conseq]
        if conf >= minConf:
            print freqSet - conseq, '-->', conseq, 'conf:', conf
            brl.append((freqSet - conseq, conseq, conf))
            prunedH.append(conseq)
            print "prunedH: ", prunedH
    return prunedH





dataSet = loadDataSet()
L, suppData = apriori(dataSet, minSupport=0.5)      # 得到频繁项集列表L,以及每个频繁项的支持度
print "频繁项集L: "
for i in L:
    print i
print "频繁项集L的支持度列表suppData: "
for key in suppData:
    print key, suppData[key]

# 基于频繁项集生成满足置信度阈值的关联规则
rules = generateRules(L, suppData, minConf=0.7)
print "rules = generateRules(L, suppData, minConf=0.7)"
print "rules: ", rules


rules = generateRules(L, suppData, minConf=0.5)
#print
#print "rules = generateRules(L, suppData, minConf=0.5)"
#print "rules: ", rules

Relevant Link:

https://blog.csdn.net/baimafujinji/article/details/53456931 
https://www.cnblogs.com/llhthinker/p/6719779.html
https://www.cnblogs.com/qwertWZ/p/4510857.html

 

3. FP-growth算法

FP-growth算法基于Apriori构建,但采用了高级的数据结构减少扫描次数,大大加快了算法速度。FP-growth算法只需要对数据库进行两次扫描,而Apriori算法对于每个潜在的频繁项集都会扫描数据集判定给定模式是否频繁,因此FP-growth算法的速度要比Apriori算法快。

FP-growth算法发现频繁项集的基本过程如下:

1. 构建FP树
2. 从FP树中挖掘频繁项集

0x1:FP树数据结构 - 用于编码数据集的有效方式

在讨论FP-growth算法之前,我们先来讨论FP树的数据结构,可以这么说,FP-growth算法的高效很大程度来源组FP树的功劳。

FP-growth算法将数据存储在一种称为FP树的紧凑数据结构中。FP代表频繁模式(Frequent Pattern)。FP树通过链接(link)来连接相似元素,被连起来的元素项可以看成一个链表。下图给出了FP树的一个例子。

与搜索树不同的是,一个元素项可以在一棵FP树种出现多次。FP树辉存储项集的出现频率,而每个项集会以路径的方式存储在树中。

存在相似元素的集合会共享树的一部分。只有当集合之间完全不同时,树才会分叉。

树节点上给出集合中的单个元素及其在序列中的出现次数,路径会给出该序列的出现次数。

相似项之间的链接称为节点链接(node link),用于快速发现相似项的位置。 

为了更好说明,我们来看用于生成上图的原始事务数据集:

事务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

上图中:

元素项z出现了5次,集合{r, z}出现了1次。于是可以得出结论:z一定是自己本身或者和其他符号一起出现了4次。

集合{t, s, y, x, z}出现了2次,集合{t, r, y, x, z}出现了1次,z本身单独出现1次。

就像这样,FP树的解读方式是:读取某个节点开始到根节点的路径。路径上的元素构成一个频繁项集,开始节点的值表示这个项集的支持度

根据上图,我们可以快速读出:

  • 项集{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}

和Apriori一样,我们需要设定一个最小阈值,出现次数低于最小阈值的元素项将被直接忽略(提前剪枝)。上图中将最小支持度设为3,所以q和p没有在FP中出现。 

0x2:构建FP树过程

1. 创建FP树的数据结构

我们使用一个类表示树结构

# coding=utf-8

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)


rootNode = treeNode('pyramid', 9, None)
rootNode.children['eye'] = treeNode('eye', 13, None)
rootNode.children['phoenix'] = treeNode('phoenix', 3, None)
rootNode.disp()

2. 构建FP树

1)头指针表

FP-growth算法需要一个称为头指针表的数据结构,就是用来记录各个元素项的总出现次数的数组,再附带一个指针指向FP树中该元素项的第一个节点。这样每个元素项都构成一条单链表。图示说明:

这里使用Python字典作为数据结构,来保存头指针表。以元素项名称为键,保存出现的总次数和一个指向第一个相似元素项的指针。

第一次遍历数据集会获得每个元素项的出现频率,去掉不满足最小支持度的元素项,生成这个头指针表。这个过程相当于Apriori里的1-频繁项集的生成过程。

2)元素项排序

上文提到过,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

3)构建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的过程

实现以上逻辑的py代码逻辑如下:

# coding=utf-8

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)


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



''' 创建FP树 '''
def createTree(dataSet, minSup=1):
    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) # 根节点

    print dataSet.items()
    for tranSet, count in dataSet.items():  # 第二次遍历数据集,创建FP树
        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


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)


def updateHeader(nodeToTest, targetNode):
    while (nodeToTest.nodeLink != None):
        nodeToTest = nodeToTest.nodeLink
    nodeToTest.nodeLink = targetNode



simpDat = loadSimpDat()
initSet = createInitSet(simpDat)
myFPtree, myHeaderTab = createTree(initSet, 3)
myFPtree.disp()

0x3:从一棵FP树种挖掘频繁项集

有了FP树之后,接下来可以抽取频繁项集了。这里的思路与Apriori算法大致类似,首先从单元素项集合开始,然后在此基础上逐步构建更大的集合。

从FP树中抽取频繁项集的三个基本步骤如下:

1. 从FP树中获得条件模式基;
2. 利用条件模式基,构建一个条件FP树;
3. 迭代重复步骤1步骤2,直到树包含一个元素项为止。

1. 抽取条件模式基

首先从头指针表中的每个频繁元素项开始,对每个元素项,获得其对应的条件模式基(conditional pattern base)。

条件模式基是以所查找元素项为结尾的路径集合。每一条路径其实都是一条前缀路径(prefix path)。简而言之,一条前缀路径是介于所查找元素项与树根节点之间的所有内容。

则每一个频繁元素项的所有前缀路径(条件模式基)为:

频繁项 前缀路径
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的条件模式基;

以此类推。

2. 创建条件FP树

对于每一个频繁项,都要创建一棵条件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”。

3. 递归查找频繁项集

有了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,递归这个过程

4. 完整FP频繁项集挖掘过程py代码

# coding=utf-8

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)


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



''' 创建FP树 '''
def createTree(dataSet, minSup=1):
    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) # 根节点

    print dataSet.items()
    for tranSet, count in dataSet.items():  # 第二次遍历数据集,创建FP树
        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


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)


def updateHeader(nodeToTest, targetNode):
    while (nodeToTest.nodeLink != None):
        nodeToTest = nodeToTest.nodeLink
    nodeToTest.nodeLink = targetNode


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


def ascendTree(leafNode, prefixPath):
    if leafNode.parent != None:
        prefixPath.append(leafNode.name)
        ascendTree(leafNode.parent, prefixPath)


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)


def fpGrowth(dataSet, minSup=3):
    initSet = createInitSet(dataSet)
    myFPtree, myHeaderTab = createTree(initSet, minSup)
    freqItems = []
    mineTree(myFPtree, myHeaderTab, minSup, set([]), freqItems)
    return freqItems


dataSet = loadSimpDat()
freqItems = fpGrowth(dataSet)
print freqItems

FP-growth算法是一种用于发现数据集中频繁模式的有效方法。FP-growth算法利用Apriori原则,执行更快。Apriori算法产生候选项集,然后扫描数据集来检查它们是否频繁。由于只对数据集扫描两次,因此FP-growth算法执行更快。在FP-growth算法中,数据集存储在一个称为FP树的结构中。FP树构建完成后,可以通过查找元素项的条件基及构建条件FP树来发现频繁项集。该过程不断以更多元素作为条件重复进行,直到FP树只包含一个元素为止。

Relevant Link: 

https://www.cnblogs.com/qwertWZ/p/4510857.html

 

4. 支持度-置信度框架的瓶颈 - 哪些模式是有趣的?强规则不一定是有趣的?

0x1:支持度-置信度框架的瓶颈

关联规则挖掘算法基本都使用支持度-置信度框架。但是在实际工程项目中,我们可能会期望从数据集中挖掘潜在的未知模式(0day),但是低支持度阈值挖掘或挖掘长模式时,会产生很多无趣的规则,这是关联规则挖掘应用的瓶颈之一。

基于支持度-置信度框架识别出的强关联规则,不足以过滤掉无趣的关联规则,它可能仅仅是数据集中包含的一个显而易见的统计规律,或者仅仅是我们传入的数据集中包含了脏数据。统计有时候就是魔鬼。

0x2:相关性度量 - 提升度(lift)

为识别规则的有趣性,需使用相关性度量来扩充关联规则的支持度-置信度框架。

相关规则不仅用支持度和置信度度量,而且还用项集A和B之间的相关性度量。一个典型的相关性度量的方法是:提升度(lift)

1. A 和 B是互相独立的:P(A∪B) = P(A)P(B);
2. 项集A和B是依赖的(dependent)和相关的(correlated):P(A∪B) != P(A)P(B);

A和B出现之间的提升度定义为:lift(A,B) = P(A∪B) / P(A) * P(B)

  • 如果lift(A,B)<1,则说明A的出现和B的出现是负相关的;
  • 如果lift(A,B)>1,则A和B是正相关的,意味每一个的出现蕴涵另一个的出现;
  • 如果lift(A,B)=1,则说明A和B是独立的,没有相关性。

Relevant Link:

https://blog.csdn.net/fjssharpsword/article/details/78291638
https://blog.csdn.net/dq_dm/article/details/38145075

 

5. 在实际工程项目中的思考

0x1:你的输入数据集是什么?是否单纯?包含了哪些概率分布假设?

在实际的机器学习工程项目中,要注意的一点是,Apriori和FP-growth是面向一个概率分布纯粹的数据集进行共现模式和关联模式的挖掘的,例如商品交易数据中,所有的每一条数据都是交易数据,算法是从这些商品交易数据中挖掘有趣关系。

如果要再入侵检测场景中使用该算法,同样也要注意纯度的问题,不要引入噪音数据,例如我们提供的数据集应该是所有发生了异常入侵事件的时间窗口内的op序列,这里单个op序列可以抽象为商品单品,每台机器可以抽象为一次交易。这种假设没太大问题。它基于的假设是全网的被入侵服务器,在大数据的场景下,都具有类似的IOC模式。

记住一句话,关联分析算法只是在单纯从统计机器学习层面去挖掘数据集中潜在的规律和关联,你传入什么数据,它就给你挖掘出什么。所以在使用算法的时候,一定要思考清楚你传入的数据意味着什么?数据中可能蕴含了哪些规则但是你不想或者没法人肉地去自动化挖掘出来,算法只是帮你自动化地完成了这个过程,千万不能把算法当成魔法,把一堆数据扔进去,妄想可以自动挖掘出0day。

0x2:频繁项集和关联规则对你的项目来说意味着什么?

关联挖掘算法是从交易数据商机挖掘的场景中被开发出来的,它的出发点是找到交易数据中的伴随购买以及购买推导关系链。这种挖掘模式在其他项目中是否能映射到一个类似的场景?这是需要开发者要去思考的。

例如,在入侵检测场景中,我们通过Apriori挖掘得到的频繁项集和关联规则可能是如下的形式:

这种结果的解释性在于:
入侵以及伴随入侵的恶意脚本植入及执行,都是成对出现的,并且满足一定的先后关系。

但是在入侵检测领域,我们知道,一次入侵往往会通过包含多种指令序列模式,我们并不需要强制在一个机器日志中完整匹配到整个频繁项集。
一个可行的做法是:
只取算法得到结果的1-频繁项集或者将所有k-频繁项集split拆分成1-频繁项集后,直接根据1-频繁项集在原始日志进行匹配,其实如果只要发现了一个频繁项集对应的op seq序列,基本上就是认为该时间点发生了入侵事件。

0x3:其他思考

项目开发过程中,我们发现有一篇paper用的方案是非常类似的,只是业务场景稍有不同。

http://www.paper.edu.cn/scholar/showpdf/NUD2UNyINTz0MxeQh

它这有几点很有趣的,值得去思考的:

1. 利用关联挖掘算法先挖掘出正常行为模式,用于进行白名单过滤
2. 加入了弱规则挖掘,即低支持度,高置信度的弱规则。根据网络攻击的实际特点,有些攻击是异常行为比较频繁的攻击,如DDOS攻击等,通过强规则挖掘能检测出此类攻击;而有些攻击异常行为不太频繁,如慢攻击在单位时间内异常扫描数量很少。强规则挖掘适合抓出批量大范围行为,弱规则挖矿适合抓出0day攻击
3. 下游stacking了贝叶斯网络来进行异常行为的最终判断

 

posted @ 2018-08-04 12:18  郑瀚Andrew  阅读(40216)  评论(1编辑  收藏  举报