037

ECFP 指纹详解:半径、位向量与分子特征

ECFP 的半径、位向量与特征选择如何影响结果?这篇深入讲清参数的含义与哈希碰撞的影响。

ECFP(Extended-Connectivity Fingerprints)的参数看似简单——半径、位数、是否用特征——但每个参数对结果的影响都很实在。理解它们,能让这个基线发挥出应有的水平。

参数一:半径

# 【半径决定了每个「子结构」有多大】
#
# radius=0:只有原子本身的属性
#   → 相当于原子组成
#   → 几乎没有结构信息
#
# radius=1(ECFP2):原子 + 直接邻居
#   → 类似官能团级别
#   → 太局部,特异性不足
#
# radius=2(ECFP4):【标准选择】
#   → 覆盖典型的官能团与小片段
#   → 特异性与泛化的平衡点
#
# radius=3(ECFP6):更大的片段
#   → 更特异
#   → 【但位向量更稀疏】
#   → 小数据上容易过拟合
#
# 【稀疏性的影响】:
#   半径越大,不同分子共享的子结构越少
#   → 相似性得分整体下降
#   → 【不同半径的相似性阈值不可直接比较】

from rdkit import Chem, DataStructs
from rdkit.Chem import rdFingerprintGenerator

m1 = Chem.MolFromSmiles("CC(=O)Nc1ccc(O)cc1")
m2 = Chem.MolFromSmiles("CC(=O)Nc1ccccc1")

for r in [1, 2, 3, 4]:
    gen = rdFingerprintGenerator.GetMorganGenerator(radius=r, fpSize=2048)
    f1, f2 = gen.GetFingerprint(m1), gen.GetFingerprint(m2)
    print(f"radius={r}: 置位 {f1.GetNumOnBits():3d}, "
          f"Tanimoto {DataStructs.TanimotoSimilarity(f1, f2):.3f}")

# 典型输出:
#   radius=1: 置位  16, Tanimoto 0.700
#   radius=2: 置位  28, Tanimoto 0.583
#   radius=3: 置位  40, Tanimoto 0.512
#   radius=4: 置位  52, Tanimoto 0.464
#
# 【重要含义】:
#   「Tanimoto > 0.7 算相似」这个经验阈值
#   是【针对 ECFP4 的】
#   → 用其它半径时阈值需要重新校准

参数二:位数与哈希碰撞

# 【问题】:
#   ECFP 产生的子结构标识是 32 位整数
#   → 可能的值有 40 多亿个
#   而位向量只有 2048 位
#   → 【必须取模映射,会有碰撞】
#
# 【碰撞的后果】:
#   两个不同的子结构映射到同一位
#   → 模型无法区分它们
#   → 相似性被高估
#
# 【碰撞率的估计】:
#   一个典型的类药分子在 ECFP4 下
#   产生约 30~60 个不同的子结构
#   → 单个分子内碰撞概率低
#   但【整个库中,不同分子的子结构会碰撞】

# 检查一个库中实际用到多少位
from collections import Counter

def bit_usage(smiles_list, radius=2, fpSize=2048):
    gen = rdFingerprintGenerator.GetMorganGenerator(
        radius=radius, fpSize=fpSize)
    counter = Counter()
    for s in smiles_list:
        m = Chem.MolFromSmiles(s)
        if m is None:
            continue
        for bit in gen.GetFingerprint(m).GetOnBits():
            counter[bit] += 1
    print(f"用到的位数: {len(counter)} / {fpSize} "
          f"({len(counter)/fpSize:.1%})")
    print(f"最常见的位出现在 {counter.most_common(1)[0][1]} 个分子中")
    return counter

# 【判读】:
#   使用率接近 100% → 位数可能不够,碰撞严重
#   使用率很低 → 位数够,甚至可以减小
#
# 【无碰撞的方案】:
#   稀疏计数指纹(不取模)
sparse = gen.GetSparseCountFingerprint(mol)
#   → 保留完整的子结构标识
#   → 【适合需要精确匹配的场景】
#   → 但长度不固定,不能直接喂给多数模型
#
# 【实践建议】:
#   2048 位适合多数场景
#   大型多样化的库(百万级以上)考虑 4096

参数三:原子不变量的选择

