简介:一份围绕FAERS数据库开展培塞利珠单抗不良事件信号挖掘与分析的完整研究报告,适合药物警戒从业者、药学研究与医学数据分析人员,也适合关注大模型与真实世界药物安全数据结合的人工智能学习者使用。资源包为单个docx文档,大小仅93KB,轻量却不失系统:全文按研究框架展开,涵盖研究背景与意义、FAERS数据获取与预处理(数据清洗、标签标准化、人口学筛选)、上市后不良反应信号识别技术、比率比及95%置信区间计算、统计分析策略、信号评估标准,并对主要不良事件信号及神经系统、皮肤及附件、消化系统等系统器官分类信号进行梳理,同时包含年龄、性别、基础疾病和治疗方案的亚组分析,以及临床意义解读、数据局限性讨论。文档既能帮助读者掌握FAERS药物警戒数据挖掘的完整方法与流程,也可作为同类课题或论文的方法学参考与写作框架。目前已62人学习,值得相关领域研究者阅读借鉴。
1. 药物警戒里的信号挖掘:FAERS遇上培塞利珠单抗,先算清楚四格表
FAERS里的培塞利珠单抗不良事件信号挖掘,说到底就是在回答一个问题:这款TNF-α抑制剂在真实世界里的不良反应报告,是不是比背景更密集。做法绕不开一张四格表——目标药物、目标不良事件、其他药物、其他事件,两两交叉之后算一个统计量。做这个分析不是为了凑论文,而是给说明书修订、产品风险评估和上市后监测体系提供前置证据。适合药物警戒专员、真实世界研究工程师和数据从业者照着复现。我的体感是:真正卡住进度的往往不是算法,而是数据下载不全、去重口径漂移、合并季度包时列对不上。信号计算本身是透明的,数据标准化才决定结果成色。
2. 从FAERS到可分析表:下载、四张核心表与OpenVigil的取舍
2.1 官方季度包的下载与解压逻辑
FAERS公开数据按季度发布,老数据文件是ASCII文本,结构与早期SRS一脉相承;新季度则有对应的ASCII与XML双轨打包。文件名形态一般是faers_ascii_YYYYqN.zip,里面按类型拆成若干TXT文件。对多数分析场景,我建议直接无脑用ASCII包:字段少、解析便宜、历史一致性比XML好,XML更适合做全字段审计或提交给监管口径的场景。
下载这一步常见做法是脚本定时拉取,文件体积不算夸张,但解压时要注意文件名里带的多余空格和大小写。一个能稳定跑过的下载与解压片段长这样:
#!/bin/bash YEAR="2024"; QTR="Q4" BASE_URL="https://example.local/filers/faers/ascii" # 替换为当前季度文件实际路径 FILE="faers_ascii_${YEAR}${QTR}.zip" # 检查命令行工具 command -v curl || { echo "缺少 curl"; exit 1; } command -v unzip || { echo "缺少 unzip"; exit 1; } # 下载并直接解压到工作目录 curl -fSL -o "$FILE" "$BASE_URL/$FILE" && unzip -o "$FILE"这段脚本里-fSL的三个参数分别代表失败即停、跟随重定向、安静模式,避免下载一半静默出错;-o指定落盘文件名。解压后你应该能看到DEMO、DRUG、REAC、INDI、THER等开头的文件清单。实际操作中最大的坑不是下载,而是解压出来的文件列顺序在跨季度时可能变化,这一点放到第5章展开。
2.2 四张核心表的业务边界与关联键
拿到原始压缩包后,第一件事不是急着算信号,而是把表和表之间的关系理顺。FAERS里没有一张现成的宽表,核心信息散在四张表里,全部靠报告标识符串联。
| 表 | 内容边界 | 关键字段 | 常见用途 |
|---|---|---|---|
| DEMO | 病例人口学与报告元信息 | PRIMARYID、CASEID、VERSION、EVENT_DT | 主表,负责去重和时间窗 |
| DRUG | 每个报告涉及的药物 | PRIMARYID、DRUGNAME、ROLE_COD | 药物维度,目标药在报告中的角色 |
| REAC | 报出的不良事件术语 | PRIMARYID、PT | 事件维度,做MedDRA映射后分箱 |
| INDI | 药物对应的适应症 | PRIMARYID、INDI_PT | 排除或分层适应症背景 |
四张表统一用PRIMARYID做关联键,CASEID只在跨版本去重时用。ROLE_COD决定某条药物记录是主要怀疑药(PS)、次要怀疑药(SS)、伴随用药(C)还是交互药(I)。我在处理生物制剂时,统计口径默认只保留PS和SS,C和I会大幅稀释信号,后面第5章会专门讲这个坑。
读取这些文件本身很简单,因为老格式是竖线分隔文本,但文件可能带表头行、说明行。用 pandas 读取的常见姿势:
import pandas as pd df_reac = pd.read_csv("REAC24Q4.TXT", sep="|", dtype={"PRIMARYID": "str"}) df_reac = df_reac.rename(columns=lambda x: x.strip()) df_drug = pd.read_csv("DRUG24Q4.TXT", sep="|", dtype={"PRIMARYID": "str"}) df_drug = df_drug.rename(columns=lambda x: x.strip()) print(df_drug.columns.tolist())留意两点:PRIMARYID必须先按字符串读,否则跨年份合并时前导零会出幺蛾子;rename(columns=lambda x: x.strip())是因为原始文件列名可能带空格,不清理后面 merge 会莫名报错。
3. 数据清洗的两道坎:案例级去重与医学本体标准化
3.1 案例去重必须以 PRIMARYID 为主键
FAERS 报告里同一个不良事件病例可能被多次提交,官方有去重机制:同一CASEID下不同VERSION代表同一病例的事后修订或补充,最终有效的是版本号最大的那条。很多初做信号挖掘的人只按PRIMARYID过滤,结果就是把同一病例的修正版本当成独立报告,目标药的计数被系统性放大,算出来的 ROR 虚高。
我一般会这样处理:先按照CASEID和VERSION降序排序,然后对每个CASEID只保留VERSION最大的那一行,再以此为准去关联DRUG、REAC、INDI。但有一个多年悬案需要特别注意:部分季度包里CASEID列会有缺失,有的是整列空白,这时候只能在PRIMARYID层面先做一次全字段去重。稳妥的顺序是先看缺失率,再决定去重策略。
# 案例级去重:同一 CASEID 只保留最新版本 df_demo = df_demo.sort_values(["CASEID", "VERSION"], ascending=[True, False]) df_demo_dedup = df_demo.drop_duplicates(subset=["CASEID"], keep="first") # 如果 CASEID 大量缺失,退化为 PRIMARYID 全量去重 if df_demo_dedup["CASEID"].isna().sum() > 1000: df_demo_dedup = df_demo_dedup.drop_duplicates(subset=["PRIMARYID"], keep="last") print("原始报告数:", len(df_demo)) print("去重后报告数:", len(df_demo_dedup))这段代码的设计逻辑是把缺失最严重的那一年单独拎出来处理,而不是混在正常年份里统一按CASEID去重——一旦把空值也当成分组键合并,结果会相当诡异。真实案例里我曾遇到某一年度数据整体缺失CASEID,直接用caseid.notna()过滤会一次性丢掉上万条报告,而退化为PRIMARYID去重反而能保住样本量。这条血泪经验直接影响所有后续信号计算。
3.2 本体标准化:MedDRA 映射与药物名归一
清洗完案例层,下一个硬骨头是文本归一。FAERS 的药物名和事件名都是自由文本,不是标准编码,同一款培塞利珠单抗在报告里可能出现CERTOLIZUMAB PEGOL、CIMZIA、certolizumab等下划线缺失或大小写不一致的脏名。不良事件这一侧,不同报告可能把同一医学概念拆分在不同粒度的术语里,信号分析前必须映射到 MedDRA 偏好术语(PT)。
药物名归一我倾向于用保守方案:大写化、去括号注释、去空白字符,然后按一个参照表做同义词合并。常见做法是把商品名和通用名都归到同一规范名,参照表可以用 WHO Drug 词典的公开子集生成。代码上就是标准化的归一函数:
import re def normalize_drug_name(name: str) -> str: if not isinstance(name, str): return "" # 全大写,去除所有非字母数字字符,保留空格分隔 name = name.upper() name = re.sub(r"\([^)]*\)", "", name) # 去掉括号注释 name = re.sub(r"[^A-Z0-9 ]+", "", name) # 去掉标点 name = re.sub(r"\s+", " ", name).strip() return name df_drug["DRUGNAME_NORM"] = df_drug["DRUGNAME"].apply(normalize_drug_name) # 以培塞利珠单抗为例,合并词形差异 target_drug_syns = {"CERTOLIZUMAB PEGOL", "CIMZIA", "CERTOLIZUMAB PEGOL INJECTION"} df_drug["IS_TARGET"] = df_drug["DRUGNAME_NORM"].isin(target_drug_syns)这个函数里re.sub(r"\([^)]*\)", "", name)是把CERTOLIZUMAB PEGOL (INJECTION)这类括号里的给药途径剥掉,因为同一药物的注射剂型差异不应该成为拆分信号的理由。IS_TARGET列会被后续的四格表计数直接引用,建议生成之后单独存一份parquet作为中间层,避免每次换季度都重算一遍。
MedDRA 映射则是另一件事。正式做信号挖掘,不良事件要在 PT 层统计,多数本地化做法是把原始 PT 文本与 MedDRA 发布版词典文件做精确匹配,无法映射的留到UNMATCHED_PT桶里人工决策。映射时要警惕一词多义,比如INJECTION SITE PAIN与PAIN是两个 PT,不能靠子串包含简单合并。匹配后用 MedDRA 的 SOC/HLGT 层级做器官系统分箱,方便后续观察培塞利珠单抗信号集中在哪个系统。
4. 信号计算实现:四格表、ROR、PRR 与 BCPNN
4.1 构建四格表:把长表转成计数矩阵
清洗完的宽表到了信号计算这一步,就可以退回到经典的四格表框架。四格表的四个单元是:目标药物且目标事件(a)、目标药物其他事件(b)、其他药物目标事件(c)、其他药物其他事件(d)。以培塞利珠单抗和某个具体 PT 为例,构建逻辑是先筛出报告集,再按事件维度展开。
# 先建立报告-事件 一对多矩阵 df_report_pt = df_reac.merge( df_demo_dedup[["PRIMARYID"]], on="PRIMARYID", how="inner" ) df_report_pt["IS_TARGET_DRUG"] = df_report_pt["PRIMARYID"].isin( df_drug.loc[df_drug["IS_TARGET"], "PRIMARYID"] ) # 遍历每个 PT,统计四格表 df_counts = [] for pt, grp in df_report_pt.groupby("PT"): a = int(grp["IS_TARGET_DRUG"].sum()) # 目标药-目标事件 b = int((grp["IS_TARGET_DRUG"] == False).sum()) # 其他药-目标事件 # c 与 d 可以从全库层面一次算好 c = int((df_report_pt["PT"] != pt) & (df_report_pt["IS_TARGET_DRUG"])).sum() d = int((df_report_pt["PT"] != pt) & (~df_report_pt["IS_TARGET_DRUG"])).sum() df_counts.append({"PT": pt, "a": a, "b": b, "c": c, "d": d}) df_signal = pd.DataFrame(df_counts)这里的循环虽然看着粗糙,但每个PT独立计算四格表,逻辑最清楚。b的定义是同一批报告中目标药物在其他 PT 上的计数,c和d要从全表减去当前 PT 的行再统计。如果报告量级较大,可以改用矩阵运算加速,但先跑通循环版对排查口径问题更友好。
4.2 三种方法的统计口径与置信区间
四格表建好后,ROR、PRR、BCPNN 三种方法只是对同一张表的变换。ROR 是比值比,PRR 是比例报告比值,BCPNN 走贝叶斯置信传播,用信息成分IC表示关联强度。三者对稀疏报告的敏感度不一样,培塞利珠单抗这类生物制剂报告基数不算特别大,单用 ROR 容易出虚假高信号,我会把三种方法全部算出来再综合。
import numpy as np def compute_signals(r): a, b, c, d = r["a"], r["b"], r["c"], r["d"] N = a + b + c + d # ROR 与近似置信区间 ror = (a / b) / (c / d) ror_se = np.sqrt(1 / max(a, 1) + 1 / max(b, 1) + 1 / max(c, 1) + 1 / max(d, 1)) ror_low = ror * np.exp(-1.96 * ror_se) ror_high = ror * np.exp(1.96 * ror_se) # PRR prr = (a / (a + b)) / (c / (c + d)) # 近似 χ² chi2 = (N * (a * d - b * c) ** 2) / ( (a + b) * (c + d) * (a + c) * (b + d) + 1e-6 ) return pd.Series({"ROR": ror, "ROR_LO": ror_low, "ROR_HI": ror_high, "PRR": prr, "CHI2": chi2}) df_signal = df_signal.join(df_signal.apply(compute_signals, axis=1))这段里的ror_se用的是标准近似法,+1e-6是为了避免分母为零造成除零警告。实战中a=0的组合应该直接剔除,因为一个零计数在任何方法下都不构成信号。BCPNN 的完整实现要迭代贝叶斯更新,代码量比 ROR 大不少,很多团队直接调现成库或只算IC的近似值。常见做法是在季度分析里用 ROR+PRR 做初筛,再用IC验证关键信号。
三种方法的经验阈值我整理过一张表,每次跑完都会按这张表过滤一遍才进入人工审阅:
| 方法 | 常用阈值 | 备注 |
|---|---|---|
| ROR | ROR_LO > 1,且a ≥ 3 | 事件稀疏时必须手动提高a下限 |
| PRR | PRR ≥ 2,CHI2 ≥ 4 | 对低频率事件很敏感,容易给假信号 |
| BCPNN | IC025 > 0 | 更保守,适合小样本复核 |
参数怎么调,取决于你手里报告的年份跨度。如果合并了十年数据,a ≥ 5和IC025 > 0组合用起来更稳;如果只看了最近两年,a ≥ 3是底线,否则统计功效不足。
5. 信号挖掘避坑篇:四类常见的翻车现场与修复顺序
5.1 季度包跨年合并时列名漂移
现象:第一年跑得好好的分析脚本,换到下一个季度包直接报KeyError或者读进来列错位。
原因:FAERS 历史文件在跨年份时列名出现过变化,例如AGE_COD有时与AGE_GRP的语义互换,甚至某些字段整体改名。竖线分隔文件在列头不一致时,pandas 会按位置或按名称其中一种方式错配,最终合并时数据张冠李戴。
解决:每次下载新季度包后,第一步先做列结构 diff,而不是直接读入主流程。我会把上一次的columns.tolist()存成 JSON,再与新的做set比较,差异列输出到schema_diff.txt。规则很简单:字段名变了就做映射,字段没了就把该列填NaN,字段多了就丢掉。从那以后,我再也没在季度更新时翻过车。
5.2 药物角色过滤失效:PS 之外的统计口径
现象:算出来的培塞利珠单抗不良事件信号数量异常巨大,随便一个 PT 都是阳性。
原因:把DRUG表里所有角色都纳入计数。报告中存在大量伴随用药记录,比如患者在注射培塞利珠单抗的同时口服叶酸、对乙酰氨基酚,这些药物会被记进其他药物组。但更危险的是交互药I和次要怀疑药SS,它们在某些季度数据里编码并不整齐,过滤时会被误当作伴随用药或漏掉,直接扭曲四格表。
解决:默认只保留ROLE_COD为PS和SS的记录,写死过滤条件并输出报告角色分布,每次运行前检查PS占比是否在预期区间。如果某季度PS占比突然从 60% 掉到 40%,先怀疑是角色编码缺失,而不是急着调整阈值。
5.3 全库基线污染导致的信号淹没
现象:某目标药物的信号在早期年份显著,在近两年反而消失,或者某个安全信号在全库分析时看不见。
原因:四格表里的c和d来自全库所有报告,如果某个 PT 本身在其他药物中也很常见,基线就被抬高,稀释了目标药物信号的显著性。这在老年患者、孕妇等特定人群用药场景里特别突出——某个事件本来就高发,目标药只是恰好被报告在同一批患者身上。
解决:分层分析。按年龄分组、性别分组或按报告年份分窗口,再做四格表。我习惯先按时间窗切成两年一段,分别计算 ROR,观察趋势是否稳定。如果早期显著而近期消失,优先怀疑适应症人群结构性变化,而不是药物风险真的降低。
5.4 同一报告多药物多事件的归属混淆
现象:一份报告里同时包含培塞利珠单抗、甲氨蝶呤和十来个不良事件术语,按PRIMARYID一对多展开后,四格表计数把药物和事件组合成了笛卡尔积,重复计算严重。
原因:FAERS 中一条报告记录的DRUG表可能有多行药物,REAC表有多行事件,直接merge之后得到的是乘积组合。逻辑上一条报告里目标药与每个事件都可以组合,是合理的,但如果把同样的组合在不同药物间重复累加,d的值就被系统放大了。
解决:计算四格表时,a的增量逻辑要新增去重条件——同一PRIMARYID下目标药和目标事件组合只计一次。稳妥办法是先对PRIMARYID + PT + IS_TARGET_DRUG做drop_duplicates,再进入四格表计数。这一步看起来简单,对最终 ROR 的影响经常在 10% 以上。
6. 让分析可复现:从增量更新到大模型辅助初筛
6.1 增量更新脚本的长相
信号挖掘一旦纳入常规监测,就不该每月手动下载合并。可以写一个调度脚本按季度自动拉包、解压、跑列结构 diff、清洗去重、更新信号表,最后把阳性信号列表推送成一份 Excel。增量更新的核心不是拉新包,而是维护一张历史累计表作为基线,每季度只计算新增报告带来的信号变化。
def merge_faers_quarters(existing: pd.DataFrame, new_quarter: pd.DataFrame): # 去重策略:以 CASEID+VERSION 为键,保留版本高者 combined = pd.concat([existing, new_quarter], ignore_index=True) combined = combined.sort_values(["CASEID", "VERSION"], ascending=[True, False]) combined = combined.drop_duplicates(subset=["CASEID"], keep="first") return combined这段代码的逻辑是把历史表和季度增量表合并,用CASEID + VERSION判定新旧。没有新报告的空列表可以直接返回旧表,桶内做一次版本穿透,下一次季度更新自动覆盖。
6.2 大模型辅助信号解读:统计之外的第二道筛
信号从统计上阳性到真正值得跟进的,中间还有很长一段路——需要跟历史报告描述、同品种药物既往信号、文献里的不良反应记载做比对。这个环节现在可以用大模型辅助初筛,但我很清楚它的边界:大模型不参与四格表计算,哪个 ROR 算得有多高,那是统计模块的事;大模型的用途仅限于把阳性信号列表和报告原文摘要交给它做知识交叉,给出疑似新信号、已知信号、疑似误报三种初判。一个可以用的提示词模板大致是这样:
你是一名药物警戒评审员。以下是某一药物在 FAERS 中检出的 阳性信号列表,每行包含 PT 术语、ROR 值与报告例数。 请基于你对药物警戒的一般知识,将每条标注为: - KNOWN:常见于药品说明书或既往研究 - POTENTIAL:有文献支持但尚无明确结论 - UNLIKELY:怀疑为混杂或报告偏倚所致 注意:只做初步分类,不要提出用药建议。输出会回到表格里做人工复核,而不是直接作为结论。大模型在这里扮演的是快速文献交叉和术语解释的角色,真正拍板必须看过原始病例描述。用好这层辅助以后,我每个季度的审阅工时大概省了三分之一,但每次换季度依旧强制先跑一遍列结构 diff 再进主流程。
药物信号挖掘的最终交付物不是一张统计表,而是一份能对质疑者解释得通的过程记录。希望你从这批数据里得出的信号经得起推敲,这份资源对你有用。
本文还有配套的精品资源,点击获取