【医药AI实战系列①】我用Python挖出了一个“隐藏“的药物不良反应信号
系列说明:这是"医药AI工程师手册"系列的第一篇。不讲理论,只讲我在实际项目中踩过的坑、跑通的代码和看到的结果。后续会陆续更新分子生成、临床数据治理、医疗大模型RAG等方向。欢迎关注,不定期更新。先说一个真实的背景2012年,GlaxoSmithKline因为隐瞒帕罗西汀(Paxil)的安全性数据,被美国司法部罚了30亿美元。这是当时医药史上最大的罚款。但问题不全是"隐瞒"——有相当一部分是真的没发现。药物上市后,不良反应(Adverse Drug Reaction,ADR)数据散落在医院电子病历、自发报告系统、保险理赔数据里,没有人有系统性的方法把信号从噪音里挖出来。药物警戒(Pharmacovigilance)这个岗位大量依赖人工审阅。一个资深审阅员每天处理报告的数量是有上限的。这个限制,AI可以打破。这篇文章要做什么用FDA FAERS(不良事件报告系统)公开数据集,从零搭建一个 ADR 信号挖掘 pipeline,核心算法是药物警戒领域用了几十年的经典方法:PRR(Proportional Reporting Ratio)和ROR(Reporting Odds Ratio),然后加入一点现代的东西——用 Embedding 对症状文本做语义聚类。数据来源FAERS 数据是 FDA 强制要求药厂、医院、患者上报的不良事件数据库,季度更新,完全公开:🔗 https://www.fda.gov/drugs/surveillance/fda-adverse-event-monitoring-system-aems我们用 2023Q4 的数据,核心用到三张表:文件内容DRUG23Q4.txt报告中涉及的药物REAC23Q4.txt对应的不良反应(PT编码)DEMO23Q4.txt报告人口统计信息PT 是 MedDRA(国际医学用语词典)的术语,标准化的。不用担心同一个症状叫法不同的问题。环境准备pipinstallpandas numpy scipy matplotlib seaborn sentence-transformers scikit-learn tqdmPython = 3.9 即可。Part 1:数据加载与清洗FAERS 的原始数据是$分隔的,坑比较多,先统一处理:importpandasaspdimportnumpyasnpimportwarnings warnings.filterwarnings('ignore')# ---- 加载核心三张表 ----# 注意:FAERS 列名全大写,且存在重复报告 (primaryid / caseid)# 这里只保留 primary reports,去掉 follow-up 重复条目defload_faers_table(path:str,sep:str='$')-pd.DataFrame:""" FAERS 原始文件加载器 处理:编码异常、列名标准化、重复行 """df=pd.read_csv(path,sep=sep,encoding='latin-1',# FAERS 老文件有 latin-1 编码dtype=str,# 全部读为字符串,防止ID被转成浮点on_bad_lines='skip',# 少量脏行直接跳过low_memory=False)df.columns=df.columns.str.strip().str.lower()returndf drug_df=load_faers_table('DRUG23Q4.txt')reac_df=load_faers_table('REAC23Q4.txt')demo_df=load_faers_table('DEMO23Q4.txt')print(f"Drug records:{len(drug_df):,}")print(f"Reaction records:{len(reac_df):,}")print(f"Demo records:{len(demo_df):,}")输出大概是:Drug records: 1,847,392 Reaction records: 2,134,561 Demo records: 412,837去重逻辑(这里是个大坑)FAERS 存在"follow-up report",同一个 case 会多次更新。如果不去重,信号会被夸大。# Demo 表里有 rept_cod 字段,'EXP' = expedited (initial/follow-up)# primaryid 唯一标识一份报告,caseid 对应同一个病例# 策略:每个 caseid 只保留最新的 primaryiddemo_df['primaryid']=demo_df['primaryid'].astype(str)demo_df['caseid']=demo_df['caseid'].astype(str)demo_df['fda_dt']=pd.to_numeric(demo_df['fda_dt'],errors='coerce')# 每个 case 保留 fda_dt 最大的那条demo_dedup=(demo_df.sort_values('fda_dt',ascending=False).drop_duplicates(subset='caseid',keep='first'))valid_primaryids=set(demo_dedup['primaryid'].tolist())print(f"去重后有效报告数:{len(valid_primaryids):,}")# 过滤药物和反应表drug_clean=drug_df[drug_df['primaryid'].isin(valid_primaryids)].copy()reac_clean=reac_df[reac_df['primaryid'].isin(valid_primaryids)].copy()Part 2:构建药物-反应共现矩阵这是信号挖掘的核心数据结构。# 标准化药物名:全小写,去空格# 实际项目里这里要接药品字典做映射,公开数据直接用名字凑合drug_clean['drugname']=(drug_clean['drugname'].str.strip().str.upper())# 只关注"可疑药物"(role_cod == 'PS' 或 'SS')# C = concomitant,不是我们关注的主要对象suspect_drug=drug_clean[drug_clean['role_cod'].isin(['PS','SS'])].copy()# 合并:每个 primaryid - 药物列表 + 反应列表drug_pivot=(suspect_drug.groupby('primaryid')['drugname'].apply(list).reset_index().rename(columns={'drugname':'drugs'}))reac_pivot=(reac_clean.groupby('primaryid')['pt'].apply(list).reset_index().rename(columns={'pt':'reactions'}))report_df=drug_pivot.merge(reac_pivot,on='primaryid',how=