## 1. 从购物篮到时间线:为什么我们需要序列模式挖掘?
如果你用过电商平台,肯定对“买了这个商品的人还买了……”这类推荐不陌生。这背后是经典的关联规则挖掘,比如Apriori算法,它关注的是**同一笔交易**里商品之间的共现关系。但现实世界中的行为往往是有**先后顺序**的。想想看:
- 一个用户本周买了手机,下周买了手机壳,下个月买了耳机。这三者之间存在一个清晰的时间序列。
- 一个病人先出现症状A,接着检查出指标B,最后被确诊为疾病C。这个诊断过程就是一个序列。
- 一个软件用户先点击了功能菜单,再打开了设置页,最后提交了反馈。这构成了一个用户行为序列。
**序列模式挖掘**要解决的,就是在这种带有时间戳的数据中,找出那些频繁出现的、有顺序的事件模式。它回答的不再是“什么商品总被一起买?”,而是“用户在买了手机之后,**接下来**通常会买什么?”或者“疾病C在发病前,通常会经历怎样的前兆序列?”
GSP算法,全称**广义序列模式算法**,就是解决这类问题的经典武器。它由Agrawal和Srikant在1996年提出,可以看作是序列挖掘领域的“Apriori”。今天,我就带你从零开始,彻底搞懂GSP的原理,并用Python手把手实现一个完整的挖掘流程。你会发现,它的核心思想非常直观,但实现起来有不少细节需要注意,这也是很多教程语焉不详的地方。咱们不玩虚的,直接上代码和案例。
## 2. GSP算法核心思想:像搭积木一样寻找模式
GSP算法的整体流程,可以用一个词概括:**逐层搜索,迭代生长**。它的思路和Apriori一脉相承,都利用了“频繁模式的所有子集也必须是频繁的”这一先验性质来进行剪枝,从而大幅减少需要考察的候选序列数量。
### 2.1 核心概念与定义
在深入算法前,我们得统一语言。假设我们分析的是一个网上书店的顾客购买序列数据。
- **项目**:最小的单位,比如具体的书。我们用单个字母表示,如 `a` 代表《三体》,`b` 代表《活着》。
- **项集**:在同一个时间点(或同一笔订单)中发生的一组项目。用括号表示,例如 `(a, b)` 表示同一笔订单中同时购买了《三体》和《活着》。项集内的项目是无序的。
- **序列**:一个有序的项集列表,表示随着时间发生的一系列事件。例如,序列 `<(a, b), c>` 表示:顾客先在一次购买中同时买了a和b,然后在之后的某次购买中单独买了c。
- **子序列**:如果序列A的所有项集都能在序列B中找到,并且保持相同的顺序,那么A就是B的子序列。例如 `<a, c>` 是 `<(a, b), c, d>` 的子序列。
- **支持度**:一个序列在**整个序列数据库**中出现的频率。如果一个序列在某个顾客的购买记录里出现了一次或多次,我们都只算它支持这个顾客一次。支持度计数就是支持该序列的顾客总数。
- **最小支持度**:我们设定的阈值。只有支持度不低于这个值的序列,才被认为是“频繁的”,值得我们关注。
### 2.2 算法步骤详解
GSP算法是一个层次化的过程,我把它分解为以下几个可操作的步骤:
**第一步:扫描数据库,找出所有频繁的“1-序列”**
所谓“1-序列”,就是长度为1的序列,它可能是一个单项目项集(如 `<a>`),也可能是一个包含多个项目的项集(如 `<(a,b)>`,但这在第一轮我们通常只考虑单项目)。我们扫描所有顾客的序列,统计每个单项目的出现次数(以顾客为单位计数),然后保留那些支持度≥最小支持度的项目。这些就构成了第一层频繁模式集合 **L1**。
**第二步:连接与剪枝,生成下一层候选集**
这是算法的核心循环。假设我们已经有了第k-1层的频繁序列集合 **L(k-1)**,如何生成第k层的候选序列集合 **Ck** 呢?
1. **连接**:将L(k-1)中的序列两两连接。连接规则是:如果序列A去掉第一个项目后,与序列B去掉最后一个项目后完全相同,那么它们可以连接。连接时,将B的最后一个项目(或项集)按原样添加到A的末尾。
* 例如,`<a, b>` 和 `<b, c>`,去掉A的首项`a`后是`<b>`,去掉B的尾项`c`后是`<b>`,相同,可以连接为 `<a, b, c>`。
* 再如,`<a, b>` 和 `<b, (c,d)>`,连接后可能是 `<a, b, (c,d)>`(如果B的最后是一个项集)。
2. **剪枝**:对于连接产生的每一个候选k-序列,检查它的所有长度为(k-1)的子序列是否都在L(k-1)中。只要有一个不在,这个候选序列就不可能是频繁的(根据先验性质),直接删除。这一步能极大减少后续计算量。
**第三步:扫描数据库,计算候选集支持度**
对于剪枝后剩下的候选序列集合Ck,我们需要再次扫描整个序列数据库,计算每个候选序列的实际支持度。这里有个关键点:判断一个序列S是否被某个顾客的序列D所包含,需要进行**子序列匹配**,这比判断集合包含要复杂一些。
**第四步:筛选频繁序列**
将支持度≥最小支持度的候选序列保留下来,形成第k层的频繁序列集合 **Lk**。
**第五步:循环**
将Lk作为新的起点,重复第二步到第四步,直到无法产生新的频繁序列为止。最终,所有层的频繁序列集合的并集,就是我们挖掘出的全部频繁序列模式。
### 2.3 生活化类比:拼乐高
你可以把整个算法想象成用乐高积木搭建越来越长的模型。
1. **找基础块(L1)**:先把所有最常用、数量足够的单块积木找出来。
2. **拼装与筛选(生成Ck)**:用这些基础块两两拼接,看看能拼出哪些2块组合。但并不是所有能物理拼接的组合都有意义,我们只保留那些“两个半截都常用”的组合(剪枝)。
3. **实地检验(计算支持度)**:拿着这些2块组合的图纸,去所有小朋友的成品里找,看哪些组合真的经常被一起用到。
4. **迭代(生成Lk)**:把经过检验的常用2块组合作为新的“基础块”,继续拼接3块组合,如此反复。
这个过程确保了效率,我们不会去考察那些根本不可能流行的复杂组合。
## 3. 手把手Python实现:从数据到模式
理论说得再多,不如一行代码。我们用一个简单的例子来贯穿整个实现过程。假设我们有5位顾客的购书序列数据,为了方便,我们用字母代表书籍。
### 3.1 数据准备与预处理
首先,我们定义数据。这里我直接用列表的列表来表示序列数据库,这样更直观。
```python
# 定义序列数据库
# 每个内部列表代表一个顾客的序列,列表中的元素代表项集,用字符串或元组表示。
# 例如:顾客1的序列是:先买a,然后同时买c和d,最后买b
seq_db = [
[['a'], ['c', 'd'], ['b']], # 顾客1: <a, (c,d), b>
[['a', 'b'], ['c'], ['d']], # 顾客2: <(a,b), c, d>
[['a'], ['c'], ['b'], ['c', 'd']], # 顾客3: <a, c, b, (c,d)>
[['a'], ['b'], ['c']], # 顾客4: <a, b, c>
[['a', 'b', 'c']] # 顾客5: <(a,b,c)>
]
# 设置最小支持度阈值(这里设为2,表示至少2个顾客包含该模式)
min_support = 2
```
### 3.2 第一步:生成频繁1-序列
我们需要一个函数来统计每个单项目出现在多少个**序列**中(注意是序列,不是项集)。
```python
def generate_frequent_1_sequences(seq_db, min_support):
"""
生成频繁1-序列
:param seq_db: 序列数据库
:param min_support: 最小支持度
:return: 频繁1-序列列表,每个序列表示为[['item']]的形式
"""
item_count = {}
for seq in seq_db:
# 为了以序列为单位计数,我们需要记录当前序列中已出现的项目,避免重复计数
appeared_in_this_seq = set()
for itemset in seq:
for item in itemset:
if item not in appeared_in_this_seq:
item_count[item] = item_count.get(item, 0) + 1
appeared_in_this_seq.add(item)
# 筛选出支持度>=min_support的项目
frequent_1_seqs = []
for item, count in item_count.items():
if count >= min_support:
# 将单个项目表示为标准序列格式
frequent_1_seqs.append([[item]])
return frequent_1_seqs
# 测试
L1 = generate_frequent_1_sequences(seq_db, min_support)
print("频繁1-序列 L1:")
for seq in L1:
print(seq)
# 输出: [['a']], [['b']], [['c']], [['d']] (假设都满足支持度2)
```
### 3.3 关键工具函数:序列连接与剪枝
这是GSP算法的引擎部分。我们需要实现连接操作和基于子序列检查的剪枝操作。
```python
def is_subsequence(s, t):
"""
判断序列s是否是序列t的子序列。
:param s: 候选子序列,格式如 [['a'], ['b']]
:param t: 目标序列,格式如 [['a'], ['c','d'], ['b']]
:return: True/False
"""
if not s:
return True
i, j = 0, 0
while i < len(s) and j < len(t):
# 检查s的第i个项集是否是t的第j个项集的子集
if set(s[i]).issubset(set(t[j])):
i += 1
j += 1
return i == len(s)
def count_support(candidate, seq_db):
"""
计算一个候选序列在数据库中的支持度(有多少个序列包含它)
"""
count = 0
for seq in seq_db:
if is_subsequence(candidate, seq):
count += 1
return count
def generate_candidates(prev_frequent_seqs, k):
"""
通过连接prev_frequent_seqs(L_{k-1})生成候选k-序列 C_k,并进行剪枝。
这里实现一个简化版的连接:仅考虑序列中每个项集都是单项目的情况,便于理解。
更通用的连接逻辑涉及判断项集合并,会复杂很多。
:param prev_frequent_seqs: 上一层的频繁序列列表
:param k: 当前要生成的序列长度
:return: 候选k-序列列表
"""
candidates = []
n = len(prev_frequent_seqs)
# 步骤1: 连接
for i in range(n):
for j in range(n):
seq1 = prev_frequent_seqs[i]
seq2 = prev_frequent_seqs[j]
# 简化连接条件:seq1去掉第一个项目后 == seq2去掉最后一个项目后
# 注意:这里我们假设序列是单项目列表,如 [['a'], ['b']]
if seq1[1:] == seq2[:-1]:
new_seq = seq1 + [seq2[-1]]
candidates.append(new_seq)
# 步骤2: 剪枝 (检查所有 (k-1)-子序列是否频繁)
pruned_candidates = []
for cand in candidates:
is_valid = True
# 生成cand的所有 (k-1)-子序列
# 方法是依次删除cand中的一个项集
for idx in range(len(cand)):
subseq = cand[:idx] + cand[idx+1:]
# 检查这个子序列是否在prev_frequent_seqs中
# 由于prev_frequent_seqs是列表,我们需要比较内容
found = False
for freq_seq in prev_frequent_seqs:
if freq_seq == subseq:
found = True
break
if not found:
is_valid = False
break
if is_valid:
pruned_candidates.append(cand)
return pruned_candidates
```
### 3.4 主循环:迭代挖掘所有频繁序列
现在,我们可以将上述模块组合起来,实现完整的GSP算法主函数。
```python
def gsp_algorithm(seq_db, min_support):
"""
GSP算法主函数
:return: 所有层的频繁序列列表
"""
all_frequent_seqs = []
# 步骤1: 生成L1
Lk = generate_frequent_1_sequences(seq_db, min_support)
all_frequent_seqs.extend(Lk)
k = 2
# 步骤2-5: 迭代生成更长的频繁序列
while Lk: # 当还能找到频繁序列时继续
# 生成候选Ck
Ck = generate_candidates(Lk, k)
# 计算支持度并筛选出Lk
Lk_next = []
for candidate in Ck:
supp = count_support(candidate, seq_db)
if supp >= min_support:
Lk_next.append(candidate)
# 保存结果
if Lk_next:
all_frequent_seqs.extend(Lk_next)
Lk = Lk_next
k += 1
else:
break
return all_frequent_seqs
# 运行算法
frequent_patterns = gsp_algorithm(seq_db, min_support)
print("\n挖掘出的所有频繁序列模式:")
for i, pattern in enumerate(frequent_patterns):
# 美化输出格式
formatted = []
for itemset in pattern:
if len(itemset) == 1:
formatted.append(itemset[0])
else:
formatted.append('(' + ','.join(itemset) + ')')
print(f"{i+1}. <{', '.join(formatted)}>")
```
运行这段代码,你就能得到类似这样的输出(具体结果取决于你的数据和支持度):
```
频繁1-序列 L1:
[['a']]
[['b']]
[['c']]
[['d']]
挖掘出的所有频繁序列模式:
1. <a>
2. <b>
3. <c>
4. <d>
5. <a, b>
6. <a, c>
7. <b, c>
8. <a, b, c>
...
```
这表示,例如,序列 `<a, b, c>`(即先买a,然后买b,最后买c)在至少2位顾客的购买行为中出现过,是一个有趣的模式。
### 3.5 处理更复杂的情况:项集与时间约束
我们上面的实现是一个**简化版**,它做了两个重要假设:
1. 序列中的每个项集只包含一个项目(单项目)。这简化了连接和子序列判断的逻辑。
2. 没有考虑时间约束。在现实中,我们可能只关心在一定时间窗口内(例如,30天内)发生的序列模式。
一个**工业强度**的GSP实现需要处理多项目项集。连接操作会变得更复杂:当连接两个序列时,最后一个项目可能被合并到前一个序列的最后一个项集中,也可能作为一个新的项集添加。子序列判断的逻辑也需要相应调整,变成“项集的子集”判断。
此外,加入**最小间隔**和**最大间隔**约束后,支持度计算会变得更具挑战性。我们不仅需要判断一个序列是否是另一个序列的子序列,还需要检查这个子序列中连续事件的时间差是否在指定的区间内。这通常需要通过深度优先搜索在时间戳列表中寻找一条满足所有约束的路径来实现,计算复杂度会显著增加。
## 4. 实战建议与避坑指南
根据我多年的经验,在真正应用GSP算法时,有几个点需要特别注意:
**第一,数据预处理是关键。** 序列数据的质量直接决定挖掘结果。你需要仔细定义什么是“一个序列”(是一个用户的全部历史?还是一个会话?),以及如何划分时间窗口。对项目进行适当的归类(例如,将“iPhone 13”和“iPhone 14”都归类为“高端手机”)也能有效减少噪声,提升模式的可解释性。
**第二,最小支持度的选择是一门艺术。** 设得太高,可能什么都挖不出来;设得太低,会产生海量毫无意义的模式,并且严重拖慢算法速度。通常需要结合业务经验和多次实验来确定。可以先从一个较高的值开始,逐步调低,观察模式数量的变化曲线,在拐点附近选取一个值。
**第三,理解算法的局限性。** GSP作为Apriori类算法,需要多次扫描数据库,当序列很长或项目很多时,候选集的数量可能会爆炸式增长(尽管有剪枝),导致性能瓶颈。对于大规模数据,可以考虑更高效的算法如**PrefixSpan**(基于投影数据库),它采用了分治策略,通常性能优于GSP。
**第四,结果的解读比挖掘更重要。** 挖出一堆频繁序列后,你需要结合业务知识进行筛选和解读。一个支持度很高的序列 `<登录, 退出>` 可能毫无意义。而一个支持度不高但置信度(如 `P(购买保险 | 浏览汽车详情页)`)极高的序列,可能蕴含巨大的商业价值。通常需要将支持度、置信度、提升度等指标结合来看。
最后,如果你在实现过程中遇到了性能问题,一个很实用的优化点是**使用更高效的数据结构来存储和查询频繁序列**,比如使用字典树或哈希表来加速子序列存在性检查(剪枝步骤)。对于超大规模数据,则需要考虑分布式计算框架(如Spark MLlib)中实现的序列挖掘算法。