FP-Growth关联规则挖掘MATLAB源码解析:建树、挖掘与规则生成

发布时间:2026/10/27 21:53:21
FP-Growth关联规则挖掘MATLAB源码解析:建树、挖掘与规则生成

简介:面向希望用 MATLAB 上手关联规则挖掘的学习者与数据分析初学者,这套内容以 FP-Growth 频繁项集发现算法的可运行实现为核心,并配有演示数据集,可用于理解 FP 树构建、条件模式基生成、递归挖掘与规则提取的完整流程。压缩包共 8 个文件,以 7 个 .m 脚本和 1 个 .mat 数据文件为主,整体约 5KB,脚本分工覆盖算法主体、子集与规则展示、树结构可视化等环节,便于按模块阅读和调试。已有 191 人学习,适合作为课程实验、市场篮子分析等场景的入门样例。读者可据此调整事务数据格式与最小支持度阈值,观察不同参数下的频繁项集和关联规则输出,并在此基础上扩展更复杂的数据挖掘任务。对希望从零理解算法细节、快速搭建实验样例的读者较为友好。

1. 用 FP-Growth 做关联规则挖掘,先解决的是“等不起”的问题

跑过 Apriori 的人对那种等待应该有印象:候选集一轮轮生成,内存曲线一路往上顶,最后发现绝大多数候选从来没进过结果集。这份 FP-Growth 关联规则挖掘的 MATLAB 源码包走的是另一条路——先把事务集压成一棵 FP-Tree,再在树上递归挖条件模式基,全程不产生候选集。它适合手里有一批交易流水、操作日志或者问卷多选题数据,想尽快拿到“哪些项经常一起出现”“出现 A 的时候 B 的概率有多高”的人;也适合正在学数据挖掘、需要一份能断点单步、能打印中间树的 MATLAB 实现来对照讲义的人。整个包是纯 .m 脚本加示例数据,不依赖额外工具箱,把路径设好、阈值填上就能出结果。

2. 解压之后先看什么:目录分层与一次挖掘的完整数据流

拿到一个压缩包,最怕的是双击一堆 .m 文件不知道从哪个开始跑。这类算法包的组织方式其实高度一致:一个入口脚本负责参数和数据,一组函数各管一段算法流程,剩下的是打印和格式转换的杂活。先把这四层分清楚,后面读代码就不会在函数之间来回跳。我一般习惯先 grep 一遍function,把所有函数签名列出来,函数之间的调用关系基本就浮出水面了,比从头读 main 快得多。

2.1 四层文件结构:入口层、算法层、工具层、数据层

解压后大致会看到这样几类文件,名字可能略有出入,但职责划分基本跑不出这个范围:

层级典型文件职责改动风险
入口层main.m、demo.m读数据、设阈值、串起整条流程、打印结果低,随便改
算法层fp_growth.m、build_fp_tree.m、mine_tree.m、gen_rules.m建树、递归挖掘、规则生成高,改错就挖不出东西
工具层load_data.m、print_rules.m、itemset2str.m数据读取、结果格式化、字符串拼接中,主要影响输入输出格式
数据层data/下的 csv 或 txt示例事务集,一行一条事务低,换成自己的就行

入口层是唯一需要你手动执行的文件,它的内容通常就是“读文件 → 定阈值 → 调算法 → 打结果”这四步,短得一眼能看完。算法层是整个包的核心,也是后面两章要逐段拆的部分。工具层最容易被忽略,但数据格式不对、结果打印成一坨的时候,问题八成出在这里。数据层那份示例数据建议先别删,它是你验证环境是否正常的基准——自己的数据跑不出结果时,换回示例数据再跑一遍,就能快速判断是环境问题还是数据问题。

判断一个包值不值得读,我有个简单标准:入口文件能不能在 30 行内看完并且看懂调用顺序。能,说明作者分层是清醒的;不能,多半是把算法逻辑写进 main 里了,那种代码读起来会比较费劲。

2.2 从 CSV 到 rules 结构体:一次挖掘的完整数据流

