Python关联规则挖掘:Apriori处方药材配伍实战
1. 从一堆处方明细说起:项目整体思路拆解
手上拿到的是某连锁药房后台导出的处方明细表,两列最关键:order_id(处方单号)和herb_name(药材名)。一张处方少则八九味,多则二十几味,几万条记录堆在 Excel 里,靠肉眼看是看不出规律的。业务方的原始诉求很朴素——"哪些药材总是成对成组地出现"、"顾客拿了甲药,是不是大概率还会拿乙药"。这类问题在数据挖掘里的标准解法就是关联规则挖掘,而 Python 生态里最经典、最容易上手落地的算法就是Apriori。它通过逐层搜索的方式找出数据集中满足最小支持度的频繁项集,再从频繁项集里推导出形如"甲出现则乙也出现"的关联规则,整个过程完全由支持度、置信度、提升度这几个指标量化,不依赖任何先验的业务判断,非常适合用来做第一轮探索。
这篇内容我想完整走一遍:从原始明细表怎么变成算法能吃的矩阵、阈值到底怎么定、代码怎么写、结果怎么读、跑不动了怎么优化,以及那些只有真跑过一遍才会踩到的坑。适合两类人看——一类是手上正好有处方、订单、购物篮这类"一单多品"结构数据,想快速出结论的;另一类是想把关联规则这套方法真正用到业务里,而不是停在调包跑个 demo 的。代码全部基于pandas+mlxtend,不涉及深度学习框架,普通笔记本 16G 内存足够跑十万级记录。
需要提前说清楚一个边界:关联规则挖出来的是统计共现规律,不是医学结论。某两味药经常出现在同一张处方里,可能因为配伍习惯、可能因为某类证型的固定组合、也可能只是某位坐诊医师的个人风格,甚至可能只是某段时间某味药缺货导致的替代。任何涉及用药的判断都必须交给有资质的中医师,我们做的是把数据里的模式呈现在台面上,仅此而已。这个定位想清楚了,后面解读结果时才不会跑偏,也不会拿着一条提升度 5 的规则就去下结论。
1.1 为什么选 Apriori,而不是聚类或协同过滤
同样一批处方数据,能用的方法不少:把每张处方当成向量做 K-Means 聚类、用药材共现矩阵做谱聚类、或者直接上协同过滤做推荐。我最初也评估过这几条路,最后还是落到 Apriori,原因有三个。
第一是可解释性。聚类出来的簇,你得回头去看每个簇里都是什么药,然后再自己总结一句"这个簇大概是什么类型的组合",中间隔了一层翻译。关联规则直接输出"甘草 → 茯苓"这种可读的形式,配上支持度置信度,业务方能自己看懂,不需要我在旁边解释半天。
第二是对稀疏数据的适应性。药材维度假设有八百味,一张处方只用十几味,这是一个极度稀疏的 0-1 矩阵。聚类方法在这种高维稀疏场景下,距离度量很容易失效,欧氏距离几乎全是 1,效果往往不如预期。Apriori 是基于集合的频繁模式搜索,天然适配"一单多品"这种结构。
第三是门槛和工程成本。mlxtend一个库搞定,没有模型训练、没有超参数调优、没有 GPU 需求,跑完直接出表格。对于一个"先看看有没有规律"的探索性需求,投入产出比明显更高。
当然它也有明确的短板,我在实际使用中体会很深:一是项数一多组合爆炸,如果放任算法去找 5 项以上的组合,候选集数量是天文数字,内存直接顶不住;二是只关心"是否出现",不关心"出现多少",某味药用了 6 克和用了 30 克,在 Apriori 眼里是一回事,这个信息损失是必须接受的;三是对高频万金油药材极度敏感,像甘草、陈皮这类几乎出现在每张方子里的药,会污染大量规则,后面必须做专门处理。这三点在后面的章节里都会展开讲应对办法。
1.2 整体链路与技术栈选型
整条链路我拆成五步,每一步都有明确的输入输出,出错时容易定位:
| 环节 | 输入 | 输出 | 关键工具 |
|---|---|---|---|
| 数据读取与清洗 | Excel / CSV 明细 | 干净的 (order_id, herb_name) 两列 | pandas |
| 别名归一 | 原始药材名 | 标准药材名 | 自定义字典 + replace |
| 事务化 | 分组后的药材列表 | 每单一个 list | groupby + apply |
| 独热编码 | list 列表 | 0/1 矩阵 | TransactionEncoder |
| 频繁项集与规则 | 0/1 矩阵 | 规则表 | mlxtend |
技术栈就是pandas+mlxtend+openpyxl(读 xlsx)+matplotlib(可选,出图)。mlxtend版本建议 0.22 以上,因为 0.23 版本对association_rules的参数做了调整,多了一个num_itemsets必填参数,很多人照抄旧教程会直接报错,这个坑后面单独说。
我特意没有引入数据库,因为多数人的原始数据就是 Excel。如果数据量到了百万级明细,建议先在数据库里做一次GROUP_CONCAT,把"一单多行"压成"一单一行",导出的数据体积能小一个数量级,后续读入内存也轻松很多。这个小优化我在一个八万单的项目里做过,Excel 从 400MB 降到 20MB,读取时间从两分多钟降到几秒。
2. 把三个指标翻译成人话:支持度、置信度、提升度
很多人卡在 Apriori 上,不是代码不会写,而是挖出一堆规则之后不知道怎么判断哪条有用。根子在于对这三个指标的理解停留在了公式层面。我用"药房柜台"的场景重新讲一遍,应该会好懂很多。
假设总共有8000 张处方。药材 A 出现在 800 张里,药材 B 出现在 1000 张里,A 和 B 同时出现在 400 张里。
支持度(Support)回答的是"这个组合有多常见"。AB 同时出现的支持度 = 400 / 8000 = 5%。这个数字的意义是:随便抽一张处方,里面有 A 也有 B 的概率是 5%。它衡量的是普遍性,支持度太低说明这个组合是偶发,可能只是巧合,样本量一小统计噪音就盖过信号了。
置信度(Confidence)回答的是"看到 A 之后,B 出现的概率有多大"。A → B 的置信度 = 400 / 800 = 50%。注意方向性,B → A 的置信度 = 400 / 1000 = 40%,两者不相等。置信度衡量的是可靠性,但它是单向的,这是初学者最容易忽略的一点。
提升度(Lift)回答的是"这个关联比随机情况强多少"。B 的整体支持度是 1000 / 8000 = 12.5%,如果 A 和 B 完全独立,那么 A 出现时 B 出现的概率应该也是 12.5%。实际是 50%,所以提升度 = 50% / 12.5% = 4。含义是:A 出现时 B 出现的可能性是随机情况的 4 倍。提升度衡量的是关联强度,大于 1 是正相关,等于 1 是独立,小于 1 是负相关(互斥)。
2.1 三个指标各自的陷阱
单看任何一个指标都会得出错误结论,我在实际项目里三个坑都踩过。
只看支持度的坑:支持度高只能说明常见,不能说明有关联。甘草在几乎每张方子里都有,支持度可能高达 60%,但"甘草 → 任何药"的置信度都接近该药本身的支持度,提升度接近 1,等于废话。所以支持度必须和提升度一起看。
只看置信度的坑:置信度会被高频项"绑架"。如果 B 本身就是个万金油,那 A → B 的置信度天然就高,因为不管 A 出不出现,B 都经常出现。这种情况必须用提升度来校正。我一般要求置信度和提升度双达标,比如置信度 > 0.5 且提升度 > 1.2。
只看提升度的坑:提升度极高但支持度极低的规则,多数是噪音。比如某两味冷门药材只在一张处方里同时出现过一次,支持度 0.012%,提升度可能算出来是几百,这种规则毫无意义,就是两个稀有项的偶然碰撞。所以支持度要设一个下限兜底。
我的经验阈值组合是:支持度下限看样本量,置信度 0.4~0.6,提升度 > 1.2,同时加上"前项和后项各自的支持度都不能是 Top 5 高频项"这个业务过滤。最后这条是从业者才会想到的,因为纯数学指标不会告诉你"甘草太常见了,含甘草的规则基本不用看"。
2.2 最小支持度到底该设多少
这是所有参数里最需要算的一个,不是拍脑袋定的。有个简单的换算:最小支持度 × 处方总数 = 该组合至少要出现的次数。
如果总共有 8000 张处方,最小支持度设 0.001,那么一个组合至少出现 8 次就能进入频繁项集。8 次在统计上太脆弱了,任何一次数据录入错误都能改变结论。反过来,设 0.05 意味着至少要 400 次共现,那么三项、四项的组合几乎全部被过滤掉,结果只剩下一堆两味药的常见搭配。
我一般按下面的经验值起步,然后根据结果数量做微调:
| 处方总量 | 建议 min_support | 含义(最少出现次数) | 适合场景 |
|---|---|---|---|
| < 1000 | 0.05 | 50 次 | 样本少,只找最强的两味组合 |
| 1000 ~ 5000 | 0.02 | 20~100 次 | 两味、三味组合都能出一些 |
| 5000 ~ 50000 | 0.01 | 50~500 次 | 主力区间,两到四项都有 |
| > 50000 | 0.005 起 | 250 次以上 | 需要配合分层挖掘 |
还有个更细的做法叫分层挖掘:先用较高的支持度(比如 0.02)挖两项集,把结果稳定下来;再降到 0.008 挖三项及以上,同时把max_len卡在 4。这样既能保证短组合的可靠性,又能捞到一些长组合的线索。这个策略在我处理六万张处方的项目里用过,比一次性设低支持度跑全量要快得多,内存也省。
注意:支持度阈值不要跨数据集直接套用。两万张处方的最佳阈值,放到八百张处方上会直接挖不出东西,因为组合的出现次数不够。换数据集必须重新算。
2.3 从频繁项集到规则的推导逻辑
Apriori 的原理部分,很多文章会画一堆集合图,其实核心就一句话:如果一个项集是频繁的,那它的所有子集也一定是频繁的;反之,如果一个项集不频繁,那它的所有超集都不频繁。这条"向下封闭"性质是整个算法能剪枝省时间的关键,也是为什么它要一层一层往上搜(先找 1 项集,再 2 项集,以此类推)。
从频繁项集到关联规则,做法是枚举每个频繁项集的非空真子集。比如频繁项集 {A, B, C},可以生成 A→BC、B→AC、C→AB、AB→C、AC→B、BC→A 六条候选规则,然后逐条算置信度,低于阈值就丢掉。所以规则数量是项集数量的函数,项集越多规则越多,这也是为什么max_len要卡住——五项的频繁项集能生成 30 条候选规则,六项是 62 条,增长很快。
mlxtend的association_rules已经把这层枚举做好了,我们只管传参和筛选就行,不需要手写。但知道它在内部做了什么,后面遇到"规则数量异常多"或者"内存爆了"的时候,才知道该从哪个方向去卡。
3. 数据准备:从处方明细到 0/1 矩阵
这一步的工作量占了整个项目的七成,但往往被写得最少。我说句实话,Apriori 本身调包只要三行代码,真正决定结果质量的是数据清洗做得够不够细。
3.1 别名归一:同一味药有五个名字
这是最影响结果的一步,也是不做就必然出错的环节。同一个药材在系统里可能有多个写法:带炮制方式的("炙甘草"和"生甘草")、带产地的("川贝母"和"浙贝母"严格说是两味药,但"杭白芍"和"白芍"很可能指的是一回事)、带规格的、还有单纯的错别字和空格。
如果"炙甘草"和"生甘草"这两味药不合并,算法会把它们当成两种不同的药,A 和 B 的共现次数被劈成两半,支持度直接砍办,很多本该出来的规则就沉下去了。这个坑我在第一次跑的时候完全没意识到,结果出来一看甘草的排名特别靠后,回头查原始数据才发现光甘草就有五种写法。
我建了一个别名字典,用字典做批量替换,代码很简单但效果立竿见影:
import re import pandas as pd # 别名映射表,建议单独维护一个 Excel 或 py 文件,方便业务方补充 herb_alias = { "炙甘草": "甘草", "生甘草": "甘草", "甘草片": "甘草", "杭白芍": "白芍", "炒白芍": "白芍", "川牛膝": "牛膝", "怀牛膝": "牛膝", "淮山药": "山药", "怀山药": "山药", "云苓": "茯苓", "白茯苓": "茯苓", "广陈皮": "陈皮", "新会陈皮": "陈皮", } df = pd.read_excel("处方明细.xlsx", sheet_name="detail") df = df[["order_id", "herb_name"]].dropna() # 基础清洗:去空格、全角转半角、去掉括号里的炮制说明 def clean_name(s: str) -> str: s = str(s).strip() s = s.replace(" ", "").replace(" ", "") s = re.sub(r"[((].*?[))]", "", s) # 去掉括号注释 return s df["herb_name"] = df["herb_name"].map(clean_name) df["herb_name"] = df["herb_name"].replace(herb_alias)括号那行要特别注意,很多系统的药材名会写成"当归(酒炒)"、"黄芪(炙)"这种形式,把括号内容去掉之后能合并掉一大批。但反过来也要小心,如果括号里写的是产地或规格,"当归(甘肃)"和"当归(云南)"被合并是对的;万一括号里是不同药材的区分信息,就有误合并的风险。我一般的处理是先把所有带括号的名字单独拉出来,人工扫一遍高频的几十个,确认没问题再批量处理。
提示:别名字典的维护一定要让业务方参与。我一开始按自己的常识写映射,结果把两种确实不同的药材合并了,被药房的老师傅指出来,后面改成"我出初稿,业务方签字确认"的流程才没再出错。
3.2 从明细表到 One-Hot 矩阵
清洗完之后,要把"一单多行"的结构转成"一单一列表",再转成算法要的 0/1 矩阵。这三步的中间产物建议都存一份,出问题了好回溯。
# 第一步:按单号聚合,每单变成药材列表 transactions = ( df.groupby("order_id")["herb_name"] .apply(lambda x: sorted(set(x))) # set 去重,防止同一味药重复录入 .tolist() ) print("处方总数:", len(transactions)) print("第一张处方:", transactions[0]) # 第二步:转 0/1 矩阵 from mlxtend.preprocessing import TransactionEncoder te = TransactionEncoder() te_array = te.fit(transactions).transform(transactions) onehot = pd.DataFrame(te_array, columns=te.columns_) print("矩阵形状:", onehot.shape) # (处方数, 药材数)set(x)这个去重很重要。有些系统会把同一味药按不同剂量拆成两行,或者同一张方子在数据库里有主药和辅药两条记录,不去重的话这一单里甘草出现两次,虽然对布尔矩阵没影响,但会让"药材出现频次统计"失真,后面定阈值时会误判。
矩阵形状一般是"几千行 × 几百列",内存占用很小。如果药材维度超过两千,TransactionEncoder生成的object类型矩阵会开始吃内存,这时候要手动转成bool或int8:
onehot = onehot.astype(bool) # 内存直接降到 1/4 甚至更低这一步虽然只有一行,但在大项目里能把内存从 3G 压到 700M 左右,属于必做优化,后面性能那一节还会提到。
3.3 脏数据与处理策略速查
我整理了一份实际遇到的脏数据清单,基本覆盖了药房系统导出的常见问题:
| 脏数据类型 | 具体表现 | 处理方式 | 不处理的后果 |
|---|---|---|---|
| 空格与全角 | "甘 草"、"甘草 " | strip + 全角转半角 + 正则去空白 | 同一味药被拆成多列 |
| 括号注释 | "当归(酒炒)" | 正则去掉括号内容 | 同一味药多个变体 |
| 炮制差异 | "炙甘草"/"生甘草" | 按业务确认后合并 | 支持度被劈开 |
| 单方处方 | 只含 1~2 味药 | 过滤掉(如 len < 3) | 撑大分母,压低所有支持度 |
| 重复行 | 同一单同一药出现两次 | set()去重 | 频次统计失真 |
| 空值 / 占位符 | "-"、"无"、NaN | dropna + 黑名单过滤 | 出现无意义的"药材" |
| 单味超大处方 | 一张单子 40+ 味 | 视情况保留,但要标记 | 拉高整体共现基线 |
最后一行要展开说一下。有些处方是膏方或者大复方,动辄三四十味药,这种单子在数据里会拉高整体的共现基线,让原本正常的规则提升度显得没那么突出。我一般的做法不是删掉,而是加一列herb_count标记出来,分析时可以做分群对比——大复方和小方剂可能对应完全不同的配伍规律,混在一起挖有时候会互相抵消。
4. 完整代码实现:从频繁项集到可用规则
前面铺垫完,这一段直接上能跑的代码,每一步都说明在干什么。
4.1 挖频繁项集
mlxtend提供了两个实现:apriori和fpgrowth。两者的输出格式完全一样,区别在于算法策略——Apriori 是逐层生成候选集再验证,FP-Growth 是先把数据集压缩成 FP 树再挖,后者在数据量大、低频项多的情况下通常快一个数量级,而且不需要反复扫描数据集。既然结果等价,我现在的默认选择是fpgrowth,只有教学演示才用apriori。
from mlxtend.frequent_patterns import fpgrowth, apriori # 先做一次低频药材过滤,把出现率低于 0.3% 的冷门项直接砍掉 min_item_support = 0.003 item_rate = onehot.mean(axis=0) keep_cols = item_rate[item_rate >= min_item_support].index onehot_filtered = onehot[keep_cols] print(f"过滤前 {onehot.shape[1]} 味,过滤后 {onehot_filtered.shape[1]} 味") # 挖频繁项集 frequent_items = fpgrowth( onehot_filtered, min_support=0.01, # 最小支持度 use_colnames=True, # 输出用列名而不是列索引 max_len=4, # 最多找 4 味药的组合,防止爆炸 verbose=0 ) frequent_items["length"] = frequent_items["itemsets"].apply(len) frequent_items = frequent_items.sort_values("support", ascending=False) frequent_items.to_csv("频繁项集.csv", index=False, encoding="utf-8-sig")max_len这个参数强烈建议设,不设的话算法有可能一直往上找,组合数指数级增长。我见过有人在八百味药的数据集上不设max_len,跑到 5 项集的时候内存直接打满。实际业务里,超过四味药的组合已经很难解释,也更难落地,卡在 3 或 4 是合理的选择。
use_colnames=True也别忘了,不然输出的itemsets里是列索引数字,你还得回头去查第 137 列对应哪味药,麻烦且容易出错。
4.2 生成并筛选关联规则
这一步是重点,参数设对了结果才有用。注意mlxtend0.23 版本之后association_rules多了一个num_itemsets参数,不传会报警告甚至报错,网上大量旧教程没提这一点。
from mlxtend.frequent_patterns import association_rules rules = association_rules( frequent_items, metric="confidence", min_threshold=0.4, num_itemsets=len(frequent_items) # 0.23+ 版本需要 ) # 多重筛选:置信度 + 提升度 + 支持度三重卡 top_items = ["甘草", "陈皮"] # 业务确认的万金油,含它们的规则单独看 def has_top_item(s): return bool(s & set(top_items)) rules = rules[ (rules["confidence"] >= 0.4) & (rules["lift"] > 1.2) & (rules["support"] >= 0.008) ] # 把 frozenset 转成可读字符串 def fmt(s): return " + ".join(sorted(s)) rules["前项"] = rules["antecedents"].apply(fmt) rules["后项"] = rules["consequents"].apply(fmt) rules["前项长度"] = rules["antecedents"].apply(len) # 标记是否含万金油项,方便分开看 rules["含高频项"] = rules["antecedents"].apply(has_top_item) | \ rules["consequents"].apply(has_top_item) rules = rules.sort_values(["lift", "support"], ascending=False)frozenset这个类型要先处理掉,因为 pandas 导出 CSV 时对它没法正常序列化,直接to_csv会变成一串frozenset({'甘草'})的 Python 对象字符串,后续没法用。先转成普通的+连接的字符串,导出之后 Excel 里能直接看、能筛选、能排序,交给业务方的时候省很多沟通成本。
筛选条件我一般会做两套输出:一套是"剔除万金油项"的干净规则,用来找真正有信息量的新增搭配;另一套是"包含万金油项"的规则,这类虽然提升度不高,但支持度大,对库存备货反而更有参考价值——甘草配什么都常见,那备货就得按最保守的量来。同一个数据,两种用途,不要用一套筛选逻辑走到底。
4.3 结果导出与基础可视化
导出这块有个小细节:中文 Excel 打开 CSV 乱码,是因为编码问题,用utf-8-sig就行了,这个很多人都踩过。
out_cols = ["前项", "后项", "support", "confidence", "lift", "前项长度", "含高频项"] rules[out_cols].to_csv("关联规则_全量.csv", index=False, encoding="utf-8-sig") # 干净版:不含万金油项,按提升度排序取前 100 clean = rules[~rules["含高频项"]].head(100) clean[out_cols].to_csv("关联规则_重点.csv", index=False, encoding="utf-8-sig") # 简单的提升度分布图,一眼看出整体质量 import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False fig, ax = plt.subplots(figsize=(8, 5)) ax.hist(rules["lift"], bins=50, color="#4C72B0", edgecolor="white") ax.axvline(1.2, color="red", linestyle="--", label="lift=1.2 参考线") ax.set_xlabel("提升度 lift") ax.set_ylabel("规则数量") ax.legend() plt.tight_layout() plt.savefig("lift_dist.png", dpi=150)看这张直方图比看表格更快判断整体情况。如果大部分规则的提升度都集中在 1.0~1.2 这个窄区间,说明数据里的共现模式很弱,基本是随机组合,不要指望能挖出什么惊艳结论,这时候应该先怀疑数据质量或者支持度设得太低。如果长尾一直到 3 甚至 5,那说明确实有强关联模式存在,值得往下深挖。
5. 结果解读:哪些规则能信,哪些是幻觉
代码跑完,表格导出来了,真正的难点才开始。一张几千行的规则表扔给业务方,没人看得完。我用下来比较有效的方法是"三层过滤 + 人工复核"。
5.1 三类必须警惕的伪规则
第一类:高频项绑架型。前项或后项本身支持度就很高,比如"党参 → 茯苓",两味药各自的支持度都是 20%,那它们的共现概率天然就高,置信度算出来可能 0.45,看着不错,但提升度只有 1.1,基本等于独立。这类规则要一律压低优先级。
第二类:稀有项巧合型。两味冷门药材在数据里各出现十几次,恰好有几次撞在一起,支持度算出来 0.1%,提升度飙到 8。这种规则看着诱人,但样本量太小,只要再增加一批数据就散了。我的做法是给每条规则加一个"共现次数"列(support × 处方总数),低于 20 次的一律不看。
第三类:方向错误型。置信度是有方向的,A → B 和 B → A 差别可能极大。有个实际例子:某两味药的 A → B 置信度 0.62,看着很强;但反过来 B → A 只有 0.18。原因很直白——B 是个大品种,出现场景很泛,而 A 只在一类特定方子里出现,一旦 A 出现基本必然带 B。如果只看单向,很容易得出"B 带来 A"的错误结论去指导陈列或者推荐,方向搞反了效果就完全相反。筛选时我习惯把两个方向都列出来对照着看。
5.2 规则怎么真正落到业务上
同一批规则,在不同岗位手里能发挥完全不同的作用,这是我在项目里体会比较深的一点。
配方与组方参考:三项以上的高提升度规则,可以整理成"常用组合"参考清单。这里必须强调,这份清单的作用是提供线索让专业人员复核,不是自动生成处方。有些组合在数据里高频,可能是因为某段时期的特定证型集中,或者某位医师的固定用法,离开那个语境就不成立了。
库存与备货:这个用途反而被低估了。支持度高、提升度一般的两味组合,对备货最有价值——因为它们经常一起出现,那就可以考虑同批次采购、同区域摆放,降低缺货率。我做过一个简单测算:把支持度 Top 20 的两项组合里的药材做一次库存关联分析,能发现十几组"总是一起断货"的品种对,调整补货策略后缺货率有可见下降。
货位与陈列:药房实际抓药是有动线的,经常一起出现的药材如果摆得远,抓一副药要来回走。把高支持度的组合尽量安排在同一货架区域,能明显提升抓药效率。这个优化听起来朴素,但对日均几百方的药房来说,节省的时间很可观。
推荐与搭配:如果是零售场景,顾客拿了甲药,可以提示"常一起购买的还有乙"。但这里要特别谨慎,涉及药材的推荐必须由专业人员把关合规性,不能简单按算法结果推。
5.3 跑不动的时候怎么优化
数据量上万单之后,几个地方会成为瓶颈,我按见效快慢排个序:
第一,先砍维度再进算法。低频药材(出现率 < 0.3%)对结果贡献极小,却会显著增加候选集数量。先做一次列过滤,通常能砍掉一半以上的列,速度提升立竿见影。
第二,用 FP-Growth 替换 Apriori。输出完全一致,速度快几倍到十几倍,这个是最省事的替换。
第三,降低数据类型精度。TransactionEncoder默认输出object或int64,转成bool后内存能降到四分之一。矩阵一大,内存换页就是主要瓶颈,这一步收益很高。
第四,分层挖掘。不要一次设很低的阈值跑全量。先高阈值挖短项集,拿到稳定结果;再降阈值挖长项集,max_len卡在 4。分两次跑,总时间往往比一次跑全量更短,而且中间结果可以分别验证。
第五,限制规则数量。筛选条件越早加越好,不要先把所有规则生成出来再筛。association_rules直接传metric="lift"和min_threshold,让它在生成阶段就过滤掉大部分,内存占用能差一个量级。
6. 常见问题与排查实录
这一段是我踩坑记录里挑出来的,基本覆盖了新手到中级阶段会遇到的大部分问题。
6.1 报错与异常速查表
| 现象 | 原因 | 解决 |
|---|---|---|
TypeError: association_rules() missing 1 required positional argument: 'num_itemsets' | mlxtend 0.23+ 新增必填参数 | 传入num_itemsets=len(frequent_items) |
ValueError: Columns must be same length as key | 传进去的矩阵含非 0/1 值 | onehot.astype(bool) |
| 频繁项集只有 1 项集,没有 2 项集 | 支持度阈值过高 | 按处方总数换算,降阈值 |
| 结果为空 DataFrame | 筛选条件太严(提升度、置信度同时卡太狠) | 逐条放宽,先只卡支持度看基础盘 |
| 内存撑爆 / 进程被杀 | max_len未设或设太大 | 卡max_len=3~4,先过滤低频列 |
| 导出的 CSV 在 Excel 里中文乱码 | 编码问题 | encoding="utf-8-sig" |
| 同一味药出现两个近似列 | 别名未归一 | 建别名字典 + 正则去括号 |
| 规则条数上万,无法阅读 | 高频项污染 + 阈值太低 | 剔除万金油项 + 提高支持度下限 |
6.2 结果"看起来没意义"的几个真实原因
很多人跑完发现规则全是"甘草 → 陈皮"这种,觉得算法没用。其实问题往往不在算法,在数据或参数。
原因一:样本量太小。只有三百张处方的时候,支持度设 0.01 就是 3 次共现,统计噪音完全盖过信号,出来的规则自然像随机抽的。这种情况建议先积累数据,或者把支持度设到 0.05 以上,只看最强的两条项组合,别贪多。
原因二:没有剔除高频项。前面反复说过,万金油药材会占据大量规则额度。加一列"是否含高频项"标记,把这类规则单独抽出来,剩下的才是真正值得看的。
原因三:时间跨度混在一起。如果把三年的处方混成一坨,季节性规律会被平均掉。比如某几味药只在冬季方子里高频共现,全年数据一平均,支持度就掉到阈值以下了。我现在的做法是按季度或按半年切片,分别挖一遍,对比不同时间窗口的差异,经常能发现单一全局分析看不到的模式。
原因四:数据本身没有共现结构。这个要接受现实。有些业务场景下,药材组合本来就是随机的,硬挖一定是噪音。判断方法很直接——看提升度的分布,如果绝大多数规则集中在 1.0~1.15,说明数据里就不存在强关联,这时候应该回头质疑"这个分析目标是否本身就不成立",而不是继续调参。
6.3 几个只有真跑过才会知道的细节
frozenset的坑。association_rules输出的antecedents和consequents是frozenset类型,直接to_csv会变成对象字符串,在 Excel 里既不能筛选也不能排序。必须先用" + ".join(sorted(x))转成普通字符串。我第一次交报告没做这一步,业务方打开表格一脸茫然。
排序键的选择。默认按support排序,看的是"多常见";按lift排序,看的是"多强"。给业务方的版本我一般按lift排,因为支持度高的那批他们凭经验本来就知道;给库存岗的版本按support排,因为他们关心的是量。
阈值不要一次定死。我现在的流程是先跑三组阈值(比如支持度 0.02 / 0.01 / 0.005),把三组结果放在一起对比。如果规则在三组里都稳定出现,那这条规则的可信度就比较高;只在最低阈值那组出现的,多半是噪音。这个交叉验证的思路比单跑一次靠谱得多。
中文字体。matplotlib 画图不设font.sans-serif的话,所有中文会变成方块。这个坑几乎人人踩一次,加上SimHei就行,Mac 上换成PingFang SC。
结果要留版本。每次调参跑出来的规则表都要带日期和参数存档,比如rules_20250101_sup001_lift12.csv。不然后面业务方问"上次那个结果怎么和现在不一样",你根本说不清是哪次改了什么。
7. 我个人在几个项目里沉淀下来的用法
最后分享几个不太写在文档里的实际做法,都是踩过坑之后总结的。
第一件事是先跑一遍"单味药频次榜"再开始挖规则。这一步花不了几分钟,但能立刻识别出哪些是万金油、哪些是稀有项,后面设阈值和做过滤全靠这份榜。我一般会把频次榜直接发给业务方确认一遍,"这几个是不是确实什么方子都有",得到确认之后再动手,能省掉大量后期解释。
第二件事是规则数量控制在 100 条以内再交付。几千条规则的表格没人看,等于没做。我的做法是按提升度排序取前 100,再人工过一遍,把明显是数据问题的(比如某味药在某段时间录入系统出错导致的异常高频)剔掉,剩下的配上几行说明,一页纸讲清楚。交付物的价值不在于全,在于能被人用起来。
第三件事是把分析结果和实际业务动作做一次闭环验证。比如根据高频组合调整了货位,一个月后回头看抓药时长有没有变化;比如根据规则清单给业务方看,问他们"这些组合你们平时是不是也这么用",如果对不上,那大概率是数据有问题,而不是他们经验有问题。这一步很多人会跳过,但它是判断分析到底有没有价值的唯一办法。
还有一个算不上技巧但很重要的认知:Apriori 挖出来的东西是"过去发生了什么",不是"应该发生什么"。它能告诉你某两味药在历史数据里经常一起出现,但不能告诉你为什么,更不能告诉你这样配对不对。真正的价值在于把数据里的模式摊开,让有经验的人去对照、去验证、去发现"原来是这样"或者"这里不对劲"。把它当成一面镜子,比当成一个答案,用处大得多。