ECFP(默认) FCFP(特征)
原子的初始标识基于 元素、连接数、氢数、电荷、成环 功能类别
苯 vs 吡啶 不同 部分相同
特异性
骨架跃迁能力
适合 找类似物 找功能相似的不同骨架
# FCFP 的原子特征类别:
#   Donor(氢键供体)
#   Acceptor(氢键受体)
#   Aromatic(芳香)
#   Halogen(卤素)
#   Basic(碱性)
#   Acidic(酸性)
#
# 【实践建议】:
#   虚拟筛选中【同时用 ECFP 与 FCFP】
#   → 两者的命中有互补性
#   → 合并结果能提高召回

inv_gen = rdFingerprintGenerator.GetMorganFeatureAtomInvGen()
fcfp_gen = rdFingerprintGenerator.GetMorganGenerator(
    radius=2, fpSize=2048, atomInvariantsGenerator=inv_gen)

其它选项

# ---- 手性 ----
gen = rdFingerprintGenerator.GetMorganGenerator(
    radius=2, fpSize=2048, includeChirality=True)
# 【默认不包含手性】
# → 对映体的 ECFP 相同
# → 【做手性相关任务时必须开启】

# ---- 键类型 ----
gen = rdFingerprintGenerator.GetMorganGenerator(
    radius=2, fpSize=2048, useBondTypes=True)   # 默认 True
# 关闭后,单键与双键视为相同

# ---- 只考虑特定原子 ----
fp = gen.GetFingerprint(mol, fromAtoms=[0, 1, 2])
# 【用途】:只编码分子的某个部分
#   如:只看骨架,或只看某个取代基

# ---- 计数上限 ----
# 计数指纹中,某个子结构出现很多次时
# 可能需要截断,避免单一特征主导
import numpy as np
counts = np.array(gen.GetCountFingerprint(mol).ToList())
counts_clipped = np.clip(counts, 0, 4)      # 【截断到 4】

# ---- 对数变换 ----
counts_log = np.log1p(counts)
# 【对树模型影响不大,对线性模型有帮助】

参数选择的实用流程

# 【不要凭感觉选,用验证集比较】

import numpy as np
import lightgbm as lgb
from sklearn.model_selection import cross_val_score

def compare_fingerprint_settings(smiles, y, cv=5):
    settings = {
        "ECFP4-2048-bit": dict(radius=2, fpSize=2048, count=False),
        "ECFP4-2048-count": dict(radius=2, fpSize=2048, count=True),
        "ECFP6-2048-count": dict(radius=3, fpSize=2048, count=True),
        "ECFP4-4096-count": dict(radius=2, fpSize=4096, count=True),
        "FCFP4-2048-count": dict(radius=2, fpSize=2048, count=True,
                                 feature=True),
    }
    for name, cfg in settings.items():
        inv = (rdFingerprintGenerator.GetMorganFeatureAtomInvGen()
               if cfg.get("feature") else None)
        gen = rdFingerprintGenerator.GetMorganGenerator(
            radius=cfg["radius"], fpSize=cfg["fpSize"],
            atomInvariantsGenerator=inv)
        X = []
        for s in smiles:
            m = Chem.MolFromSmiles(s)
            if m is None:
                X.append(np.zeros(cfg["fpSize"]))
                continue
            fp = (gen.GetCountFingerprint(m).ToList() if cfg["count"]
                  else list(gen.GetFingerprint(m)))
            X.append(fp)
        X = np.array(X)
        scores = []
        for seed in range(3):
            m = lgb.LGBMRegressor(n_estimators=500, random_state=seed,
                                  verbose=-1)
            s = cross_val_score(m, X, y, cv=cv,
                                scoring="neg_root_mean_squared_error")
            scores.append(-s.mean())
        print(f"{name:22s} RMSE = {np.mean(scores):.4f} ± {np.std(scores):.4f}")

# 【经验规律】:
#   多数任务上,ECFP4 计数版 + 描述符 已经接近最优
#   参数微调的收益通常小于种子间方差
#   → 【不要在这上面花太多时间】
#   → 【把精力放在数据质量与评测设计上】

关键要点

  • 「Tanimoto > 0.7 算相似」是针对 ECFP4 的经验阈值——换半径必须重新校准;
  • 哈希碰撞会让不同子结构映射到同一位——检查位使用率判断位数是否够;
  • 默认不包含手性——对映体的 ECFP 相同,手性任务必须开启;
  • 参数微调的收益通常小于种子间方差,把精力放在数据与评测上

延伸资源