去年跑一个药物重定位项目,需要把Drugbank的全量XML数据吃进去。我当时想得太简单了,直接一个ET.parse()把整个文件读进内存,结果笔记本风扇狂转到起飞,16G内存被吃干抹净,连鼠标都拖不动——那种挫败感直到今天我还记得。后来老老实实研究Drugbank XML的结构、命名空间、事件流解析,才把这条路彻底走通。
这篇东西就是把那次从"暴力解析"到"优雅吃下几GB数据"的全过程做个复盘。如果你正在做药物信息挖掘、靶点网络构建,或者只是想把Drugbank的数据导成自己的结构化格式,这篇文章应该能帮你省下不少试错时间。
1. Drugbank XML长什么样:拿到文件先弄清这四件事
1.1 数据分发格式与文件获取
Drugbank在官网的Downloads页面提供多种格式的数据分发,最常见的是XML和SDI(SDF格式)。SDI适合做化学结构搜索,但想拿药物描述、靶点、酶、通路这些关联信息,XML几乎是唯一选择。
文件下载下来是一个drugbank_all_full_database.xml.gz压缩包,解压后正式XML文件体积通常在4到6GB之间,不同版本有差异。最新版本中大约包含一万五千多个药物条目,每个条目内部嵌套的靶点、酶、载体、转运体和通路信息量非常大。
有个前提必须先说清楚:Drugbank数据分为学术和非学术两套授权体系,学术用途下载没问题,商业用途需要走审批流程。自己做研究、发论文,走学术渠道就行。
1.2 XML顶层结构:drug节点与命名空间
解压后别急着写代码,先把文件头打开看一眼。Drugbank XML的根节点长这样:
<drugbank xmlns="http://www.drugbank.ca" version="5.1.x" exported-on="2023-..."> <drug type="biotech" created="2005-06-13" updated="2023-01-03"> ... </drug> <drug type="small molecule" created="..." updated="..."> ... </drug> </drugbank>这里有个极其容易踩的坑:整个文档声明了默认命名空间xmlns="http://www.drugbank.ca"。这意味着你所有的find、findall路径都不能只写标签名,必须带上命名空间,否则结果永远是空。
我最初就是没意识到这一点,写了个root.findall("drug"),返回值是[],一时间怀疑人生,以为是文件没加载对。
1.3 从sample文件验证解析路径
大文件不能直接拿完整版做实验,我的做法是从完整文件中抽取一条记录,生成一个小的sample文件用来写代码验证。
Linux下可以用sed快速截取前几十行看结构,但XML不像JSON那样按行分割,一条drug记录可能占几十KB甚至上百KB,而且本身跨多行。我建议先用Python配合iterparse抓出第一条完整drug记录,写入sample文件,后续所有解析逻辑都在这个sample上开发调试,跑通了再上全量数据。
另外,Drugbank官网会提供XML的XSD Schema文件,也就是schema.dtd或drugbank.xsd。对比Schema可以准确掌握节点层级关系,比对着原始XML眼睛看好用得多,特别是面对大量嵌套的polypeptide、pathway这类深层节点时,有个结构图在手边会省力很多。
2. 解析方案选型对比:为什么我推荐iterparse这条路线
2.1 四条技术路线的取舍
面对这种几个GB的XML文件,可以用的技术路线大概有四条,我实际对比过,先列个表:
| 方案 | 实现方式 | 内存占用 | 速度 | 适用场景 |
|---|---|---|---|---|
| 一次性DOM解析 | ET.parse()或lxml.etree.parse() | 极高,与文件体积成正比 | 最快 | 只适合几十MB以内的小文件 |
| 事件流解析 | ET.iterparse() | 低且可控 | 中等 | 标准库方案,生产级推荐 |
| 流式+底层加速 | lxml.etree.iterparse() | 低且可控 | 较快 | 有第三方依赖时首选 |
| SAX | xml.sax | 极低 | 中等 | 只关心极少字段,不构建对象 |
2.2 iterparse的核心机制:事件驱动+内存回收
iterparse的原理是边读边解析,每遇到一个结束标签就触发一次事件,事件回调里可以拿到当前节点。关键是,处理完这个节点之后,显式执行elem.clear()把该节点从内存中释放,这样整个文件跑完,内存峰值只取决于最深的一条解析路径,而不是文件总大小。
类比一下:一次性DOM解析相当于把整本书全部复印一遍放在桌上再开始看,而iterparse是一页页翻完,看完一页就扔掉一页。对于Drugbank XML这种动辄几GB的文件,前者内存必然爆,后者可以稳定跑完。
选择标准库还是lxml,其实是个朴素的问题:如果跑代码的机器上有权限装第三方库,直接用lxml。如果目标环境是受限服务器或者某个封闭科研集群,那标准库方案足够稳。两个版本的代码结构几乎一样,替换成本非常低。
我这里先展示标准库方案,这是最保底的做法。
3. 核心解析代码:把药物、靶点、通路逐层拆出来
3.1 基础辅助函数与命名空间处理
命名空间先固化成常量,后续所有路径都用前缀加标签名,这是避免空结果的前提:
import xml.etree.ElementTree as ET NS = {"db": "http://www.drugbank.ca"}为了减少重复代码,我先封装两个小工具函数,专门从当前drug节点里提取文本值:
def get_text(elem, xpath): node = elem.find(xpath, NS) if node is not None and node.text: return node.text.strip() return None def get_texts(elem, xpath): nodes = elem.findall(xpath, NS) return [node.text.strip() for node in nodes if node is not None and node.text]这两个函数看起来简单,但后面所有字段提取全靠它们。Drugbank XML里有大量节点是可以出现多次的,比如drugbank-id、groups下的group、靶点下的actions,统一用列表接收更安全。
3.2 药物主字段解析:drugbank-id、groups与description清洗
药物主字段包括drugbank-id、name、type、description、groups等。这里有个细节:一个药物并不只有一个drugbank-id,其中带primary="true"属性的才是主ID,其余的是历史版本ID或外部数据库映射ID。
def parse_drug_basic(drug_elem): primary_id = None all_ids = [] for dbid in drug_elem.findall("db:drugbank-id", NS): if dbid.text: all_ids.append(dbid.text.strip()) if dbid.get("primary") == "true" and dbid.text: primary_id = dbid.text.strip() drug_type = drug_elem.get("type") name = get_text(drug_elem, "db:name") description = get_text(drug_elem, "db:description") groups = get_texts(drug_elem, "db:groups/db:group") return { "primary_id": primary_id, "all_ids": all_ids, "type": drug_type, "name": name, "description": description, "groups": groups, }description字段有个隐蔽问题:内容里混有HTML标签。Drugbank的描述不是纯文本,里面带着<p>、<i>、<b>这些标签。如果你要把数据落到数据库或做文本分析,这些标签会干扰。我在实际处理时用了一个简单的清理函数:
import re def clean_html(text): if not text: return "" text = re.sub(r"<[^>]+>", "", text) return text.strip()不过要提醒一句:如果只是做网页展示,保留HTML反而是对的。是否清理取决于下游用途,我在项目里两种数据都保留了,原始字段和清洗字段各存一列,用的时候按需取。
3.3 靶点/酶/载体/转运体统一解析
Drugbank的靶点(targets)、酶(enzymes)、载体(carriers)、转运体(transporters)这四类节点,XML结构高度相似。父节点是targets、enzymes这些复数形式,子节点是target、enzyme这样的单数形式。结构相似意味着可以写一个通用函数,按照传入的节点名批量处理。
def parse_proteins(drug_elem, parent_tag, child_tag): results = [] parent_node = drug_elem.find(f"db:{parent_tag}", NS) if parent_node is None: return results for child in parent_node.findall(f"db:{child_tag}", NS): protein_id = get_text(child, "db:id") name = get_text(child, "db:name") organism = get_text(child, "db:organism") actions = get_texts(child, "db:actions/db:action") uniprot_id = None gene = None poly = child.find("db:polypeptide", NS) if poly is not None: uniprot_id = get_text(poly, "db:uniprot-id") gene = get_text(poly, "db:gene") # 也可以继续取外部标识符,一般够用了 results.append({ "protein_id": protein_id, "name": name, "organism": organism, "actions": actions, "uniprot_id": uniprot_id, "gene": gene, }) return results调用时分别传参:
targets = parse_proteins(drug_elem, "targets", "target") enzymes = parse_proteins(drug_elem, "enzymes", "enzyme") carriers = parse_proteins(drug_elem, "carriers", "carrier") transporters = parse_proteins(drug_elem, "transporters", "transporter")这里最常见的漏坑是:一个靶点可能有多个actions,也可能一个action都没有。我的实际经验是解析后用len()统计一下,再对比Drugbank网页上该药物的数据量,如果偏差太大,说明解析路径有问题,排查方向基本都集中在polypeptide节点的嵌套层级上。
3.4 通路与smpdb关联解析
通路信息在pathways节点下,每个pathway节点包含smpdb-id、name、category,以及通路关联的蛋白质Uniprot ID列表。
def parse_pathways(drug_elem): pathways = [] pw_node = drug_elem.find("db:pathways", NS) if pw_node is None: return pathways for pathway in pw_node.findall("db:pathway", NS): enzymes_node = pathway.find("db:enzymes", NS) uniprot_ids = [] if enzymes_node is not None: for uniprot in enzymes_node.findall("db:uniprot-id", NS): if uniprot.text: uniprot_ids.append(uniprot.text.strip()) pathways.append({ "smpdb_id": get_text(pathway, "db:smpdb-id"), "name": get_text(pathway, "db:name"), "category": get_text(pathway, "db:category"), "uniprot_ids": uniprot_ids, }) return pathways通路数据在构建代谢网络或是做通路富集分析时非常有用。解析出的smpdb-id可以直接关联SMPDB数据库,拿到更详细的通路图和分子互作信息。
4. 大文件解析的性能瓶颈与踩坑现场
4.1 内存爆炸的第一课:为什么不能一次性parse
ET.parse()把整个XML读进内存构建DOM树。Drugbank全量XML解压后有几个GB,DOM树在内存中的膨胀系数通常是文件体积的3到8倍,也就是说,实际占用可能逼近十几GB甚至更高。普通开发机16GB内存根本扛不住。
ET.iterparse()的事件流处理方式不一样,它借助XML解析器的增量解析能力,读一段解析一段。配合节点释放,内存峰值可以控制在百MB级别。我的实测数据:解析全量Drugbank XML(约5GB),标准库iterparse版本内存占用稳定在300MB上下,lxml版本则更低,大约200MB左右。
4.2 四个容易踩的坑及定位方法
第一个坑是命名空间问题。前面说过,findall("drug")永远返回空,必须写findall("db:drug", NS)。这几乎是所有初识XML的人都会撞上的问题,排查方法很简单:把根节点打印出来看tag属性,会发现它带着长长的一串命名空间前缀。
第二个坑是drugbank-id的primary属性筛选。一个药物有多个drugbank-id,如果不加区分把最后一个当成主ID,后面做关联查询时会对不上。必须遍历所有drugbank-id,检查primary属性值是否为true。标准库的XPath支持有限,不支持复杂的[@primary="true"]写法,所以手动遍历是最稳的。
第三个坑是<drug type>的获取方式。type是drug节点的XML属性,不是子节点,不能走findtext,必须用drug_elem.get("type")。我见过有人在这上面卡了半小时,明明数据都在文件里,就是取不到值。这是XML属性和节点的基本概念问题,新手特别容易混淆。
第四个坑是description中的HTML标签。之前提过,<p>、<i>、<b>这些标签混在文本中,如果直接入库,后续做关键词搜索或文本匹配时会出现一堆奇怪的干扰项。建议清洗前先统计一下需要清理的标签类型,不同版本的Drugbank描述格式有差异,正则表达式要兼容多种情况。
4.3 全量解析的主循环与控制逻辑
主循环是整个解析过程的骨架,分层级处理。我的做法是:把所有drug的解析结果先暂存到内存列表,每处理完1000条批量写一次数据库,写完立即清理列表。这样既减少了数据库连接开销,又能保持内存稳定。
def parse_drugbank_xml(file_path, batch_size=1000, on_batch=None): batch = [] count = 0 for event, elem in ET.iterparse(file_path, events=("end",)): if elem.tag != f'{{http://www.drugbank.ca}}drug': continue drug_basic = parse_drug_basic(elem) targets = parse_proteins(elem, "targets", "target") enzymes = parse_proteins(elem, "enzymes", "enzyme") carriers = parse_proteins(elem, "carriers", "carrier") transporters = parse_proteins(elem, "transporters", "transporter") pathways = parse_pathways(elem) batch.append({ "drug": drug_basic, "targets": targets, "enzymes": enzymes, "carriers": carriers, "transporters": transporters, "pathways": pathways, }) count += 1 elem.clear() if len(batch) >= batch_size: if on_batch: on_batch(batch, count) batch.clear() if batch: if on_batch: on_batch(batch, count)elem.clear()这行特别关键,忘了加的话,内存还是会缓慢增长,解析到最后依然可能被系统杀掉。加上这一行才真正做到了线性内存占用。
5. 解析结果怎么落库:从XML到SQLite再到下游应用
5.1 表结构设计与批量写入
Drugbank数据属于多对多网络结构,不适合单表平铺。我选了SQLite做落地存储,简单轻量,不需要单独部署数据库服务。
主表存药物基本信息,子表分别存蛋白质关联和通路关联:
CREATE TABLE drugs ( drugbank_id TEXT PRIMARY KEY, name TEXT, type TEXT, groups TEXT, description TEXT ); CREATE TABLE proteins ( id INTEGER PRIMARY KEY AUTOINCREMENT, drugbank_id TEXT, category TEXT, protein_id TEXT, name TEXT, organism TEXT, actions TEXT, uniprot_id TEXT, gene TEXT ); CREATE TABLE pathways ( id INTEGER PRIMARY KEY AUTOINCREMENT, drugbank_id TEXT, smpdb_id TEXT, name TEXT, category TEXT, uniprot_ids TEXT );groups和actions这类多值字段可以直接用中文逗号拼接成字符串存储,查询时再拆分。相比额外建关联表,这种方式对大多数分析场景更实用。
批量写入时,SQLite的executemany配合事务能把效率提升一个数量级。每处理完一批drug记录,统一执行一次commit,避免每条都提交导致磁盘频繁刷写。
5.2 三条数据应用方向参考
数据落库后的应用方向取决于你的业务诉求,我实际尝试过的有三条:
第一,构建药物-靶点网络。把drugs表和proteins表关联起来,导入图数据库(Neo4j或NetworkX),可以快速分析一个靶点被多少药物作用、一个药物影响了多少靶点。这是药物重定位研究最常用的分析思路。
第二,药物属性统计分析。通过type字段可以统计小分子药物和生物药的比例;通过groups字段可以统计上市药物、实验药物、撤市药物的数量分布。这些统计结果可以用来做数据质量控制,验证解析过程有没有丢数据——比如统计出来的总数和Drugbank官方公布的数量对不上,那一定是解析逻辑出问题了。
第三,通路富集分析。把pathways表的uniprot_ids关联到基因列表,用clusterProfiler这类工具做KEGG或GO富集分析,可以快速观察某个药物集合集中在哪些生物学通路上。这项分析在药物机制研究中特别常用。
6. 如果换一种数据源,这套解析思路怎么复用
6.1 Drugbank之外的同类生物数据库
Drugbank的数据模型其实很有代表性。ChEMBL也提供XML下载,只是分组逻辑变成以化合物为中心;UniProt的XML则围绕蛋白质条目组织;PubMed的XML围绕文献条目组织。表面上看数据结构千差万别,但只要掌握了"命名空间处理、事件流解析、节点粒度切分、分批持久化"这套方法论,换数据库只是换个标签名的事。
6.2 通用XML解析方法论复盘
把经验抽象成可复用的步骤,对做数据工程的同行可能更有参考价值:
不管是几MB还是几GB的XML,动手前先花半小时看Schema。用解析小sample文件的方式验证所有字段路径,确认无误后再上全量。如果文件是流式结构,优先选择iterparse,不要因为代码写着复杂就退回不太现实的一天再说方案。处理完的节点记得clear,否则内存累积还是会出问题。批量提交数据库,哪怕最终结果只是存成CSV逐行追加也行,关键是别一次性把所有数据堆在内存里最后统一写。
只要守住这几条,XML解析的坑基本就避开了绝大部分。
按我自己的经验,最磨人的不是写解析代码本身,而是在数据量巨大的时候反复跑、反复看结果对不对。特别是Drugbank这种更新频繁的数据库,每个版本的字段层级可能都有微调,跑完解析之后务必抽几条记录和Drugbank官网页面做人工比对。这个步耗不了多少时间,但能避免后面下游分析全部建立在错误数据上的灾难性后果。
顺带分享一个小技巧:如果你只是临时想查某个药物的具体靶点,不想写完整解析脚本,可以直接用grep把<drugbank-id>DB00001</drugbank-id>所在位置前后的内容拉出来,视觉上快速验证。正式做项目时再走完整解析流程,两种方式搭配使用效率最高。