039

Tanimoto 相似性:分子相似度检索的核心指标

Tanimoto 是分子相似性的标准指标。这篇讲清它的定义、阈值含义与相似性搜索的实践。

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。

延伸资源