先说个真实感受:很多人第一次看到“序列模式挖掘”这几个字,第一反应是“这不就是关联规则换个名字嘛”。等真上手跑了一轮 GSP 算法,才发现完全不是一回事。序列模式挖的不只是“哪些东西常一起出现”,而是“哪些行为在时间轴上反复出现”。这个区别,直接决定了算法的输入格式、处理逻辑、结果解读方式,全都变了。
我最初是在一个用户行为分析项目里接触 GSP 的。当时要回答一个业务问题:用户在三个月内的操作路径里,是否存在高频的“搜索-比较-下单”模式。关联规则给不了答案,因为它的核心是“共现”,对顺序不敏感。而 GSP 这种基于 Apriori 思想扩展出来的序列挖掘算法,处理的就是这种带次序、带时间上下文的模式发现任务。
这篇文章我不打算堆公式,也不搞“从入门到精通”那种教科书式铺开。我就按自己从一个只会跑 Apriori 的菜鸟,到能完整实现并调优 GSP 的真实过程来写:核心概念怎么理解、候选生成为什么这么设计、支持度计数有哪些坑、手工推演和 Python 实现怎么对得上、以及实际业务里哪些场景真正需要它。
1. 为什么要用 GSP:序列模式挖掘到底解决了什么问题
1.1 从“购物篮”到“购物序列”:GSP 与关联规则的本质区别
关联规则大家很熟了,经典的“啤酒-尿布”故事。它的输入是一条条交易记录,每条记录内部是一组商品,算法去统计“啤酒”和“尿布”在多少条交易里共同出现。注意,一条交易内部是不分先后的,买了啤酒再买尿布,和买了尿布再买啤酒,在关联规则看来没有任何区别。
但很多现实业务问题天生就是分先后的。举几个例子:用户登录网站后,先看了首页推荐,然后进入详情页,最后提交了订单。这是个有时间顺序的行为序列。病人入院后,先是发热,然后是咳嗽,再是肺部影像异常。这也是一个时序序列。再比如,用户在搜索引擎上先输入 A 关键词,过了 5 分钟又输入 B 关键词,两个关键词在时间上是有先后顺序的。
GSP 全称是 Generalized Sequential Pattern,通用序列模式算法,它哥哥是 Apriori,父亲是同一个思想体系。GSP 解决的核心问题就是:给定一堆“带顺序的序列数据”,找出在所有序列中出现次数足够多的“子序列模式”。比如在上面那个用户行为例子里,如果能挖出“搜索某手机型号 → 点击首条搜索结果 → 加入购物车”这个模式在很多用户身上出现了足够多次,那这就是一个稳定的高频路径。
区别我再往深里说一层:关联规则里一个项集出现的“支持度”,是统计包含该项集的交易数;GSP 里一个序列的“支持度”,是统计包含该序列的数据序列数。前者是“哪些东西在篮子里共现”,后者是“哪些行为在时间轴上按顺序出现”。由于序列天然携带顺序信息,GSP 的搜索空间比 Apriori 大得多,所以它才需要专门设计候选生成和剪枝策略,而不是简单套用 Apriori 的项集合并逻辑。
1.2 核心概念速查:序列、项集、支持度这些名词到底在说什么
建个模型先,不然后面推演全是乱的。
一条“数据序列”(data sequence)就是一条完整的行为记录,通常对应一个用户、一个病人或其他主体在一段时间内的行为轨迹。每个数据序列由若干“事件”按时间排序组成。每个事件内部,又可以包含多个“项”。
比如某用户的行为序列:第一天搜索了“数码相机”,同一天又搜索了“存储卡”,第二天点击了某商品详情页。那这条序列可以记作:
<{数码相机, 存储卡} {商品详情页}>尖括号表示整个序列,里面每一对大括号表示一个时间点上的事件,同一个事件内部的多个项用逗号分隔。这里有个关键约定:同一事件内部的项,在逻辑上视为同时发生,排列不分先后,通常按字典序排好;不同事件之间,严格按时间先后排列。
“子序列”的定义也跟字符串子串不一样。序列s = <a (b c) d>的一个子序列,不需要连续,只需要按顺序“嵌”在里面就行。比如<a d>是它的子序列,<a c>也是,但<d a>不是。这个“允许跳步”的设计非常重要,后面手工推演你会看到。
“支持度”就是包含某个候选序列的数据序列条数。注意,一条数据序列即使里面出现多次同一模式,也只能计 1 次。这个细节我在实际实现时踩过坑,后面专门说。
再有一个容易混淆的概念是“频繁序列”和“序列模式”。频繁序列是指支持度不低于阈值的序列,而很多时候我们还要给这些序列附加一个“占比”概念,即它出现在多少比例的数据序列里。这个比例就是常说的“支持率”或“相对支持度”,阈值一般就叫 min_sup。GSP 算法最终输出的,就是所有支持度达到 min_sup 的频繁子序列。
1.3 GSP 算法的定位:基于 Apriori 思想的序列模式挖掘框架
GSP 的算法骨架和 Apriori 是同一个模子刻出来的:迭代搜索,逐层扩展。从长度为 1 的候选序列开始,扫描数据库数出支持度,留下频繁的;然后用频繁的生成下一轮长度为 2 的候选,再扫库计数,再留下频繁的……直到某轮没有新的频繁序列产生,算法终止。
这个过程每一步都建立在同一个核心性质上,也就是 Apriori 性质:如果一个序列是频繁的,那它的所有子序列也一定是频繁的。反过来说,如果一个序列存在某个子序列不频繁,那这个序列本身也不可能频繁。这个性质给了 GSP 一把锋利的剪刀,能在候选生成阶段把大量注定没出路的候选序列提前剪掉,否则序列搜索空间爆炸到根本没法算。
不过 GSP 不是 Apriori 的无脑照搬。最大的改动在于候选生成方式。Apriori 生成 k+1 项集时,只需要做简单的项集拼接,因为项集内部是无序的;而 GSP 生成 k+1 序列时,序列内部是有严格顺序的,同一个序列还可能有多个时间点、每个时间点多个项,怎么把两个 k 序列拼成一个 k+1 序列,还要保证不重不漏,就需要专门的连接规则,也就是“删除第一项/删除最后一项后剩余部分相同”的那个经典做法。这个我在下一节展开讲。
2. GSP 算法核心流程拆解:候选生成、计数、剪枝三步走
2.1 第一步:候选生成规则——L1 拼 L1,L2 拼 L2,分离点怎么找
候选生成是整个 GSP 里最容易懵、也最值得讲透的地方。我们分两种情况看。
先说从频繁 1-序列生成候选 2-序列。假设当前频繁 1-序列集合 L1 里有三个项:a、b、c。那候选 2-序列 C2 是怎么来的?就是两两组合,但要区分两种情况:两个项是“同一事件内”还是“不同事件”。
- 同一事件内:写成
<(a, b)>,表示两个项同时发生。 - 不同事件:写成
<(a) (b)>,表示 a 先发生,b 后发生。
所以a和b组合,会产生两个候选:<(a, b)>和<(a) (b)>。两个方向的顺序怎么处理?因为序列是有序的,<(a) (b)>和<(b) (a)>是两条不同候选,都需要生成。但同一事件内的项要按字典序排好,避免重复——<(a, b)>和<(b, a)>是同一个序列,只保留一个。这个区分是 GSP 最容易出 bug 的地方,我见过很多实现把同一事件内的组合丢了,导致频繁 2-序列少算一大截。
再说从 Lk 生成 Ck+1,这是 GSP 最核心的合并逻辑。规则描述起来挺绕:两个不同的频繁 k-序列,如果删除第一个序列的第一个元素后得到的序列,与删除第二个序列的最后一个元素后得到的序列完全相等,那就可以连接生成一个新的 k+1 序列。
“第一个元素”和“最后一个元素”怎么理解?这里要把“元素”界定清楚,要么是单个项,要么是整个事件。我直接用例子说明。
假设 L3 中有两个频繁 3-序列:
s1 = <{a} {b} {c}> s2 = <{a} {b} {d}>删除 s1 的第一个元素(即第一个事件里的 a),得到<{b} {c}>。删除 s2 的最后一个元素(即最后一个事件里的 d),得到<{a} {b}>。这两个剩余部分不相等,不能连接。
换个例子:
s1 = <{a} {b c}> s2 = <{a b} {c}>删除 s1 第一个元素 a,得到<{b c}>。删除 s2 的最后一个元素 c(它在第二个事件里),注意删完后第二个事件变成一个空事件,直接去掉,得到<{a b}>。两个剩余部分还是不等,不能连。
正确的例子:
s1 = <{a b} {c}> s2 = <{a} {b c}>删除 s1 的第一个元素,这里 s1 第一个事件是{a b},如果只删掉项 a,事件里还剩 b,那么剩余序列是<{b} {c}>。删除 s2 的最后一个元素,s2 最后一个事件是{b c},删掉项 c 后事件里还剩 b,剩余序列是<{a} {b}>。两个剩余部分不等,还是不能连。
关键来了,真正能连接的例子:
s1 = <{a} {b c}> s2 = <{b c} {d}>删除 s1 的第一个元素,结果是<{b c}>(第一个事件整个被移除)。删除 s2 的最后一个元素,结果也是<{b c}>(最后一个事件整个被移除)。剩余部分相同,可以连接。连接规则是:取 s1 的完整内容,再加上 s2 的最后一个元素作为新事件;或者写成取 s1 的第一个元素作为新事件,再加上 s2 的完整内容。由于刚才删掉的是第一个和最后一个,实际生成方式有两种变体:
- 新序列 = s1 后面追加 s2 的最后一个元素,比如
<{a} {b c} {d}>。 - 或者更一般地看:s1 删除第一个 + 追加 s2 最后元素。
用文字描述会把人绕晕,我这里直接给一个更容易记忆的说法:把 s1 和 s2 对齐到一个公共中部P,如果 s1 是(第一元素) + P,s2 是P + (最后元素),那么连接结果就是(第一元素) + P + (最后元素)。其中元素可以是“单个事件”也可以是“事件内部的单个项”,这引出了 GSP 里很著名的“分离点”概念。
分离点(split point)指的是两个要连接的序列,从哪个位置分开后,能让第一部分尾部等于第二部分头部。分离点在一个事件内部,还是在两个事件之间,决定了连接时最后一件事物的合并方式。我在代码实现里,没有去抽象地找分离点,而是用一个更工程化的方式:对于 Lk 中的每条序列,尝试与其他每条序列合并,判断“删除第一”和“删除最后”后的剩余序列是否一致,一致则按规则扩展。这样虽然时间复杂度高一些,但逻辑绝对正确,不会漏候选。
这里必须提醒一个常见误区:有人会把 Lk 里的序列两两连接时,只看“前半相等拼后半”这种简单拼接,忽略了同一事件内部拆分的情况,导致 Ck+1 有大量漏项。正确做法是,每次从 s1 中作为“要删除的第一个元素”的,可以是整个事件,也可以是事件内的某一个项。我建议初次实现时直接在候选生成函数里写两个分支:元素级连接和事件级连接,分别处理,然后做去重,宁可多生成几个候选,靠剪枝和计数把它们筛掉,也不要漏生成。
2.2 第二步:支持度计数——为什么 GSP 要逐条扫描数据库
候选生成完,Ck+1 里可能躺着成千上万条序列,下一步就是扫数据库,数一数每条候选序列被多少条数据序列包含。
计数逻辑本身不难:对每条数据序列d,判断候选序列s是不是d的子序列。如果是,给s的支持度 +1。注意关键约束:无论d里包含多少次s,每次计数只加 1。举个例子,数据序列<{a} {b} {a} {b}>包含子序列<{a} {b}>吗?包含,而且有两次(第一次 a 和第一次 b,第二次 a 和第二次 b 甚至可以交叉匹配)。但计数时只算 1。这就是“一次匹配只计一次”的规则。我最初实现时用了一个集合去重,后来发现没必要——直接一层循环做完匹配就返回即可,根本不会重复计数。
具体到子序列匹配,我用的是一种类似贪心的指针扫描法,也叫“滑动匹配”。对候选序列s里的每个事件、事件里的每个项,到数据序列d里从上次匹配位置之后继续找匹配。能全部找到,就说明s是d的子序列。
这个方法在某些特殊场景下会出问题。比如数据序列d = <{a} {a} {a}>,候选序列s = <{a} {a}>,贪心法会匹配到第一次出现的 a 和第二次出现的 a,判定成功。那如果这样不算成功呢?实际中,GSP 的匹配规则就是只要存在一种匹配方式就算包含,所以贪心法是可行的。但如果加入了时序约束,比如窗口大小和最小/最大间隔,问题就变复杂了。街面上很多 GSP 的描述默认不启用时间窗口约束,但在真实数据里,如果你不设置任何时间约束,结果会出现很多跨度过长的“弱相关”模式。比如用户年初搜索了相机,年末才购买,这条路径也能连起来,业务上很可能没有意义。
我实际使用时,对带时间戳的数据,会先把数据序列按时间分小窗口,把窗口内部的序列单独提出来计数,再把窗口合并。这个操作不是 GSP 的标准算法内容,但工程实用性很高。后面我会给一个简化处理办法。
2.3 第三步:剪枝优化——Apriori 性质如何帮你砍掉无用候选
扫描数据库计数是 GSP 性能的瓶颈,候选越多,扫描越慢。所以候选生成之后,必须执行剪枝,把那些“不可能频繁”的候选提前删掉,不进入计数阶段。
剪枝的依据就是 Apriori 性质:若候选 k+1 序列s是频繁的,则它的所有长度为 k 的子序列都必须是频繁的。因此,我只要检查候选序列的每个 k-子序列是否都在 Lk 里,只要有一个不在,这个候选就可以直接丢弃。
这步实现起来有讲究。一个 k+1 序列,要生成它的所有 k-子序列,有两种删除方式:删除一个事件,或者删除事件里的一个项。不能只删除整个事件,因为同一个事件内部多个项拆分后也能形成不同子序列。如果漏掉了“事件内部删一个项”的情况,剪枝会剪掉不该剪的候选,最后导致结果缺失。
举一个具体的例子。候选序列s = <{a b} {c}>,长度为 2。它的 1-子序列有哪些?删除第一个事件里的项 a,得到<{b} {c}>;删除第一个事件里的项 b,得到<{a} {c}>;删除整个第二个事件,得到<{a b}>。这三个都是它的 1-子序列,任何一个不在 L1 里,候选就被剪掉。同样的,生成 k-子序列时,要做两层循环:一层遍历位置,删除事件;一层在保留事件时遍历每个事件内部逐项删除。
我在代码里实现剪枝时,是先写了一个函数generate_all_subsequences(s, k),用组合方式生成所有删除操作,然后在判断sub_seq in Lk时,用一个frozenset哈希 Lk 集合,查询复杂度降到 O(1)。用 Python 里的set存序列元组,配合序列编码(见下文代码),性能还不错。
3. 手工推演 + 代码实现:从零跑通 GSP
3.1 手工推演:一个 5 条序列的小数据集完整跑一遍
纸上得来终觉浅,拿一个小数据集完整跑一遍 GSP,胜过读十遍原理。设最小支持度min_sup = 2,数据库如下:
| 数据序列编号 | 内容 |
|---|---|
| 1 | <{a, b} {c}> |
| 2 | <{a} {c} {d}> |
| 3 | <{b} {c} {e}> |
| 4 | <{a} {d} {c}> |
| 5 | <{a, b} {d}> |
第一步,生成所有 1-候选序列。这里的“候选 1-序列”其实就是数据库里出现过的所有项,以及它们可能组成的“单事件序列”和“单事件项集序列”。严格来说,一个单独的项可以出现在一个事件里,那 1-序列统一记作<{a}>、<{b}>这种形式。同行内两个项{a, b}不能算 1-序列,它们在候选 2-序列阶段才出现。
扫描数据库统计每一项出现在多少条序列里(注意按序列计数,不是按出现次数):
- a:序列 1、2、4、5,共 4 条。
- b:序列 1、3、5,共 3 条。
- c:序列 1、2、3、4,共 4 条。
- d:序列 2、4、5,共 3 条。
- e:序列 3,共 1 条,低于 min_sup=2,淘汰。
所以 L1 = {<{a}>,<{b}>,<{c}>,<{d}>}。
第二步,用 L1 生成候选 2-序列 C2。根据前面说的规则,每个项对要生成三种候选(严格来说同一事件内的项对按字典序去重后生成一个,不同事件的正反顺序各生成一个):
以 a、b 为例,生成:<(a, b)>、<(a) (b)>、<(b) (a)>。三个都要保留。同理处理其他项对,C2 一共会生成 4 个项自配对组合、12 个不同项对的双方向序列,加上同事件组合,总共约 28 条候选。这里为了展示核心逻辑,不逐个列出了。
逐一扫描数据库。以候选<(a) (c)>为例:数据序列 1<{a b} {c}>,包含 a 后包含 c,算 1 次;数据序列 2<{a} {c} {d}>,也算;数据序列 3 无 a,不算;数据序列 4<{a} {d} {c}>,a 之后有 d 再有 c,所以 a 之后能找到 c,算;数据序列 5 无 c,不算。总计支持度 3,通过。候选<(a, c)>(同一事件)只在数据序列里一个事件同时含 a 和 c 才满足,本例所有序列都没有同一事件同时含 a、c,支持度为 0,淘汰。
这一步手工跑完其实相当耗时,我在初学阶段用纸推过一次十项左右的数据库,两小时起步。强烈建议第一次学习时用笔推,能极大加深对候选生成和计数的理解。推完可以跟我下面的代码逻辑对照,检查有没有漏项。
第三步,从频繁 2-序列生成候选 3-序列并剪枝。假设 L2 里有<(a) (c)>和<(c) (d)>,前者删除第一个元素得到<(c)>,后者删除最后一个元素得到<(c)>,剩余部分相同,所以可以连接成<(a) (c) (d)>。生成之后做剪枝:它的所有 2-子序列分别是<(a) (c)>、<(a) (d)>、<(c) (d)>,检查它们是否都在 L2 里。如果<(a) (d)>不在,那么候选被剪掉。
继续循环直到某一轮 Lk为空,算法结束。最终输出的就是所有频繁序列。上面这个例子里,如果你完整跑完,会发现<(a) (c) (d)>支持度是 1(只有序列 2 同时出现 a、c、d 且顺序符合),达不到阈值,所以不会被输出。如果 min_sup 降到 1,它就会浮出水面。
3.2 基于 Python 的 GSP 实现与关键代码解读
手工推演和代码实现互相印证,是最稳的学习路径。我提供一个简化但功能完整的 Python 实现,保留了 GSP 的核心逻辑,去掉了复杂的时间窗口扩展,方便理解。
from itertools import combinations def generate_c1(data): """从数据库提取所有单项,构成候选1-序列""" items = set() for seq in data: for event in seq: for item in event: items.add(item) return [((item,),) for item in sorted(items)] def get_support(cand_seq, data, min_sup): """计算候选序列在数据库中的支持度,提前终止""" count = 0 for seq in data: if is_subsequence(cand_seq, seq): count += 1 if count >= min_sup: return count return count def is_subsequence(cand, seq): """判断候选序列是否为数据序列的子序列(贪心匹配)""" pos = 0 for event in cand: matched = False while pos < len(seq): if set(event).issubset(set(seq[pos])): matched = True pos += 1 break pos += 1 if not matched: return False return True def candidate_gen(lk, k): """从频繁k-序列生成候选(k+1)-序列,带剪枝""" cands = set() lk_list = list(lk) for s1 in lk_list: for s2 in lk_list: # 元素级连接:删除s1第一个事件的第一项,删除s2最后一个事件的一项 for i in range(len(s1[0])): # 移除s1第一个事件中的第i项 first_event1 = list(s1[0]) first_event1.pop(i) if first_event1: s1_removed = (tuple(first_event1),) + s1[1:] else: s1_removed = s1[1:] for j in range(len(s2[-1])): last_event2 = list(s2[-1]) last_event2.pop(j) if last_event2: s2_removed = s2[:-1] + (tuple(last_event2),) else: s2_removed = s2[:-1] if s1_removed == s2_removed: # 连接:s1 + s2的最后一个事件(保留最后一项) new_seq = s1 + (s2[-1],) # 事件级连接 cands.add(new_seq) # 元素级连接:将最后一项并入最后一个事件 if len(s2[-1]) > 1: last_item = s2[-1][-1] merged_event = tuple(sorted(s1[-1] + (last_item,))) new_seq2 = s1[:-1] + (merged_event,) cands.add(new_seq2) # 剪枝:检查所有k-子序列是否在lk中 pruned = set() for cand in cands: if all(subseq in lk for subseq in generate_all_k_subseq(cand, k)): pruned.add(cand) return pruned def generate_all_k_subseq(seq, k): """生成一个序列的所有长度为k的子序列(用于剪枝检查)""" result = set() # 逐个删除事件 for i in range(len(seq)): new_seq = seq[:i] + seq[i+1:] if sum(len(e) for e in new_seq) == k: result.add(new_seq) # 逐个删除事件内项 for i in range(len(seq)): for j in range(len(seq[i])): new_event = list(seq[i]) new_event.pop(j) new_events = list(seq) if new_event: new_events[i] = tuple(new_event) else: new_events.pop(i) new_seq = tuple(new_events) if sum(len(e) for e in new_seq) == k: result.add(new_seq) return result def gsp(data, min_sup): """GSP主流程""" lk = set(generate_c1(data)) all_frequent = [] k = 1 while lk: frequent = [] for cand in lk: sup = get_support(cand, data, min_sup) if sup >= min_sup: frequent.append((cand, sup)) frequent = sorted(frequent, key=lambda x: (len(x[0]), x[0])) all_frequent.extend(frequent) lk = set(cand for cand, _ in frequent) k += 1 lk = candidate_gen(lk, k-1) return all_frequent我来说几个代码里的关键决策。
is_subsequence实现的是贪心匹配,按候选序列的事件顺序,依次在数据序列中找“能包含该事件所有项”的事件,找到后继续找下一个。这个实现有一个局限:它把候选的一个事件,匹配到数据序列的一个事件,要求数据序列事件必须完整包含候选事件的所有项。这是 GSP 里最常见的匹配语义,如果想支持更宽松的跨事件匹配(如候选事件中的项可以分散在数据序列的多个相邻事件里),匹配逻辑需要重写,但对大多数业务场景,现有版本够用。
generate_all_k_subseq要兼顾“删整个事件”和“删事件内的项”两种情况,否则剪枝会误伤。注意,当一个事件删到为空时,要把整个事件从序列中移除,否则会产生一个“空事件”,导致子序列计数不准。这里我做了处理。
candidate_gen里同时跑了事件级连接和元素级连接。事件级连接是把 s2 的最后一个事件原样追加到 s1 后面;元素级连接是把 s2 最后一个事件“拆散”合并到 s1 的最后一个事件里。这两种连接正好对应了分离点在事件之间和事件内部两种情况。生成完成后统一剪枝,剪枝通过就进下一轮。这个实现会多生成一些重复候选,靠最后set去重兜底,工程上没问题,因为候选集再大也比扫描数据库便宜。
3.3 关键参数选择:最小支持度怎么定
min_sup 是 GSP 唯一直接控制输出规模的参数,选多少直接决定结果质量。
我实际项目里一般这么试探:先跑一个较高的阈值,比如 0.2 相对支持度,看输出模式数量和业务可解释性。然后逐步降低,每次观察频繁序列数量增长曲线。如果从 0.1 降到 0.05,频繁序列数量从几百暴涨到几万,说明你可能已经挖到大量低质量、弱相关的关联,业务上往往不可用,需要回调。
选择 min_sup 还要结合数据特点。点击流数据、搜索日志这类高频行为数据,序列数量大、重复性高,可以把阈值设高一点;而医疗病历、故障工单这类低频数据,序列条数少、行为差异大,阈值要放得很低,甚至设到 1 或 2 条绝对支持度。
有一个经验值得记住:min_sup 的高低不是算法自己能决定的,它本质上是业务风险偏好。阈值高,结果少而精,但可能漏掉长尾中真正有价值的稀有模式;阈值低,结果多而杂,需要投入大量人力筛选。第一次跑的时候,宁可先设高一点,把核心稳定模式找出来,再逐步放宽挖掘长尾,这样更容易说服业务方接受结果。
4. 常见问题与排错实录
4.1 候选生成常见的几个坑
第一个坑:连接条件判断错了对象。很多人会把“删除第一个元素和删除最后一个元素”误解成“删除第一个事件和删除最后一个事件”,导致事件内有多个项时,候选生成不完整。我建议把“元素”拆成两个层级来理解:一个是事件级,一个是项级。连接时先对每条序列生成所有可能的“删除第一个元素后的版本”,以及所有可能的“删除最后一个元素后的版本”,用集合比对。两个集合有交集,就说明存在可连接的分离点,然后把对应的连接结果加进去。
第二个坑:连接结果的末尾处理。两个序列通过公共部分 P 连接,得到第一元素 + P + 最后元素,但“最后元素”到底是一个独立事件,还是合并到倒数第二个事件里,取决于分离点的位置。比如 s1 的最后一个事件有多项,而 s2 的第一部分末尾是一个事件内的子集,这时候连接结果有可能把最后一项跟前面合并成同一事件。我在代码里同时生成两种连接结果,让剪枝和计数去筛,从结果正确性来说是安全的。
第三个坑:重复候选。用两两连接的方式生成候选,必然产生大量重复,比如通过 s1+s2 和通过 s2+s1 可能生成同一个序列(顺序相同时)。去重必须做,而且去重要在剪枝之前做,否则剪枝时同一个候选查好几遍,浪费时间。Python 里直接用set装冻结元组即可,注意别用 list 存候选,否则无法哈希。
4.2 支持度计数容易出错的细节
计数阶段最容易错的是把“包含子序列”误判成“包含子串”。序列<{a} {b}>在数据序列<{a} {c} {b}>里是子序列,因为 a 在 c 之前、b 在 c 之后,顺序满足。但很多人第一直觉会认为 a 和 b 必须相邻,导致支持度被低估。GSP 的匹配语义就是允许中间插入任何无关事件,这一点在看结果时也要提醒业务方:模式<{a} {b}>不代表 a 和 b 紧挨着发生,只代表 a 在 b 之前发生过。
另一个高频错误是“重复出现只计一次”。我见过一个数据序列里模式出现了三次,实现者给支持度上了三,结果整条频繁序列的排名全乱了。排查方式很简单:一条数据序列在is_subsequence里返回 True 后就应该跳出循环,不再继续匹配下一个候选出现位置。
还有一类问题出在空事件处理上。如果候选序列里有一个事件是空的(比如()),is_subsequence里会直接匹配成功。在剪枝和连接过程中,空事件是中间产物,但如果没清理干净就进入计数阶段,会出现大量“幽灵支持度”。我的建议是所有候选序列在生成和剪枝阶段结束时,统一做一次“移除空事件”的清理。
4.3 数据格式设计与性能优化
GSP 的数据格式设计很影响实现难度。我在代码里用的是 Python 元组嵌套元组,比如((a,b),(c,))表示第一事件包含 a、b,第二事件包含 c。这个格式直接对应算法的数据模型,调试时也容易打印。但真实业务数据通常是从日志表里查出来的,一般要先做两步清洗:一是把事件按时间排序,同一秒或同一分钟内的项归并成一个事件;二是把用户 ID、操作类型、商品 ID 转成整数编码,加快匹配速度。
性能方面,GSP 的主要瓶颈在扫描数据库和子序列匹配。我实测过几轮,当数据序列超过几万条、候选序列几千条时,纯 Python 逐条匹配会慢得让人抓狂。有几个优化策略非常有效:第一,把候选序列按第一个事件的首项建立索引,扫描时先筛掉完全不可能匹配的序列,减少is_subsequence调用次数;第二,用“垂直数据格式”把数据列成项到序列 ID 的倒排表,计数时只扫描包含候选首项的数据序列;第三,对超长数据序列做切段,把间隔过远的部分拆成两个独立序列,既降低匹配长度,也过滤掉无意义的长跨度匹配。
我自己的一个项目里,数据约两万条序列、每条约十个事件,min_sup 设成 30,GSP 跑一轮要十几秒。加了倒排索引和首项过滤后,压到两秒左右。如果数据到百万级,纯 Python 基本不可行,需要换 C++/Java 实现或使用现成库,但对理解和调参来说,Python 版本完全够用。
5. 应用场景与实践建议
5.1 哪些场景真正需要 GSP
GSP 最适合的不是“发现共现”,而是“发现行为路径”。我实际接触过的几类典型场景:
电商和内容平台的用户路径分析。业务想看的不是“啤酒和尿布一起买”,而是“用户先看了某个商品页,第二天又看了同类商品页,最终在第四天完成购买”这个完整路径。把用户的每一次行为按天(或小时)切成事件,每天内可以有多个项,喂给 GSP,能挖出一批高频转化路径或流失路径。这个信息比关联规则直观得多,因为结果带着时间顺序,业务方可以直接照着模式设计运营策略。
运维故障工单分析。机房故障工单通常有先后步骤:告警出现、人工介入、尝试 A 操作、尝试 B 操作、最终恢复。用 GSP 去挖高频恢复路径,可以总结出常见故障的标准处置流程。这里有个特殊性:工单数据量不大,但每条序列非常长,时间跨度也很长,min_sup 要设得很低,甚至要按绝对条数设。
医疗路径挖掘。病人从就诊、化验、确诊直到治疗方案,是一串带时间的行为序列。挖掘高频诊疗路径,对优化临床路径管理有参考价值。这类数据隐私要求极高,GSP 跑出来的模式需要经过严格医学解释,更多是辅助发现而不是自动决策。
其他还有 Web 访问日志挖掘、金融反洗钱路径分析、用户在 App 内的操作序列规律发现,都是经典适用场景。共同点是:数据必须具备明确的主体(用户/患者/工单)、时间戳、行为类型三个要素,缺一个 GSP 效果都会大打折扣。
5.2 与其它算法对比与选型建议
GSP 之外,序列挖掘领域还有 PrefixSpan、SPADE、SPAM 等算法。简单说一下我的选型经验。
GSP 和 PrefixSpan 是最常被拿来对比的。GSP 是类 Apriori 的宽度优先搜索,要先生成大量候选,再做反复扫描;PrefixSpan 用前缀投影的方式,把每个频繁前缀对应的后缀投影出来,不需要生成候选,在长序列密集数据上性能通常更好。我自己的体会是,序列平均长度在 10 以下、事件内项数不多时,GSP 实现简单、结果直观,完全够用。序列很长(比如每个用户几千个行为点)时,GSP 的候选量和扫描代价会膨胀得很厉害,PrefixSpan 会舒服很多。
SPADE 走的是垂直数据格式路线,对支持度计算的优化很到位,但实现复杂度高,适合数据量大且有工程团队支持的项目。SPAM 则是专门为 Web 点击流这类非常长的序列设计的一种深度优先算法,内存占用小但在长度短的数据上优势不明显。
做选型时还有一个重要考量:数据里有没有严格的时间间隔约束。如果业务上要求模式里的两个事件必须在短时间内紧邻(比如 10 分钟内),GSP 可以通过时间窗口扩展来处理,但实现复杂度会直线上升。如果数据天然已经按天/按小时聚成事件,直接用 GSP 默认的时间粒度即可,这时它的简洁性反而是优点。
最后说一个我在项目里反复遇到的“算法之外”的问题:GSP 挖出来的模式,怎么跟业务解释。很多业务方第一次看到<{a} {b} {c}>这类符号,会下意识把它当成严格连续发生,然后质疑“用户不可能 3 分钟就完成这一串操作”。这里其实是因为算法默认“可跳过中间项”匹配,业务方用“连续动作”的语义去理解,产生偏差。我的建议是输出模式时,附带上两条信息:每个事件之间的时间间隔分布(中位数、四分位数),以及模式在全部支持序列中的完整覆盖率。有了这些统计量,业务方才能真正判断模式是否有运营价值。GSP 只是帮你把候选模式捞出来,最终决策永远要结合业务上下文。
我个人跑了这么多轮 GSP 后的最大体会是:序列模式挖掘的难点从来不在算法本身,而在于数据序列化的设计和结果语义的解释。把用户行为切成一个个“事件”,哪些行为合并成一个事件、哪些必须分开,直接决定算法输出的模式长什么样。同一个业务问题,换一种序列化方式,结果可能天差地别。所以如果你打算在自己的项目里用 GSP,我建议第一步别急着写代码,先花时间设计好“数据序列”的定义——每个主体怎么标识、每个事件怎么聚合、时间粒度选多大。这一步做得越扎实,后面 GSP 跑出来的模式就越有业务价值。