使用RDKit读取SDF文件:从开箱到提取

SDF格式,说白了就是化学数据的通用包装盒。每个分子自带一个结构,还能挂一堆自定义属性。我个人习惯不管项目多急,第一步永远是先把文件读进来,看看里面到底装了什么。

RDKit 提供了两个核心读取器:Chem.SDMolSupplier 和 Chem.SDMolSupplier 的变体。今天我们就啃透它。

Chem.SDMolSupplier 处理流程 SDF 文件(.sdf) Chem.SDMolSupplier (懒加载 · 逐个解析) for mol in supplier: mol.GetPropsAsDict() mol.GetNumAtoms() 提取结果:分子对象 + 属性字典 (注意空分子与键错误) ⚠ 小贴士:Supplier 是懒加载,不会一次性将整个文件读到内存

核心函数:Chem.SDMolSupplier

你只需要一行代码就能创建读取器:

from rdkit import Chem

supplier = Chem.SDMolSupplier('ligands.sdf')

嗯,这里要注意。Chem.SDMolSupplier 返回的不是列表,而是一个迭代器。说白了,它是懒加载的——只有当你循环到某个分子时,它才去解析那一条数据。我在项目中遇到过同事直接 list(supplier) 把整个文件塞进内存,结果一个 5 万分子的文件吃掉了 4GB 内存。没必要。

遍历分子,提取属性

最常见的姿势如下:

for mol in supplier:
    if mol is None:
        continue
    print(mol.GetNumAtoms(), mol.GetProp('_Name'))

为什么会有一个 if mol is None?

因为 SDF 文件里可能存在解析失败的条目。比如坏键、非法原子类型。RDKit 拿它没办法,就返回 None。不跳过的话,下一行代码就会崩。这招是我在早期踩坑学来的。

要提取 SDF 中的自定义属性(就是 > <field_name> 里的那些),用 mol.GetProp():

mol_id = mol.GetProp('ID')
ic50 = float(mol.GetProp('IC50_nM'))
smiles = mol.GetProp('SMILES')

如果属性不存在,RDKit 会抛 KeyError。所以要么先用 mol.HasProp('key') 检查一下,要么用 GetPropsAsDict() 一把抓。

我个人更喜欢后者:

props = mol.GetPropsAsDict()
# props = {'ID': 'CHEM-001', 'IC50_nM': '12.5', 'SMILES': 'CC(=O)Oc1ccccc1C(=O)O'}

这样返回的是一个普通字典,键都是字符串,值可能是字符串、数字或者列表(如果 SDF 里同一个字段出现多次的话)。你放心,有列表的情况很少,但处理管线里加上类型判断总是好的。

跨平台兼容性

你可能在不同操作系统间传文件。Windows 下的 SDF 换行符是 \r\n,Linux 下是 \n。RDKit 内部做了处理,但我自己仍然习惯先统一一下:

with open('ligands.sdf', 'r') as f:
    raw = f.read()
raw = raw.replace('\r\n', '\n').replace('\r', '\n')
supplier = Chem.SDMolSupplier()
提示:如果 SDF 文件名包含非英文字符(比如中文路径),Windows 下请用 open() 再传给 ForwardSDMolSupplier:
from rdkit.Chem import ForwardSDMolSupplier
with open('配体库.sdf', 'rb') as f:
    supplier = ForwardSDMolSupplier(f)

性能与内存管理

SDF 文件动辄几百 MB。我曾处理过一个 2.3 GB 的化合物库。如果一口气 list(supplier),内存直接爆炸。正确的做法是分批处理:

def batch_process(supplier, batch_size=1000):
    batch = []
    for idx, mol in enumerate(supplier):
        if mol is None:
            continue
        batch.append(mol)
        if (idx + 1) % batch_size == 0:
            yield batch
            batch = []
    if batch:
        yield batch

for batch in batch_process(supplier, 2000):
    # 插入数据库 / 写入文件
    pass

这样每处理完一批,内存就能被回收。你想想看,如果你直接加载全量,Python 根本撑不住。

避坑指南

注意:
  • 属性不存在:用 mol.GetProp('IC50') 前先 mol.HasProp('IC50'),否则 KeyError 会直接中断循环。
  • 字符串转数值:SDF 所有属性都是字符串。float('1.23e-9') 没问题,但遇到空字符串 '' 会抛 ValueError。我在项目中一度被这个坑惨了。
  • 多值字段:同一属性名在 SDF 中出现多次时,GetProp() 返回最后一个,GetPropsAsDict() 返回列表。规范文件通常不会有,但你不是总能控制上游数据。

实战:一个完整的读取流程

把上面所有东西串起来,写个稳健的读取器:

import numpy as np
from rdkit import Chem
from rdkit.Chem import Descriptors

def safe_read_sdf(path):
    supplier = Chem.SDMolSupplier(path)
    data = []
    for mol in supplier:
        if mol is None:
            continue
        # 提取属性
        props = mol.GetPropsAsDict()
        # 清洗数值字段
        ic50 = props.get('IC50_nM', None)
        if ic50 is not None:
            try:
                ic50 = float(ic50)
            except ValueError:
                ic50 = None
        # 计算衍生描述符
        mw = Descriptors.MolWt(mol)
        logp = Descriptors.MolLogP(mol)
        data.append({
            'mol': mol,
            'ic50': ic50,
            'mw': mw,
            'logp': logp,
            'props': props
        })
    return data

# 使用
records = safe_read_sdf('compound_library.sdf')
print(f"成功读取 {len(records)} 个有效分子")

这里我特意用 props.get() 而不是 GetProp(),就是为了避免键不存在时的崩溃。数值转换也套了 try-except——你永远不知道上游会塞进来什么。

嗯,读完文件只是第一步。拿到分子对象之后,你就可以做清洗、过滤、描述符计算,或者直接扔进机器学习模型了。但说到底,读文件这个动作是整个流程的地基。地基没打稳,上面盖什么都白搭。