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 相同,手性任务必须开启;
- 参数微调的收益通常小于种子间方差,把精力放在数据与评测上。
延伸资源
- Morgan 指纹:036《Morgan Fingerprint 入门》;分子指纹总览:035《分子指纹是什么》;MACCS:038《MACCS Keys》;
- Tanimoto:039《Tanimoto 相似性》;Chemprop:131《Chemprop / D-MPNN 论文精读》。