数据挖掘经典算法PrefixSpan的一个简单Python实现

数据挖掘经典算法PrefixSpan的一个简单Python实现

前言

用python实现了一个没有库依赖的“纯” py-based PrefixSpan算法。

Github 仓库 https://github.com/Holy-Shine/PrefixSpan-py

首先对韩老提出的这个数据挖掘算法不清楚的可以看下这个博客,讲解非常细致。我的实现也是基本照着这个思路。

PrefixSpan算法原理总结

再简单提一下这个算法做了一件什么事。

假设有多个时间序列串:

串序号 序列串
0 1, 4, 2, 3
1 0, 1, 2, 3
2 1, 2, 1, 3, 4
3 2, 1, 2, 4, 4
4 1, 1, 1, 2, 3

查看上面的5条记录串,可以发现 (1,2,3) 这个子序列频繁出现,那么这很有可能就是所有串中潜在的一种序列模式。举个病毒攻击的例子来说,所有设备在一天内遭受到的攻击序列有一个公共子序列(攻击类型,攻击发起者ip),那么这种子序列很有可能是同一个黑客组织发起的一次大规模的攻击,PrefixSpan就是用来高效地检测出这种潜在的序列模式。

1. 代码概览

整个算法实现大概120来行代码,关键函数就4个,结构如下:

|__PrefixSpan(...)  # PrefixSpan
	|__createC1(...)  # 生成初始前缀集合
	   |__rmLowSup(...) # 删除初始前缀集合中的低support集
   	|__psGen(...)   # 生成新的候选前缀集
   	|__genNewPostfixDic(..) # 根据候选集生成新的后缀集合

2. 实现细节

假设我们的数据集长这样(对应上述表格):

D = [
    [1,4,2,3],
    [0, 1, 2, 3],
    [1, 2, 1, 3, 4],
    [2, 1, 2, 4, 4],
    [1, 1, 1, 2, 3],
]

其中每条数据表示一个序列。

算法流程大致如下:

# 生成初始前缀集合和初始后缀集合
L1, postfixDic = createC1(D, minSup)
# 定义结果集 L,放入初始后缀集和
L = [], k = 2
L.append(L1)
# 前缀不断增长1,生成新的前缀,当新的前缀集合大小=0的时候,循环退出
while len(L[k-2]) > 0:
    # 生成新的候选前缀集合(长度比之前的大1)
    Lk = psGen()
    # 根据前缀更新后缀集和
    posfixDic = genNewPostfixDic()
    # 加入到结果集中
    L.append(Lk)
    k+=1

2.1 创建初始前缀集合

首先来看下createC1 的代码清单:

def createC1(D, minSup):
    '''生成第一个候选序列,即长度为1的集合序列
	
    '''
    C1 = []
    postfixDic={}  
    lenD = len(D)

    for i in range(lenD):
        for idx, item in enumerate(D[i]):
            if tuple([item]) not in C1:
                postfixDic[tuple([item])]={}
                
                C1.append(tuple([item]))
            if i not in postfixDic[tuple([item])].keys():
                postfixDic[tuple([item])][i]=idx

    L1, postfixDic = rmLowSup(D, C1, postfixDic, minSup)              
    
    return L1, postfixDic

参数:

  • D:数据集
  • minSup: PrefixSpan算法的关键参数min_support

返回值:

  • L1:剔除低support集后的候选前缀集合
  • postfixDic: 对应候选集合的后缀集

前缀集合C1

初始前缀集合包含只含单个元素的集合,在调用rmLowSup方法前,上述代码的初始前缀集合C1的结果为:[(0,),(1,),(2),(3,),(4,)](其中每个前缀用tuple的形式,主要是为了能够hash);

后缀集合postfixDic

postfixDic 是前缀集合C1 的后缀,它是一个Python字典,每个元素表示当前前缀在数据集中某一条序列中最早出现的结尾位置(这样处理,后续访问后缀的时候,就不需要从头开始遍历了),例如运行完上述代码后:

postfixDic[(1,)]={0:0, 1:1, 2:0, 3:1, 4:0}

回顾数据集D,可以发现1在每一行都出现了,且在第一行(下标为0)出现的结尾为0,第二行位置为1... (位置从0开始)

依次类推:

postfixDic[(1,2,3)]={0:3, 1:3, 2:3, 4:4}

表示前缀 (1,2,3) 在第 0,1,2,4 行都出现了,在第一行的结尾为3,第二行为3...

同时我们可以发现调用 len(postfixDic[prefix]) 就可以知道前缀prefix 在多少序列中出现了,据此可以删除低support 前缀

删除低support前缀

rmLowSup函数清单如下:

def rmLowSup(D,Cx, postfixDic,minSup):
    '''
    根据当前候选集合删除低support的候选集
    '''
    Lx = Cx
    for iset in Cx:
        if len(postfixDic[iset])/len(D) < minSup:
            Lx.remove(iset)
            postfixDic.pop(iset)
        
    return Lx, postfixDic