把整条链路摊开看,一次完整的关联规则挖掘会经过这么几个阶段:

  1. 读取原始数据:load_data把 csv 或 txt 读成 cell 数组,每一行是一条事务,每个元素是一个项的名字(字符串)。
  2. 事务内去重:同一条事务里重复出现的项只保留一次,否则支持度计数会偏高,这是最容易翻车的地方之一。
  3. 第一遍扫描统计频次:遍历所有事务,得到每个项的出现次数,按最小支持度计数过滤掉低频项,剩下的按频次降序排好,形成头表。
  4. 第二遍扫描建树:每条事务按头表顺序重排、丢掉非频繁项,然后逐条插入 FP-Tree。
  5. 递归挖掘频繁项集:从频次最低的项开始,构造条件模式基、建条件 FP-Tree、递归下去,收集所有频繁项集及其支持度计数。
  6. 生成关联规则:对每个项数 ≥2 的频繁项集枚举非空真子集,算出置信度和提升度,按阈值过滤。
  7. 格式化输出:把 rules 结构体打印成表格或写回文件。

这条链路上,第 3 步和第 4 步的顺序依赖很紧:头表的排序结果直接决定树的形状,树形又影响第 5 步的递归深度和内存占用。很多“跑得特别慢”的情况,不是算法写错了,是排序方向选反了——按频次升序建树会让低频项靠近根,树的分支数暴涨,内存直接吃满。

中间结果建议留一个开关,比如在mine_tree里加个verbose参数,每挖完一层就打印一次当前前缀和项集数量。关联规则挖掘的结果是个黑匣子,中间不打印,最后结果为空你根本不知道是阈值太高还是树建错了。

2.3 跑起来之前的三件事:路径、数据格式、阈值初值

环境上没什么坑,MATLAB 基础版本就能跑,不需要 Statistics and Machine Learning Toolbox,也不依赖任何 MEX 编译。真正需要注意的是三件事。

第一是当前工作目录。包里如果有data/这样的相对路径,必须cd到解压后的根目录再执行,否则读文件直接报“找不到文件”。

第二是数据格式。常见做法是一行一条事务、逗号或制表符分隔,读进来是 cell 数组。如果你的数据是“订单号,商品名”这种长表,得先自己做一次 groupby 聚合成一行一条事务,不能直接喂进去。

% main.m 的典型骨架,按这个结构对号入座即可 clc; clear; close all; dataFile = fullfile('data', 'transactions.csv'); % 相对路径,必须在根目录下执行 minSup = 0.02; % 最小支持度,写成比例,内部会乘以事务总数 minConf = 0.5; % 最小置信度,0~1 之间 maxLen = 4; % 规则前件+后件的最大项数,防止子集枚举爆炸 transactions = load_data(dataFile); % 返回 1×N cell,每格是一条事务 nTrans = numel(transactions); % 比例转计数,向上取整,避免 0.02*99=1.98 被 floor 成 1 minSupCount = ceil(minSup * nTrans); [freqSets, tree, header] = fp_growth(transactions, minSupCount, maxLen); rules = gen_rules(freqSets, minConf, nTrans); print_rules(rules, 20); % 只打印提升度最高的前 20 条

minSup写成比例还是计数,取决于包里main.m的约定,改之前先看一眼它传给fp_growth的是什么。ceil这一步别省,浮点乘法出来的 1.999999 被floor成 1,结果会莫名其妙多出一堆低频项集。maxLen是很多人会忽略的参数,它限制单个项集的最大长度,主要作用是给子集枚举兜底——一个 20 项的频繁项集能拆出 2^20 量级的子集,不设上限会直接把内存打穿。

第三是阈值初值。第一次跑建议minSup取 0.01~0.05,minConf取 0.3~0.5,先看能不能出结果,再根据结果条数往回收紧。一上来就 0.2 的支持度,很可能一条规则都没有,然后你开始怀疑代码有问题,实际只是阈值定高了。

3. FP-Tree 构建与递归挖掘:两遍扫描、节点索引与条件模式基

算法层的核心只有两个函数:一个负责把事务集压成树,一个负责在树上递归。理解了这两段,整个包就没有秘密了。FP-Growth 相对 Apriori 的全部优势都来自“不生成候选集”这一点,代价是要多维护一棵树和一张头表。树建对了,挖掘就是顺着链表往上爬的事。

3.1 第一遍扫描:项频统计与头表

