036

Morgan Fingerprint 入门:ECFP 为什么如此常用

Morgan 指纹是最常用的分子指纹。这篇讲清它的迭代算法、参数含义与在实践中的使用要点。

Morgan 指纹(RDKit 中 ECFP 的实现)是化学信息学中使用最广的指纹。它的算法思想简单而有效——理解这个算法,就能理解为什么它与图神经网络的信息范围本质上等价。

算法:迭代的邻域扩展

# 【核心思想】:
#   每个原子的「标识」由它自己和周围的环境决定
#   通过迭代,逐步扩大考虑的环境范围
#
# 【步骤】:
#
# 第 0 轮(radius=0):
#   每个原子分配一个初始标识
#   基于原子的固有性质:
#     - 元素类型
#     - 连接的重原子数
#     - 连接的氢数
#     - 形式电荷
#     - 是否在环中
#     - 同位素质量
#   → 【这 6 个属性构成 Daylight 的原子不变量】
#
# 第 1 轮(radius=1):
#   每个原子的新标识 =
#     哈希(自己的旧标识, 所有邻居的旧标识与键类型)
#   → 现在标识包含了 1 跳邻域的信息
#
# 第 2 轮(radius=2):
#   同样的操作再做一次
#   → 标识包含 2 跳邻域的信息
#
# ...
#
# 【收集】:
#   把所有轮次中出现过的所有标识收集起来
#   → 这就是分子的「子结构集合」
#
# 【哈希到固定长度】:
#   每个标识对固定位数取模,置位
#   → 得到定长的位向量
#
# 【关键观察】:
#   这个迭代过程与 GNN 的消息传递【结构完全一致】
#   → radius=K 的 ECFP ≈ K 层 GNN 的信息范围
#   → 【这解释了为什么两者性能常常接近】(见 159)
#   → 差别在于:ECFP 是「枚举+哈希」,GNN 是「学习聚合」

命名约定:ECFP4 = radius 2

# 【最容易搞混的一点】
#
#   ECFP 后面的数字是【直径】,不是半径
#   RDKit 的 radius 参数是【半径】
#
#   ECFP2  ←→  radius=1
#   ECFP4  ←→  radius=2   【最常用】
#   ECFP6  ←→  radius=3
#
# 【读论文时要注意】:
#   看到「ECFP4」,对应 RDKit 的 radius=2

from rdkit import Chem
from rdkit.Chem import rdFingerprintGenerator

mol = Chem.MolFromSmiles("CC(=O)Nc1ccc(O)cc1")

# ECFP4 = radius 2(标准选择)
gen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)
fp = gen.GetFingerprint(mol)
print(f"置位数: {fp.GetNumOnBits()} / {fp.GetNumBits()}")

# 【注意】:旧的 API 已废弃
#   AllChem.GetMorganFingerprintAsBitVect(mol, 2, nBits=2048)
#   → 新代码应该用 rdFingerprintGenerator

查看每一位对应什么子结构

# 【这是 ECFP 相对深度学习模型的一个优势】:
#   每一位可以追溯到具体的化学子结构

from rdkit import Chem
from rdkit.Chem import rdFingerprintGenerator, Draw

mol = Chem.MolFromSmiles("CC(=O)Nc1ccc(O)cc1")
gen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)

ao = rdFingerprintGenerator.AdditionalOutput()
ao.AllocateBitInfoMap()
fp = gen.GetFingerprint(mol, additionalOutput=ao)
bit_info = ao.GetBitInfoMap()

# bit_info: {位号: ((中心原子索引, 半径), ...)}
for bit, envs in list(bit_info.items())[:5]:
    print(f"位 {bit}: {envs}")

# 可视化某一位对应的子结构
bits = list(bit_info.keys())[:12]
img = Draw.DrawMorganBits(
    [(mol, b, bit_info) for b in bits],
    molsPerRow=4,
    legends=[f"bit {b}" for b in bits],
)

# 【用途】:
#   1) 【模型可解释性】:哪些子结构驱动了预测(见 168)
#   2) 调试:检查指纹是否捕捉到了预期的特征
#   3) 教学:直观理解指纹在编码什么

变体:FCFP 与计数版

# ---- FCFP:用药效团特征替代原子类型 ----
inv_gen = rdFingerprintGenerator.GetMorganFeatureAtomInvGen()
fcfp_gen = rdFingerprintGenerator.GetMorganGenerator(
    radius=2, fpSize=2048, atomInvariantsGenerator=inv_gen)
fp_fcfp = fcfp_gen.GetFingerprint(mol)

# 【差异】:
#   ECFP 的原子不变量:元素类型、连接数等【具体属性】
#   FCFP 的原子不变量:【功能类别】
#     - 氢键供体 / 受体
#     - 芳香性
#     - 卤素
#     - 正电 / 负电
#
# 【含义】:
#   ECFP:苯环上的碳 ≠ 吡啶环上的碳
#   FCFP:都是「芳香原子」→ 可能相同
#
# → 【FCFP 更抽象,更容易发现骨架不同但功能相似的分子】
# → 骨架跃迁场景下值得试(见 352)

# ---- 计数指纹 ----
fp_count = gen.GetCountFingerprint(mol)
counts = fp_count.ToList()
# 【保留了「某个子结构出现几次」的信息】
# → 机器学习中常常略优于位向量

# ---- 稀疏版本(不哈希,保留完整标识)----
sparse = gen.GetSparseCountFingerprint(mol)
# 【无哈希碰撞】,但长度不固定
# → 适合需要精确子结构匹配的场景

实践中的要点

要点 说明
复用 generator 对象 不要在循环内创建——初始化有开销
BulkTanimotoSimilarity 批量相似性比循环快很多
先标准化分子 去盐、规范化,否则同一化合物的指纹可能不同
手性的处理 includeChirality=True 才区分对映体
缓存指纹 大库只算一次存起来
位数的选择 2048 通常够;化学空间大时用 4096
# 大规模计算的模板
from rdkit import Chem, DataStructs
from rdkit.Chem import rdFingerprintGenerator
from multiprocessing import Pool
import numpy as np
import pickle

GEN = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)

def compute_fp(smiles):
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    return GEN.GetFingerprint(mol)

def batch_fingerprints(smiles_list, n_workers=8):
    with Pool(n_workers) as pool:
        fps = pool.map(compute_fp, smiles_list)
    valid = [(s, f) for s, f in zip(smiles_list, fps) if f is not None]
    print(f"【{len(smiles_list) - len(valid)} 个失败】")
    return valid

# 【缓存】:大库的指纹算一次就存起来
# with open("fps.pkl", "wb") as f:
#     pickle.dump(valid, f)

# 批量相似性搜索
def search(query_smiles, library_fps, top_k=100):
    q = compute_fp(query_smiles)
    if q is None:
        return []
    sims = DataStructs.BulkTanimotoSimilarity(
        q, [f for _, f in library_fps])
    order = np.argsort(sims)[::-1][:top_k]
    return [(library_fps[i][0], sims[i]) for i in order]

关键要点

  • ECFP 后的数字是直径——ECFP4 对应 RDKit 的 radius=2;
  • 迭代邻域扩展的过程与 GNN 消息传递结构完全一致,这解释了两者性能相近;
  • 每一位可追溯到具体子结构,这是相对深度模型的可解释性优势;
  • FCFP 用功能类别替代具体元素,更适合骨架跃迁场景。

延伸资源