根据后缀集和postfixDic的说明,前缀prefix的支持度为: len(postfixDic[prefix])/len(D), 例如上述前缀(1,2,3) 的支持度为 4/5=0.8,低于阈值minSup的前缀和其相应在postfixDic 中的key将被剔除。

2.2 生成新的候选前缀集合

psGen 代码清单如下:

def psGen(D, Lk, postfixDic, minSup, minConf):
    '''生成长度+1的新的候选集合
    '''

    retList = []
    lenD = len(D)
    # 访问每一个前缀
    for Ck in Lk:
        item_count = {}  # 统计item在多少行的后缀里出现
        # 访问出现前缀的每一行
        for i in postfixDic[Ck].keys():
            # 从前缀开始访问每个字符
            item_exsit={}
            for j in range(postfixDic[Ck][i]+1, len(D[i])):
                if D[i][j] not in item_count.keys():
                    item_count[D[i][j]]=0
                if D[i][j] not in item_exsit:
                    item_count[D[i][j]]+=1
                    item_exsit[D[i][j]]=True

        c_items = []

        # 根据minSup和minConf筛选候选字符
        for item in item_count.keys():
            if item_count[item]/lenD >= minSup and item_count[item]/len(postfixDic[Ck])>=minConf:
                c_items.append(item)
        
        # 将筛选后的字符组成新的候选前缀,加入候选前缀集合
        for c_item in c_items:
            retList.append(Ck+tuple([c_item]))
    
    return retList

对于当前前缀集(长度相等)中的每个前缀,通过后缀集合postfixDic 能挖掘到其可能的下一个字符。例如前缀 (1,2)postfixDic[(1,2)]={0:2, 1:2, 2:1, 3:2, 4:3}, 表示在第0,1,2,3,4行都存在前缀(1,2), 通过其在每行的前缀结尾位置,例如第0行的结尾位置,可以在[postfixDic[(1,2)][0], len(D[0])) 范围内查找是否有符合条件的新元素,即第0行的 [2, 4) 范围内搜索。

具体方法是统计后缀中不同元素分别在当前行是否出现,再统计它们出现的行数,查找过程如下表所示(对应函数清单的前半部分):

查找行/元素候选 0 1 2 3 4
0 0 0 0 1 0
1 0 0 0 1 0
2 0 1 0 1 1
3 0 0 1 0 1
4 0 0 0 1 0
总计 0 1 1 4 2

可以看到候选元素3,4分别出现了4次和2次,则表示候选前缀 (1,2,3)(1,2,4) 在5行序列中的4行和2行中出现,可以很快计算得到它们的support值为0.8和0.5。

传统的PrefixSpan只在这里用min_support的策略过滤候选前缀集,而代码里同时用了min_confidence 参数,这里就不细讲了。

2.3 更新后缀集和postfixDic

同样来看下代码清单:

def genNewPostfixDic(D,Lk, prePost):
    '''根据候选集生成相应的PrefixSpan后缀

    参数:
        D:数据集
        Lk: 选出的集合
        prePost: 上一个前缀集合的后缀

    基于假设:
        (1,2)的后缀只能出现在 (1,)的后缀列表里 
    e.g.
        postifixDic[(1,2)]={1:2,2:3}  前缀 (1,2) 在第一行的结尾是2,第二行的结尾是3
    '''
    postfixDic = {}

    for Ck in Lk:
        # (1,2)的后缀只能出现在 (1,)的后缀列表里
        postfixDic[Ck]={}
        tgt = Ck[-1]
        prePostList = prePost[Ck[:-1]]
        for r_i in prePostList.keys():
            for c_i in range(prePostList[r_i]+1, len(D[r_i])):
                if D[r_i][c_i]==tgt:
                    postfixDic[Ck][r_i] = c_i
                    break
    
    return postfixDic

现在我们要根据新的候选前缀集合更新后缀集和postfixDic,为此我们需要旧的前缀集合 postfixDic 作为辅助,可以大大减小时间复杂度。

例如我们更新前缀 (1,2,3) 的后缀,我们不需要再从 D的第0行开始遍历所有的序列。

因为(1,2,3) 必然来自前缀 (1,2),因此只要遍历出现前缀 (1,2) 的行进行查找即可,这也是我们需要旧的前缀集合的原因。

接下来就简单了,只要在这些行,找到新的元素的位置即可。例如对于前缀 (1,2),其后缀postfixDic[(1,2)]={0: 2, 1: 2, 2: 1, 3: 2, 4: 3},所以在0,1,2,3,4行的这些位置+1开始寻找是否存在3这个元素。上述代码做的就是这个。

以此类推我们就可以得到所有符合条件的子序列。