第一遍扫描只做一件事:数每个项出现了多少次。这里有个细节必须处理好——同一条事务里重复出现的项只能计一次,否则支持度会虚高。比如一条订单里同一商品扫了两次码,不去重的话这个项的支持度直接翻倍,后面所有指标都不可信。

function [header, itemOrder] = build_header(transactions, minSupCount) % 第一遍扫描:统计项频次,过滤低频项,输出按频次降序排列的头表 % transactions : 1×N cell,每个元素是一条事务(字符串 cell) % minSupCount : 最小支持度计数 % header : struct 数组,字段 name / count / head(头指针,0 表示空) % itemOrder : containers.Map,项名 -> 排序后的名次,供第二遍扫描重排用 freq = containers.Map('KeyType', 'char', 'ValueType', 'double'); for i = 1:numel(transactions) items = unique(transactions{i}); % 事务内去重,关键一步 for j = 1:numel(items) key = items{j}; if isKey(freq, key) freq(key) = freq(key) + 1; else freq(key) = 1; end end end % 过滤低频项,剩下的按频次降序排;频次相同按项名字典序,保证结果可复现 keys = freq.keys(); cnts = cell2mat(freq.values()); keep = cnts >= minSupCount; keys = keys(keep); cnts = cnts(keep); [~, idx] = sortrows([-cnts(:), (1:numel(cnts))']); % 降序 keys = keys(idx); cnts = cnts(idx); header = struct('name', {}, 'count', {}, 'head', {}); itemOrder = containers.Map('KeyType', 'char', 'ValueType', 'double'); for k = 1:numel(keys) header(k) = struct('name', keys{k}, 'count', cnts(k), 'head', 0); itemOrder(keys{k}) = k; % 名次越小,越靠近根 end end

排序用sortrows对[-cnts, 原下标]做,是为了让频次相同的项有一个稳定的次序。MATLAB 的sort在相等元素上的顺序不保证稳定,不加这个兜底,同一份数据两次运行可能得到不同形状的树,调试时非常难受。header里head字段是后面要用的头指针,指向该列在树中的第一个节点,初始化为 0 表示空。

这段代码返回的itemOrder是第二遍扫描的“施工图”,每条事务都要按这个名次重排。名次越小越靠近根节点,这是让高频项共享前缀、把树压矮的关键。要是反过来按升序排,低频项堆在根附近,分支数会大幅增加。

3.2 第二遍扫描:过滤、重排、逐条插入建树

第二遍扫描是建树的主体。每条事务先按头表过滤掉非频繁项,再按名次重排,然后从根节点往下走,能复用已有子节点就复用并把计数加一,不能就新建节点。

function [tree, header] = build_fp_tree(transactions, minSupCount) % 第二遍扫描:建 FP-Tree % tree : struct 数组,下标即节点 id;tree(1) 固定为根 % 字段 name / count / parent / children / next % children 存子节点 id 数组,next 存同名列的下一个节点 id(0 表示链尾) [header, itemOrder] = build_header(transactions, minSupCount); % 初始化根节点,下标 1 tree = struct('name', 'ROOT', 'count', 0, 'parent', 0, ... 'children', [], 'next', 0); for i = 1:numel(transactions) items = unique(transactions{i}); % 只保留频繁项,并按头表名次排序 items = items(cellfun(@(x) isKey(itemOrder, x), items)); if isempty(items), continue; end [~, order] = sort(cellfun(@(x) itemOrder(x), items)); items = items(order); cur = 1; % 从根节点出发 for j = 1:numel(items) name = items{j}; kids = tree(cur).children; hit = 0; for c = kids % 找已有子节点 if strcmp(tree(c).name, name) hit = c; break; end end if hit > 0 tree(hit).count = tree(hit).count + 1; cur = hit; else % 新建节点,挂到当前节点下面 newNode = struct('name', name, 'count', 1, 'parent', cur, ... 'children', [], 'next', 0); tree(end+1) = newNode; newId = numel(tree); tree(cur).children(end+1) = newId; % 更新头表:插到同名列链表的头部 hIdx = itemOrder(name); tree(newId).next = header(hIdx).head; header(hIdx).head = newId; cur = newId; end end end end

这里的节点结构是整个包最需要看懂的地方。MATLAB 没有指针,作者用的是“struct 数组 + 下标当指针”的经典写法:tree是一个不断增长的 struct 数组,节点 id 就是它在数组里的下标,parent和children存的是 id 而不是引用,next则是同一列节点的单链表,配合header(k).head就能遍历某一列的全部节点。

这种写法有两个直接后果。一是插入时必须用tree(end+1)扩容,MATLAB 每次扩容都要重新分配内存,事务量大(十万级以上)时这一步会成为明显的性能瓶颈,常见做法是先按事务总项数预估一个上界做预分配,或者分批建树。二是children是线性查找,一个节点下的子节点越多,插入越慢,实务里可以在节点里加一个containers.Map做名字到子节点 id 的索引,用空间换时间。

3.3 条件模式基:从某个项回溯到根

树建好之后,挖掘的起点不是根,而是头表里最右边(频次最低)的那一项。取这一列的所有节点,每个节点沿parent往上爬到根,路径上的名字就是一条前缀路径,路径上的计数取该节点的计数,这一堆前缀路径合起来就是“条件模式基”。

理解这个概念可以换个说法:条件模式基回答的是“所有包含项 X 的事务里,X 之前还出现过什么”。因为建树时已经按频次降序排过,X 之前的项一定是频次比它高的项,这就保证了递归的方向不会乱。

function paths = conditional_pattern_base(tree, header, itemIdx) % 取出某一项对应的全部前缀路径(条件模式基) % 返回 cell,每个元素是 {前缀项名 cell, 计数} paths = {}; nodeId = header(itemIdx).head; while nodeId ~= 0 % 沿同名列链表遍历 cnt = tree(nodeId).count; pref = {}; p = tree(nodeId).parent; while p ~= 0 && p ~= 1 % 爬到根为止,根不算 pref{end+1} = tree(p).name; p = tree(p).parent; end if ~isempty(pref) paths{end+1} = {fliplr(pref), cnt}; % 翻回来才是从根到叶的顺序 end nodeId = tree(nodeId).next; end end

注意fliplr这一步。往上爬得到的是从叶到根的顺序,而建条件 FP-Tree 时必须从根往下插,顺序反了树就建错了,而且这种错误不会报错,只会让结果数量变少,非常隐蔽。p ~= 1是跳过根节点,根的名字是ROOT,不能作为项参与挖掘。

路径上的计数取的是叶节点的计数,不是路径上每个节点各自的计数。这是 FP-Growth 的一个容易混淆的点:一条前缀路径代表“有 cnt 条事务同时包含这些项”,cnt 就是路径末端节点的计数,因为每个节点在插入时已经按事务累加过了。

3.4 递归挖掘与单路径优化

有了条件模式基,递归的框架就很直白了:对每一项,用它的条件模式基重新统计频次、过滤、建一棵条件 FP-Tree,然后把这一项接到前缀上形成新的频繁项集,再对新树递归。

function mine_tree(tree, header, prefix, minSupCount, out) % 递归挖掘,结果追加到 out(containers.Map,key 为项集字符串,value 为支持度计数) % prefix : 当前累积的前缀,cell of char % 单路径优化:如果整棵树只有一条路径,直接把路径上所有项的组合一次性输出 if is_single_path(tree) names = path_items(tree); for m = 1:(2^numel(names) - 1) idx = logical(bitget(m, 1:numel(names))); set_ = [prefix, names(idx)]; key = strjoin(sort(set_), '|'); % 该组合的支持度 = 路径上最后一个被选中的节点的计数 out(key) = min_node_count(tree, names(idx)); end return; end for i = numel(header):-1:1 % 从频次最低的项开始 item = header(i).name; newSet = [prefix, {item}]; key = strjoin(sort(newSet), '|'); out(key) = header(i).count; % 该项集的支持度计数 paths = conditional_pattern_base(tree, header, i); if isempty(paths), continue; end [subTree, subHeader] = build_tree_from_paths(paths, minSupCount); if numel(subHeader) > 0 mine_tree(subTree, subHeader, newSet, minSupCount, out); end end end

递归方向是从numel(header)递减到 1,也就是从频次最低的项开始处理。这个方向不能反:处理低频项时构造的条件模式基只包含比它更频繁的项,树规模递减;反过来做,条件树会越建越大,递归层数和内存都会失控。

单路径优化是这段代码里性价比最高的部分。当一棵条件 FP-Tree 只有一条路径时,不需要再递归拆解,直接把路径上所有项的所有非空组合一次性输出即可,复杂度从指数级递归降到一次组合枚举。没有这个优化,稠密数据集上递归次数会多到肉眼可见地慢。min_node_count是在取组合的支持度——组合的支持度等于路径上最深那个被选中节点的计数,取最小值即可。

out用containers.Map以排序拼接的字符串为 key,天然完成了项集去重。同一个项集可能被不同分支重复挖到,不排序拼接的话会存成两条,最后统计频繁项集总数就会偏大。

4. 关联规则生成:从频繁项集到置信度、提升度的筛选

挖掘拿到的是一堆频繁项集和它们的支持度计数,还不是规则。“A 和 B 一起出现”是项集,“买 A 的人里有 70% 也买了 B”才是规则。从项集到规则这一步,本质是对每个项集枚举所有拆分方式,然后按指标卡阈值。

4.1 非空真子集枚举:一条 k 项集能拆出多少条规则

一条 k 项集能拆出 2^k - 2 条候选规则,减掉的 2 是空集和全集——前件或后件为空都没有意义。枚举方式用位掩码最省事:把 1 到 2^k-2 看成二进制,哪一位是 1 就把对应项放进前件,剩下的放进后件。

function cands = enum_rules(itemset) % 枚举一条频繁项集的所有非空真子集拆分 % 返回 cell of struct,字段 ante / cons(均为 cell of char) n = numel(itemset); cands = {}; for mask = 1:(2^n - 2) bits = logical(bitget(mask, 1:n)); cands{end+1} = struct('ante', {itemset(bits)}, ... 'cons', {itemset(~bits)}); end end

bitget(mask, 1:n)返回的是低位在前的位序列,和itemset的下标一一对应,所以itemset(bits)就是前件。这个写法简洁,但要注意 n 的上限:n=15 时单条项集就有三万多条候选,n=20 时过百万,整个项集列表跑一遍会直接把内存吃干净。这就是maxLen参数存在的意义——限制参与规则生成的项集长度,通常 4 到 6 已经能覆盖绝大多数有业务解释力的规则。

另一个实务上的优化是剪枝:如果某条规则的前件支持度已经低于最小支持度计数,那么以它为子集的所有拆分都不用算了。实现上可以先按项集长度从小到大算,把支持度缓存进 map,枚举时先查一次再决定要不要继续。

4.2 三个指标怎么算:支持度、置信度、提升度

规则的质量靠三个指标卡,它们的定义和常见误用值得单独说清楚:

指标公式含义常见误读
支持度 Supportcount(A∪B) / NA 和 B 同时出现的比例只反映频率,不反映关联强弱
置信度 Confidencecount(A∪B) / count(A)出现 A 的条件下 B 也出现的比例高置信度可能只是 B 本身很常见
提升度 LiftConfidence / (count(B)/N)相比独立假设,A 对 B 的提升倍数小于 1 表示负相关,不是“没关系”

置信度高不等于关联强,这是血泪经验里最常见的一条。假设 B 这个项本身出现在 90% 的事务里,那任何一条“A → B”的规则置信度都会接近 0.9,但它毫无信息量。提升度就是为了补这个漏:只有提升度明显大于 1,才说明 A 的出现确实让 B 更可能发生。

function rules = gen_rules(freqSets, minConf, nTrans, maxLen) % 由频繁项集生成关联规则 % freqSets : containers.Map,key 为排序拼接的项集串,value 为支持度计数 % minConf : 最小置信度 % nTrans : 事务总数,用于把计数换算成支持度比例 % maxLen : 参与规则生成的最大项集长度 rules = struct('ante', {}, 'cons', {}, 'sup', {}, ... 'conf', {}, 'lift', {}); keys = freqSets.keys(); for k = 1:numel(keys) itemset = strsplit(keys{k}, '|'); if numel(itemset) < 2 || numel(itemset) > maxLen, continue; end supAB = freqSets(keys{k}) / nTrans; for c = enum_rules(itemset) anteKey = strjoin(sort(c{1}.ante), '|'); consKey = strjoin(sort(c{1}.cons), '|'); if ~isKey(freqSets, anteKey) || ~isKey(freqSets, consKey) continue; % 子集不频繁,规则不可能频繁 end supA = freqSets(anteKey) / nTrans; supB = freqSets(consKey) / nTrans; conf = supAB / supA; if conf < minConf, continue; end lift = conf / supB; rules(end+1) = struct('ante', {c{1}.ante}, 'cons', {c{1}.cons}, ... 'sup', supAB, 'conf', conf, 'lift', lift); end end end

代码里那个isKey判断是必要的防御。理论上 A∪B 频繁则 A、B 都频繁(支持度的反单调性),但浮点计数转换、事务去重不一致等边界情况下这个前提可能被破坏,不判断就会isKey报错中断。这类防御性检查在算法包里看着啰嗦,实际跑真实数据时能省掉很多次莫名其妙的崩溃。

supAB用计数除以事务总数得到比例,输出时比裸计数好读。lift用置信度除以supB,等价于supAB / (supA * supB),数学上完全一致,但用置信度做中间量可以少算一次除法,也更好解释。

4.3 规则去重、排序与导出

规则生成完之后,结果集里可能还有重复和镜像。A→B 和 B→A 是两条不同的规则,置信度通常不等,都要保留;但同一条规则如果因为不同项集路径被生成两次,就得去重。去重键用“前件排序串 + 分隔符 + 后件排序串”,比逐字段比较快得多。

排序默认按提升度降序,其次按置信度降序。按支持度排在前面的往往是那些“特别常见”的组合,业务价值反而低。实际用的时候我会先看提升度 top 20,再单独筛支持度高的那批做对照,两批规则的业务含义通常差别很大。

导出这块,包里一般提供一个print_rules做控制台格式化,加一个写 csv 的函数会更实用:

function export_rules(rules, outFile) % 把规则写成 csv,方便丢进表格软件里再筛 fid = fopen(outFile, 'w', 'n', 'UTF-8'); fprintf(fid, 'antecedent,consequent,support,confidence,lift\n'); for i = 1:numel(rules) fprintf(fid, '%s,%s,%.4f,%.4f,%.4f\n', ... strjoin(rules(i).ante, ' & '), strjoin(rules(i).cons, ' & '), ... rules(i).sup, rules(i).conf, rules(i).lift); end fclose(fid); end

fopen的第三个参数指定字节序,第四个参数指定编码,中文项名不指定 UTF-8 打开会是乱码。%.4f保留四位小数够用了,再多只是噪音。

5. 避坑与排查:结果为空、递归爆栈、指标虚高这三类问题

这个包不大,但踩坑的地方集中在几个固定位置。下面这几条是我在不同数据集上反复遇到的,按“现象 → 原因 → 解决”写,对号入座能省不少时间。

5.1 数据格式与阈值设置引发的故障

现象一:跑完没有任何输出,频繁项集数量为 0,也不报错。

原因通常是阈值和事务规模不匹配。minSup写成比例 0.05,事务只有 60 条,ceil(0.05*60)=3,如果数据里每个项都只出现一两次,过滤之后头表直接空了,建树建出一个只有根节点的空树,挖掘自然什么都没挖到。另一种情况是读数据时把每条事务读成了一个长字符串,unique之后只剩一个元素,所有事务看起来都只包含一个巨大无比的项。

解决方式是先把中间量打出来:numel(transactions)是多少、头表里还剩几项、第一遍扫描后最高频的项出现几次。这三个数字一看就知道问题在哪。阈值先用1/nTrans这种极小值跑一遍,确认链路通了再往上调。读数据那段检查返回值是不是1×N的 cell、每个元素是不是 cell of char,不是的话在load_data里改。

现象二:报错“未定义函数或变量”,或者函数名对不上。

原因是当前目录不对。MATLAB 只在当前目录和搜索路径里找函数,解压后在子目录里直接run main找不到算法层的函数。解决很简单,cd到解压根目录再执行,或者在入口脚本开头加一句addpath(genpath(pwd))把子目录都加进来。加完之后记得savepath之前想清楚,临时加路径不要持久化,否则以后在别的项目里会莫名其妙调到同名的旧函数。

5.2 递归深度与内存占用引发的故障

现象三:事务量一大就卡死,或者报“内存不足”。

根因在建树阶段。tree(end+1) = newNode这种写法每次扩容都要搬一遍整个 struct 数组,几万个节点之后耗时是肉眼可见的。同时build_header里用containers.Map逐个累加,几十万次isKey+ 赋值也是不小的开销。

解决分两步。建树前先预估节点上界:把所有事务的项数加起来,这是最坏情况下的节点数,用tree = repmat(struct(...), 1, upperBound)一次性分配,再维护一个nodeCount计数器代替end+1。这一步通常能带来数倍的提速。第二步是把containers.Map换成两个数组加一次unique,先收集所有项名做一次全局unique得到名字表,用下标代替字符串做 key,统计时直接对整数数组做累加,比字符串哈希快得多。

现象四:递归到一半栈溢出,或者条件树越建越大。

这是排序方向写反的典型症状。建树时如果按频次升序排,低频项靠近根节点,条件模式基里全是低频项的组合,条件树规模不降反升,递归深度跟着涨。检查build_header里排序那一行的符号,必须是-cnts降序。另外maxLen限制的是项集长度不是递归深度,递归深度由树的实际路径长度决定,如果数据里存在超长事务(比如一条日志里挂了上千个标签),建议在建树前把这类事务单独降采样或截断,否则单条事务就能把树撑成一条长链。

5.3 规则解读与评估指标引发的偏差

现象五:规则数量特别多,但提升度全是 1 附近,业务上看不出价值。

这不是代码 bug,是数据特性。当项的出现频率分布很均匀、项之间接近独立时,提升度自然都在 1 附近。这时候硬调阈值只会让规则变少而不会变得更有意义。解决办法是换评估维度:看杠杆率(supAB - supA*supB)的绝对值,或者对项做一次分层,只在高频项集合内部挖规则。另一个常见原因是事务粒度太粗,一行数据里塞了几十个项,任意两项的共现概率都被抬高,这时候应该先降粒度。

现象六:同一条规则在不同阈值下反复出现,支持度和置信度对不上手工计算的结果。

大概率是事务内没去重。手工算的时候默认“一条事务里同一个项只算一次”,代码里如果直接遍历原始数组累加,重复项会被计多次,支持度就偏高。检查build_header和build_fp_tree里unique是不是都加了,两处必须一致——只在一处去重会导致头表计数和树节点计数不匹配,挖出来的支持度是错的但不报错,这种坑最难查。

6. 二次开发:把阈值扫描、数据源和输出都改成顺手的样子

这个包最实用的改造方向不是换算法,而是把“试阈值”这件事自动化。手动改minSup重跑一遍,一次两次还行,要找一个合适的阈值区间得跑十几遍,纯体力活。我一般会在入口外面再套一层扫描:给定一组支持度,循环跑挖掘,只记录频繁项集数量和规则数量,最后画一条曲线,拐点基本就是合适的阈值。注意每轮都要clear掉上一轮的树和头表,不然内存会一轮比一轮高。

第二个方向是数据源解耦。load_data现在读 csv,实际场景里数据可能在数据库或者一个长表里。把它改成接受一个 cell 数组,读写文件的逻辑挪到调用方,fp_growth就变成了一个纯函数,可以拿来做单元测试,也可以被别的脚本复用。改的时候保留原来的 csv 读取函数做默认实现,不要直接删掉,示例数据还得靠它跑通。

第三个方向是输出。规则表加一列“前件项数”和“后件项数”,筛“单项 → 单项”这类最易解释的规则时会方便很多;再加一列规则的稳定度(把数据随机切两半分别挖,两边都出现的规则打个标记),能过滤掉一批只在特定样本上成立的巧合规则。这个改动不大,但对结果可信度的提升比调参明显得多。

验证改造是否正确的办法很简单:用示例数据跑一遍,改造前后的频繁项集集合必须完全一致,规则集合也必须一致。不一致就说明改动碰到了算法逻辑,回头 diff 一遍函数签名和参数传递。我吃过这个亏——有一次为了“优化”把事务内去重挪到了建树函数里,头表统计忘了同步,结果支持度整体偏高,规则看起来更多更漂亮,实际上是错的,白高兴了一场。

从那以后我每次动算法层的代码,都会先留一份示例数据的输出做基线,改完立刻对拍,一致了才继续往下写。希望帮到你。

本文还有配套的精品资源,点击获取