Tanimoto 系数(也叫 Jaccard 系数)是分子相似性比较的事实标准。它的定义简单,但「多少算相似」这个问题的答案,取决于指纹类型、化学空间与具体任务——照搬别处的阈值是常见的错误。
定义
# 【位向量的 Tanimoto】
#
# T(A, B) = |A ∩ B| / |A ∪ B|
# = c / (a + b - c)
#
# 其中:
# a = A 中置位的数量
# b = B 中置位的数量
# c = A 与 B 共同置位的数量
#
# 取值范围:0(完全不同)到 1(完全相同)
#
# 【计数向量的 Tanimoto】
#
# T(A, B) = Σ min(a_i, b_i) / Σ max(a_i, b_i)
#
# 【为什么用 Tanimoto 而非余弦相似度】:
# 1) 分子指纹是稀疏的二值向量
# 2) Tanimoto 惩罚「一方有另一方没有」的位
# → 更符合化学相似性的直觉
# 3) 【历史与惯例】:领域内已经形成共识
# → 便于比较不同工作的结果
from rdkit import Chem, DataStructs
from rdkit.Chem import rdFingerprintGenerator
gen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)
def similarity(smi1, smi2):
m1, m2 = Chem.MolFromSmiles(smi1), Chem.MolFromSmiles(smi2)
if m1 is None or m2 is None:
return None
return DataStructs.TanimotoSimilarity(
gen.GetFingerprint(m1), gen.GetFingerprint(m2))
print(similarity("CC(=O)Nc1ccc(O)cc1", "CC(=O)Nc1ccccc1"))
# 【批量计算:比循环快很多】
fps = [gen.GetFingerprint(Chem.MolFromSmiles(s)) for s in library]
sims = DataStructs.BulkTanimotoSimilarity(query_fp, fps)
阈值的含义
| ECFP4 Tanimoto | 大致含义 |
|---|---|
| > 0.85 | 非常相似——常常只差一个小取代基 |
| 0.7 ~ 0.85 | 相似——通常是同一化学系列 |
| 0.5 ~ 0.7 | 中等相似——可能共享骨架 |
| 0.3 ~ 0.5 | 弱相似 |
| < 0.3 | 基本不相似 |
# 【重要提醒】:
#
# 1) 【这些阈值是针对 ECFP4 的】
# MACCS 的值系统性更高(见 038)
# 半径不同,值也不同(见 037)
# → 【换指纹必须重新校准】
#
# 2) 【阈值依赖化学空间】
# 在一个高度多样的库中,0.5 已经算相似
# 在一个同系列的库中,0.85 才算相似
# → 【看你的库的相似度分布】
#
# 3) 【相似性悖论】
# 「结构相似 → 活性相似」只是【统计上的倾向】
# 存在【活性悬崖】:
# Tanimoto 0.95 的两个分子,
# 活性可能差 1000 倍
# → 【这是相似性搜索的根本局限】
# → 也是最有信息量的数据点(见 073)
# 【校准阈值的方法】:
def calibrate_threshold(library_smiles, n_sample=1000, seed=0):
"""看看你的库中相似度的分布"""
import numpy as np
rng = np.random.default_rng(seed)
fps = [gen.GetFingerprint(Chem.MolFromSmiles(s))
for s in library_smiles if Chem.MolFromSmiles(s)]
sims = []
for _ in range(n_sample):
i, j = rng.integers(0, len(fps), 2)
if i != j:
sims.append(DataStructs.TanimotoSimilarity(fps[i], fps[j]))
sims = np.array(sims)
print(f"随机分子对的相似度分布:")
for p in [50, 90, 95, 99, 99.9]:
print(f" {p}% 分位数: {np.percentile(sims, p):.3f}")
# 【「相似」应该定义在这个分布的尾部】
return sims
相似性搜索的实践
from rdkit import Chem, DataStructs
from rdkit.Chem import rdFingerprintGenerator
import numpy as np
import pickle
GEN = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)
class SimilaritySearcher:
def __init__(self, library_smiles):
self.smiles, self.fps = [], []
for s in library_smiles:
m = Chem.MolFromSmiles(s)
if m is not None:
self.smiles.append(s)
self.fps.append(GEN.GetFingerprint(m))
print(f"索引了 {len(self.fps)} 个分子")
def search(self, query, top_k=100, threshold=None):
m = Chem.MolFromSmiles(query)
if m is None:
return []
q = GEN.GetFingerprint(m)
sims = np.array(DataStructs.BulkTanimotoSimilarity(q, self.fps))
if threshold is not None:
idx = np.where(sims >= threshold)[0]
idx = idx[np.argsort(-sims[idx])]
else:
idx = np.argsort(-sims)[:top_k]
return [(self.smiles[i], float(sims[i])) for i in idx]
def multi_query_search(self, queries, top_k=100, mode="max"):
"""多个查询分子:用最大或平均相似度"""
qfps = []
for q in queries:
m = Chem.MolFromSmiles(q)
if m is not None:
qfps.append(GEN.GetFingerprint(m))
all_sims = np.array([
DataStructs.BulkTanimotoSimilarity(qf, self.fps) for qf in qfps])
sims = all_sims.max(axis=0) if mode == "max" else all_sims.mean(axis=0)
idx = np.argsort(-sims)[:top_k]
return [(self.smiles[i], float(sims[i])) for i in idx]
def save(self, path):
with open(path, "wb") as f:
pickle.dump({"smiles": self.smiles, "fps": self.fps}, f)
# 【多查询搜索的价值】:
# 有多个已知活性分子时,
# 用「与任一个相似」(max)比「与某一个相似」召回更高
# → 【这是实际筛选中的常用策略】
大规模搜索的优化
# 【问题】:亿级库的暴力搜索太慢
#
# 【优化一:位数上界剪枝】
# Tanimoto 的上界:
# T(A,B) ≤ min(a, b) / max(a, b)
# → 如果两个分子的置位数差异太大,
# Tanimoto 不可能超过阈值
# → 【可以先按置位数分桶,跳过不可能的】
def tanimoto_upper_bound(n_bits_a, n_bits_b):
return min(n_bits_a, n_bits_b) / max(n_bits_a, n_bits_b)
def prefiltered_search(query_fp, library_fps, threshold=0.7):
qa = query_fp.GetNumOnBits()
results = []
for i, fp in enumerate(library_fps):
b = fp.GetNumOnBits()
# 【剪枝:上界低于阈值就跳过】
if tanimoto_upper_bound(qa, b) < threshold:
continue
sim = DataStructs.TanimotoSimilarity(query_fp, fp)
if sim >= threshold:
results.append((i, sim))
return sorted(results, key=lambda x: -x[1])
# 【优化二:用专门的化学检索工具】
# ChemFP、FPSim2 等库做了底层优化
# → 比 RDKit 的朴素实现快很多
#
# 【优化三:近似最近邻】
# 用 LSH、HNSW 等索引
# → 牺牲一点召回换速度
#
# 【优化四:预计算并缓存】
# 指纹算一次存起来
# → 【这是最基本也最有效的优化】
# 【实践建议】:
# 百万级:RDKit + 剪枝够用
# 千万级以上:用 FPSim2 或 ChemFP
其它相似性指标
| 指标 | 特点 | 适用 |
|---|---|---|
| Tanimoto | 标准 | 默认选择 |
| Dice | 比 Tanimoto 值高 | — |
| Tversky | 不对称,可调权重 | 子结构搜索 |
| Cosine | 不惩罚单方独有的位 | 计数指纹 |
| Russel | 只看共有位 | 少用 |
# 【Tversky 的实用价值】:
# T(A,B) = c / (α·(a-c) + β·(b-c) + c)
#
# α=β=1 → Tanimoto
# α=1, β=0 → 【「A 有多少被 B 包含」】
#
# 【场景】:
# 查询是一个小片段,想找包含它的大分子
# → 用 Tanimoto 会因为大小差异得分很低
# → 【用不对称的 Tversky 更合适】
sim = DataStructs.TverskySimilarity(fp_fragment, fp_molecule,
a=1.0, b=0.0)
# → 衡量「片段有多少被分子包含」
关键要点
- 阈值依赖指纹类型与化学空间——0.7 是针对 ECFP4 的,换指纹必须重新校准;
- 「结构相似 → 活性相似」只是统计倾向——活性悬崖是相似性搜索的根本局限;
- 多个已知活性分子时,用「与任一个相似」(max)召回更高;
- 片段搜索应该用不对称的 Tversky,而非 Tanimoto。
延伸资源
- 分子指纹:035《分子指纹是什么》;ECFP:036《Morgan Fingerprint 入门》、037《ECFP 指纹详解》;MACCS:038《MACCS Keys》;
- 聚类:050《分子相似性聚类》;多样性评估:053《分子多样性评估》;SAR:073《SAR 构效关系》。